4.Hooks工作机制

7/21/2023 react

参考:https://nlrx-wjc.github.io/Blog/react/hook/Hooks%E5%B7%A5%E4%BD%9C%E6%9C%BA%E5%88%B6.html

# React Hooks有两条使用规则:

  1. 只在React函数中调用Hook
  2. 不要在循环、条件或嵌套函数中调用Hook
    其中第一条规则很好理解,React Hooks本来就是为函数组件而生的,在普通函数中调用没有任何意义。
    关于第二条规则,官方这么补充:你必须确保Hook在每次渲染中都按照同样的顺序被调用。这样才能让React在多次的hook调用之间保持hook状态的正确。
    重点:确保每一次渲染中都按照同样的顺序被调用。
    这涉及到Hook的实现机制了,下面从源码角度出发,来探究一下React为什么要限制Hook的调用顺序。

# 分析原理:

# 首先,实现一个Demo

import React, { useState } from "react";

function PersonalInfoComponent() {
  const [name,setName] = useState("难凉热血");
  const [age] = useState(18);
  const [career] = useState("前端开发");
  
  return (
    <div className="personalInfo">
      <p>姓名:{name}</p>
      <p>年龄:{age}</p>
      <p>职业:{career}</p>
      <button
        onClick={() => {
          setName("热血难凉");
        }}
      >
        修改姓名
      </button>
    </div>
  );
}

export default PersonalInfoComponent;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

在这个 Demo 中,我们使用 useState 定义了三个状态,分别是:姓名、年龄和职业。并在视图中将这三个状态展示出来,点击「修改姓名」按钮可修改姓名。没有问题,点击「修改姓名」按钮也能正常修改姓名,以上都是在正常使用Hooks。
接下来,我们做一点调整,使得 Hooks 的调用顺序不再保持一致,代码如下:

import React, { useState } from "react";

let isMounted = false;
function PersonalInfoComponent() {
  let name, age, career, setName;
  console.log("isMounted", isMounted);

  if (!isMounted) {
    // eslint-disable-next-line
    [name, setName] = useState("难凉热血");
    // eslint-disable-next-line
    [age] = useState(18);
    // if 内部的逻辑执行一次后,就将 isMounted 置为 true(说明已挂载,后续都不再是首次渲染了)
    isMounted = true;
  }
  [career] = useState("前端开发");
  console.log("career", career);

  return (
    <div className="personalInfo">
      <p>姓名:{name}</p>
      <p>年龄:{age}</p>
      <p>职业:{career}</p>
      <button
        onClick={() => {
          setName("热血难凉");
        }}
      >
        修改姓名
      </button>
    </div>
  );
}

export default PersonalInfoComponent;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

从上面代码中可以看到,我们把一部分的 useState 操作放进 if 条件语句里,只有在组件首次渲染的时候,名字和年龄的 useState 才会被调用,在之后的更新渲染中就只有职业的 useState 被调用,接下来,我们再点击「修改姓名」按钮,看看会发生什么样的现象:当刷新页面,渲染结果跟之前无异,一切都正常渲染。并且 F12 打开控制台,可以看到我们打印的 career 也是正确的。接着,我们点击「修改姓名」按钮,发现页面直接报错了:Rendered fewer hooks than expected. This may be caused by an accidental early return statement
从报错信息中可以看出 React 在提示我们Rendered fewer hooks than expected(组件渲染的 Hooks 比期望中更少)。没错,首次渲染的时候有三个 Hook 被调用了,但是当点击按钮之后进入更新渲染的时候却只有一个 Hook 被调用,上文也说了, React 要求在每次渲染中所有的 Hook 都必须以相同的顺序被调用,看来, React 提示的没毛病。此时,打开控制台,看到打印:

isMounted true
career 热血难凉 
isMounted true
career 热血难凉 
Error: The above error occurred in the <PersonalInfoComponent> component: 'xxx'
1
2
3
4
5

# 开始分析:

从上一章节中的 Demo ,我们可以看到:如果不保证每次渲染中所有 Hook 的调用顺序的话,会造成 Hook 状态的紊乱。 那么为什么会出现这种情况呢?接下来,我们就从源码的角度来一探究竟。 我们以最简单的 useState 为例,在 React 源码中,useState是这样定义的:

export function useState(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}
1
2
3
4

可以看到, useState 的源码定义非常简单,就是先获取一下当前的 dispatcher ,然后再调用 dispatcher.useState 方法并且传入 useState 的初始值。那么当前的 dispatcher 又是什么呢?通过研究源码发现,当函数组件进入 render 阶段 的时候,如果发现组件内存在 Hooks ,那么会调用 renderWithHooks方法,在这个方法中会根据不同渲染情况对当前的 dispatcher 进行赋值,如下:

ReactCurrentDispatcher.current =
  current === null || current.memoizedState === null ?
  HooksDispatcherOnMount :
  HooksDispatcherOnUpdate;
1
2
3
4

从代码中可以看到,首先使用 current === null || current.memoizedState === null 区分当前组件是在 mount 还是 update ,然后把不同情况的 dispatcher 赋值给全局变量 ReactCurrentDispatcher 的 current 属性,赋值完以后,在 useState 定义里通过调用 resolveDispatcher 方法就能获取到当前的 dispatcher 了。 从这里我们就知道了, Hooks 在首次渲染和更新渲染时使用的是不同的 dispatcher ,从而执行的是不用的逻辑。下面我们就来看下首次渲染和更新渲染这两种不同的 dispatcher 里都干了些什么。

# 首次渲染:

从上文分析得知,首次渲染使用的dispatcher是HooksDispatcherOnMoun,其代码如下:

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,
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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

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];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

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

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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

到这里可以看出,hook 相关的所有信息收敛在一个 hook 对象里,而 hook 对象之间以单向链表的形式相互串联。 到这里,真相已经开始逐渐浮出水面了:组件中的所有 Hooks 之间都是以单向链表的形式串联,环环相扣的。 我们可以大胆猜想一下:假如链接中间有一环丢失了,那么这条链表的顺序就会发生变化,从而有可能导致还存在在链表之中的 Hook 状态与之前无法对应的情况,从而导致 hook 状态紊乱。 那么真相到底是不是如我们猜想的这般呢?别急,我们继续往下看,看完了首次渲染的情况,我们继续看更新渲染时都干了些什么。

# 更新渲染:

更新渲染使用的 dispatcher 是HooksDispatcherOnUpdate,代码如下:

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,
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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

function updateState(initialState) {
  return updateReducer(basicStateReducer, initialState);
}
1
2
3

可以看到,updateState方法最终会落到updateReducer这个方法中。
其实updateState做的的事情很容易理解,那就是:按顺序去遍历之前构建好的链表,取出对应的数据信息进行渲染

# 小结:

  • mountState (首次渲染)构建链表并渲染;
  • updateState (更新渲染)依次遍历链表并渲染。 看来,真相只有一个:hooks的渲染是通过「依次遍历」来定位每个hooks内容的。如果前后两次读到的链表在顺序上出现差异,那么渲染的结果自然是不可控的。

# 接着分析:

了解了Hooks的工作机制后,我们回看上文写的Demo ,从工作机制角度分析一波。 在Demo中我们定义了三个状态,分别是:

const [name, setName] = useState("难凉热血");
const [age] = useState(18);
const [career] = useState("前端开发");
1
2
3

在首次渲染中,构建得到的Hooks链表是这样的: react-hooks-demo 当点击「修改姓名」按钮进行更新渲染时,此时 name 和 age 的 useState 不会被调用,只会调用 career 的 useState 。此时对于 React 来说,我不会看你命名的变量名是 career 还是别的什么,我只认你这是第一次 useState 调用,于是就会认为:喔,原来你想要的是第一个位置的 hook 啊。
而在构建链表的时候,链表第一个位置是 name ,并且对应的值被改为了:热血难凉。
所以就出现了 Demo 中的那一幕:从开发者角度来看,我修改的是 name 状态,你却给我改的是 career 状态;而从 React 角度来看,你要的仅仅是第一个Hook而已。

# 总结:

函数组件中的 Hooks 是以单向链表形式相互串联的,在首次渲染时 mountState 构建链表并渲染,在更新渲染时 updateState 会依次遍历链表、读取数据并渲染。注意这个过程就像从数组中依次取值一样,是完全按照顺序(或者说索引)来的。
所以,在开发中,必须确保所有 Hook 在每次渲染中都按照同样的顺序被调用,保证链表的完整性!

最近更新时间: 8/28/2023, 10:24:36 AM
강남역 4번 출구
Plastic / Fallin` Dild