搞懂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 在每次渲染中都按照同样的顺序被调用!

相关推荐
花花鱼3 分钟前
vue3 axios ant-design-vue cdn的方式使用
前端·javascript·vue.js
GoppViper37 分钟前
uniapp中实现<text>文本内容点击可复制或拨打电话
前端·后端·前端框架·uni-app·前端开发
Sam90291 小时前
【Webpack--007】处理其他资源--视频音频
前端·webpack·音视频
Code成立1 小时前
HTML5精粹练习第1章博客
前端·html·博客·html5
架构师ZYL1 小时前
node.js+Koa框架+MySQL实现注册登录
前端·javascript·数据库·mysql·node.js
gxhlh2 小时前
React Native防止重复点击
javascript·react native·react.js
miao_zz2 小时前
基于react native的锚点
android·react native·react.js
一只小白菜~2 小时前
实现实时Web应用,使用AJAX轮询、WebSocket、还是SSE呢??
前端·javascript·websocket·sse·ajax轮询
晓翔仔2 小时前
CORS漏洞及其防御措施:保护Web应用免受攻击
前端·网络安全·渗透测试·cors·漏洞修复·应用安全
jingling5552 小时前
后端开发刷题 | 数字字符串转化成IP地址
java·开发语言·javascript·算法