react hook 权威指南

4 阅读49分钟

hook API 设计的初衷

Sebastian Markbåge 最早提出了 Hooks 的基本想法。Andrew Clark 和 Sebastian 一起开发了最初的原型实现。

在 2018-10-25,时任 react core team leader 的 sophiebits 首次提交了 React hook 的 RFC。在这份 RFC 中,她明确指出,引入 React hook 是为了实现以下的三个目标:

  • Hooks let you reuse logic between components without changing your component hierarchy.
  • Hooks let you split one component into smaller functions based on what pieces are related.
  • Hooks let you use React without classes.

其实,压缩一下,用中文表达就两个目标:

  • 用有状态的函数组件代替 class 组件
  • 实现逻辑代码上的「高内聚,低耦合」

在推出 hook 之前,只能是 class 组件具有 state。而基于 class 组件的 HOC 和 render props 逻辑复用范式最大的弊端就是:

  • 在逻辑上强关联的代码块不能紧密地内聚到一块;
  • 为了实现逻辑复用,需要在组件层级上进行嵌套,这样就会带来性能上的损耗,也增加了代码维护的成本。

而 hook 的引入,react 很好地解决这些问题。hook 可以完全脱离 UI 而存在,且高度可组合。这两个特性使得它可以很好地实现了逻辑代码的「高内聚,低耦合」和跨平台的高可复用性。

hook 的诞生的背后

函数式编程

React 团队并没有明确表示过对函数式编程(Functional Programming, FP)的“偏好”,但 React 的设计理念和演进方向确实与函数式编程的许多核心理念高度契合。这属于典型的用脚投票。

fiber 架构

react 团队在 react 16 中将之前的 stack reconciler 架构重构为 fiber reconciler 架构。一般情况来说, 一个组件(包括了 host component 等所有类型的组件)(在同一颗 fiber 树上)会对应着一个 fiber 节点。一个 fiber 就是一个工作单元(work unit)。换另外一种通俗易通的话来说,你写所写的某个组件会产生很多大大小小的 work,所有这些 work 汇集到一块就是一个工作单元。react 作为代理人,它的任务就是负责处理 fiber 树上的这些工作单元。举个简单的例子:

function App() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log("count changed:", count);
  }, [count]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

当 count 的值发生变化时,APP 组件就会产生了一个副作用 - 「执行 console.log("count changed:", count)」。把这个副作用 commit 掉,对于 fiber 来说,就是一个小小的 work。围绕着 「work」这个元术语,react 内部组合出了很多其他重要的术语。用一张图来表述就是:

work-terms转存失败,建议直接上传图片文件

Fiber 架构给 react 带来的以下的关键能力:

  • 可中断的更新:为异步/并发渲染提供实现基础
  • 优先级调度:能够处理不同优先级的更新
  • 状态保存:为函数组件保存状态提供了机制

而本文中的主角 - hook 并不是空中楼阁的,它所依附的正是这个 fiber 架构上基础单元 - fiber 节点。简单来说,hook 存储在 fiber 节点上。具体来说,每个 fiber 节点都有一个 memoizedState 属性,它指向一个链表的头节点。这个链表就是 hook 链表。每个 hook 节点都有一个 next 属性,指向下一个 hook 节点。这样,就可以通过遍历这个链表,来访问到所有的 hook。

hook 的概念

眼尖的读者可能会注意到,我上面提到了「hook」,那到底什么是 hook 呢?

其实,单单是「hook」这个词,这是一个很宽泛的概念。为了更准确的阐述,我们有必要厘清和限定本文中有关于「hook」的概念和术语,以便于消除不必要的歧义和争论。后面的章节中,我不会再说「hook」 这个词,而是用「hook xxx」来代替。 在 react 中,跟 hook 相关的概念和术语主要有:

  • hook 函数
  • hook 对象
  • hook 链表
  • hook flag

hook 函数

hook 函数是 react 内部向外提供的一些列 hook API。比如 useStateuseEffect 等。每个 hook 函数都有一个特定的作用,用于「钩进」react 界面更新流程的特定的卡点上,以便实现不同的功能。

hook 对象

hook 对象是指 react 内部用来存储 hook 函数状态和行为的一个数据结构。它是一个源码内部的概念。说白一点,它就是 js 字面量对象:

// packages/react-reconciler/src/ReactFiberHooks.js#195-201
type Hook = {
  memoizedState: any, // Current state value or effect list
  baseState: any, // State before pending updates
  baseQueue: Update | null, // Updates skipped by priority
  queue: any, // UpdateQueue for useState/useReducer
  next: Hook | null, // Next hook in list
};

hook 函数跟 hook 对象是一一对应的关系。每个 hook 函数在被调用的时候,都会创建或者通过浅复制来得到一个 hook 对象。这个 hook 对象会按照当前 hook 函数在众多的 hook 函数的书写顺序,被插入到 hook 链表中。

hook 链表

hook 链表是一个单向链表,用来存储函数组件中所有 hook 函数所对应的 hook 对象。每个 hook 对象都有一个 next 属性,指向下一个 hook 对象。最后一个 hook 对象的 next 属性为 null

至于 hook 链表中 hook 对象的顺序,是根据 hook 函数在函数组件中的调用顺序来确定的。

hook flag

hook flag 是一个二进制掩码,用来表示当前的 hook 对象所装载的副作用在 commit 阶段的哪个子阶段去执行。

源码中的 「hookFlags」命名中,「hook」应该要理解为动词,意在表达它应该「钩进」commit 的哪个子阶段。比如,HookPassive 就表示副作用在 commit 阶段的 passive 子阶段执行。

副作用的执行有以下几个子阶段:

  • Insertion
  • Layout
  • Passive

源码 packages/react-reconciler/src/ReactHookEffectTags.js 中定义了这些 flag:

// packages/react-reconciler/src/ReactHookEffectTags.js#15-23
......

// Represents the phase in which the effect (not the clean-up) fires.
export const Insertion = /* */ 0b0010;
export const Layout = /*    */ 0b0100;
export const Passive = /*   */ 0b1000;

小结

假设我们有这样的一个简单 react 应用代码:

function App() {
  const [count, setCount] = useState(0);

  useLayoutEffect(() => {
    console.log("count changed:", count);
  }, [count]);

  useEffect(() => {
    console.log("count changed:", count);
  }, [count]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </div>
  );
}

那么,根据上面的代码,我们可以得到这样的一个 hook 对象和与它相关的其他数据结构可视化图:

hook-single-linked-list转存失败,建议直接上传图片文件

Dispatcher Pattern

react 是一个跨平台的渲染框架,这就意味着它的代码执行的场景或者上下文是多样化和复杂的。但是,react 为了贯彻它的设计原则 - 「make it simple」,它始终致力于保证对外公开 API 的简单性和一致性。那在 react 内部,它是如何实现在不同的场景或者上下文中去执行不同的代码版本呢?答案是: “Dispatcher Pattern”。

其实,一个最简答和粗糙的实现方案无疑在用 if...else 条件分支语句去在不同的场景去执行不同的代码逻辑。这么做,不但不优雅,而且还会因为没有保持清晰的关注点分离而导致了代码维护成本陡增。react 显然既要优雅,也要代码可维护性。所以,它选择了 Dispatcher Pattern 来实现。

设计目标

  • 在不同的上下文之间“一键切换”一套差异化的 hook 实现;
  • 保证 Hooks 只在正确的上下文中执行,违反规则时会抛出错误。

这里的「上下文」主要是指 react 组件的不同生命周期阶段 - mount 阶段和 update 阶段。

主要的 dispatcher

聚焦到客户端的 react 上,主要的 dispatcher 有以下几个:

  • HooksDispatcherOnMount:在组件首次挂载时候的 dispatcher
  • HooksDispatcherOnUpdate:在组件随后更新时的 dispatcher
  • HooksDispatcherOnRerender:在组件函数体内调用 setState 触发更新的时的 dispatcher
  • ContextOnlyDispatcher:在非函数组件顶层作用域内调用时的 dispatcher

dispatcher resolve 过程

本小节是讲在什么时机设置什么样的 dispatcher

react package 的入口文件是packages/react/src/index.js,在这里我们可以看到 react core 导出的所有 API, 包括所有的 hook 函数。如果继续按图索骥,我们会发现,所有的 hook 函数的实现 wrapper 都是在 packages/react/src/ReactHooks.js 中定义的。比如:

export function useState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

export function useReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useReducer(reducer, initialArg, init);
}

export function useRef<T>(initialValue: T): {current: T} {
  const dispatcher = resolveDispatcher();
  return dispatcher.useRef(initialValue);
}

.....

稍微细看这里的 hook wrapper 函数,我们就能看到要一个固定范式:

  1. 第一步,先 resolve 出要一个 dispatcher;
  2. 第二步,调用 dispatcher 上的对应的同名 hook 函数。

而在 packages/react/src/ReactHooks.js 中,resolveDispatcher 函数的实现如下:

function resolveDispatcher() {
  const dispatcher = ReactSharedInternals.H;
  if (__DEV__) {
    if (dispatcher === null) {
      console.error(
        "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for" +
          " one of the following reasons:\n" +
          "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" +
          "2. You might be breaking the Rules of Hooks\n" +
          "3. You might have more than one copy of React in the same app\n" +
          "See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem."
      );
    }
  }
  // Will result in a null access error if accessed outside render phase. We
  // intentionally don't throw our own error because this is in a hot path.
  // Also helps ensure this is inlined.
  return ((dispatcher: any): Dispatcher);
}

简单来说,resolveDispatcher 函数的作用就是从一个叫做 ReactSharedInternals.H 的全局变量中取出当前的 dispatcher。在什么情况下,会将 ReactSharedInternals.H 设置为什么样的 dispatcher 就是本节要讲述的内容。

在 react 的仓库代码中,简单全局搜索一下“ReactSharedInternals.H =”,你就会发现多达 62 个地方:

hook-single-linked-list转存失败,建议直接上传图片文件

因为本文将会聚焦在 react 客户端的实现上,所以这里只关注客户端的 dispatcher。

初始值

ReactSharedInternals.H 的真正导入来源是 react 通过使用一种叫做 module forking 的技术来在打包构建的时候,根据当前所在的包来动态决定的。具体来说,在 packages/react/src/ReactHooks.js的导入语句 import ReactSharedInternals from 'shared/ReactSharedInternals';会被替换为 import ReactSharedInternals from './ReactSharedInternalsClient.js';

packages/react/src/ReactSharedInternalsClient.js 中,ReactSharedInternals.H 的初始值设置为 null

const ReactSharedInternals: SharedStateClient = ({
  H: null,
  A: null,
  T: null,
  S: null,
}: any);

在渲染阶段开始前的值

react 引入 fiber 架构后,react 的渲染模式分为两种:「同步渲染」和「并发渲染」。同步渲染的入口函数是 renderRootSync,并发渲染的入口函数是 renderRootConcurrent (两者都在文件 packages/react-reconciler/src/ReactFiberWorkLoop.js 中定义)。

而这两个函数实现都采用了「栈」这种算法来设置当前的 hook dispatcher:

function renderRootSync(
  root: FiberRoot,
  lanes: Lanes,
  shouldYieldForPrerendering: boolean
): RootExitStatus {
  // ......
  const prevDispatcher = pushDispatcher(root.containerInfo);

  // do the work loop
  popDispatcher(prevDispatcher);

  // ......
}

function renderRootConcurrent(root: FiberRoot, lanes: Lanes): RootExitStatus {
  // ......
  const prevDispatcher = pushDispatcher(root.containerInfo);

  // do the work loop

  popDispatcher(prevDispatcher);
  // ......
}

function pushDispatcher(container: any) {
  const prevDispatcher = ReactSharedInternals.H;
  ReactSharedInternals.H = ContextOnlyDispatcher;
  if (prevDispatcher === null) {
    // The React isomorphic package does not include a default dispatcher.
    // Instead the first renderer will lazily attach one, in order to give
    // nicer error messages.
    return ContextOnlyDispatcher;
  } else {
    return prevDispatcher;
  }
}

function popDispatcher(prevDispatcher: any) {
  ReactSharedInternals.H = prevDispatcher;
}

从上面的源码中,我们看到一个固定范式:

  1. 第一步,先把 ReactSharedInternals.H 当前的值作为 prevDispatcher,然后无条件地将 ReactSharedInternals.H 设置为 ContextOnlyDispatcher,;
  2. 第二步, work loop 结束后,将 ReactSharedInternals.H 恢复为之前留档下来的 prevDispatcher。

因为,一个 react 渲染阶段起始点不是 renderRootSync 就是 renderRootConcurrent,而两者都在开始前将 ReactSharedInternals.H 设置为 ContextOnlyDispatcher。所以,这里我们就可以得到结论是:在渲染开始前的 dispatcher 是 ContextOnlyDispatcher

ContextOnlyDispatcher 长什么样呢?:

// packages/react-reconciler/src/ReactFiberHooks
export const ContextOnlyDispatcher: Dispatcher = {
  readContext,
  use,
  useCallback: throwInvalidHookError,
  useContext: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  useImperativeHandle: throwInvalidHookError,
  useLayoutEffect: throwInvalidHookError,
  useInsertionEffect: throwInvalidHookError,
  useMemo: throwInvalidHookError,
  useReducer: throwInvalidHookError,
  useRef: throwInvalidHookError,
  useState: throwInvalidHookError,
  useDebugValue: throwInvalidHookError,
  useDeferredValue: throwInvalidHookError,
  useTransition: throwInvalidHookError,
  useSyncExternalStore: throwInvalidHookError,
  useId: throwInvalidHookError,
  useHostTransitionStatus: throwInvalidHookError,
  useFormState: throwInvalidHookError,
  useActionState: throwInvalidHookError,
  useOptimistic: throwInvalidHookError,
  useMemoCache: throwInvalidHookError,
  useCacheRefresh: throwInvalidHookError,
};

function throwInvalidHookError() {
  throw new Error(
    "Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for" +
      " one of the following reasons:\n" +
      "1. You might have mismatching versions of React and the renderer (such as React DOM)\n" +
      "2. You might be breaking the Rules of Hooks\n" +
      "3. You might have more than one copy of React in the same app\n" +
      "See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem."
  );
}

可以看到,直接调用 ContextOnlyDispatcher 身上的的 hook 函数(readContextuse ),都是直接报错的。这样做保证了 react hook 函数只能在函数组件的 body 中调用。这就是著名的 react hook 规则之一。

在函数组件渲染前的值

在上述的准备工作完毕后,react 会进入著名的 work loop。在 work loop 中,react 会用「深入优先遍历算法」遍历 fiber tree,对每个 fiber 节点一次进行 beginWork 和 completeWork。而在对 fiber 节点进行 beginWork 时,react 会调用著名的 renderWithHooks 函数。而我们的组件函数就是在这里被调用。请看代码:

// packages/react-reconciler/src/ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes
): any {
  // .......
  if (__DEV__) {
    if (current !== null && current.memoizedState !== null) {
      ReactSharedInternals.H = HooksDispatcherOnUpdateInDEV;
    } else if (hookTypesDev !== null) {
      // This dispatcher handles an edge case where a component is updating,
      // but no stateful hooks have been used.
      // We want to match the production code behavior (which will use HooksDispatcherOnMount),
      // but with the extra DEV validation to ensure hooks ordering hasn't changed.
      // This dispatcher does that.
      ReactSharedInternals.H = HooksDispatcherOnMountWithHookTypesInDEV;
    } else {
      ReactSharedInternals.H = HooksDispatcherOnMountInDEV;
    }
  } else {
    ReactSharedInternals.H =
      current === null || current.memoizedState === null
        ? HooksDispatcherOnMount
        : HooksDispatcherOnUpdate;
  }

  // .......
  let children = __DEV__
    ? callComponentInDEV(Component, props, secondArg)
    : Component(props, secondArg);
  shouldDoubleInvokeUserFnsInHooksDEV = false;

  // ......

  finishRenderingHooks(current, workInProgress, Component);

  return children;
}

上述源码中的 Component 就是开发者编写的函数组件。如果我们只关注生产环境分支的的代码,对上述的代码再次进行删减,则这里的逻辑更加清晰了:

// packages/react-reconciler/src/ReactFiberHooks.js
export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes
): any {
  // .......
  ReactSharedInternals.H =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;

  // .......
  let children = Component(props, secondArg);

  // ......
  finishRenderingHooks(current, workInProgress, Component);

  return children;
}

到这里,我们就可以清晰地看到,在调用我们的函数组件之前,react 做了一个设置 ReactSharedInternals.H 的操作 - 根据条件(current === null || current.memoizedState === null)来决定是否设置为HooksDispatcherOnMount还是 HooksDispatcherOnUpdate

那上面的判断条件是什么意思呢?current === null 比较好理解,就是意味着组件首次挂载的时候。因为此时只有 workInProgress fiber 节点, 没有 current fiber。

current.memoizedState === null 怎么理解呢? current.memoizedState 指向的是 hook 链表。hook 链表为 null,则意味着当前的函数组件没有使用任何的 「有状态的」hook 函数。因为只有有状态的 hook 函数才会产生值并存放在 hook 链表中fiber.memoizedState 中。

是的,hook 又可以分为「有状态的」hook 函数和「无状态的」hook 函数。像 useStateuseReducer 和 effect 类 hook 函数等都是「有状态的」hook 函数,而像 useContext、则是「无状态的」hook 函数。

简而言之,react 在调用我们的 hook 函数之前会根据当前组件是否是初次挂载去设置一个 dispatcher - 如果是组件的初始挂载阶段,这个 dispatcher 就是 HooksDispatcherOnMount,否则就是 HooksDispatcherOnUpdate

这两个 dispatcher 长什么样呢?

// packages/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  useCallback: mountCallback,
  useContext: readContext,
  useEffect: mountEffect,
  useImperativeHandle: mountImperativeHandle,
  useLayoutEffect: mountLayoutEffect,
  useInsertionEffect: mountInsertionEffect,
  useMemo: mountMemo,
  useReducer: mountReducer,
  useRef: mountRef,
  useState: mountState,
  useDebugValue: mountDebugValue,
  useDeferredValue: mountDeferredValue,
  useTransition: mountTransition,
  useSyncExternalStore: mountSyncExternalStore,
  useId: mountId,
  useHostTransitionStatus: useHostTransitionStatus,
  useFormState: mountActionState,
  useActionState: mountActionState,
  useOptimistic: mountOptimistic,
  useMemoCache,
  useCacheRefresh: mountRefresh,
};
if (enableUseEffectEventHook) {
  (HooksDispatcherOnMount: Dispatcher).useEffectEvent = mountEvent;
}

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  use,
  useCallback: updateCallback,
  useContext: readContext,
  useEffect: updateEffect,
  useImperativeHandle: updateImperativeHandle,
  useInsertionEffect: updateInsertionEffect,
  useLayoutEffect: updateLayoutEffect,
  useMemo: updateMemo,
  useReducer: updateReducer,
  useRef: updateRef,
  useState: updateState,
  useDebugValue: updateDebugValue,
  useDeferredValue: updateDeferredValue,
  useTransition: updateTransition,
  useSyncExternalStore: updateSyncExternalStore,
  useId: updateId,
  useHostTransitionStatus: useHostTransitionStatus,
  useFormState: updateActionState,
  useActionState: updateActionState,
  useOptimistic: updateOptimistic,
  useMemoCache,
  useCacheRefresh: updateRefresh,
};
if (enableUseEffectEventHook) {
  (HooksDispatcherOnUpdate: Dispatcher).useEffectEvent = updateEvent;
}

到这里,我们就发现了二个有趣的事实:

  • 在组件初始挂载和随后的更新阶段,同一个 hook 函数其实是对应不同的源码实现的;
  • 初始挂载阶段的 hook 实现函数的命名风格为:mountXXX;随后的更新阶段的 hook 实现函数的命名风格为:updateXXX。(这里的 “XXX” 就是把我们日常使用的 hook 函数名中的 "use" 单词去掉后剩余的字符串 )

组件渲染结束后

在组件渲染结束后,react 会调用 finishRenderingHooks 函数来清理一些资源。在这个函数中,react 会将 ReactSharedInternals.H 重置为 ContextOnlyDispatcher

function finishRenderingHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any
): void {
  // ......

  // We can assume the previous dispatcher is always this one, since we set it
  // at the beginning of the render phase and there's no re-entrance.
  ReactSharedInternals.H = ContextOnlyDispatcher;

  // ........
}

小结

通过研究 react 在 client 侧的 dispatcher 的 resolve 过程,我们得到了来一个认知:我们平常所用的 hook 函数,在组件初始挂载和随后的更新阶段,其实是各自对应一套不同的源码实现的(但是都是放在了 packages/react-reconciler/src/ReactFiberHooks.js 文件中):

  • 组件初始挂载阶段,使用的是 HooksDispatcherOnMount 中的实现函数;
  • 组件后续的更新阶段,使用的是 HooksDispatcherOnUpdate 中的实现函数。

这个认知很重要,因为它为后面的源码研究奠定了坚实的基础。

hook 系统中的架构

hook 对象架构

我们在上面概念章节中,已经给出了 hook 对象的数据结构:

// packages/react-reconciler/src/ReactFiberHooks.js#195-201
type Hook = {
  memoizedState: any, // Current state value or effect list
  baseState: any, // State before pending updates
  baseQueue: Update | null, // Updates skipped by priority
  queue: any, // UpdateQueue for useState/useReducer
  next: Hook | null, // Next hook in list
};

而这里所有的「hook 对象中的架构」就是指,在 hook 对象这个五个属性中,其实是存在分层的概念的。

通用的,处于底层的字段有两个:

  • memoizedState:代表着 hook 对象的当前值,这个值可以是 effect 链表或者是非 effect 链表的其他类型的值;
  • next:指向下一个 hook 对象的指针。hook 对象就是依靠它来连接成一条单向链表。

所有类型的 hook 对象都需要用到上面这两个字段。剩下的字段主要是 useReducer/useState (比如说, useSyncExternalStore 这个 hook 函数也用到了 queue 字段)这两个 hook 函数专用的字段,处于更上层:

  • baseState:本次渲染周期中「值的计算过程」的 base 值;
  • baseQueue: 上一个渲染周期中因为优先级不够而被跳过的更新队列;
  • queue:本轮渲染周期等待处理的更新队列。

hook 函数实现中的架构

纵观 hook 函数的实现,我们可以发现,大部分 hook 函数的实现,都是遵循这样的一个架构:

  • mount 阶段:都会利用 mountWorkInProgressHook 函数来创建一个全新的 hook 对象;
  • update 阶段:都会利用 updateWorkInProgressHook 函数从上一个渲染周期的 hook 链表中相同位置的 hook 对象,浅复制出一个新的 hook 对象。

接下来的工作,视每个 hook 函数所承担的职责的不同,它们都会把自己的业务领域所需要的数据存储在 hook 对象上,以便于在下一个渲染周期去使用(基于它去计算或者复用等)。

hook 是分类的

无论是从 hook 函数的实现源码来看,还是 hook 函数的用途来看,hook 函数其实都是被分类的。我认为 hook 函数可以分为下面这五大类:

  • 无状态类
  • 有状态类
  • 副作用处理类
  • 并发更新类
  • 原生增强类

所谓的「状态」,原始语义就是「随着时间的推移而变化」的值。而限定在 react hook 系统这个上下文里,「有状态 hook」的定义则是:「 该 hook 函数的主体实现逻辑是利用 hook 对象的 memoizedState 字段来存储和在不同的渲染周期之间更新状态值」。这就是区分「有状态 hook」和「无状态 hook」的关键。

而另外一个分类是「副作用处理类」。这种类型的 hook 函数的主体实现逻辑,是创建,注册,执行相应业务领域的副作用。

我们日常所用的 hook 函数,大部分都是「有状态类 hook」和「副作用处理类 hook」。至于其他的归属于其他类型的 hook 函数则相对地用得比较少。原因无非就是要么 hook 函数太新(原生增强类的 hook),要么是 hook 函数提供的功能是比较高级(并发更新相关的 hook),而这些 hook 函数的使用场景相对地比较少。

下面我会着重讲述「有状态类 hook」和「副作用处理类 hook」的实现原理。其他类型的 hook 函数的实现原理,我会在后面的章节中单独讲述。

无状态类 hook

  • useContext

为什么说它是无状态 hook 呢?因为它不但没有使用 hook 对象的 memoizedState 字段来存储,它甚至连 hook 对象都没有使用。我们可以从 HooksDispatcherOnMountHooksDispatcherOnUpdate 这两个 dispatcher 中,分别找到 useContext 这个 hook 函数的实现:

// packages/react-reconciler/src/ReactFiberHooks.js
const HooksDispatcherOnMount: Dispatcher = {
  readContext,

  use,
  useCallback: mountCallback,
  useContext: readContext,
  .......
}

const HooksDispatcherOnUpdate: Dispatcher = {
  readContext,

  use,
  useCallback: updateCallback,
  useContext: readContext,
  ......
}

再往下追溯的话,我们会发现 useContext 函数的真正实现是readContextForConsumer(源码文件packages/react-reconciler/src/ReactFiberNewContext.js)。从它的实现中,我们根本就没有看到我们熟悉的 hook 对象访问架构所涉及的两个重要的函数:mountWorkInProgressHook 函数 和 updateWorkInProgressHook 函数。这说明,useContext 这个 hook 函数完全是没有产生 hook 对象并相应地追加到 hook 链表中的。它的实现逻辑,可以说是脱离了主流的 hook 系统的。因为没有创建 hook 对象,所以,它就是无状态类 hook。至于它的实现原理,稍后再补充。

有状态类 hook

  • useState
  • useReducer
  • useMemo
  • useCallback
  • useRef
  • useId
  • useDebugValue

以上就是有状态类 hook。翻看源码,他们都遵循这 hook 系统的主体框架:

  • mount 阶段:都会利用 mountWorkInProgressHook 函数来创建一个全新的 hook 对象;
  • update 阶段:都会利用 updateWorkInProgressHook 函数从上一个渲染周期的 hook 链表中相同位置的 hook 对象,浅复制出一个新的 hook 对象。
  • 都是主要利用 hook 对象的 memoizedState 字段来存储和在不同的渲染周期之间更新状态值。

它们之间的差异其实是很小的。差异点主要体现在:

  • 是否是「重计算」- 比如 useStateuseReducer 就是是重计算 hook,而其他的甚至都可以说没有值的计算逻辑在。
  • memoizedState 值的数据类型不同 - 这是由不同的 hook 函数的职责不同所导致的。比如 useStateuseReducer 都是存储一个单值(这个单值可以是任何类型), useCallbackuseMemo 都是存储一个 tuple,形如[值,依赖]等等。

根据上述的差异点,我们大致都可以把它们分为两个小类:

  • 值的计算类
  • 值的流转/持久化类

值的计算类

useStateuseReducer的底层都是用同一套实现框架。然后实现源码的主体逻辑都是集中「如何根据上个渲染周期的 updateQueue 去计算出当前渲染周期的状态值」。因为这里们的计算逻辑涉及到更新的优先级,所以,这里面的计算逻辑是相当的复杂,请容许我为你娓娓道来。

  • useState
  • useReducer

值的流转/持久化类

这一类型的 hook 函数的实现就相对很简单了。因为它们的实现逻辑,都是简单地利用 hook 对象的 memoizedState 字段来存储和在不同的渲染周期之间更新或者流转状态值。

而所谓的「值的流转/持久化」,就是指 hook 函数在不同的渲染周期之间,能够保持住上一个渲染周期的值。这就可能意味着在当前渲染周期用户传递进来的值,react 内部是不使用的。这种情况下,我们可以说这种类型的 hook 函数就是一种缓存类的 hook 函数,比如 useMemouseCallback 就是这种类型。缓存,顾名思义,这些 hook 函数都是性能相关的而不是功能相关的。

  • useMemo
  • useCallback
  • useRef
  • useId
  • useDebugValue

因为 useDebugValue 是由 react-debug-hooks package 注入的。所以,这里的分析暂时不包括它。

下面,我们逐个逐个地分析这些 hook 函数的实现原理。

遵循惯例,所有的源码都会把开发环境的代码给去掉

useMemo
// packages/react-reconciler/src/ReactFiberHooks.js
function mountMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null
): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

function updateMemo<T>(
  nextCreate: () => T,
  deps: Array<mixed> | void | null
): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  // Assume these are defined. If they're not, areHookInputsEqual will warn.
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

从上面的实现可以看出,useMemo 使用元组 [值,依赖数组] 来把值存储在 hook 对象的 memoizedState 字段上。

  • 首先,在 mount 阶段,调用用户传递进来的计算函数 nextCreate 得到的一个初始值,并存储在 hook 对象的 memoizedState 字段上。
  • 然后,到下个渲染周期的时候,会先判断前后依赖是否有变化。如果没有变化,就直接返回上一个渲染周期的值(也就是说,不计算了)。否则,就调用用户传递进来的计算函数 nextCreate 再计算一次,得到新的值,然后更新 memoizedState 字段。
  • 最后,返回最终确定的值给用户。

至于比较两个值是否相等的算法,可以翻看我的掘金文章触摸 react 的命门 - 值的相等性比较(上篇)触摸 react 的命门 - 值的相等性比较(下篇)

通过上面的方式,react 在 fiber 架构上实现了本质上昂贵计算的缓存功能。其实,我们一直返回开发者对它的滥用,不是什么值都需要用 useMemo 来缓存。只有当计算成本比较昂贵或者计算逻辑比较复杂的情况下,才是使用 useMemo 的适合时机。

useCallback
function mountCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

function updateCallback<T>(callback: T, deps: Array<mixed> | void | null): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps: Array<mixed> | null = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];
    }
  }
  hook.memoizedState = [callback, nextDeps];
  return callback;
}

从源码上来看,useCallback 的实现原理跟 useMemo 是基本上是一模一样的。所以,实现原理在这里就不再赘述了。两者唯一的不同是,useCallback 缓存的是一个函数引用,而 useMemo 缓存的是一个值。

useRef
function mountRef<T>(initialValue: T): RefObject<T> {
  const hook = mountWorkInProgressHook();
  const ref = {
    current: initialValue,
  };
  hook.memoizedState = ref;
  return ref;
}

function updateRef<T>(initialValue: T): { current: T } {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;
}

从上面的实现来看,useRef 的实现原理就是十分简答,代码也是寥寥可数的几行。它的原理无非就借助 fiber 节点这一只在组件上方看不见手,来保持着 mount 阶段传递进来的引用。然后在后续的渲染周期里面,直接忽视用户传递进来的值,只返回第一次的引用。通过这种方式来保持着引用的不变性。

不信?你可以看看 updateRef 函数的实现代码,它有消费用户传递进来的 initialValue 参数吗?

useId
function mountId(): string {
  const hook = mountWorkInProgressHook();

  const root = ((getWorkInProgressRoot(): any): FiberRoot);
  ......
  const identifierPrefix = root.identifierPrefix;

  let id;
  if (getIsHydrating()) {
    ......
  } else {
    // Use a lowercase r prefix for client-generated ids.
    const globalClientId = globalClientIdCounter++;
    id = '_' + identifierPrefix + 'r_' + globalClientId.toString(32) + '_';
  }

  hook.memoizedState = id;
  return id;
}


function updateId(): string {
  const hook = updateWorkInProgressHook();
  const id: string = hook.memoizedState;
  return id;
}

同样,从源码上来看,useId 的实现原理跟 useRef 是基本上是一模一样的。所以,实现原理在这里就不再赘述了。两者唯一的不同是,useId 缓存的是一个全局唯一的 id 性质的值,而 useRef 缓存的是一个引用类型的值(这里的引用类型,是指 hook.memoizedState 字段上存储的那个对象)。

并发更新相关的 hook

  • useDeferredValue
  • useOptimistic
  • useTransition

原生增强类的 hook

  • useActionState - 增强 标签

effect hook

  • useLayoutEffect
  • useEffect
  • useEffectEvent
  • useInsertionEffect
  • useSyncExternalStore 综合类
  • useImperativeHandle 综合类

什么是 effect 类的 hook 函数?从实现原理角度来看,effect 类的 hook 函数就是那些会创建 effect 对象,并把它追加到 fiber 节点的 updateQueue.lastEffect 循环链表上的 hook 函数。

注意,这里没有的定义并没有认为「创建 effect 对象,并挂载在 hook 对象的 memoizedState 字段上」就是 effect 类的 hook 函数。这是因为有些 effect 类的 hook 函数的实现并没有把 effect 对象挂载在 hook 对象的 memoizedState 字段上。 updateQueue.lastEffect 循环链表才是处理 effect 的终极地方。

到这里,我们就引出了我们的主角 - “effect 对象”。 effect 对象的数据结构如下:

//packages/react-reconciler/src/ReactFiberHooks.js
export type Effect = {
  tag: HookFlags,
  inst: EffectInstance,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
  next: Effect,
};

type EffectInstance = {
  destroy: void | (() => void),
};

//packages/react-reconciler/src/ReactHookEffectTags.js
export type HookFlags = number;

下面我们简单地介绍一下 effect 对象的字段含义:

  • tag - 表示 effect 的类型(比如 InsertionEffect LayoutEffectPassiveEffect 等)。从另外一个视角来看,也可以说 effect 执行的阶段。因为在 react 实现内部是根据 effect 所执行的阶段来分类的。在源码中,我们会看到 tag 会是 HookInsertion,HookPassive之类的,你需要把这里的「hook」理解为动词,意思就是「当前的这个 effect 勾进的阶段是 passive 阶段」。
  • inst - 用一个对象包住的 destroy 函数。直接平铺出来就行啦?为什么这么繁琐呢?具体原因可看源码注释中的解释。
  • create - effect 的实质性代表。也就是我们开发这给 useEffect 传递进来的第一个函数值。所谓的「执行 effect」,基本上可以理解为就是调用这个函数。
  • deps - 表示 effect 的依赖数组,当依赖数组中的某个依赖项发生变化时,才会触发 effect 的执行。
  • next - 表示 effect 链表中的下一个 effect 对象。它的值不可能为 null,则说明了 effect 对象组成的是一个循环链表。

effect 也分类

上面,我们也提到了,在内部实现源码中,react 会根据 effect 的执行阶段来进行分类。在 commit 阶段,effect 会在下面的几个阶段中被执行:

  • before mutation - intersection effect
  • after mutation - layout effect
  • passive - passive effect

因此,我们可以分为三大类:“InsertionEffect”、“LayoutEffect”、“PassiveEffect”。

effect 类 hook 函数的实现框架

effect 类 hook 函数的实现是存在一个代码架构的。它从底到上可以分三层:

  • 底层 - 底层是业务逻辑无关的一层,它只是负责创建全新的 effect 对象,并把它追加到当前 fiber 节点的 updateQueue.lastEffect 循环链表上。
  • 核心实现层 - 核心实现层负责决定 effect 函数是否需要执行。如果需要执行,那需要在 commit 哪个子阶段去执行。
  • 应用层 - 主要是根据自身所承担的业务职责,决定具体的需要执行的 effect 函数是什么。

实现框架概览

effect-hook-architecture转存失败,建议直接上传图片文件

分层架构

应用层

上面讲到了,应用层主要是根据自身所承担的业务职责,决定具体的需要执行的 effect 函数是什么。其实这里的表述还少了一些其他必要的要素。这一点,从 mountEffectImplupdateEffectImpl 这两个函数的函数签名就可以看出。

function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  ......
}

function updateEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
    ......
}

作为应用层的下层, 核心实现层的诉求除了 effect 函数(create)外,还要求应用层要传递 fiberFlags , hookFlagsdeps这三个参数。接下来,我们简单先说说这四个参数的含义和用途,然后再来看具体的应用 hook 函数是怎样实现这个参数的 resolve 的。

  • fiberFlags - 副作用 flag。所有的 fiber flag 会被累积到当前渲染 fiber 节点的 flags 字段上,用于表示当前 fiber 节点存在 effect 需要在 commit 阶段去执行。
  • hookFlags - effect 对象 flag。我个人认为这个命名是容易误导人的。这里的 hookFlags 并不是说是 hook 对象上的 flag,而是指 effect 对象的 tag 字段值。当把这个 “hook” 理解为动词之意的时候,那么可以说通了。比如说,当一个 effect 对象的 hookFlags 被赋值为 “HookPassive”,则就表示,当前的这个 effect 要「勾进的是 passive 阶段」。所谓的「勾进的是 xxxx」,就是表示 effect 需要在 commit 的哪个子阶段去执行。 其实,hookFlags 的另外的一个重要信息是,它包含了两个维度的 flag。一个维度是「勾进的是哪个阶段」,另一个维度是「需不需要执行」。
  • deps - 表示 effect 函数的依赖数组。它的作用也不用多说了,就是用来做新旧依赖值的比较值,只有依赖值发现变化了。react 才会给当前 fiber 节点和 effect 对象打上表示「存在需要执行的副作用」的标签。

fiber 上有哪些 flag 呢?effect 对象上有哪些 hookFlags 呢?这些会在后续的章节中详细介绍。现在,我们明白了核心实现方的诉求后,我们看看应用层是如何实现这个参数的 resolve 的。

另外一点是,众所周知,同一个 hook 函数是存在两个版本(mount 阶段和 update 阶段)的实现。圈定在 effect 类 hook 函数的实现原理这个讨论范畴内,这两者之间虽然存在一些差异,但是主体脉络是一致的。在这里,我们先讨论 mount 阶段的实现,后面再指出 update 阶段相对于 mount 阶段的差异点在哪里。如此一对比,对总体的实现原理的理解更清晰和立体了。

mount 阶段
function mountEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
    mountEffectImpl(
      PassiveEffect | PassiveStaticEffect,
      HookPassive,
      create,
      deps,
    );
}

function mountLayoutEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;
  return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}

function mountInsertionEffect(
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null,
): void {
  mountEffectImpl(UpdateEffect, HookInsertion, create, deps);
}


function mountImperativeHandle<T>(
  ref: {current: T | null} | ((inst: T | null) => mixed) | null | void,
  create: () => T,
  deps: Array<mixed> | void | null,
): void {
  .......
  // TODO: If deps are provided, should we skip comparing the ref itself?
  const effectDeps =
    deps !== null && deps !== undefined ? deps.concat([ref]) : null;

  let fiberFlags: Flags = UpdateEffect | LayoutStaticEffect;
  ......
  mountEffectImpl(
    fiberFlags,
    HookLayout,
    imperativeHandleEffect.bind(null, create, ref),
    effectDeps,
  );
}

上面的源码中,我们可以看到,effect 类 hook 函数在 mount 阶段的实现函数都是直接将用户传递过来的两个参数(createdeps, 这里的 create 就是我们调用 useEffect 时候的传进去第一个参数 - callback 函数,deps 是依赖数组)透传下去了。而其余两个参数是硬编码的。

mountImperativeHandle 是例外的那个。它自己生产了 create 函数和依赖数组。

对于 mountEffect 函数,硬编码的两个参数是:

  • PassiveEffect | PassiveStaticEffect - 表示当前的 fiber 节点存在需要执行的 passive 类型的副作用。于此同时,还打上了PassiveStaticEffect 标记。静态类型的 flag 会在 flag 系统小节说。
  • HookPassive - 表示这是属于 passive 阶段的 effect。如果需要执行的话,那么就在 passive 阶段执行。

对于 mountLayoutEffectmountImperativeHandle 函数,它们的两个硬编码参数分别是:

  • UpdateEffect | LayoutStaticEffect - 表示当前的 fiber 节点存在需要执行的 layout 类型的副作用。于此同时,还打上了LayoutStaticEffect 标记。静态类型的 flag 会在 flag 系统小节说。
  • HookLayout - 表示这是属于 layout 阶段的 effect。如果需要执行的话,那么就在 layout 阶段执行。

对于 mountInsertionEffect 函数,它的两个硬编码参数分别是:

  • UpdateEffect - 表示当前的 fiber 节点存在需要执行的 insertion 类型的副作用。
  • HookInsertion - 表示这是属于 insertion 阶段的 effect。如果需要执行的话,那么就在 insertion 阶段执行。

到这里,我们就可以明确地看到,react 是在应用层来明确 effect 类 hook 函数所属的类型的 - 根据 effect 执行所在的阶段来对 effect 类函数进行了分类。而这些设定都是硬性的,没有任何的计算逻辑。这就是这里的硬编码的含义。回到开发者视角,我们得到的认知是:

  • 通过 useEffect hook 函数注册的 effect 会在 passive 阶段执行。
  • 通过 useLayoutEffect hook 函数注册的 effect 会在 layout 阶段执行。
  • 通过 useInsertionEffect hook 函数注册的 effect 会在 insertion 阶段执行。
  • 通过 useImperativeHandle hook 函数注册的 effect 会在 layout 阶段执行。
update 阶段

effect 类函数在 update 阶段的实现与 mount 阶段的实现是几乎是一致的。唯二的差异点在于:

  • update 阶段的 hook 实现函数不会传递 static 类的 fiber flag;
  • update 阶段的 hook 实现函数调用下层的核心实现层的函数是 updateEffectImpl 函数而不是 mountEffectImpl 函数;

同样, static 类的 fiber flag 的作用会在 flag 系统去阐述。updateEffectImpl 函数的实现与 mountEffectImpl 函数实现的具体差异会在「核心实现层」去阐述。

核心实现层

从上面的实现框架概览图可以看出,核心实现层的函数有两个:

  • mountEffectImpl 函数
  • updateEffectImpl 函数
function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushSimpleEffect(
    HookHasEffect | hookFlags,
    createEffectInstance(),
    create,
    nextDeps
  );
}

function updateEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const effect: Effect = hook.memoizedState;
  const inst = effect.inst;

  // currentHook is null on initial mount when rerendering after a render phase
  // state update or for strict mode.
  if (currentHook !== null) {
    if (nextDeps !== null) {
      const prevEffect: Effect = currentHook.memoizedState;
      const prevDeps = prevEffect.deps;
      // $FlowFixMe[incompatible-call] (@poteto)
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        hook.memoizedState = pushSimpleEffect(
          hookFlags,
          inst,
          create,
          nextDeps
        );
        return;
      }
    }
  }

  currentlyRenderingFiber.flags |= fiberFlags;

  hook.memoizedState = pushSimpleEffect(
    HookHasEffect | hookFlags,
    inst,
    create,
    nextDeps
  );
}

它们代码的基本盘是负责共同实现了「hook 函数实现中的架构」小节里面提到的架构。除此之外,它们要实现的就是 fiber flag 和 effect flag 的 resolve 逻辑。在上面应用层,我们已经决定了一个 effect 如果要执行的话,它要放在哪个阶段去执行。而在核心实现层,我们要做的就是决定这个 effect 要不要执行。这个就决定出 fiber flag 和 effect flag 这两个 flag 的目的之所在。相应的内在逻辑是:

  • 如果 react 觉得一个 effect 要执行,它就给当前的 effect 对象打上 HookHasEffect 标记。
  • 于此同时,它还会会给当前的渲染 fiber 节点打上对应类型的 effect flag,比如 PassiveEffect 或者 UpdateEffect

值得一提的是,上面的打标操作具有原子性。也就是说,一个 effect 对象如果要打上 HookHasEffect 标记,那么同步第, fiber 节点也要打上对应类型的 effect flag。至于为什么?我会在「flag 系统」一节中阐述。

有了上述的理解,我们再来看看 mountEffectImpl 函数和 updateEffectImpl 函数的实现上的差异。

首先,我们看到 mountEffectImpl 的实现是包含了硬编码逻辑。什么意思?那就是它写死了打标记的逻辑了。换句话说,mount 阶段注册的 effect 必须要执行。这与我们开发者的日常使用上的认知是一致的 - 在组件的 mount 阶段,无论你的依赖数组是什么,都是会执行一次你的 effect callback 函数。

然后,我们再来看看updateEffectImpl 函数实现。与 mountEffectImpl 函数不同的是,updateEffectImpl 函数的打标记逻辑并不是硬编码的 - 而是根据前后渲染周期是否发生变化了决定的。如果 react 认为依赖数组没有变化,那么就不会打上HookHasEffect 标记。这与我们开发者的日常使用上的认知是一致的 - 在组件的 update 阶段,只有当依赖数组发生变化时,才会执行 effect callback 函数。

到这里,我们都理解了核心实现层的主要任务是:根据相应的条件来决定是否要给当前的 effect 对象打上 HookHasEffect 标记。

当一切「原料」都准备好了:

  • effect flag - 决定了 effect 是否要执行以及在哪个阶段执行
  • effect 函数 - 真正要执行的那个函数
  • 依赖数组
  • EffectInstance - 用于储存 cleanup 函数

作为承下的动作,核心实现层会把以上「原料」作为实惨传递给底层。

底层

底层是业务无关的,它只负责两件事:

  1. 创建 effect 对象;
  2. 把 effect 对象追加到 effect 循环链表上。
创建 effect 对象

effect 对象的创建是非常简单的。它只需要根据传入的「原料」,创建一个新的 effect 对象即可。这一切在 pushSimpleEffect 函数的实现源码中一览无遗:

function pushSimpleEffect(
  tag: HookFlags,
  inst: EffectInstance,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): Effect {
  const effect: Effect = {
    tag,
    create,
    deps,
    inst,
    // Circular
    next: (null: any),
  };
  return pushEffectImpl(effect);
}
追加 effect 对象到循环链表上

首先我们需要知晓的的,effect 循环链表是记录在 fiber 节点维度的。具体来说,就记录在 fiber.updateQueue.lastEffect 上。fiber 节点的 updateQueue 是一个对象,除了 lastEffect 字段,还有其他跟本主题无关的字段。这里略过不表。下面直接看看源码:

function pushEffectImpl(effect: Effect): Effect {
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
  }
  const lastEffect = componentUpdateQueue.lastEffect;
  if (lastEffect === null) {
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    const firstEffect = lastEffect.next;
    lastEffect.next = effect;
    effect.next = firstEffect;
    componentUpdateQueue.lastEffect = effect;
  }
  return effect;
}

从上面的代码可以看出,当前传入的 effect 对象是第一个 effect 对象时,我们会把 updateQueue.lastEffect 指向当前的 effect 对象。否则,我们会把当前的 effect 对象追加到循环链表的末尾。updateQueue.lastEffect 永远是指向循环链表的最后一个 effect 对象。

另外值得一提的是,入队后的 effect 对象还会原路返回,最后挂载到当前 hook 函数所对应的那个 hook 对象的 memoizedState 字段上。相当于为 effect 对象创建了两个访问入口。这一点从上面的章节所给出的「fiber -> hook 对象 -> effect 对象」关系图中可以看出来。

到了这里,如果深入思考的人就会发现,在每一个渲染周期的渲染阶段,每调用一次 effect 类的 hook 函数就会往 fiber 的 effect 循环链表中追加一个 effect 对象,岂不是会有两个问题:

  • effect 函数在同一个链表中重复;
  • 如此累积起来,内存岂不是要爆炸?

这里想必会有一个清除机制?是的,在渲染阶段中,每一次渲染组件(调用组件函数)之前,它会做一次清理。这一切发生在 renderWithHooks 函数中。有代码为证:

export function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  nextRenderLanes: Lanes,
): any {
  ......
  workInProgress.memoizedState = null;
  workInProgress.updateQueue = null;
  workInProgress.lanes = NoLanes;
  ........
  let children = __DEV__
    ? callComponentInDEV(Component, props, secondArg)
    : Component(props, secondArg);

  ......
  return children;
}

从上面摘抄的代码可以看出,在渲染阶段中,每一次渲染组件(Component(props, secondArg))之前, react 会将 fiber 节点的 updateQueue 字段设为 nullworkInProgress.updateQueue = null)。因为 effect 循环链表就是存储在 updateQueue 对象上,所以,这里就是一个清空链表的行为。从另外的一个角度说,effect 循环链表会在每一次的组件渲染的时候从零地去重建。以上的源码剖析很好地解除上面提出的两个疑惑点。

小结

通过上面对 effect 类 hook 函数的实现框架的分析,我们可以看到 effect 在一个界面更新周期中的完整处理流程:

  1. 首先,在渲染一个组件之前,react 会清空 fiber 节点上的 effect 循环链表,为重建新的 effect 循环链表做准备。
  2. 然后,在应用层去创建 effect 函数,并决定 effect 在哪个阶段去执行(如果要执行的话);
  3. 再然后,在核心实现层,根据所在阶段和依赖是否发生变化来决定 effect 是否要执行;
  4. 最后, 底层在用户每调用一次 hook 函数就创建一个新的 effect 对象,并把它追加到 effect 循环链表上。

至此,一条全新的 effect 链表就被创建出来了。

以上内容,本质上是在讲「在渲染阶段中,react 是如何收集 effect,并对 effect 做标记的」。接下来的章节就会讲「在 commit 阶段中,react 是如何遍历和执行 effect 的」。

但是,在深入探究「在 commit 阶段中,react 是如何遍历和执行 effect 的」之前,我们有必要了解一下 react 的 flag 系统的。

flag 系统

没错,react 的 flag 系统是相对独立的。目标是服务于各种场景下的打标记需求。react 源码内部主要存在两种类型的 flag:

  • fiber flag
  • effect flag

分别用于不同的抽象层次和目的。

fiber flag

fiber flag 就是存储在 fiber 节点的flags 字段上的值。这些标志位标记 Fiber 节点本身需要执行的副作用操作。在源码文件 packages/react-reconciler/src/ReactFiberFlags.js中罗列所有的 fiber flag。这里,我摘取主要的出来:

export type Flags = number;

// Don't change these values. They're used by React Dev Tools.
export const NoFlags = /*                      */ 0b0000000000000000000000000000000;
export const PerformedWork = /*                */ 0b0000000000000000000000000000001;
export const Placement = /*                    */ 0b0000000000000000000000000000010;
export const DidCapture = /*                   */ 0b0000000000000000000000010000000;
export const Hydrating = /*                    */ 0b0000000000000000001000000000000;

// You can change the rest (and add more).
export const Update = /*                       */ 0b0000000000000000000000000000100;
export const Cloned = /*                       */ 0b0000000000000000000000000001000;

export const ChildDeletion = /*                */ 0b0000000000000000000000000010000;
export const ContentReset = /*                 */ 0b0000000000000000000000000100000;
export const Callback = /*                     */ 0b0000000000000000000000001000000;
/* Used by DidCapture:                            0b0000000000000000000000010000000; */

export const ForceClientRender = /*            */ 0b0000000000000000000000100000000;
export const Ref = /*                          */ 0b0000000000000000000001000000000;
export const Snapshot = /*                     */ 0b0000000000000000000010000000000;
export const Passive = /*                      */ 0b0000000000000000000100000000000;
/* Used by Hydrating:                             0b0000000000000000001000000000000; */

export const Visibility = /*                   */ 0b0000000000000000010000000000000;
export const StoreConsistency = /*             */ 0b0000000000000000100000000000000;

// It's OK to reuse these bits because these flags are mutually exclusive for
// different fiber types. We should really be doing this for as many flags as
// possible, because we're about to run out of bits.
// flag 位的复用是鉴于一个前提和窘境来做出的决策
// 1. 前提条件 - 复用的 flag 所代表的副作用必须是「在 fiber 类型上互斥」的。什么意思?那 `export const FormReset = Snapshot;`举例。那就是同一个类型上 fiber 节点上假如会有FormReset副作用,那就不会有 Snapshot 副作用。
// 2. 窘境 - 可用的掩码位已经快用完了
export const Hydrate = Callback;
export const ScheduleRetry = StoreConsistency;
export const ShouldSuspendCommit = Visibility;
export const ViewTransitionNamedMount = ShouldSuspendCommit;
export const DidDefer = ContentReset;
export const FormReset = Snapshot;
export const AffectedParentLayout = ContentReset;

fiber flag 的具体用途有很多。比较容易理解的用途是:

  • 标记 DOM 操作(插入、更新、删除)
  • 标记 ref 操作、快照操作等
  • 标记生命周期相关的副作用
  • 标记函数组件 hook 函数的 effect

不同 work tag 的组件会处理不同类型的 flags,如函数组件主要处理 Hook 相关 flags,类组件处理生命周期 flags,宿主组件处理 DOM 操作 flags 等。大概的分类表如下:

Work Tag (工作标签)组件类型主要处理的 Flags相关文件
FunctionComponent (0)函数组件Update, Passive (用于 effect 类的 hook)1
ClassComponent (1)类组件Update (用于生命周期方法)2
HostRoot (3)根节点Placement, Update, ChildDeletion, Passive (用于 transition tracing)3
HostComponent (5)DOM 元素Placement, Update, Ref, ChildDeletion4
HostText (6)文本节点Placement, Update4
Profiler (12)Profiler 组件Update, Passive (用于 onPostCommit 回调)5
SuspenseComponent (13)Suspense 组件DidCapture, ShouldCapture, Visibility6
OffscreenComponent (22)离屏组件Visibility, Passive (用于离屏效果)7
CacheComponent (24)缓存组件Passive (用于缓存管理)8
TracingMarkerComponent (29)追踪标记组件Passive (用于 transition tracing)9
ViewTransitionComponent (30)视图过渡组件Visibility, Snapshot10
ActivityComponent (31)活动组件ShouldCapture, DidCapture11

从另外一个角度来看,不同的 fiber flag 会在不同的 commit 子阶段被 commit 掉:

阶段包含的 Flags处理时机
BeforeMutationSnapshot, Update, ChildDeletion, VisibilityDOM 变更前
MutationPlacement, Update, ChildDeletion, ContentReset, Ref, Hydrating, Visibility, FormResetDOM 变更时
LayoutUpdate, Callback, Ref, VisibilityDOM 变更后
PassivePassive, Visibility, ChildDeletion异步执行

从上面的信息,我们可以得知,fiber flag 并不只是只服务于 effect 类函数的副作用。或者说,effect 类函数的副作用只是众多副作用中的一种。

static flag

在理解了 fiber flag 和 effect flag 的基本概念之后,我们有必要进一步了解一种特殊的 flag —— static flag(静态标记)。这类 flag 在 React 的 bailout 优化策略中扮演着关键角色。

什么是 static flag?说白一点,static flag 描述的是 Fiber 节点的固有特性,它与 Fiber 节点的生命周期共存,而不是像常规 flag 那样仅与单次渲染周期绑定。用源码中的注释来说:

Static tags describe aspects of a fiber that are not specific to a render, e.g. a fiber uses a passive effect (even if there are no updates on this particular render).

这种区别的实际意义是什么?让我们先看源码中定义了哪些 static flag:

// packages/react-reconciler/src/ReactFiberFlags.js#66-87
// Static tags describe aspects of a fiber that are not specific to a render,
// e.g. a fiber uses a passive effect (even if there are no updates on this particular render).
// This enables us to defer more work in the unmount case,
// since we can defer traversing the tree during layout to look for Passive effects,
// and instead rely on the static flag as a signal that there may be cleanup work.
export const Forked = /*                       */ 0b0000000000100000000000000000000;
export const SnapshotStatic = /*               */ 0b0000000001000000000000000000000;
export const LayoutStatic = /*                 */ 0b0000000010000000000000000000000;
export const RefStatic = LayoutStatic;
export const PassiveStatic = /*                */ 0b0000000100000000000000000000000;
export const MaySuspendCommit = /*             */ 0b0000001000000000000000000000000;
export const ViewTransitionNamedStatic =
  /*    */ SnapshotStatic | MaySuspendCommit;
export const ViewTransitionStatic = /*         */ 0b0000010000000000000000000000000;
export const PortalStatic = /*                 */ 0b0000100000000000000000000000000;

其中最值得关注的是 PassiveStaticLayoutStatic

  • PassiveStatic:标记当前 Fiber 节点使用了 useEffect(即使本次渲染没有 effect 需要执行)
  • LayoutStatic:标记当前 Fiber 节点使用了 useLayoutEffect
  • RefStatic:实际上是 LayoutStatic 的别名,说明 LayoutStatic 也用于标记 ref 的使用

那么,为什么要区分 static 和 non-static 呢?这就要说到 React 的 bailout 机制了。

当组件的 props 和 state 都没有变化时,React 会跳过该组件及其子树的渲染(bailout)。在 bailout 场景下,本次渲染产生的 flags 不会冒泡到父节点。但如果子树中使用了 effect,在组件卸载(unmount)时,React 仍然需要执行相应的 cleanup 函数。

问题来了:如果在 bailout 时 flags 没有冒泡,React 怎么知道子树中是否有需要清理的 effect 呢?

这就是 static flag 存在的意义。由于 static flag 描述的是节点的固有特性,即使在 bailout 时,它也会继续冒泡到父节点。这样,当父节点需要卸载时,React 可以通过检查 subtreeFlags 中的 static flags,快速判断子树中是否存在需要清理的 effect,而无需遍历整棵树。

源码中的注释明确指出了这一点:

This enables us to defer more work in the unmount case, since we can defer traversing the tree during layout to look for Passive effects, and instead rely on the static flag as a signal that there may be cleanup work.

为了更清晰地理解,我用一个表格来对比 static flag 和常规 flag 的区别:

特性Static FlagsNon-Static Flags
生命周期与 Fiber 节点共存,跨渲染持久单次渲染周期,commit 后重置
用途标记节点固有特性(如是否有 effect)标记本次渲染的变更(如 Placement)
冒泡行为Bailout 时仍冒泡Bailout 时不冒泡
典型代表PassiveStatic, LayoutStaticPlacement, Update, ChildDeletion

此外,React 还定义了一个 StaticMask,用于在 Fiber 克隆(clone)时保留这些标记:

// packages/react-reconciler/src/ReactFiberFlags.js#137-145
// Union of tags that don't get reset on clones.
// This allows certain concepts to persist without recalculating them,
// e.g. whether a subtree contains passive effects or portals.
export const StaticMask =
  LayoutStatic |
  PassiveStatic |
  RefStatic |
  MaySuspendCommit |
  ViewTransitionStatic |
  ViewTransitionNamedStatic |
  PortalStatic |
  Forked;

这个 mask 的作用在 bubbleProperties 函数中会得到体现——当 React 需要克隆 Fiber 节点时,所有 static flags 都会被保留,确保节点的固有特性不会丢失。

flag 的冒泡

理解了 static flag 的特殊性之后,我们自然会问一个问题:这些 flags 是如何在 Fiber 树中传递的?这就是本节要讲的「flag 的冒泡」机制。

所谓「冒泡」,形象地说,就是子节点的 flags 像水中的气泡一样,从底部向上浮,最终汇集到父节点。React 通过这种方式,让父节点能够感知到整个子树中所有后代节点的状态变化。

这个机制的核心实现位于 bubbleProperties 函数中:

// packages/react-reconciler/src/ReactFiberCompleteWork.js#781-841
function bubbleProperties(completedWork: Fiber) {
  const didBailout =
    completedWork.alternate !== null &&
    completedWork.alternate.child === completedWork.child;

  let newChildLanes: Lanes = NoLanes;
  let subtreeFlags: Flags = NoFlags;

  if (!didBailout) {
    // 正常路径:没有 bailout,收集所有子节点的 flags
    let child = completedWork.child;
    while (child !== null) {
      newChildLanes = mergeLanes(
        newChildLanes,
        mergeLanes(child.lanes, child.childLanes),
      );

      subtreeFlags |= child.subtreeFlags;
      subtreeFlags |= child.flags;  // 关键:收集子节点的 flags

      child = child.sibling;
    }

    completedWork.subtreeFlags |= subtreeFlags;
  } else {
    // Bailout 路径:复用之前的结果,但只收集 Static flags
    let child = completedWork.child;
    while (child !== null) {
      newChildLanes = mergeLanes(
        newChildLanes,
        mergeLanes(child.lanes, child.childLanes),
      );

      // "Static" flags share the lifetime of the fiber/hook they belong to,
      // so we should bubble those up even during a bailout. All the other
      // flags have a lifetime only of a single render + commit, so we should
      // ignore them.
      subtreeFlags |= child.subtreeFlags & StaticMask;  // 只收集 static flags
      subtreeFlags |= child.flags & StaticMask;         // 只收集 static flags

      child = child.sibling;
    }

    completedWork.subtreeFlags |= subtreeFlags;
  }

  completedWork.childLanes = newChildLanes;
  return didBailout;
}

从上面的代码可以看出,bubbleProperties 函数的逻辑分为两个分支:

1. 正常路径(非 bailout)

当组件正常渲染时(!didBailout),React 会遍历该 Fiber 节点的所有子节点,将子节点的 flagssubtreeFlags 全部或运算(|=)到父节点的 subtreeFlags 中。

这里的 subtreeFlags 字段存储在父 Fiber 节点上,表示整个子树中所有后代节点的 flags 的并集。这种设计带来了一个重要的优化:在 commit 阶段,React 可以通过检查父节点的 subtreeFlags 快速判断是否需要遍历某个子树——如果 subtreeFlags 为 0,说明子树中没有任何副作用需要处理,可以直接跳过。

2. Bailout 路径

当组件发生 bailout 时(子树没有变化),上述代码进入了 else 分支。注意这里的关键区别:

subtreeFlags |= child.subtreeFlags & StaticMask;  // 只收集 static flags
subtreeFlags |= child.flags & StaticMask;         // 只收集 static flags

通过位运算 & StaticMask,只有 static flags 会被冒泡到父节点,而常规的 flags(如 PlacementUpdate 等)则被忽略。

源码中的注释清楚地解释了原因:

"Static" flags share the lifetime of the fiber/hook they belong to, so we should bubble those up even during a bailout. All the other flags have a lifetime only of a single render + commit, so we should ignore them.

换言之,常规 flags 描述的是「本次渲染」的变化,既然组件 bailout 了,说明本次没有变化,这些 flags 自然不需要冒泡。但 static flags 描述的是节点的固有特性,即使本次没有渲染,节点仍然具有这些特性,因此需要继续冒泡。

冒泡机制的性能意义

flag 冒泡机制配合 subtreeFlags 字段,使得 React 在 commit 阶段能够快速剪枝:

// 典型的递归遍历模式(以 Layout Effects 为例)
function recursivelyTraverseLayoutEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // 关键优化:通过 subtreeFlags 判断是否需要遍历
  if (parentFiber.subtreeFlags & LayoutMask) {
    let child = parentFiber.child;
    while (child !== null) {
      const current = child.alternate;
      commitLayoutEffectOnFiber(root, current, child, lanes);
      child = child.sibling;
    }
  }
}

通过检查 parentFiber.subtreeFlags & LayoutMask,React 可以在 O(1) 时间内判断子树是否包含 layout effect。如果不包含,就跳过整个子树的遍历,避免无效的 DFS 遍历。这在大型应用中尤为重要。

effect flag

effect flag 存储在 effect 对象的 tag 字段中。它用来标识 Hook effect 的是否需要执行和在哪个 commit 子阶段执行。所有的 effect flag 都在源码packages/react-reconciler/src/ReactHookEffectTags.js 罗列出来了:

/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 */

export type HookFlags = number;

export const NoFlags = /*   */ 0b0000;

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

// Represents the phase in which the effect (not the clean-up) fires.
export const Insertion = /* */ 0b0010;
export const Layout = /*    */ 0b0100;
export const Passive = /*   */ 0b1000;

值得一提的是,在 effect 函数的实现源码中,对上面四个标志位重命名了:

import {
  HasEffect as HookHasEffect,
  Layout as HookLayout,
  Passive as HookPassive,
  Insertion as HookInsertion,
} from "./ReactHookEffectTags";

在「flag 系统」前面的小节,我们已经说明了 HasEffect 是用来标识当前的 effect 是否需要执行。而 Insertion, Layout, Passive 则是用来标识当前的 effect 应该在哪个 commit 子阶段执行。上面这两个维度的 effect flag 会组合起来使用。例如:

  • HasEffect | Insertion 表示当前的 effect 对象有一个需要执行的 effect 函数,需要在 Insertion 子阶段执行。
  • HasEffect | Layout 表示当前的 effect 对象有一个需要执行的 effect 函数,需要在 Layout 子阶段执行。
  • HasEffect | Passive 表示当前的 effect 对象有一个需要执行的 effect 函数,需要在 Passive 子阶段执行。
相互协调

当 Hook effect 需要执行时,React 会同时设置 Fiber 的 flags 和 Effect 对象的 tag。更具体地说,当 react 将某个 effect 对象打上 HasEffect 标记,那么它一定会将当前的渲染 fiber 节点的 flags 设置为 PassiveEffect 或者 UpdateEffect。 关于这一点,有下面的代码为证:

function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  .......
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushSimpleEffect(
    HookHasEffect | hookFlags,
    createEffectInstance(),
    create,
    nextDeps
  );
}

function updateEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  .......

  currentlyRenderingFiber.flags |= fiberFlags;

  hook.memoizedState = pushSimpleEffect(
    HookHasEffect | hookFlags,
    inst,
    create,
    nextDeps
  );
}

这就是意味着,一个函数组件一旦有一个需要执行的 hook effect,那么它就会将当前的渲染 fiber 节点的 flags 设置为 PassiveEffect 或者 UpdateEffect

effect 的执行阶段

通过前面的章节,我们已经了解了 effect 是如何被创建、标记和收集的。本节将深入探讨 effect 在 commit 阶段的执行流程——即 React 是如何真正地「执行」这些副作用的。

React 的 commit 阶段分为三个主要的子阶段,按顺序执行:

阶段执行时机处理的 Effect 类型主要操作
BeforeMutationDOM 变更前Snapshot读取 DOM 状态(如 getSnapshotBeforeUpdate)
MutationDOM 变更时Insertion、Layout(destroy)DOM 插入/更新/删除,执行 cleanup
LayoutDOM 变更后Layout(create)、Ref执行 layout effect,绑定 ref
Passive异步延迟执行Passive(destroy + create)执行 useEffect
BeforeMutation 阶段

这是 commit 阶段的第一个子阶段,主要用于在 DOM 变更前读取当前状态:

// packages/react-reconciler/src/ReactFiberCommitWork.js#400-500
function commitBeforeMutationEffectsOnFiber(
  finishedWork: Fiber,
  isViewTransitionEligible: boolean,
) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;

  // 处理 Snapshot effect
  if ((flags & Snapshot) !== NoFlags) {
    switch (finishedWork.tag) {
      case ClassComponent: {
        if (current !== null) {
          // Class 组件的 getSnapshotBeforeUpdate
          commitClassSnapshot(finishedWork, current);
        }
        break;
      }
    }
  }
}

在这个阶段,React 会处理 Snapshot flag,主要用于类组件的 getSnapshotBeforeUpdate 生命周期方法。对于函数组件来说,这个阶段的直接影响较小。

Mutation 阶段

Mutation 阶段是实际进行 DOM 变更的阶段。在这个阶段,React 会:

  1. 执行 DOM 操作(插入、更新、删除)
  2. 执行 Insertion effect 的 cleanup
  3. 执行 Layout effect 的 cleanup
// packages/react-reconciler/src/ReactFiberCommitWork.js#1980-2100
export function commitMutationEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
) {
  inProgressLanes = committedLanes;
  inProgressRoot = root;

  commitMutationEffectsOnFiber(finishedWork, root, committedLanes);

  inProgressLanes = null;
  inProgressRoot = null;
}

对于函数组件,mutation 阶段的一个关键操作是执行 Layout effect 的 destroy 函数:

// 在 commitMutationEffectsOnFiber 中
if (flags & Update) {
  // 先 unmount insertion effect
  commitHookEffectListUnmount(
    HookInsertion | HookHasEffect,
    finishedWork,
    finishedWork.return,
  );
  // 再 mount insertion effect
  commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);
  // unmount layout effect(在 mutation 阶段 destroy layout effect)
  commitHookLayoutUnmountEffects(
    finishedWork,
    finishedWork.return,
    HookLayout | HookHasEffect,
  );
}

一个重要但容易混淆的设计是:为什么 Layout effect 的 destroy 在 mutation 阶段执行,而 create 在 layout 阶段执行?

这是因为 React 需要保证在同一个阶段的 effect 执行顺序。如果在 layout 阶段同时执行 destroy 和 create,可能会出现兄弟组件之间的 effect 互相干扰的情况。通过将 destroy 提前到 mutation 阶段,可以确保在 layout 阶段只看到最新的 effect。

Layout 阶段

Layout 阶段在 DOM 变更完成后执行。此时,所有的 DOM 操作已经完成,浏览器还没有进行绘制。这个阶段执行的 effect 可以同步读取到最新的 DOM 状态:

// packages/react-reconciler/src/ReactFiberCommitWork.js#2951-3100
export function commitLayoutEffects(
  finishedWork: Fiber,
  root: FiberRoot,
  committedLanes: Lanes,
): void {
  inProgressLanes = committedLanes;
  inProgressRoot = root;

  resetComponentEffectTimers();

  const current = finishedWork.alternate;
  commitLayoutEffectOnFiber(root, current, finishedWork, committedLanes);

  inProgressLanes = null;
  inProgressRoot = null;
}

commitLayoutEffectOnFiber 中,React 会:

  1. 递归遍历子树(使用 subtreeFlags & LayoutMask 进行剪枝)
  2. 执行 Layout effect 的 create 函数
  3. 绑定 ref
function recursivelyTraverseLayoutEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // 关键优化:通过 subtreeFlags 判断是否需要遍历
  if (parentFiber.subtreeFlags & LayoutMask) {
    let child = parentFiber.child;
    while (child !== null) {
      const current = child.alternate;
      commitLayoutEffectOnFiber(root, current, child, lanes);
      child = child.sibling;
    }
  }
}
Passive 阶段

Passive 阶段与其他三个阶段最大的不同是:它是异步执行的。React 使用 scheduler 包将 passive effect 的调度推迟到浏览器绘制完成后:

// packages/react-reconciler/src/ReactFiberCommitWork.js#3497-3600
export function commitPassiveMountEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
  renderEndTime: number,
): void {
  resetComponentEffectTimers();

  commitPassiveMountOnFiber(
    root,
    finishedWork,
    committedLanes,
    committedTransitions,
    enableProfilerTimer && enableComponentPerformanceTrack ? renderEndTime : 0,
  );
}

Passive 阶段的异步特性使得它不会阻塞浏览器的绘制,从而保证用户体验的流畅性。这也是 useEffectuseLayoutEffect 的主要区别——前者异步执行,后者同步执行。

Passive Unmount 的处理也很特别:

export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
  resetComponentEffectTimers();
  commitPassiveUnmountOnFiber(finishedWork);
}

function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
  const deletions = parentFiber.deletions;

  if ((parentFiber.flags & ChildDeletion) !== NoFlags) {
    if (deletions !== null) {
      for (let i = 0; i < deletions.length; i++) {
        const childToDelete = deletions[i];
        // 处理被删除子树的 passive unmount effect
        nextEffect = childToDelete;
        commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
          childToDelete,
          parentFiber,
        );
      }
    }
  }

  // 只有子树包含 passive effect 才遍历
  if (parentFiber.subtreeFlags & PassiveMask) {
    let child = parentFiber.child;
    while (child !== null) {
      commitPassiveUnmountOnFiber(child);
      child = child.sibling;
    }
  }
}

值得注意的是,当组件被删除时,React 需要遍历被删除的子树来执行 cleanup 函数。这里使用了迭代遍历而非递归遍历,目的是避免深层递归导致的栈溢出。

effect 是否需要执行的判断

我们已经了解了 effect 在各个阶段的执行流程,但还有一个关键问题没有解答:React 是如何决定某个 effect 是否需要执行的呢?

答案是:通过 HookHasEffect 标记来判断

回顾 effect 对象的 tag 字段,它实际上包含两个维度的信息:

  1. 是否需要执行:由 HookHasEffect(即 HasEffect,二进制 0b0001)标识
  2. 在哪个阶段执行:由 HookInsertionHookLayoutHookPassive 标识

这两个维度的 flag 会组合使用。例如,HookHasEffect | HookPassive 表示这个 effect 需要执行,并且在 passive 阶段执行。

mount 阶段的硬编码逻辑

在 mount 阶段,所有 effect 都会被打上 HookHasEffect 标记:

// packages/react-reconciler/src/ReactFiberHooks.js#1015-1030
function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushSimpleEffect(
    HookHasEffect | hookFlags,  // 硬编码:mount 阶段必打 HookHasEffect
    createEffectInstance(),
    create,
    nextDeps
  );
}

从上面的代码可以看到,mountEffectImpl 函数在创建 effect 对象时,直接将 HookHasEffect | hookFlags 作为 tag 传入。这意味着在 mount 阶段,所有的 effect 都会被执行——这与我们日常开发中的认知一致:组件第一次渲染时,effect 一定会执行一次。

update 阶段的依赖比较逻辑

在 update 阶段,React 需要通过比较依赖数组来决定是否执行 effect:

// packages/react-reconciler/src/ReactFiberHooks.js#1032-1070
function updateEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
): void {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const effect: Effect = hook.memoizedState;
  const inst = effect.inst;

  // currentHook is null on initial mount when rerendering after a render phase
  // state update or for strict mode.
  if (currentHook !== null) {
    if (nextDeps !== null) {
      const prevEffect: Effect = currentHook.memoizedState;
      const prevDeps = prevEffect.deps;
      // $FlowFixMe[incompatible-call] (@poteto)
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 依赖没有变化,不打 HookHasEffect
        hook.memoizedState = pushSimpleEffect(
          hookFlags,  // 注意:这里没有 HookHasEffect
          inst,
          create,
          nextDeps
        );
        return;
      }
    }
  }

  // 依赖有变化,打上 HookHasEffect
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushSimpleEffect(
    HookHasEffect | hookFlags,
    inst,
    create,
    nextDeps
  );
}

从上面的代码可以看到关键逻辑:

  1. 如果依赖没有变化areHookInputsEqual(nextDeps, prevDeps) 返回 true),则 effect 的 tag 只包含 hookFlags,不包含 HookHasEffect。这意味着这个 effect 在本次渲染中不会执行。

  2. 如果依赖有变化,或者没有依赖数组(nextDeps === null),则给 effect 打上 HookHasEffect | hookFlags,effect 会执行。

fiber flag 与 effect flag 的协调

值得注意的是,当一个 effect 被标记为需要执行时(打上 HookHasEffect),React 会同时给 fiber 节点打上对应的 fiber flag:

// mount 阶段
currentlyRenderingFiber.flags |= fiberFlags;  // 给 fiber 打 flag
hook.memoizedState = pushSimpleEffect(
  HookHasEffect | hookFlags,  // 给 effect 打 tag
  ...
);

// update 阶段(依赖变化时)
currentlyRenderingFiber.flags |= fiberFlags;  // 给 fiber 打 flag
hook.memoizedState = pushSimpleEffect(
  HookHasEffect | hookFlags,  // 给 effect 打 tag
  ...
);

这种「双重标记」机制的意义在于:

  • fiber flag(如 PassiveEffectUpdateEffect)用于快速判断整棵子树是否包含需要执行的 effect,从而在 commit 阶段进行剪枝
  • effect flagHookHasEffect)用于精确判断单个 effect 是否需要执行

两者结合,既保证了性能(通过 fiber flag 快速跳过无需处理的子树),又保证了正确性(通过 effect flag 精确控制单个 effect 的执行)。

effect 的遍历框架

在前面的章节中,我们多次提到 effect 存储在「循环链表」中。本节将详细介绍 effect 的存储结构以及 React 是如何遍历这些 effect 的。

Effect 的存储结构

所有 effect 对象都被存储在一个循环链表中。这个链表挂在 fiber 节点的 updateQueue.lastEffect 上:

// FunctionComponentUpdateQueue 类型定义
export type FunctionComponentUpdateQueue = {
  lastEffect: Effect | null,  // 指向 effect 链表的最后一个节点
  events: Array<EventFunctionPayload<any, any, any>> | null,
  stores: Array<StoreConsistencyCheck<any>> | null,
  memoCache: MemoCache | null,
};

// Effect 的存储结构是循环链表
function pushEffectImpl(effect: Effect): Effect {
  let componentUpdateQueue: null | FunctionComponentUpdateQueue =
    (currentlyRenderingFiber.updateQueue: any);
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = (componentUpdateQueue: any);
  }

  const lastEffect = componentUpdateQueue.lastEffect;
  if (lastEffect === null) {
    // 第一个 effect,创建循环链表
    componentUpdateQueue.lastEffect = effect.next = effect;
  } else {
    // 插入到循环链表尾部
    const firstEffect = lastEffect.next;
    lastEffect.next = effect;
    effect.next = firstEffect;
    componentUpdateQueue.lastEffect = effect;
  }
  return effect;
}

从上面的代码可以看出:

  • 当第一个 effect 被插入时,lastEffect 指向它自己,形成一个自环
  • 后续插入的 effect 被插入到 lastEffect 之后,形成循环链表
  • lastEffect 始终指向链表的最后一个节点
  • lastEffect.next 始终指向链表的头部(第一个节点)
Effect 链表的遍历

在 commit 阶段,React 使用 commitHookEffectListMountcommitHookEffectListUnmount 函数来遍历和执行 effect:

// packages/react-reconciler/src/ReactFiberCommitEffects.js#141-200
export function commitHookEffectListMount(
  flags: HookFlags,
  finishedWork: Fiber,
) {
  try {
    const updateQueue: FunctionComponentUpdateQueue | null =
      (finishedWork.updateQueue: any);
    const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
    if (lastEffect !== null) {
      const firstEffect = lastEffect.next;  // 获取第一个 effect
      let effect = firstEffect;
      do {
        if ((effect.tag & flags) === flags) {
          // 匹配成功,执行 effect
          let destroy;
          const create = effect.create;
          const inst = effect.inst;
          destroy = create();
          inst.destroy = destroy;  // 保存 destroy 函数供下次使用
        }
        effect = effect.next;
      } while (effect !== firstEffect);  // 循环遍历直到回到起点
    }
  } catch (error) {
    captureCommitPhaseError(finishedWork, finishedWork.return, error);
  }
}

遍历逻辑的关键点:

  1. lastEffect.next 开始:因为链表是循环的,lastEffect.next 指向头部
  2. 使用 do...while 循环:确保至少遍历一次,然后检查是否回到起点
  3. 位运算过滤(effect.tag & flags) === flags 用于筛选特定阶段/类型的 effect
  4. 循环终止条件effect !== firstEffect,即遍历一圈后回到起点

Unmount 的遍历逻辑类似:

// packages/react-reconciler/src/ReactFiberCommitEffects.js#248-302
export function commitHookEffectListUnmount(
  flags: HookFlags,
  finishedWork: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  try {
    const updateQueue: FunctionComponentUpdateQueue | null =
      (finishedWork.updateQueue: any);
    const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
    if (lastEffect !== null) {
      const firstEffect = lastEffect.next;
      let effect = firstEffect;
      do {
        if ((effect.tag & flags) === flags) {
          const inst = effect.inst;
          const destroy = inst.destroy;
          if (destroy !== undefined) {
            inst.destroy = undefined;  // 清空 destroy 函数
            safelyCallDestroy(finishedWork, nearestMountedAncestor, destroy);
          }
        }
        effect = effect.next;
      } while (effect !== firstEffect);
    }
  } catch (error) {
    captureCommitPhaseError(finishedWork, finishedWork.return, error);
  }
}
递归遍历 vs 迭代遍历

在遍历 Fiber 树执行 effect 时,React 主要使用两种遍历模式:

1. 递归遍历(用于正常流程)

function recursivelyTraverseLayoutEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes,
) {
  // 通过 subtreeFlags 判断是否需要遍历
  if (parentFiber.subtreeFlags & LayoutMask) {
    let child = parentFiber.child;
    while (child !== null) {
      const current = child.alternate;
      commitLayoutEffectOnFiber(root, current, child, lanes);
      child = child.sibling;
    }
  }
}

递归遍历代码清晰易懂,但在处理深层嵌套的组件时可能导致栈溢出。

2. 迭代遍历(用于 Deletion)

当组件被删除时,React 需要遍历被删除的子树来执行 cleanup。这里使用了迭代遍历:

// packages/react-reconciler/src/ReactFiberCommitWork.js#5078-5130
function commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
  deletedSubtreeRoot: Fiber,
  nearestMountedAncestor: Fiber | null,
) {
  while (nextEffect !== null) {
    const fiber = nextEffect;

    // 处理当前节点的 effect
    commitPassiveUnmountInsideDeletedTreeOnFiber(fiber, nearestMountedAncestor);

    const child = fiber.child;
    // 只有包含 PassiveStatic flag 的子树才需要遍历
    if (child !== null) {
      child.return = fiber;
      nextEffect = child;
    } else {
      commitPassiveUnmountEffectsInsideOfDeletedTree_complete(deletedSubtreeRoot);
    }
  }
}

function commitPassiveUnmountEffectsInsideOfDeletedTree_complete(
  deletedSubtreeRoot: Fiber,
) {
  while (nextEffect !== null) {
    const fiber = nextEffect;
    const sibling = fiber.sibling;
    const returnFiber = fiber.return;

    // 清理 Fiber 字段
    detachFiberAfterEffects(fiber);

    if (fiber === deletedSubtreeRoot) {
      nextEffect = null;
      return;
    }

    if (sibling !== null) {
      sibling.return = returnFiber;
      nextEffect = sibling;
      return;
    }

    nextEffect = returnFiber;
  }
}

迭代遍历使用 while 循环和显式的栈(通过 nextEffect 指针)来替代函数调用栈,避免了深层递归导致的栈溢出问题。

遍历的优化策略

React 在 effect 遍历中采用了多种优化策略:

  1. SubtreeFlags 剪枝:在遍历子树之前,先检查 subtreeFlags,如果为 0 则跳过整个子树
  2. Static Flags 优化:在遍历删除的子树时,只遍历带有 PassiveStatic 标记的节点
  3. Effect Tag 过滤:使用位运算 (effect.tag & flags) === flags 快速筛选需要执行的 effect

这些优化策略共同保证了 React 即使在大型应用中也能高效地处理 effect。

细讲常用 hook API 的底层原理

在前面章节中,我们已经了解了 hook 系统的整体架构和 effect hook 的实现细节。本节将深入探讨最常用的两个 hook——useStateuseReducer 的底层实现原理。

为什么说这两个 hook 值得单独拿出来深入讲解?因为它们是 React 状态管理的核心,其实现涉及到更新队列、优先级处理、状态计算等复杂机制,远比其他 hook 复杂。

useState 与 useReducer 的关系

首先,我们需要明确一个关键认知:useState 本质上是 useReducer 的语法糖

从源码中可以清楚地看到这一点:

// packages/react-reconciler/src/ReactFiberHooks.js#1251-1254
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

// packages/react-reconciler/src/ReactFiberHooks.js#1936-1940
function updateState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  return updateReducer(basicStateReducer, initialState);
}

basicStateReducer 是一个简单的 reducer 函数:

  • 如果 action 是函数,则调用该函数并传入当前 state,返回结果
  • 如果 action 不是函数,则直接返回 action 作为新 state

这正是 setState 支持两种调用方式的底层原因:setState(newState)setState(prevState => newState)

mount 阶段的实现

mountState

// packages/react-reconciler/src/ReactFiberHooks.js#1894-1934
function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    const initialStateInitializer = initialState;
    initialState = initialStateInitializer();
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,           // 待处理的更新队列
    lanes: NoLanes,          // 更新所属的赛道(优先级)
    dispatch: null,          // dispatch 函数
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

function mountState<S>(
  initialState: (() => S) | S,
): [S, Dispatch<BasicStateAction<S>>] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch: Dispatch<BasicStateAction<S>> = (dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

mount 阶段的核心工作:

  1. 创建 hook 对象:通过 mountWorkInProgressHook 创建新的 hook
  2. 计算初始状态:如果 initialState 是函数,则执行它获取初始值
  3. 初始化 queue:创建更新队列,初始时 pending 为 null
  4. 绑定 dispatch:创建 dispatchSetState 的绑定函数,并存储到 queue 中

注意这里 memoizedStatebaseState 被设置为相同的初始值。这两个字段的区别在 update 阶段会变得重要。

mountReducer

mountReducer 的实现与 mountState 几乎相同,区别在于:

// packages/react-reconciler/src/ReactFiberHooks.js#1256-1292
function mountReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = mountWorkInProgressHook();
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);  // 支持惰性初始化
  } else {
    initialState = ((initialArg: any): S);
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, A> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: reducer,     // 使用传入的 reducer
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  const dispatch: Dispatch<A> = (dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  ): any);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

主要区别:

  1. 使用用户传入的 reducer 替代 basicStateReducer
  2. 支持 init 函数进行惰性初始化

update 阶段的实现

update 阶段是 useState/useReducer 最复杂的部分,涉及到更新队列的处理、优先级的判断、状态的计算等。

updateReducer 的核心逻辑

// packages/react-reconciler/src/ReactFiberHooks.js#1293-1400
function updateReducer<S, I, A>(
  reducer: (S, A) => S,
  initialArg: I,
  init?: I => S,
): [S, Dispatch<A>] {
  const hook = updateWorkInProgressHook();
  return updateReducerImpl(hook, ((currentHook: any): Hook), reducer);
}

function updateReducerImpl<S, A>(
  hook: Hook,
  current: Hook,
  reducer: (S, A) => S,
): [S, Dispatch<A>] {
  const queue = hook.queue;
  queue.lastRenderedReducer = reducer;

  // 1. 获取 baseQueue 和 pendingQueue
  let baseQueue = hook.baseQueue;
  const pendingQueue = queue.pending;

  // 2. 将 pendingQueue 合并到 baseQueue
  if (pendingQueue !== null) {
    if (baseQueue !== null) {
      // 合并队列:baseQueue 的尾部指向 pendingQueue 的头部
      baseQueue.next = pendingQueue.next;
    } else {
      // 没有 baseQueue,pendingQueue 成为新的 baseQueue
      baseQueue = pendingQueue.next;
    }
    hook.baseQueue = baseQueue;
    queue.pending = null;  // 清空 pendingQueue
  }

  // 3. 计算新的状态
  if (baseQueue !== null) {
    const first = baseQueue.next;
    let newState = hook.baseState;  // 从 baseState 开始计算
    let update = first;

    do {
      const updateLane = update.lane;
      
      // 检查更新优先级是否在当前渲染赛道中
      if (!isSubsetOfLanes(renderLanes, updateLane)) {
        // 优先级不够,跳过这个更新,但保留在 baseQueue 中
        const clone: Update<S, A> = {
          lane: updateLane,
          revertLane: NoLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: null,
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
      } else {
        // 优先级足够,执行更新
        if (newBaseQueueLast !== null) {
          // 如果已经有被跳过的更新,当前更新也要保留
          const clone: Update<S, A> = {
            lane: NoLane,  // 已处理的更新标记为 NoLane
            revertLane: NoLane,
            action: update.action,
            hasEagerState: update.hasEagerState,
            eagerState: update.eagerState,
            next: null,
          };
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
        
        // 计算新状态
        const action = update.action;
        if (update.hasEagerState) {
          newState = ((update.eagerState: any): S);
        } else {
          newState = reducer(newState, action);
        }
      }
      
      update = update.next;
    } while (update !== null && update !== first);

    // 更新 hook 的状态
    if (newBaseQueueLast === null) {
      // 所有更新都被处理了,没有剩余的
      newBaseState = newState;
    } else {
      // 形成新的 baseQueue 循环链表
      newBaseQueueLast.next = newBaseQueueFirst;
    }
    
    hook.memoizedState = newState;
    hook.baseState = newBaseState;
    hook.baseQueue = newBaseQueueLast;
    queue.lastRenderedState = newState;
  }

  const dispatch: Dispatch<A> = (queue.dispatch: any);
  return [hook.memoizedState, dispatch];
}

关键概念解析

1. baseState 与 memoizedState 的区别

这是理解 React 状态更新机制的关键:

  • baseState:本次渲染周期中状态计算的「起点」
  • memoizedState:本次渲染周期中状态计算的「终点」

当所有更新都被处理时,两者相等。但当有更新因为优先级不够被跳过时:

  • baseState 保持在最后一个被完全处理的更新的状态
  • memoizedState 则包含了所有可处理的更新后的最终状态
2. baseQueue 与 pendingQueue
  • pendingQueue:新产生的、尚未被处理的更新队列,存储在 queue.pending
  • baseQueue:等待处理的更新队列,包括之前被跳过的高优先级更新

每次渲染开始时,pendingQueue 会被合并到 baseQueue 中,然后统一处理。

3. 优先级处理

React 使用「赛道(Lane)」模型来管理更新优先级:

if (!isSubsetOfLanes(renderLanes, updateLane)) {
  // 当前更新的优先级不在本次渲染的赛道中,跳过
  // 但保留在 baseQueue 中,等待更高优先级的渲染
}

这种设计使得 React 可以实现「可中断的渲染」——当高优先级更新到来时,可以中断当前的低优先级渲染,优先处理高优先级更新。

dispatchSetState 的实现

当用户调用 setState 时,实际执行的是 dispatchSetState 函数:

// packages/react-reconciler/src/ReactFiberHooks.js#1680-1800
function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A,
): void {
  const lane = requestUpdateLane(fiber);  // 请求更新赛道(优先级)
  
  // 创建更新对象
  const update: Update<S, A> = {
    lane,
    revertLane: NoLane,
    action,
    hasEagerState: false,
    eagerState: (null: any),
    next: null,
  };

  // 乐观更新:如果可能,立即计算新状态
  if (isUnsafeClassRenderPhaseUpdate(fiber)) {
    // 在 render 阶段调用 setState(不安全的做法)
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    // 正常的更新路径
    const root = enqueueConcurrentHookUpdate(fiber, queue, update, lane);
    if (root !== null) {
      const eventTime = requestEventTime();
      scheduleUpdateOnFiber(root, fiber, lane, eventTime);
    }
  }

  // 标记是否有乐观更新
  markUpdateInDevTools(fiber, lane);
}

dispatch 的核心逻辑:

  1. 请求赛道(优先级)requestUpdateLane(fiber) 根据当前上下文决定更新的优先级
  2. 创建更新对象:封装 action 和优先级信息
  3. 入队更新enqueueConcurrentHookUpdate 将更新加入队列
  4. 调度更新scheduleUpdateOnFiber 触发 React 的调度机制

小结

通过深入分析 useState/useReducer 的实现,我们可以总结出以下核心认知:

  1. useState 是 useReducer 的语法糖:通过 basicStateReducer 实现
  2. 状态更新是异步的:dispatch 只是将更新加入队列,真正的状态计算在下次渲染时进行
  3. 支持优先级:通过 Lane 模型实现可中断的渲染
  4. 队列管理:使用 baseQueuependingQueue 管理更新,支持被跳过的更新在后续渲染中恢复

这些机制共同构成了 React 强大而灵活的状态管理系统。

自定义 hook 的本质

在 React 开发中,自定义 hook 已经成为逻辑复用的主流方式。但你是否思考过一个问题:React 源码中有没有专门的「自定义 hook」实现?

答案是:没有

自定义 hook 并不是一个 React 内部的概念,它完全是 JavaScript 函数调用机制和 React Hook 系统自然结合的产物。本节将从两个视角来揭示自定义 hook 的本质。

视角一:洋葱模型——函数嵌套

从代码形态上看,自定义 hook 的本质是遵循洋葱模型的函数嵌套

考虑以下代码:

function App() {
  const [count, setCount] = useCounter(0);  // 调用自定义 hook
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

function useCounter(initialValue) {
  const [count, setCount] = useState(initialValue);  // 调用内置 hook
  useEffect(() => {
    console.log('Count changed:', count);
  }, [count]);
  return [count, setCount];
}

App 组件渲染时,函数调用栈形成了这样的结构:

App (组件函数)
  └── useCounter (自定义 hook)
        ├── useState (内置 hook)
        └── useEffect (内置 hook)

这就像洋葱一样,一层包裹一层。关键在于:所有这些 hook 调用共享同一个全局状态——currentlyRenderingFiberworkInProgressHook

// packages/react-reconciler/src/ReactFiberHooks.js
let currentlyRenderingFiber: Fiber = (null: any);  // 当前渲染的 fiber
let workInProgressHook: Hook | null = null;         // 当前工作的 hook

useCounter 内部调用 useState 时,它并不是在创建一个新的 hook 链表,而是在延续 App 组件已经开始的 hook 链表构建过程。useState 调用的 mountWorkInProgressHook(或 updateWorkInProgressHook)会继续操作同一个链表。

这就是为什么自定义 hook 能够「复用」状态逻辑——它本质上只是将内置 hook 的调用封装在另一个函数中,而这些调用仍然按照它们在组件函数中的调用顺序被加入到同一个 hook 链表中。

视角二:闭包作用域链——变量常驻内存

如果说洋葱模型解释了「hook 调用如何被正确收集」,那么闭包作用域链则解释了「状态为何能在渲染间保持」。

闭包的形成

JavaScript 的函数作用域规则使得每个函数都创建了一个闭包。当自定义 hook 调用内置 hook 时,形成了一条闭包链:

内置 hook 的闭包
  └── 可以访问 workInProgressHook(指向当前 hook)
      └── 可以访问 currentlyRenderingFiber(指向当前 fiber)
          └── fiber.memoizedState(指向 hook 链表头部)

currentlyRenderingFiber 是一个模块级别的变量,在 renderWithHooks 函数中被赋值:

// packages/react-reconciler/src/ReactFiberHooks.js#511-532
function renderWithHooks<Props, SecondArg>(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: (p: Props, arg: SecondArg) => any,
  props: Props,
  secondArg: SecondArg,
  renderLanes: Lanes,
): any {
  // ...
  currentlyRenderingFiber = workInProgress;  // 设置当前渲染的 fiber
  workInProgressHook = null;
  
  // 执行组件函数
  let children = Component(props, secondArg);
  
  // ...
  return children;
}

这意味着,在组件函数及其调用的所有自定义 hook 中,通过闭包都能访问到同一个 currentlyRenderingFiber。这个引用关系使得 hook 对象能够被正确地关联到 fiber 节点上。

变量常驻内存的机制

为什么状态能在多次渲染之间保持?关键在于引用链的维持

  1. Fiber 节点由 React 内部持有:fiber 树是 React 内部维护的数据结构,只要组件存在,对应的 fiber 节点就不会被垃圾回收。

  2. Fiber 持有 hook 链表fiber.memoizedState 指向 hook 链表的头节点。

  3. Hook 对象持有状态值hook.memoizedState 存储了具体的状态值。

// Hook 对象结构
type Hook = {
  memoizedState: any,  // 存储状态值
  baseState: any,
  baseQueue: Update<any, any> | null,
  queue: any,
  next: Hook | null,   // 指向下一个 hook
};
  1. 闭包链阻止垃圾回收:即使组件函数执行完毕,由于闭包的存在,整个引用链仍然可达,因此状态值得以常驻内存。

更新时如何工作

当组件重新渲染时:

  1. React 再次调用组件函数,创建新的执行上下文
  2. currentlyRenderingFiber 仍然指向同一个 fiber 节点
  3. 通过 updateWorkInProgressHook,React 从 fiber 上保存的 hook 链表中恢复状态
  4. 新的渲染基于保存的状态继续计算
function updateWorkInProgressHook(): Hook {
  // 从 current fiber 获取对应位置的 hook
  let nextCurrentHook: null | Hook;
  if (currentHook === null) {
    nextCurrentHook = (currentlyRenderingFiber.alternate: any).memoizedState;
  } else {
    nextCurrentHook = currentHook.next;
  }
  
  // 克隆到 workInProgress fiber
  const newHook: Hook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  };
  
  // ...
  return newHook;
}

两个视角的统一

洋葱模型和闭包作用域链这两个视角是相辅相成的:

视角解释的问题核心机制
洋葱模型为什么 hook 调用顺序不会乱函数调用栈的同步执行保证
闭包作用域链为什么状态能跨渲染保持引用链阻止垃圾回收

洋葱模型保证了在同一个渲染周期内,hook 按照正确的顺序被调用和收集到链表中;闭包作用域链则保证了这些状态能够在多次渲染之间持久化,并在每次渲染时通过闭包访问到。

为什么自定义 hook 能工作

综合以上分析,我们可以回答最初的问题:为什么自定义 hook 能工作?

  1. 没有命名依赖,只有顺序依赖:React 不追踪 hook 的名称,只依赖调用顺序。自定义 hook 只是将内置 hook 的调用封装在函数中,不改变调用顺序。

  2. 共享全局状态:所有 hook 调用(无论直接还是间接)共享 currentlyRenderingFiberworkInProgressHook 这两个全局状态。

  3. 函数调用的自然特性:JavaScript 的函数调用机制天然支持这种嵌套调用,无需额外实现。

  4. 闭包保证状态访问:通过闭包链,hook 函数能够访问到正确的 fiber 和 hook 对象。

这解释了为什么自定义 hook 必须遵守「只在最顶层调用」的规则——因为 React 依赖调用顺序来维护 hook 链表,任何条件调用都会破坏这个顺序,导致状态错乱。

总结

经过前面章节的深入探讨,我们已经全面解析了 React Hook 系统的实现原理。在结束本文之前,让我们回顾并总结核心认知。

Hook 系统的核心设计

React Hook 系统的本质,是基于 Fiber 架构的状态持久化方案。它解决了函数组件无法保持状态的问题,同时保持了函数式编程的简洁性。

整个系统可以概括为以下几个核心机制:

1. Dispatcher Pattern——阶段分发

通过在不同的渲染阶段(mount/update)切换不同的 dispatcher,React 实现了同一 hook API 在不同阶段的差异化行为:

ReactSharedInternals.H =
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount      // 首次挂载
    : HooksDispatcherOnUpdate;    // 后续更新

这种设计的精妙之处在于:对开发者暴露简单的 API,内部实现复杂的逻辑

2. Hook 链表——顺序保证

Hook 不是魔法,而是数据结构。所有 hook 按照调用顺序被组织成一个单向链表,存储在 fiber 节点的 memoizedState 上:

fiber.memoizedState → hook0 → hook1 → hook2 → ...

这种设计解释了为什么 hook 必须「在顶层调用」——React 依赖调用顺序来定位链表中的节点。

3. Effect 链表与 Flag 系统——副作用管理

Effect 类 hook 创建了第二套数据结构——循环链表,并通过双层标记(fiber flag + effect flag)实现精确的副作用控制:

  • Fiber flag 用于快速判断子树是否包含需要执行的 effect
  • Effect flagHookHasEffect)用于精确控制单个 effect 是否执行
  • Static flag 解决 bailout 场景下的 cleanup 问题

4. 更新队列与优先级——可中断的渲染

useState/useReducer 通过更新队列(update queue)和 Lane 优先级模型,实现了:

  • 批量更新:多个 setState 合并为一次重新渲染
  • 可中断渲染:高优先级更新可以中断低优先级更新
  • 状态一致性baseStatememoizedState 的区分保证了复杂更新场景的正确性

React 的设计哲学体现

Hook 系统的设计体现了 React 的核心设计哲学:

声明式

开发者只需要声明「在什么状态下执行什么 effect」,而不需要关心 effect 的注册、调度、清理等底层细节。React 负责将声明式代码转换为高效的命令式操作。

可预测

Hook 的规则(只在顶层调用、只在函数组件中使用)虽然看起来是限制,但实际上保证了状态行为的可预测性。给定相同的调用顺序,hook 总是返回相同的语义结果。

高性能

subtreeFlags 的剪枝优化,到 Static flag 的 bailout 处理,再到 Lane 模型的优先级调度,React 在不增加开发者心智负担的前提下,实现了极致的性能优化。

给开发者的启示

理解 Hook 的实现原理,对日常开发有以下启示:

  1. 遵守规则:Hook 规则不是 arbitrary 的,而是源于链表实现的本质要求。违反规则会破坏链表的顺序一致性。

  2. 合理使用 useEffect:理解 effect 在三个子阶段(mutation/layout/passive)的执行时机,可以帮助你正确选择 useEffect vs useLayoutEffect

  3. 避免过度使用 useMemo/useCallback:这两个 hook 本身也有开销。理解它们的实现(简单的值比较)有助于判断何时真正需要它们。

  4. 自定义 hook 的边界:自定义 hook 没有魔法,它只是函数封装。任何在组件中能做的事(包括条件逻辑)都可以在自定义 hook 中做——只要最终调用的内置 hook 遵守规则。

结语

React Hook 是近年来前端领域最重要的创新之一。它不仅仅是一个 API,更是一种编程范式的转变——从「面向生命周期」到「面向状态逻辑」的转变。

通过深入理解其底层实现,我们不仅能更好地使用它,也能从中汲取设计思想,应用到自己的代码架构中。正如本文开篇所言,Hook 的设计初衷是「用有状态的函数组件代替 class 组件,实现逻辑代码的高内聚低耦合」。理解其原理,是掌握这一编程范式的关键一步。


本文基于 React 19.3.0 源码分析撰写。由于 React 持续演进,部分实现细节可能随版本变化,但核心架构和设计理念保持稳定。