搞懂React Hooks运行机制🧐🧐🧐

前言

在前文# React Hooks诞生动机是什么?介绍了关于Hooks的动机,即回答了它解决了什么问题,但是要求开发者必须严格遵守以下两点规则:

  • 只能在 React 函数中调用 Hook
  • 不要在循环、条件或嵌套函数中调用 Hook

到底是因为什么让React有这些规则呢?尤其是第二点,如果非要打破这个规则,那将会怎么样?

要想回答以上问题,那就需要搞懂Hooks的运行机制。

规则解释

至于第一条只能在React函数中调用Hook比较好理解,因为在普通函数中调用Hooks没有意义,来测试一下:

jsx 复制代码
import React, { useState } from "react";
import { Button } from "antd";

function testFunction() {
  const [status] = useState(0)
  console.log('status', status);
  return status
}

function TestComponent() {
  const status = testFunction()

  return (
    <div>
    </div>
  );
}
export default TestComponent

上面代码中,在TestComponent中调用普通函数testFunction,而普通函数中调用useState钩子获取状态,运行结果报错,它要求必须要函数式组件中或者自定义Hooks中调用。

而至于第二条,还不能让我在循环或条件语句、嵌套函数中调用?

是的,没错!React必须要保证每次渲染时 Hook 调用的顺序是一致的。

为什么顺序必须是一致呢?

因为在渲染的时候存在多个state,为了能够让stateuseState对应上,从而能够渲染出正确的UI,React根据固定Hooks顺序来保证每次渲染是正确的,这不就意味着React的改变会导致整个vdom变化吗?

没错!正是因为这样,React16引入了fiber架构。

好了,我们来测试一下按照正确顺序使用hooks的方式:

jsx 复制代码
import React, { useState } from "react";
import { Button } from "antd";

function TestComponent() {
  const [name, setName] = useState("mouse");
  const [number] = useState(0);
  const [blog] = useState("奶酪");

  return (
    <div style={{padding: 15}}>
      <p>博主:{name}</p>
      <p>数量:{number}</p>
      <p>博客名:{blog}</p>
      <Button
        onClick={() => {
          setName("jmin");
        }}
      >
        修改名称
      </Button>
    </div>
  );
}
export default TestComponent

上面代码中,通过定义三个状态,分别namenumberblog,点击修改名称,可以修改name属性:

好,点击按钮可以正常修改名称:

接下来我们稍微修改一下代码,加上条件语句限制:

jsx 复制代码
import React, { useState } from "react";
import { Button } from "antd";

let isClick = false;
function TestComponent() {
  let name, setName, number;
  if (!isClick) {
    // eslint-disable-next-line
    [name, setName] = useState("mouse");
    // eslint-disable-next-line
    [number] = useState(0);
    isClick = true;
  }
  const [blog] = useState("奶酪");
  console.log('blog: ', blog);

  return (
    <div style={{padding: 15}}>
      <p>博主:{name}</p>
      <p>数量:{number}</p>
      <p>博客名:{blog}</p>
      <Button
        onClick={() => {
          setName("jmin");
        }}
      >
        修改名称
      </Button>
    </div>
  );
}
export default TestComponent

通过条件判断,在二次渲染的时候我减少了渲染的钩子数量,点击按钮之后控制台应该显示blog奶酪才对呀,怎么会先是setName的内容呢?并且也报了一个错,说渲染的hooks比预期的少!!!

所以,是因为我通过条件判断减少了React渲染时候的钩子数量,导致它在渲染的时候没有按照原本顺序执行,显然第三个state跑到第一个位置去了。

来看看React源码是怎么处理的,首先从setState开始

jsx 复制代码
export function useState(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

获取一下当前的 dispatcher ,然后再调用 dispatcher.useState 方法并且传入 useState 的初始值。

那么当前的 dispatcher 又是什么呢?通过研究源码发现,当函数组件进入 render 阶段 的时候,如果发现组件内存在 Hooks ,那么会调用 renderWithHooks方法,在这个方法中会根据不同渲染情况对当前的 dispatcher 进行赋值,如下:

jsx 复制代码
ReactCurrentDispatcher.current =
  current === null || current.memoizedState === null ?
  HooksDispatcherOnMount :
  HooksDispatcherOnUpdate;

从代码中可以看到,首先使用 current === null || current.memoizedState === null 区分当前组件是在 mount 还是 update ,然后把不同情况的 dispatcher 赋值给全局变量 ReactCurrentDispatchercurrent 属性,赋值完以后,在 useState 定义里通过调用 resolveDispatcher 方法就能获取到当前的 dispatcher 了。

从这里我们就知道了, Hooks 在首次渲染和更新渲染时使用的是不同的 dispatcher ,从而执行的是不用的逻辑。

首次渲染

从上文分析得知,首次渲染使用的 dispatcherHooksDispatcherOnMount (opens new window),其代码如下:

jsx 复制代码
const HooksDispatcherOnMount: Dispatcher = {
  readContext,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useMutableSource: mountMutableSource,
  useOpaqueIdentifier: mountOpaqueIdentifier,
  unstable_isNewReconciler: enableNewReconciler,
};

在首次渲染阶段调用 useState 实际上调用的是 HooksDispatcherOnMount.useStatemountState 方法,我们继续追 mountState 的内部实现,它是这样定义的:

jsx 复制代码
function mountState(initialState) {
  // 将新的 hook 对象追加进链表尾部
  const hook = mountWorkInProgressHook();
  // initialState 可以是一个回调,若是回调,则取回调执行后的值
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  // 将 initialState 作为一个"记忆值"存下来
  hook.memoizedState = hook.baseState = initialState;
  // 创建当前 hook 对象的更新队列,这一步主要是为了能够依序保留 dispatch
  const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  const dispatch = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  // 返回目标数组,dispatch 其实就是常常见到的 setXXX 这个函数
  return [hook.memoizedState, dispatch];
}

从这段源码中我们可以看出,mounState 的主要工作是初始化 Hooks 。在整段源码中,最需要关注的是 mountWorkInProgressHook 方法,它为我们道出了 Hooks 背后的数据结构组织形式。以下是 mountWorkInProgressHook 方法的定义

jsx 复制代码
function mountWorkInProgressHook() {
  // 注意,单个 hook 是以对象的形式存在的
  const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // 若正在处理的 hook 链表为空,则将上面定义好的 hook 作为链表的头节点
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 若链表不为空,则将 hook 追加到链表尾部
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

到这里可以看出,hook 相关的所有信息收敛在一个 hook 对象里,而 hook 对象之间以单向链表的形式相互串联

所以,组件中的所有 Hooks 之间都是以单向链表的形式串联。

那么,假如链接中间有一环丢失了,那么这条链表的顺序就会发生变化,从而有可能导致还存在在链表之中的 Hook 状态与之前无法对应的情况,从而导致 hook 状态紊乱。

那么真相到底是不是如我们猜想的这般呢?别急,我们继续往下看,看完了首次渲染的情况,我们继续看更新渲染时都干了些什么。

更新渲染

更新渲染使用的 dispatcherHooksDispatcherOnUpdate,代码如下:

jsx 复制代码
const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useMutableSource: updateMutableSource,
  useOpaqueIdentifier: updateOpaqueIdentifier,
  unstable_isNewReconciler: enableNewReconciler,
};

在更新渲染阶段调用 useState 实际上调用的是 HooksDispatcherOnUpdate.useStateupdateState 方法,它是这样定义的:

jsx 复制代码
function updateState(initialState) {
  return updateReducer(basicStateReducer, initialState);
}

可以看到, updateState 方法最终会落到 updateReducer 这个方法中。

其实 updateState 做的的事情很容易理解,那就是:按顺序去遍历之前构建好的链表,取出对应的数据信息进行渲染

总结

现在,我们知道了:

mountState (首次渲染)构建链表并渲染;

updateState (更新渲染)依次遍历链表并渲染。

hooks 的渲染是通过「依次遍历」来定位每个 hooks 内容的。如果前后两次读到的链表在顺序上出现差异,那么渲染的结果自然是不可控的

所以,这就是为什么React必须要求开发者保证遵守上面的规则,用于所有 Hook 在每次渲染中都按照同样的顺序被调用!

相关推荐
JeffongTan7 小时前
在LWC中镶嵌VF Page获取用户IP
前端·javascript·salesforce
不cong明的亚子9 小时前
web live多屏互动平台
javascript·经验分享·音视频
陆枫Larry9 小时前
Astro 是什么
前端
是立不是利11 小时前
写给设计师的 CSS 博客美学指南
前端·css
dsyyyyy110111 小时前
JS复习,难点,易错点总结
开发语言·javascript·ecmascript
默_笙11 小时前
🏝 Docker 就是"房地产开发":从施工图纸到小区物业的容器化指南
前端·javascript
计算机魔术师11 小时前
Rohan Paul 谈用时间跨度衡量智能体能力,并引用 OpenAI 自动化研究实习生里程碑
前端
kyriewen11 小时前
我手写了个极简版 React Router——才搞懂 v6 为什么砍了这么多 API
前端·javascript·程序员
a11177611 小时前
Shirone 二次元博客主题 开源
前端·开源
IT_陈寒12 小时前
Python的切片赋值把我坑惨了,这不是bug是特性
前端·人工智能·后端