阅读 6224

烤透 React Hook

今日主菜

我们来研究一下最近天天都在使用 React Hook。说起 Hook,烧烤哥也用了好一阵子了,但是一直不知道 Hook 背后到底是怎么运作的,在出现 Bug 的时候只能靠一半猜一半试来解 Bug,这显然是不行的。所以,今天开始就让我们来把 React Hook 慢慢烤透,以便在使用 Hook 写出 Bug 的时候能“直击灵魂”,从原理和运行机制的角度去分析问题,从而快速解决问题(当然原理想通了,出 Bug 的可能也会相对减少嘛)。

文章篇幅过长,建议收藏后观看

调用 React Hook 到底时背后到底发生了什么?

找到 Hook 的源码

当我们在程序中调用 useXxx() 的时候,背后到底发生了什么一连串的事情呢?虽然有点不情愿,但是想要知道答案,那就必须得去看源码了。

首先第一件事情当然是下载源码了。让我们在命令行输入:

git clone https://github.com/facebook/react.git
复制代码

我们今天要看的源码全部都在在 /packages 这个目录下。

平常我们在项目中引入 Hook 的时候,一般都是这样写的:

import React, { useState } from 'react';
复制代码

后面的 from 'react' 提示了我们 useState 这个 Hook 的源码藏在 /react 这个目录下。顺藤摸瓜,很快便找到了导出 Hook 们的地方:/packages/react/src/React.js点这里看源码)。

// /packages/react/src/React.js
...
import {
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useDebugValue,
  useLayoutEffect,
  useMemo,
  useReducer,
  useRef,
  useState,
  useResponder,
  useTransition,
  useDeferredValue,
} from './ReactHooks';
...

export {
  Children,
  createRef,
  Component,
  PureComponent,
  ...
  useCallback,
  useContext,
  useEffect,
  useImperativeHandle,
  useDebugValue,
  useLayoutEffect,
  useMemo,
  useReducer,
  useRef,
  useState,
  ...
};
复制代码

从这个文件中,我们看到 Hook 们都来自 ./ReactHook.js,于是乎我们来到了 /packages/react/src/ReactHook.js点这里看源码)。在里面,我们开始慢慢看到了以 useXxx 命名的函数,但是这里还不是 Hook 们真正的源码(哪有这么短呢)。看到这个文件中经常出现的单词 dispatcher,我们大概可以知道这个文件主要是起到一个 调度 的作用,当用户调用 useXxx 时,会通过 resolveDispatcher() 这个方法生成一个调度器 dispatcher,然后通过调度器去调取用户想要的 hook,假如调度失败,那么就会在控制台打印错误信息:

当我们调用 resolveDispatcher() 获取调度器时,实际上是调用了 ./ReactCurrentDispatcher.js点这里源码 )的 dispacther.useState()

import type {Dispatcher} from 'react-reconciler/src/ReactFiberHooks';

/**
 * Keeps track of the current dispatcher.
 */
const ReactCurrentDispatcher = {
  /**
   * @internal
   * @type {ReactComponent}
   */
  current: (null: null | Dispatcher),
};

export default ReactCurrentDispatcher;

复制代码

在这个文件中,有一个比较奇怪的语法:import type,它其实是 Flow 的一个语法。Flow 是一个 JavaScript 的静态类型检查工具,是 Facebook 自家的开源项目,简单来说,它是 TypeScript 的一种替代方式。虽然说现在 TypeScript 目前很流行,但是要将整个 JavaScript 代码换成 TypeScript 是一个很大的工程(React 整个项目基本上都是 js 代码),所以 Flow 提供了一种新数据类型检查方式,它从头到尾只是一个类型检查工具,针对 .js 文件中的代码进行数据类型检查,它可以与各种现有的 JavaScript 代码兼容,如果你那天不想用了,就去掉标记即可。开头的 import type 的作用其实就是从另一个模块中导入数据类型,也就是导入 /packages/react-reconciler/src/ReactFiberHooks.js点这里看源码)的 Dispatcher 类型。

那就让我们继续前往 ReactFiberHooks.js 康康里面有啥。

// /packages/react-reconciler/src/ReactFiberHooks.js
export type Dispatcher = {|
  readContext<T>(
    context: ReactContext<T>,
    observedBits: void | number | boolean,
  ): T,
  useState<S>(initialState: (() => S) | S): [S, Dispatch<BasicStateAction<S>>],
  useReducer<S, I, A>(
    reducer: (S, A) => S,
    initialArg: I,
    init?: (I) => S,
  ): [S, Dispatch<A>],
  useContext<T>(
    context: ReactContext<T>,
    observedBits: void | number | boolean,
  ): T,
  useRef<T>(initialValue: T): {|current: T|},
  useEffect(
    create: () => (() => void) | void,
    deps: Array<mixed> | void | null,
  ): void,
  
  // 其他 Hook 的类型定义
  ...
};
复制代码

来到 ReactFiberHooks.js,我们发现这是一个包含了 2000 多行代码的”怪物“,所有官方提供的 Hook 的源码就放在这个文件里。文件开头的 type Dispatcher 其实是各个 Hook 的类型定义。接下来后面就是每个 Hook 具体实现的功能了。

Hook 的实现

首先大家应该会留意到,每一个 Hook 都有的两个相关函数:mountXxx()updateXxx(),它们分别是 Hook 在 Mount 阶段(即组件的挂载、或者说初始化阶段、又或者说是第一次执行 useXxx()的时候)和 Update阶段(即组件的更新、或者说组件重新渲染阶段)的逻辑。为了方便管理和调用,react 的工程师把 Hook 在 Mount 阶段的逻辑存到 (HooksDispatcherOnMount) 对象中,把 Update 阶段的逻辑存到 (HooksDispatcherOnUpdate) 对象中,并以 Hook 自己的名字(useXxx)作为键名:

const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useResponder: createDeprecatedResponderListener,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
};

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useResponder: createDeprecatedResponderListener,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
};
复制代码

那也就是说,Hook 们在组件的 Mount 阶段和 Update 阶段所做的事情是有点不一样的。

Hook 在 Mount 阶段干了啥?

每个 Hook 的工作方式都不太一样,由于篇幅有限,在这里我们用 useState 作为例子分析。useState 在 Mount 阶段的逻辑写在 mountState() 方法中:

// react-reconciler/src/ReactFiberHooks.js
function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {

  // 获取当前 Hook 节点,同时将当前 Hook 添加到 Hook 链表中
  const hook = mountWorkInProgressHook();
  
  // 初始化 Hook 的状态,即读取初始 state 值
  if (typeof initialState === 'function') {
    initialState = initialState();
  }
  hook.memoizedState = hook.baseState = initialState;
  
  // 创建一个新的链表作为更新队列,用来存放更新(setXxx())
  const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  });
  
  // 创建一个 dispatch 方法(即 useState 返回的数组的第二个参数:setXxx()),
  // 该方法的作用是用来修改 state,并将此更新添加到更新队列中,另外还会将改更新和当前正在渲染的 fiber 绑定起来
  const dispatch: Dispatch<
    BasicStateAction<S>,
  > = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any));
  
  // 返回当前 state 和 修改 state 的方法
  return [hook.memoizedState, dispatch];
}
复制代码

小结一下, useState 在 Mount (组件初始化)阶段所做的工作是:

  1. 获取当前 Hook 节点,同时将当前 Hook 添加到 Hook 链表中
  2. 初始化 Hook 的状态,即读取初始 state 值
  3. 创建一个新的链表作为更新队列,用来存放更新操作(setXxx())
  4. 创建一个 dispatch 方法(即 useState 返回的数组的第二个参数:setXxx()),该方法的用途是用来修改 state,并将此更新操作添加到更新队列中,另外还会将该更新和当前正在渲染的 fiber 绑定起来
  5. 返回当前 state 和 修改 state 的方法(dispatch)

存储 Hook 的数据结构——链表

上面 mountState() 的代码中出现了 const hook = mountWorkInProgressHook(); 这么一行代码,我们去看看其中的 mountWorkInProgressHook() 函数的内容,就可以知晓 Hook 的数据结构:

// react-reconciler/src/ReactFiberHooks.js
function mountWorkInProgressHook(): Hook {
  // 新建一个 Hook
  const hook: Hook = {
    memoizedState: null,

    baseState: null,
    baseQueue: null,
    queue: null,

    next: null,  // next 指向下一个 Hook
  };

  // workInProgressHook 指向当前组件 的 Hook 链表
  if (workInProgressHook === null) {
    // 如果当前组件的 Hook 链表为空,那么就将刚刚新建的 Hook 作为 Hook 链表的第一个节点(头结点) 
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 如果当前组件的 Hook 链表不为空,那么就将刚刚新建的 Hook 添加到 Hook 链表的末尾(作为尾结点)
    // Append to the end of the list
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}
复制代码

从上面的代码我们可以知道,一个函数组件中的所有 Hook 是以 链表 的形式存储的。链表中的每个节点就是一个 Hook,每个 Hook 节点的定义是这样的:

export type Hook = {
  memoizedState: any, // Hook 自身维护的状态
  ...
  queue: UpdateQueue<any, any> | null, // Hook 自身维护的更新队列
  next: Hook | null, // next 指向下一个 Hook
};
复制代码

在 Mount 阶段(组件初始化)的时候,调用 useState() , mountState() 就会调用 mountWorkInProgressHook() 方法来创建一个新的 Hook 节点,并把它添加到 Hook 链表上。我们来举个例子,假如现在有一个函数组件,并且第一次执行下面的代码:

const [firstName, setFirstName] = useState('尼古拉斯');
const [lastName, setLastName] = useState('赵四');
useEffect(() => {});
复制代码

由于是第一次执行,也就是在 Mount 阶段,会创建如图所示的一个 Hook 链表:

整个 Hook 链表保存在哪里

关于这个问题,在上面 mountWorkInProgressHook() 源码中其实已经有大致提示:

// react-reconciler/src/ReactFiberHooks.js
function mountWorkInProgressHook(): Hook {
  ...
  if (workInProgressHook === null) {
    // This is the first hook in the list
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    ...
  }
  ...
}
复制代码

假如原来还没有 Hook 链表,那么就会将新建的 Hook 节点作为 Hook 链表的头结点,然后把 Hook 链表的头结点保存在 currentlyRenderingFiber.memoizedState 中,也就是当前 FiberNode 的 memoizedState 属性(关于 FiberNode 的属性类型定义是写在 /react-reconciler/src/ReactFiber.js 中)。这样简单小小的一个赋值语句,就可以把当前组件和里面的各种 Hook 关联起来的。

useState 如何处理 state 更新

用过 useState 的老铁们都知道,useState 返回数组的第一个元素是当前 state 的值,而第二个元素是一个用来设置(更新)state 的函数(一般这个函数会命名为 setXxx)。

在上面我们看 mountState() 源码的时候,会新建一个更新队列链表 queue,这一个链表的作用是用来存放更新操作,链表中的每一个节点就是一次更新 state 的操作(就是调用了一次 setXxx()),以便后面 Update 阶段可以拿到最新的 state。我们再来看看这个链表的内容

const queue = (hook.queue = {
    pending: null,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
});
复制代码
  • pending:最近一个等待执行的更新
  • dispatch:更新 state 的方法(setXxx)
  • lastRenderedReducer: 组件最近一次渲染时用的 reducer (useState 实际上是一个简化版的 useReducer,之所以用户在使用 useState 时不需要传入 reducer,是因为 useState 默认使用 react 官方写好的 reducer:basicStateReducer
  • lastRenderedState:组件最近一次渲染的 state

创建完更新队列之后,接下来会创建一个 dispatch 方法(即:更新 state 的方法,setXxx),这个 dispatch 方法实际上就是 dispatchAction() 函数 ,通过 .bind 把当前 fiber、更新队列和这个 dispatch 方法关联起来:

const dispatch: Dispatch<A> = (queue.dispatch = (dispatchAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
): any));
复制代码

当我们调用跟新 state 的方法(setXxx)的时候,即时调用了 dispatchAction(),这个函数的工作是:新建一个新的 update 对象,添加到更新队列中(queue 链表)上,而且这实际是一个 循环链表。我们来看一下 dispatchAction() 的代码::

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  ...
  // 为当前更新操作新建 update 对象
  const update: Update<S, A> = {
    expirationTime,
    suspenseConfig,
    action,
    eagerReducer: null,
    eagerState: null,
    next: (null: any),
  };
  ...
  // pending 指向的是最新的 update 对象
  // Append the update to the end of the list.
  const pending = queue.pending;
  if (pending === null) {
    // 如果更新队列为空,那么将当前的更新作为更新队列的第一个节点,并且让它的 next 属性指向自身,以此来保持为循环链表
    // This is the first update. Create a circular list.
    update.next = update;
  } else {
    // 如果更新队列为非空,那么就将当前的更新对象插入到列表的头部
    update.next = pending.next;
    // 链表的尾结点指向最新的头节点,以保持为一个循环链表
    pending.next = update;
  }
  // 让 queue.pending 指向最新的头节点
  queue.pending = update;
  
  ...
}
复制代码

第一次看上面循环链表的代码的时候确实有点懵,但是画个图模拟一下逻辑就瞬间清楚了:

假如现在有连续 3个更新状态的操作:

setFirstName('Tom');
setFirstName('Allen');
setFirstName('Bill');
复制代码

所以执行完上面三个 setXxx 之后,我们整个 Hook 链表就变成了这样:

在 Hook 链表上的 useState Hook 节点,会有像上图那样,通过循环链表的形式存放着历史更新操作,通过上面的图示我们可以知道,queue.pending 一直都会指向最近一次的更新操作。

何时触发组件更新(重新渲染)使组件到达Update 阶段

在将 update 对象添加到 Hook 的更新队列链表后,dispatchAction() 还会去判断当前调用 setXxx(action) 传入的值(action)和上一次渲染的 state(此时正显示在屏幕上的 state)作对比,看看有没有变化,如果有变化,则调用 scheduleWork() 安排 fiberNode 的更新工作(组件重新渲染),如果没变化,则直接跳过,不安排更新(组件重新渲染):

function dispatchAction<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
) {
  ...
  // 生成 update 对象,并将 update 对象添加到更新队列链表中
  ...
  
  // 获取上一次渲染的 state (也就是此时正显示在屏幕上的 state)
  const currentState: S = (queue.lastRenderedState: any);
  
  // 获取当前最新计算出来的 state(这个 state 还没有渲染,只是“迫切想要”渲染)
  const eagerState = lastRenderedReducer(currentState, action); // 如果是 useState,这一句相当于是:const eagerState = action;
  update.eagerReducer = lastRenderedReducer;
  update.eagerState = eagerState;
  
  // 判断 eagerState(当前最新计算出来的 state)和 currentState (上一次渲染时 state) 的值是否相同,如果相同则直接跳过,不再安排 fiberNode 的更新工作(取消组件的重新渲染)
  if (is(eagerState, currentState)) {
    return;
  }
  
  ...
  
  // 触发 fiberNode 安排更新工作(组件重新渲染)
  scheduleWork(fiber, expirationTime);
}
复制代码

关于 scheduleWork() 是如果安排更新工作的问题,这里先不暂开讲了,因为涉及到另外更多的逻辑和机制,例如 fiber 的几个阶段的工作:begin 阶段、complete 阶段、commit 阶段、unwind 阶段......我们择日在研究。总之有一点就是,调用 scheduleWork不是 马上就更新 fiberNode 让组件重新渲染了,它其中还有各种更新优先级的判断处理还有更新的合并,例如这里的 useState,要等当前所有的 setXxx() 都逐一执行完了,假如其中有调用到 scheduleWork,最终会集中进行一次更新(组件的重新渲染)。

Hook 在 Update 阶段干了啥?

接下来,我们来看看 Update 阶段(组件重新渲染)时干了啥。还是以 useState 这个 Hook 来举例子。在 Update 阶段的时候,也就是组件重新渲染,即第二、第三、第N次执行 useState 的时候,这时就不是执行 mountState() 了,而是执行 updateState() 了。来看一波 updateState()源码

function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, (initialState: any));
}
复制代码

诶,大家可以发现,updateState() 里面实际上调用的是 updateReducer(),这也就再次实锤了 useState 实际上只是简化版的 useReducer 而已。因为我们调用 useState 的时候不会传入 reducer,所以这里会默认传一个 basicStateReducer 进去作为 reducer。

// 默认 reducer
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  // $FlowFixMe: Flow doesn't like mixed types
  return typeof action === 'function' ? action(state) : action;
}
复制代码

在使用 useState(action) 时,action 通常会是一个值,而不是一个函数。所以 basicStateReducer() 会直接返回 action。

接下来,我们来看看 updateReducer() 这个方法:

function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  // 获取正在执行的处于更新阶段 Hook 节点
  const hook = updateWorkInProgressHook();
  // 获取更新队列链表
  const queue = hook.queue;
  ... 
  // 获取更新队列最初的 update 对象节点
  let first = baseQueue.next;
  // 初始化 newState
  let newState = current.baseState;
  ...
  let update = first;
  ... 
  do {
    ...
    // 循环遍历更新队列链表
    // 从最早的那个 update 对象开始遍历,每次遍历执行一次更新,去更新状态
    const action = update.action;
    newState = reducer(newState, action);
    update = update.next;
  } while (update !== null && update !== first);
  ...
  // 返回最新的状态和修改状态的方法
  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}
复制代码

updateReducer() 会去遍历更新队列链表,执行每一个节点里面的更新操作,得到最新的状态并返回,以此来保证我们每次刷新组件都能拿到当前最新的状态。useState 的 reducer 是 baseStateReducer,因为传入的 update.action 是一个值,所以直接返回了 update.aciton 了,而 useReducer 的 reducer 是用户自定义的 reducer,所以会根据每次传入的 action 和每次循环得到的 newState 逐步计算出最新的状态。

小结一下,当 Update 阶段,即第二次、第三次...第N次执行 useState 时,所做的事情是:

  1. 获取正在执行的处于更新阶段 Hook 节点;
  2. 获取该 Hook 节点的更新队列链表;
  3. 从该更新队列的最早的 update 对象节点开始遍历,一直遍历到最近添加的(最新的)update 对象节点,遍历到每个节点的时候执行该节点的更新操作,将该次更新的 state 值存到 newState 中;
  4. 当遍历完最近的一个 update 对象节点后,此时 newState 里存放的就是最新的 state 值,最后返回 newState,于是用户就拿到了最新的 state;

useState 运行流程

上面介绍了 useState(useReducer)在 mount 阶段、 update 阶段分别做的事情以及组件何时触发组件更新,现在来总结一下 useState 整体的运行流程:

组件初次渲染(挂载)时

此时是第一次执行 useState,也就是 mount 阶段,所以执行的是 mountState。

  1. 在 Hook 链表上添加该 useState 的 Hook 节点
  2. 初始化 state 的值
  3. 返回此次渲染的 state 和 修改 state 的方法

当调用 setXxx/dispatchAction 时

  1. 创建 update 对象,并将 update 对象添加到该 Hook 节点的更新队列链表;
  2. 判断传入的值(action)和当前正在屏幕上渲染的 state 值是否相同,若相同则略过,若不相同,则调用 scheduleWork 安排组件的重新渲染;
  3. 当前所有 setXxx 都逐一执行完后,假如其中能满足(2)的条件,即有调用 scheduleWork 的话,则触发更新(组件重新渲染),进入 Update 阶段;

组件重新渲染(更新)时

组件重新渲染,进入 Update 阶段,即第 2 、第 3 、... n 次执行 useState:

  1. 获取该 useState Hook 的更新队列链表;
  2. 遍历这个更新队列链表,从最早的那一个 update 对象进行遍历,直至遍历到最近的添加那一个 update 对象,最后得到最新的 state 并返回,作为组件此次渲染的 state;
  3. 返回此次渲染的 state 和 修改 state 的方法

再康康 useEffect 的源码

经过了上面的分析,我们知道,一个 Hook 在组件的初次渲染(挂载)和重新渲染的时候所执行的操作是不一样的。在初次渲染的时候执行的是 mountXxx() 方法,而在重新渲染的时候,执行的是 updateXxx() 方法。

useEffect 这个 Hook 也同理,它对应着也有 mountEffectupdateEffect

mountEffect

function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  ...
  return mountEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,  // HookPassive 一个 hook effect tag,表示如果这个 effect 需要被执行,那么它将会在组件 UI 渲染完后执行
    create,
    deps,
  );
}
复制代码

mountEffect 只是作为一个入口,真正开始干活的是 mountEffectImpl

function mountEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  // 获取当前 Hook 节点,同时将当前 Hook 添加到 Hook 链表中
  const hook = mountWorkInProgressHook();
  
  // 获取依赖
  const nextDeps = deps === undefined ? null : deps;
  
  // pushEffect 的作用是将当前 effect 添加到 FiberNode 的 updateQueue 中,然后返回这个当前 effcet
  // 然后是把返回的当前 effect 保存到 Hook 节点的 memoizedState 属性中
  hook.memoizedState = pushEffect(
    HookHasEffect | hookEffectTag, // 这里用了位运算,HookHasEffect 也是一个 hook effect tag,表示这个 effect 需要执行。如果一个 effect 没有被打上 HookHasEffect 这个 tag,那么这个 effect 将会被跳过,不会执行
    create,
    undefined,
    nextDeps,
  );
}
复制代码

mountEffectImpl 中,会依次获取当前 Hook 节点以及 useEffect 的依赖,并调用 pushEffect 将当前 effect 添加到 FiberNode 的 updateQueue 队列中以及将当 effect 保存在当前 Hook 节点的 memoizedState 属性中。

function pushEffect(tag, create, destroy, deps) {
  const effect: Effect = {
    tag,
    create,
    destroy,
    deps,
    // Circular
    next: (null: any),
  };
  // 获取当前 FiberNode 的 updateQueue
  let componentUpdateQueue: null | FunctionComponentUpdateQueue = (currentlyRenderingFiber.updateQueue: any);
  
  if (componentUpdateQueue === null) {
    // 如果 updateQueue 为空,那就创建一个新的 updateQueue,其中 lastEffect 指向最新添加进来的 effect
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
    // 将当前 effect 添加到 updateQueue 中,并同样保持循环链表的结构
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      // 假如 lastEffect 指向 null,说明此时链表还不是循环链表的结构,那么就要控制最新的 effect 的 next 的指向,使其变为循环链表的结构 
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      // 将当前 effect 添加到 updateQueue 中
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;
      effect.next = firstEffect;
      // 令 lastEffect 始终指向最新添加进来的 effect
      componentUpdateQueue.lastEffect = effect;
    }
  }
  // 返回当前 effect
  return effect;
}
复制代码

小结一下,useEffect 在 mount 阶段主要做的事情是:

  1. 获取当前 Hook 节点,并把它添加到 Hook 链表中;
  2. 获取本次 effect 的 deps 依赖;
  3. 将 effect 添加到 fiberNode 的 updateQueue 中。updateQueue 的 lastEffect 属性指向的始终是最新添加进队列的 effect,lastEffect 的 next 始终指向最早添加进来的 effect,以次来又形成一次 循环链表 的结构。

updateEffect

看完 mountEffect,再来看看 updateEffect

function updateEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  ...
  return updateEffectImpl(
    UpdateEffect | PassiveEffect,
    HookPassive,
    create,
    deps,
  );
}
复制代码

updateEffect 的调用流程和 mountEffect 相似,updateEffect 只是一个入口,真正干活的是 updateEffectImpl

function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  // 获取当前 Hook 节点,并把它添加到 Hook 链表中
  const hook = updateWorkInProgressHook();
  
  // 获取依赖
  const nextDeps = deps === undefined ? null : deps;
  
  // 初始化清除 effect 函数
  let destroy = undefined;

  if (currentHook !== null) {
    // 获取上一次渲染的 Hook 节点的 effect
    const prevEffect = currentHook.memoizedState;
    
    // 获取上一次渲染的 Hook 节点的 effect 的清除函数
    destroy = prevEffect.destroy;
    if (nextDeps !== null) {
      // 获取上一次渲染的 Hook 节点的 effect 的依赖
      const prevDeps = prevEffect.deps;
      
      // 对比前后依赖的值是否相同
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 如果依赖的值相同,即依赖没有变化,那么只会给这个 effect 打上一个 HookPassive 一个 tag,然后在组件渲染完以后会跳过这个 effect 的执行
        pushEffect(hookEffectTag, create, destroy, nextDeps);
        return;
      }
    }
  }

  currentlyRenderingFiber.effectTag |= fiberEffectTag;

  // pushEffect 的作用是将当前 effect 添加到 FiberNode 的 updateQueue 中,然后返回这个当前 effcet
  // 然后是把返回的当前 effect 保存到 Hook 节点的 memoizedState 属性中
  hook.memoizedState = pushEffect(
    HookHasEffect | hookEffectTag, // 给 effect 打上 HookHasEffect 和 HookPassive 两个 tag,表示在组件 UI 渲染完后需要执行这个 effect
    create,
    destroy,
    nextDeps,
  );
}
复制代码

update 阶段的 useEffect 和 mount 阶段所做的事情基本相似,唯独不一样就是 update 阶段会考虑 effect 的依赖是否有变化,如果没有变化,那么就只会给这次 effect 打上 HookPassive tag,在最后 commit 阶段(组件视图渲染完成后)会跳过 effect 的执行;如果有依赖有变化,那么就会给这次 effect 打上 HookPassive 和 HookHasEffect 两个 tag,表示这个 effect 将会在组件视图渲染完成后执行。

小结一下,update 阶段的 useEffect 做了一下事情:

  1. 获取当前 Hook 节点,并把它添加到 Hook 链表中;
  2. 获取本次 effect 的 deps 依赖;
  3. 拿本次 effect 的 依赖和上一次渲染时的 effect 的依赖做对比:
    • 假如没有依赖没有发生改变,那么就只给这次 effect 打上 HookPassive 的 tag,在 commit 阶段(组件视图渲染完成后),跳过这一次 effect 的执行;
    • 假如依赖有发生改变,那么就会给这次 effect 打上 HookPassive 和 HookHasEffect 两个 tag,在 commit 阶段(组件视图渲染完成后),执行这一次 effect;
  4. 将本次 effect 添加到 fiberNode 的 updateQueue 中,并将本次 effect 保存在当前 Hook 节点的 memoizedState 属性中

useEffect 运行流程

一个使用 useEffect Hook 的函数组件,在运行的时候的运行流程如下:

组件初次渲染(挂载):

  1. 执行 useEffect 时,将 useEffect Hook 添加到 Hook 链表中,然后创建 fiberNode 的 updateQueue,并把本次 effect 添加到 updateQueue 中;
  2. 渲染组件的 UI;
  3. 完成 UI 渲染后,执行本次 effect;

组件重新渲染(更新):

  1. 执行 useEffect 时,将 useEffect Hook 添加到 Hook 链表中,判断依赖:
    • 假如没有传入依赖(useEffect 没有传入第二个参数),那么直接给这个 effect 打上 “需要执行” 的 tag(HookHasEffect);
    • 假如有传入依赖 deps 并且当前依赖和上次渲染时的依赖对比有发生改变,那么就给这个 effect 打上 “需要执行” 的 tag(HookHasEffect);
    • 假如有传入依赖 deps,但是依赖没有发生改变,则 不会 给这个 effect “需要执行” 的 tag;
    • 假如有传入依赖 deps,但是传入的是一个空数组 [],那么也 不会 给这个 effect “需要执行” 的 tag;
  2. 渲染组件的 UI;
  3. 假如有清除函数(effect 中的 return 内容),则执行上一次渲染的清除函数;如果依赖是 [],则先不用执行清除函数,而是等到组件销毁时才执行;
  4. 判断本次 effect 是否有“需要执行” 的 tag(HookHasEffect),如果有,就执行本次 effect;如果没有,就直接跳过,不执行 本次 effect;

组件销毁时:

  1. 在组件销毁之前,先执行完组件上次渲染时的清除函数

补充一下给 effect 打 tag 的逻辑

首先我们先来看看 Hook 的 effect 有哪一些 tag。

Hook 的 effect tag 被定义在 /packages/react-reconciler/src/ReactHookEffectTags.js 中:

export type HookEffectTag = number;

export const NoEffect = /*  */ 0b000;

// Represents whether effect should fire.
export const HasEffect = /* */ 0b001;

// Represents the phase in which the effect (not the clean-up) fires.
export const Layout = /*    */ 0b010;
export const Passive = /*   */ 0b100;
复制代码

各个 tag 的意义如下:

  • NoEffect:它的作用是作为判断条件,实际上打 tag 的时候不会用它来打
  • HasEffect:如果 effect 被打上这个 tag ,则表示该 effect 需要执行
  • Passive:如果 effect 被打上这个 tag,则表示:假如该 effect 需要执行,那么它将会在 组件 UI 渲染完成后执行
  • Layout:如果这个 effect 被打上这个 tag,则表示:假如该 effect 需要执行,那么它将会在 组件 UI 渲染之前执行

概括的来说,NoEffect 的作用是判断条件,用来判断有没有打上哪一个 tag;HasEffect 的作用是决定 effect 会不会执行;而 PassiveLayout 这两个 tag 的作用是决定 effect 在哪个阶段执行。

从源码我们可以看到,这些 tag 实际上是一个 二进制常量(js 中 0b 开头的表示二进制),因为,关于这个 effect tag 的相关逻辑是通过 位运算 这个骚操作来进行。下面我们来看看打 tag 的过程。给 effect 打 tag 这个操作,实际上是通过在调用 pushEffect() 时,传入第一个参数:

function updateEffectImpl(fiberEffectTag, hookEffectTag, create, deps): void {
  ...
  // hookEffectTag = HookPassive
  hook.memoizedState = pushEffect(
    HookHasEffect | hookEffectTag,
    create,
    destroy,
    nextDeps,
  );
}
复制代码

HookHasEffect | hookEffectTag 这里用了位运算的按位或(|),执行完的结果时 0b101,表示这个 effect 将会在组件 UI 渲染完成后执行。

打完 tag 后,在 commit 阶段:

function schedulePassiveEffects(finishedWork: Fiber) {
  ...
      do {
        const {next, tag} = effect;
        // 判断 effect tag 有没有同时有 HookPassive 和 HookHasEffect 两个 tag,假如都有,那么这个 effect 将会在组件 UI 渲染完成后执行
        if (
          (tag & HookPassive) !== NoHookEffect &&
          (tag & HookHasEffect) !== NoHookEffect
        ) {
          enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
          enqueuePendingPassiveHookEffectMount(finishedWork, effect);
        }
        effect = next;
      } while (effect !== firstEffect);
    }
  }
}
复制代码

又例如:

// hookEffectTag = HookPassive
if (areHookInputsEqual(nextDeps, prevDeps)) {
  pushEffect(hookEffectTag, create, destroy, nextDeps);
  return;
}
复制代码

上面的例子只传入了 HookPassive,那么这个 effect 的 tag 只有 HookPassive。表示这个 effect 如果需要执行,它将会在组件 UI 渲染完成后执行,但是,由于这个 effect 没有打上 HookHasEffect,所以这个 effect 实际上 不会执行

PS

  1. 关于 fiberNode的 commit 阶段到底是在什么时候,这里暂时先理解为 组件视图渲染完成后,具体到底是什么时候、还干了什么操作?由于篇幅有限这里暂时不做展开讨论,有兴趣可以看源码中的 react-reconciler/src/ReactFiberCommitWork.js 这个文件。另外还有几个阶段:Begin 阶段Complete 阶段Unwind 阶段

  2. 我们看源码的时候会看到 currentHookworkInProgressHook 两个全局变量,他们其实相当于两个指针,指向同一个 Hook 单链表,只不过指向的节点不同而已。他们的区别是:currentHook 指向的节点代表的是当前正被用于显示在屏幕上的 Hook,这个 Hook 的一些 state 正显示在屏幕上;而 workInProgressHook 指向当前正在异步处理中的 Hook,这个 Hook 的 state 还在背后计算处理中,还不是最终显示到屏幕上的 state。关于他们之间具体的描述,请看源码中 react-reconciler/src/ReactUpdateQueue.js 文件头部的注释,作者会在注释中详细介绍两者的概念、作用和区别。

尾声

总算写完了(长吁一口气),文中可能还存在一些还没有说清楚或者有误的地方,非常欢迎和希望老铁们吃完烧烤后在评论区中指出,大家一起交流探讨,期待通过和老铁们的交流来加深对前端知识的理解。

参考文献