React 19 内核探秘

第7章 Hooks 的实现原理

作者 杨艺韬 · 13,257 字

第7章 Hooks 的实现原理

本章要点

  • Hooks 的数据结构:单向链表与 Fiber 节点的绑定关系
  • useState 的完整实现:mount 阶段与 update 阶段的差异
  • useReducer 的工作机制:与 useState 的同源性
  • useEffect 与 useLayoutEffect 的内部调度差异
  • useRef 为什么是最简单的 Hook——以及它为什么不触发重渲染
  • useMemo 与 useCallback 的缓存策略与失效机制
  • useContext 的订阅模型与性能陷阱
  • Hook 规则的技术根因:为什么不能条件调用 Hook
  • Dispatcher 切换机制:React 如何在不同阶段使用不同的 Hook 实现

Hooks 是 React 16.8 引入的最重大的范式变革。表面上,它让函数组件拥有了状态和副作用的能力;但从内核的角度看,Hooks 的设计远比 API 表面呈现的要精妙得多。

当你写下 const [count, setCount] = useState(0) 时,你可能会好奇:一个看似无状态的函数,每次调用都从头执行,它是怎么”记住”上一次的值的?更关键的是,当同一个组件中有多个 useState,React 是怎么知道哪个 state 对应哪个调用的?

答案藏在两个核心设计中:Fiber 节点上的 Hook 链表基于调用顺序的索引机制

7.1 Hook 的数据结构

每个 Hook 在 React 内部对应一个 Hook 对象,这些对象通过 next 指针串成一条单向链表,挂载在 Fiber 节点的 memoizedState 属性上:

// Hook 的核心数据结构
type Hook = {
  memoizedState: any;        // 当前状态值(不同 Hook 存储不同的东西)
  baseState: any;            // 基准状态(用于并发模式下的状态计算)
  baseQueue: Update<any> | null;  // 上次未处理完的更新队列
  queue: UpdateQueue<any> | null; // 当前待处理的更新队列
  next: Hook | null;         // 指向下一个 Hook
};

// 不同 Hook 的 memoizedState 存储的内容
// useState     → state 值
// useReducer   → state 值
// useEffect    → Effect 对象
// useRef       → { current: value }
// useMemo      → [cachedValue, deps]
// useCallback  → [callback, deps]
// useContext   → context 的当前值(但实际上不使用 Hook 链表)

当 React 渲染一个函数组件时,Hook 链表的构建过程如下:

function MyComponent() {
  const [name, setName] = useState('React');     // Hook 1
  const [count, setCount] = useState(0);          // Hook 2
  const ref = useRef(null);                        // Hook 3
  useEffect(() => { /* ... */ }, [count]);         // Hook 4
  const memoized = useMemo(() => count * 2, [count]); // Hook 5

  return <div>{name}: {count} (x2 = {memoized})</div>;
}
graph LR
    F["Fiber<br/>memoizedState"] --> H1["Hook 1<br/>useState('React')<br/>memoizedState: 'React'"]
    H1 -->|next| H2["Hook 2<br/>useState(0)<br/>memoizedState: 0"]
    H2 -->|next| H3["Hook 3<br/>useRef(null)<br/>memoizedState: {current: null}"]
    H3 -->|next| H4["Hook 4<br/>useEffect<br/>memoizedState: Effect"]
    H4 -->|next| H5["Hook 5<br/>useMemo<br/>memoizedState: [0, [0]]"]
    H5 -->|next| N["null"]

图 7-1:Hook 链表结构示意图

7.2 Dispatcher:Hook 的两张面孔

React 中的每个 Hook API(如 useState)并不是一个固定的实现,而是一个”门面”,它的实际行为取决于当前的 Dispatcher。React 维护了一个全局变量 ReactCurrentDispatcher,在不同的执行阶段会切换到不同的 Dispatcher:

// React 的 Dispatcher 机制
const ReactCurrentDispatcher = {
  current: null as Dispatcher | null,
};

// 三种主要的 Dispatcher
const HooksDispatcherOnMount: Dispatcher = {
  useState: mountState,
  useEffect: mountEffect,
  useRef: mountRef,
  useMemo: mountMemo,
  useCallback: mountCallback,
  useReducer: mountReducer,
  useContext: readContext,
  // ...
};

const HooksDispatcherOnUpdate: Dispatcher = {
  useState: updateState,
  useEffect: updateEffect,
  useRef: updateRef,
  useMemo: updateMemo,
  useCallback: updateCallback,
  useReducer: updateReducer,
  useContext: readContext,
  // ...
};

const InvalidHooksDispatcher: Dispatcher = {
  useState: throwInvalidHookError,
  useEffect: throwInvalidHookError,
  // ... 所有方法都抛错
};

当 React 开始渲染一个函数组件时,会根据情况设置 Dispatcher:

function renderWithHooks(
  current: Fiber | null,
  workInProgress: Fiber,
  Component: Function,
  props: any
) {
  // 根据是首次渲染还是更新,设置不同的 Dispatcher
  if (current !== null && current.memoizedState !== null) {
    // 更新阶段
    ReactCurrentDispatcher.current = HooksDispatcherOnUpdate;
  } else {
    // 首次挂载
    ReactCurrentDispatcher.current = HooksDispatcherOnMount;
  }

  // 执行函数组件
  let children = Component(props);

  // 渲染完成后,将 Dispatcher 设为无效版本
  // 这就是为什么在组件外调用 Hook 会报错
  ReactCurrentDispatcher.current = InvalidHooksDispatcher;

  return children;
}

这就是为什么在组件渲染函数外部调用 useState 会得到一个”Invalid hook call”错误——此时的 Dispatcher 已经被切换为 InvalidHooksDispatcher,所有 Hook 调用都会抛出异常。

7.3 useState 的完整实现

useState 是使用最广泛的 Hook,它的实现也是理解所有 Hook 工作原理的基石。

7.3.1 Mount 阶段:初始化

首次渲染时,useState 调用的是 mountState

function mountState<S>(initialState: (() => S) | S): [S, Dispatch<SetStateAction<S>>] {
  // 1. 创建一个新的 Hook 对象并添加到链表末尾
  const hook = mountWorkInProgressHook();

  // 2. 处理初始值(支持函数形式的惰性初始化)
  if (typeof initialState === 'function') {
    initialState = (initialState as () => S)();
  }

  // 3. 设置初始状态
  hook.memoizedState = initialState;
  hook.baseState = initialState;

  // 4. 创建更新队列
  const queue: UpdateQueue<S> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;

  // 5. 创建 dispatch 函数(即 setState)
  const dispatch = (queue.dispatch = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,
    queue
  ));

  return [hook.memoizedState, dispatch];
}

// mountWorkInProgressHook 负责创建 Hook 对象并链接到链表
function mountWorkInProgressHook(): Hook {
  const hook: Hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };

  if (workInProgressHook === null) {
    // 这是链表的第一个 Hook
    currentlyRenderingFiber.memoizedState = hook;
    workInProgressHook = hook;
  } else {
    // 追加到链表末尾
    workInProgressHook.next = hook;
    workInProgressHook = hook;
  }

  return hook;
}

有一个重要的细节:dispatch 函数通过 bind 绑定了 Fiber 节点和更新队列。这就是为什么 setState 可以在组件外部被调用(比如在 setTimeout 中)——它已经”记住”了要更新哪个组件。

7.3.2 Update 阶段:处理更新

当组件因为 setState 被调用而重新渲染时,useState 调用的是 updateState

function updateState<S>(initialState: (() => S) | S): [S, Dispatch<SetStateAction<S>>] {
  // useState 在 update 阶段本质上就是一个 useReducer
  return updateReducer(basicStateReducer, initialState);
}

// useState 的 reducer 就是这么简单
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function'
    ? (action as (S) => S)(state)
    : action;
}

这揭示了一个重要事实:useState 在内部就是一个使用了 basicStateReduceruseReducer

function updateReducer<S, A>(
  reducer: (S, A) => S,
  initialArg: S
): [S, Dispatch<A>] {
  // 1. 获取当前 Hook(从链表中按顺序取下一个)
  const hook = updateWorkInProgressHook();
  const queue = hook.queue!;

  queue.lastRenderedReducer = reducer;

  const current = currentHook!;
  let baseQueue = current.baseQueue;

  // 2. 将 pending 更新合并到 baseQueue
  const pendingQueue = queue.pending;
  if (pendingQueue !== null) {
    if (baseQueue !== null) {
      // 合并两个环形链表
      const baseFirst = baseQueue.next;
      const pendingFirst = pendingQueue.next;
      baseQueue.next = pendingFirst;
      pendingQueue.next = baseFirst;
    }
    current.baseQueue = baseQueue = pendingQueue;
    queue.pending = null;
  }

  // 3. 逐个处理更新队列中的 update
  if (baseQueue !== null) {
    const first = baseQueue.next;
    let newState = current.baseState;
    let newBaseState = null;
    let newBaseQueueFirst = null;
    let newBaseQueueLast = null;
    let update = first;

    do {
      const updateLane = update.lane;

      if (!isSubsetOfLanes(renderLanes, updateLane)) {
        // 优先级不够,跳过此更新(保留到下次)
        const clone = {
          lane: updateLane,
          action: update.action,
          hasEagerState: update.hasEagerState,
          eagerState: update.eagerState,
          next: null as any,
        };
        if (newBaseQueueLast === null) {
          newBaseQueueFirst = newBaseQueueLast = clone;
          newBaseState = newState;
        } else {
          newBaseQueueLast = newBaseQueueLast.next = clone;
        }
      } else {
        // 优先级足够,计算新状态
        if (update.hasEagerState) {
          // 使用预计算的状态(性能优化)
          newState = update.eagerState;
        } else {
          const action = update.action;
          newState = reducer(newState, action);
        }
      }

      update = update.next;
    } while (update !== null && update !== first);

    hook.memoizedState = newState;
    hook.baseState = newBaseState ?? newState;
    hook.baseQueue = newBaseQueueLast;

    queue.lastRenderedState = newState;
  }

  return [hook.memoizedState, queue.dispatch!];
}

7.3.3 dispatchSetState:更新的触发

当你调用 setCount(count + 1) 时,真正执行的是 dispatchSetState

function dispatchSetState<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  action: A
) {
  // 1. 获取更新优先级
  const lane = requestUpdateLane(fiber);

  // 2. 创建 update 对象
  const update: Update<S, A> = {
    lane,
    action,
    hasEagerState: false,
    eagerState: null,
    next: null as any,
  };

  if (isRenderPhaseUpdate(fiber)) {
    // 在渲染过程中调用 setState(render phase update)
    enqueueRenderPhaseUpdate(queue, update);
  } else {
    // 3. 🔑 关键优化:Eager State
    const alternate = fiber.alternate;
    if (
      fiber.lanes === NoLanes &&
      (alternate === null || alternate.lanes === NoLanes)
    ) {
      // 当前没有其他待处理的更新
      // 可以立即计算新状态,如果和旧状态相同就跳过调度
      const lastRenderedReducer = queue.lastRenderedReducer;
      if (lastRenderedReducer !== null) {
        const currentState = queue.lastRenderedState;
        const eagerState = lastRenderedReducer(currentState, action);
        update.hasEagerState = true;
        update.eagerState = eagerState;

        if (Object.is(eagerState, currentState)) {
          // 新旧状态相同,无需调度更新!
          enqueueUpdate(fiber, queue, update, lane);
          return;
        }
      }
    }

    // 4. 将 update 加入队列
    enqueueUpdate(fiber, queue, update, lane);

    // 5. 调度更新
    const root = scheduleUpdateOnFiber(fiber, lane);
  }
}

这里有一个极其重要的优化——Eager State(急切状态计算)。当组件当前没有其他待处理的更新时,React 会立即计算新状态。如果新状态与旧状态相同(通过 Object.is 比较),React 可以完全跳过这次更新的调度——连 Render 阶段都不需要进入。

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

  // 连续调用两次 setCount(1)
  const handleClick = () => {
    setCount(1); // 第一次:0 → 1,需要更新
    setCount(1); // 第二次:1 → 1,Eager State 优化,跳过
  };

  console.log('render'); // 只会打印一次

  return <button onClick={handleClick}>{count}</button>;
}

7.3.4 更新队列的环形链表

React 的更新队列使用环形链表来存储 update 对象。为什么不用普通链表或数组?

function enqueueUpdate<S, A>(
  fiber: Fiber,
  queue: UpdateQueue<S, A>,
  update: Update<S, A>,
  lane: Lane
) {
  // 环形链表:新节点插入到 pending 和 pending.next 之间
  const pending = queue.pending;
  if (pending === null) {
    // 空队列:自己指向自己
    update.next = update;
  } else {
    // 插入到 pending 之后
    update.next = pending.next;
    pending.next = update;
  }
  // pending 始终指向最后一个插入的 update
  queue.pending = update;
}

环形链表的巧妙之处在于:queue.pending 指向最后一个 update,而 queue.pending.next 指向第一个 update。这样,既能 O(1) 地追加新 update 到末尾,也能 O(1) 地访问到第一个 update,无需维护头尾两个指针。

graph LR
    subgraph "环形链表"
        Q["queue.pending"] --> U3["Update 3<br/>(最新)"]
        U3 -->|next| U1["Update 1<br/>(最早)"]
        U1 -->|next| U2["Update 2"]
        U2 -->|next| U3
    end

图 7-2:更新队列的环形链表结构

7.3.4.1 源码核对:dispatchSetState 的第三个”匿名参数”陷阱 + InvalidNestedHooksDispatcherOnUpdateInDEV

真实 dispatchSetState(ReactFiberHooks.js:2748-2827)和教学版有三处值得细看的差别:

1、对”传第四个参数”的警告(第 2753-2761 行):

if (__DEV__) {
  if (typeof arguments[3] === 'function') {
    console.error(
      "State updates from the useState() and useReducer() Hooks don't support the " +
        'second callback argument. To execute a side effect after ' +
        'rendering, declare it in the component body with useEffect().',
    );
  }
}

这条警告拦截一个只有从 React 15 迁移过来的老用户会犯的错误——this.setState(newState, callback)。React 函数式的 setState 只接受一个参数(新值或 updater),但有人会写 setSomething(newValue, () => { console.log('done') }) ——React 会静默忽略回调,bug 非常难发现。

React 团队把这个错误模式预埋一个 DEV 警告——只要你多传了一个 function 参数,立刻报错”请用 useEffect 实现 rendering 后的副作用”。这是从旧 API 用户的常见误用中学到的产品经验、固化到框架代码里。注意检测用的是 arguments[3]——因为 dispatchSetState 是通过 bind(null, fiber, queue) 预绑前 2 个参数、用户实际传的第 1 个参数是 arguments[2](action)、第 2 个是 arguments[3](那个被误传的 callback)。

2、Eager State 计算期间切换到”invalid hooks”Dispatcher(第 2788-2792 行):

if (__DEV__) {
  prevDispatcher = ReactCurrentDispatcher.current;
  ReactCurrentDispatcher.current =
    InvalidNestedHooksDispatcherOnUpdateInDEV;
}
try {
  const eagerState = lastRenderedReducer(currentState, action);
  ...
}

为什么调 reducer 之前要切 Dispatcher?因为 reducer 里不应该调 Hook——React 设计里 reducer 是纯函数、无副作用、无状态。但用户可能不自觉地在 reducer 里调了 useRef 等——这种违规如果不拦截,会在 render 阶段造成不可预测的行为。InvalidNestedHooksDispatcherOnUpdateInDEV 里所有 Hook 都会立刻报错——让违规在 dispatch 阶段立刻显形,而不是等到实际 render 时才报。

3、enqueueConcurrentHookUpdateAndEagerlyBailout——这个函数名就描述了整个语义:“并发地入队这个 Hook update,并 eagerly bail out(不 schedule 再渲染)“。当 Eager State 判定新旧值相等时,update 仍然入队(保证 reducer 如果之后变了能 rebase)、但不 schedule render。注释里有一行暗示的承诺:“It’s still possible that we’ll need to rebase this update later, if the component re-renders for a different reason and by that time the reducer has changed.”——这是 React 对 “reducer 可能在后续 render 中改变” 的防御代码。

这三条机制叠加起来让 dispatchSetState 从一个”触发 re-render”的简单 API 变成了”检查+预算+发警告+条件 schedule”的精密机器——每一条都是从真实 bug/反模式中积累下的硬防御。

7.3.5 源码核对:updateWorkInProgressHook 的两种路径——新建 vs 复用 WIP

§7.3 给的 mountWorkInProgressHook 是新建 Hook 的路径。真实的 updateWorkInProgressHook(ReactFiberHooks.js:961-1030)做的事要复杂得多——它必须在两种场景下都能正确工作:

  1. 普通 update:从 current.memoizedState 开始按顺序克隆
  2. render-phase update:在同一次 render 中再次渲染(比如 setState 在 render 内被调)——这时 workInProgress 链表已经部分建好,需要复用而不是重建

真实代码里两种情况通过 nextWorkInProgressHook 是否存在来区分:

let nextWorkInProgressHook: null | Hook;
if (workInProgressHook === null) {
  nextWorkInProgressHook = currentlyRenderingFiber.memoizedState;
} else {
  nextWorkInProgressHook = workInProgressHook.next;
}

if (nextWorkInProgressHook !== null) {
  // There's already a work-in-progress. Reuse it.
  workInProgressHook = nextWorkInProgressHook;
  nextWorkInProgressHook = workInProgressHook.next;
  currentHook = nextCurrentHook;
} else {
  // Clone from the current hook.
  if (nextCurrentHook === null) {
    // 抛错:Rendered more hooks than during the previous render.
    throw new Error('Rendered more hooks than during the previous render.');
  }
  currentHook = nextCurrentHook;
  const newHook: Hook = {
    memoizedState: currentHook.memoizedState,
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  };
  // 追加到 workInProgress 链表
}

三条从这段代码能读出的深层机制:

1、“Rendered more hooks than during the previous render.” 的错误来源。这条错误消息几乎每个 React 开发者都见过——它其实是 updateWorkInProgressHooknextCurrentHook === null 路径抛出的。当次渲染调的 Hook 个数比上次多时,在第 N+1 个 Hook 处 currentHook.next 已经为 null——没有可克隆的旧 Hook——只能抛错。

这条错误的根因和解决方案:不要在条件里调 Hook,哪怕是 if (cond) useState(0) 这样无害的条件。因为 cond 在第一次渲染为 true、第二次为 false 时,第二次渲染的 Hook 数比第一次少;反之,第二次多调的那个 Hook 在当前链表里找不到对应的 current——两种方向都会出错。Hook 规则的”严格同序、同数量”不是建议,是算法强制要求

2、render-phase update 的”复用已建 WIP”机制。用户代码里 setState 直接在渲染函数体里被调时(比如 if (needToSync) setState(...)),React 不会启动新的渲染——而是在同一次渲染里记录更新、然后重新运行组件函数。这时第二轮调用走到 useState,WIP 链表已经建好了——nextWorkInProgressHook !== null 的分支让代码直接复用已有 Hook,而不是抛”more hooks”错。

这个分支存在的意义:允许用户在 render 内部无限(实际有上限)次地 setState 并重新渲染,直到状态稳定——这是 React 的 render-phase update 机制,用得少但偶尔非常有用(比如”从 prop 派生 state”的合法实现)。

3、currentHook 和 workInProgressHook 是两个独立的游标。前者指向 current.memoizedState 链表上的当前位置;后者指向 workInProgress.memoizedState 链表上的当前位置。每次 Hook 调用时两者同步往前走——这保证了”第 N 个 Hook 调用能对应旧 Hook 的第 N 个”。如果你想象成一个双指针算法——currentHook 是只读的”历史磁带”、workInProgressHook 是可写的”未来磁带”——这个模型就很清晰了。

7.3.6 源码核对:mountStateImpl 的 initialState lazy 求值

useState 能传两种 initialState:useState(0))和函数useState(() => expensive()))。后者叫 “lazy initial state”——React 保证函数只在 mount 时调用一次。真实 mountStateImpl(ReactFiberHooks.js:1721-1737)体现这一点:

function mountStateImpl<S>(initialState: (() => S) | S): Hook {
  const hook = mountWorkInProgressHook();
  if (typeof initialState === 'function') {
    initialState = initialState();   // ← 这里求值,只在 mount 时
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, BasicStateAction<S>> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: basicStateReducer,    // ← §7.3.2 讲过的内置 reducer
    lastRenderedState: (initialState: any),
  };
  hook.queue = queue;
  return hook;
}

两条容易踩的细节:

1、只在 mount 阶段求值。updateState 走 updateReducer(basicStateReducer, initialState) 根本不看 initialState——update 阶段传入的 initialState 是被忽略的。这意味着你写 useState(() => expensive()),expensive 只在组件首次 mount 时执行一次;后续每次 update,React 完全不碰这个函数引用。

但如果你写 useState(expensive())(去掉箭头——立刻调用),expensive 每次 render 都会执行——虽然 React 只用第一次的结果。这是性能上肉眼可见的差别。为什么会有这条设计?JS 的函数调用语义决定的——useState(expensive()) 里 expensive 的执行发生在 useState 调用之前的参数求值阶段,React 无法拦截。只能依靠用户”把昂贵的计算包在函数里懒执行”——useState(() => expensive())

2、lastRenderedState: (initialState: any) 的作用。这个字段是 §7.3.3 Eager State 优化的基础——React 需要记住”上次 render 时这个 state 的值”,才能在 setState 时立刻用 reducer 算出新值。mount 时 lastRenderedState === initialState——和 memoizedState 相同。每次 render 完成后 queue.lastRenderedState 会被更新(§7.3.2 的 updateReducer 第 318 行)。

这个字段是专门为”立即判断是否要 bailout”服务的——它存在的唯一理由是 eagerState 优化。如果没有 Eager State 机制,React 完全不需要记这个值。React 为了这一项优化专门维护一份额外的状态,说明这个优化在真实场景中的频率足够高——“连续两次 setState 同一个值”在现实 UI 代码里比想象的更常见(controlled input 的同键重复、防抖过程中的重复 fire 等)。

7.4 useReducer:useState 的泛化形式

从源码角度看,useStateuseReducer 的特化版本。两者的区别仅在于 reducer 函数的来源:

// useState 的 mount
function mountState(initialState) {
  const hook = mountWorkInProgressHook();
  // ...
  hook.queue.lastRenderedReducer = basicStateReducer; // 内置 reducer
  // ...
}

// useReducer 的 mount
function mountReducer(reducer, initialArg, init) {
  const hook = mountWorkInProgressHook();
  const initialState = init !== undefined ? init(initialArg) : initialArg;
  hook.memoizedState = initialState;
  // ...
  hook.queue.lastRenderedReducer = reducer; // 用户提供的 reducer
  // ...
}

useReducer 的真正价值在于复杂状态逻辑的封装和复用:

type TodoAction =
  | { type: 'ADD'; text: string }
  | { type: 'TOGGLE'; id: number }
  | { type: 'DELETE'; id: number };

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

function todoReducer(state: Todo[], action: TodoAction): Todo[] {
  switch (action.type) {
    case 'ADD':
      return [...state, {
        id: Date.now(),
        text: action.text,
        completed: false,
      }];
    case 'TOGGLE':
      return state.map((todo) =>
        todo.id === action.id
          ? { ...todo, completed: !todo.completed }
          : todo
      );
    case 'DELETE':
      return state.filter((todo) => todo.id !== action.id);
    default:
      return state;
  }
}

function TodoApp() {
  const [todos, dispatch] = useReducer(todoReducer, []);

  // dispatch 的引用在整个组件生命周期中是稳定的
  // 可以安全地传递给子组件而不需要 useCallback
  return <TodoList todos={todos} dispatch={dispatch} />;
}

一个常被忽视的优势是:dispatch 函数的引用在组件的整个生命周期中是稳定的(与 useStatesetState 一样),可以安全地传递给 React.memo 包裹的子组件。

7.4.1 源码核对:mountReducer 的 init 函数 + useReducer 和 useState 的 dispatch 差异

§7.4 给的 mountReducer 代码只展示了 initialState 的三种来源(直接值 / init 函数 / lazy)。真实 mountReducer(ReactFiberHooks.js:1165-1192)里还有一个dispatch 绑定的差异

function mountReducer<S, I, A>(reducer, initialArg, init) {
  const hook = mountWorkInProgressHook();
  let initialState;
  if (init !== undefined) {
    initialState = init(initialArg);   // 懒初始化
  } else {
    initialState = initialArg;
  }
  hook.memoizedState = hook.baseState = initialState;
  const queue: UpdateQueue<S, A> = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: reducer,
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  const dispatch = queue.dispatch = dispatchReducerAction.bind(
    null,
    currentlyRenderingFiber,
    queue,
  );
  return [hook.memoizedState, dispatch];
}

注意 dispatch 绑的是 dispatchReducerAction——不是 useState 用的 dispatchSetState。两者的区别在哪?打开 dispatchReducerAction(ReactFiberHooks.js:2709):

function dispatchReducerAction<S, A>(fiber, queue, action) {
  // ... 和 dispatchSetState 几乎一样,
  // 但【没有】Eager State 分支——
  // 因为 useReducer 的 reducer 可能是昂贵的、也可能有副作用
  // 不能在 dispatch 时就调 reducer 求值
}

useReducer 不走 Eager State 优化。这是因为 useState 的 reducer 是 basicStateReducer——极其简单、无副作用、可以随便多调几次。useReducer 的 reducer 是用户提供的任意函数——可能很慢(比如深度计算)、可能有 console.log 副作用——React 不能假设它是幂等的、也不应该在 dispatch 阶段提前调用

这条差别解释了一条微妙的性能观察:useReducer(r, 0) + dispatch('INC')useState(0) + setState(n => n+1) 在”连续 dispatch 同样导致 state 不变”场景下行为不同。前者每次都会触发 re-render(因为没 Eager State 比较);后者可以完全 bail out。如果你的 reducer 是纯函数、值稳定、且 reducer 执行很快——两者性能相当;否则用 useState 性能更好。

useReducer 的优势不在性能,在复杂 state 逻辑的封装、type 安全的 action 类型、可测试性——这些价值足以抵消没有 Eager State 的代价。

7.5 useEffect 与 useLayoutEffect

Effect Hook 的实现比 State Hook 更复杂,因为它需要处理副作用的创建、清理和依赖比较。

7.5.1 Effect 的数据结构

type Effect = {
  tag: HookFlags;           // 标识 Effect 类型(Layout/Passive/Insertion)
  create: () => (() => void) | void; // 副作用函数
  destroy: (() => void) | void;      // 清理函数(create 的返回值)
  deps: Array<mixed> | null;         // 依赖数组
  next: Effect;             // 指向下一个 Effect(也是环形链表)
};

Effect 对象不仅存储在 Hook 的 memoizedState 中,还会被添加到 Fiber 的 updateQueue 中——这是一个专门用于 Effect 的环形链表,Commit 阶段通过这个队列来执行副作用。

7.5.2 Mount 阶段

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

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

function mountEffectImpl(
  fiberFlags: Flags,
  hookFlags: HookFlags,
  create: () => (() => void) | void,
  deps: Array<mixed> | void | null
) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;

  // 在 Fiber 上标记存在 effect
  currentlyRenderingFiber.flags |= fiberFlags;

  // 创建 Effect 对象并挂载到 Hook 和 Fiber 的 updateQueue 上
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    undefined, // destroy 在 mount 阶段还没有
    nextDeps
  );
}

注意 useEffectuseLayoutEffect 的差异仅在于它们的 flags 不同:

  • useEffectPassiveEffect + HookPassive
  • useLayoutEffectUpdateEffect + HookLayout

这些 flags 决定了 Commit 阶段在哪个子阶段执行它们。

7.5.3 Update 阶段:依赖比较

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

  if (currentHook !== null) {
    const prevEffect = currentHook.memoizedState;
    destroy = prevEffect.destroy;

    if (nextDeps !== null) {
      const prevDeps = prevEffect.deps;
      // 🔑 逐项比较依赖
      if (areHookInputsEqual(nextDeps, prevDeps)) {
        // 依赖没变,创建一个没有 HookHasEffect 标记的 Effect
        // 这个 Effect 在 Commit 阶段会被跳过
        hook.memoizedState = pushEffect(hookFlags, create, destroy, nextDeps);
        return;
      }
    }
  }

  // 依赖变了,标记需要执行
  currentlyRenderingFiber.flags |= fiberFlags;
  hook.memoizedState = pushEffect(
    HookHasEffect | hookFlags,
    create,
    destroy,
    nextDeps
  );
}

// 依赖比较函数
function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null
): boolean {
  if (prevDeps === null) return false;

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

关键点:当依赖没有变化时,React 仍然会创建一个新的 Effect 对象(因为 Hook 链表需要保持完整),但这个 Effect 没有 HookHasEffect 标记,所以 Commit 阶段会跳过它的执行。

7.6 useRef:最简单的 Hook

useRef 的实现简单到令人惊讶:

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

function updateRef<T>(initialValue: T): { current: T } {
  const hook = updateWorkInProgressHook();
  // 直接返回之前创建的 ref 对象,什么都不做
  return hook.memoizedState;
}

useRef 之所以不会触发重渲染,是因为它从不与 Fiber 的更新机制交互。它既不标记任何 flags,也不创建任何 update。修改 ref.current 就是普通的 JavaScript 对象属性赋值——React 完全不知道这件事发生了。

这也解释了为什么 useRef 可以用来存储任何可变值(不仅仅是 DOM 引用),而且修改它不会导致重渲染:

function Timer() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef<NodeJS.Timeout | null>(null);
  const countRef = useRef(count);

  // 保持 countRef 与最新的 count 同步
  countRef.current = count;

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      // 使用 ref 读取最新的 count,避免闭包陷阱
      console.log('当前 count:', countRef.current);
      setCount((c) => c + 1);
    }, 1000);

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, []); // 空依赖数组,只在挂载时执行

  return <div>{count}</div>;
}

7.5.4 源码核对:pushEffect 的环形链表双写——Hook 链表 和 Fiber updateQueue

§7.5 讲了 Effect 被 push 到 Fiber.updateQueue,但没展开环形链表的维护。真实 pushEffect(ReactFiberHooks.js:1838-1870)是一个精巧的双端维护:

function pushEffect(tag, create, inst, deps): Effect {
  const effect: Effect = {
    tag, create, inst, deps,
    next: null,    // Circular
  };
  let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
  if (componentUpdateQueue === null) {
    componentUpdateQueue = createFunctionComponentUpdateQueue();
    currentlyRenderingFiber.updateQueue = componentUpdateQueue;
    componentUpdateQueue.lastEffect = effect.next = effect;    // 自指环
  } else {
    const lastEffect = componentUpdateQueue.lastEffect;
    if (lastEffect === null) {
      componentUpdateQueue.lastEffect = effect.next = effect;
    } else {
      const firstEffect = lastEffect.next;
      lastEffect.next = effect;                                // 新 effect 接在尾
      effect.next = firstEffect;                               // 指回头
      componentUpdateQueue.lastEffect = effect;                // 尾指针更新
    }
  }
  return effect;
}

三条值得读懂的细节:

1、lastEffect 是尾指针,lastEffect.next 是头。这是 React 贯穿整个代码库的环形链表约定——和 §7.3.4 讲过的 queue.pending 的更新队列是同一个模式。用单一指针维护环形链表——O(1) 追加新 effect、O(1) 访问头——不需要维护独立的 head/tail 两个指针。

2、初始化时的 effect.next = effect 自指。第一个 effect 加入空队列时,它的 next 必须指向自己(形成长度为 1 的环)——保证”从 head 开始遍历一圈能回到起点”这条不变量。如果这里写 effect.next = null,后续 commit 阶段遍历 effect list 时会走到 null 而不是绕回头——语义完全错。

3、effect 同时出现在两个地方:Hook 链表的 hook.memoizedState(让下次 render 能通过 Hook 顺序找到它做依赖比较)+ Fiber 的 updateQueue(让 commit 阶段按 effect 顺序执行)。两个引用指向同一个 effect 对象——不是拷贝——这意味着 commit 阶段执行 effect.destroy 后把结果写回 effect.inst,下次 render 时 Hook 链表里读到的也是新 inst

这条”双挂载”设计让 effect 既享受”按 Hook 调用顺序索引”(被 deps 比较时快速定位),又享受”按执行顺序遍历”(commit 阶段高效执行)。一个对象、两条链表、两套访问语义——这是 React 源码里很典型的对象复用模式。

7.6.1 源码核对:useRef 的 DEV 模式代理 + Object.seal

§7.6 说 useRef 的 mount 实现”简单到令人惊讶”——真实的 mountRef(ReactFiberHooks.js:1893-1959)在生产路径确实是 3 行:

const ref = {current: initialValue};
hook.memoizedState = ref;
return ref;

但在 DEV 模式下、且 enableUseRefAccessWarning flag 开启时,mountRef用 getter/setter 代理包装 ref 对象(第 1908-1946 行),检测渲染期间的 mutable read/write

const ref = {
  get current() {
    if (!hasBeenInitialized) {
      // 懒初始化模式:允许读 undefined 一次
      didCheckForLazyInit = true;
      lazyInitGetterStack = getCallerStackFrame();
    } else if (currentlyRenderingFiber !== null && !didWarnAboutRead) {
      if (lazyInitGetterStack === null ||
          lazyInitGetterStack !== getCallerStackFrame()) {
        didWarnAboutRead = true;
        console.warn(
          '%s: Unsafe read of a mutable value during render.\n\n' +
            'Reading from a ref during render is only safe if:\n' +
            '1. The ref value has not been updated, or\n' +
            '2. The ref holds a lazily-initialized value that is only set once.\n',
          ...
        );
      }
    }
    return current;
  },
  set current(value) {
    if (currentlyRenderingFiber !== null && !didWarnAboutWrite) {
      if (hasBeenInitialized || !didCheckForLazyInit) {
        didWarnAboutWrite = true;
        console.warn(
          '%s: Unsafe write of a mutable value during render.\n\n' +
            'Writing to a ref during render is only safe if the ref holds ' +
            'a lazily-initialized value that is only set once.\n',
          ...
        );
      }
    }
    hasBeenInitialized = true;
    current = value;
  },
};
Object.seal(ref);

四条教学点:

1、 DEV 才有警告、生产零开销。通过 enableUseRefAccessWarning feature flag 门控——现代 React 开发工具能自动检测这条警告,提示你”哪个 useRef 在 render 中被读或写了”。生产 bundle 完全不走这段代码,useRef 开销和”手写 {current: x} 对象”一样。

2、懒初始化模式的例外。如果 initialValue == null 且你在 render 里第一次ref.current = x——被视为”懒初始化”,不警告。这是为了支持 React 官方文档推荐的 useRef(null) + if (!ref.current) ref.current = createInstance() 模式。lazyInitGetterStack 记录第一次读的调用栈,后续同位置读也不警告。

3、didWarnAboutRead / didWarnAboutWrite 每 ref 独立。同一个 ref 的多次违规只警告一次——避免刷屏。但不同 ref 违规分别警告。这条”每违规点最多一次警告”的设计在 React 的多个警告机制里都有出现。

4、Object.seal(ref) 禁止添加新属性。防止用户写 ref.foo = 1——ref 不是随意的可变容器,它只有 current 一个槽位。seal 后试图设置其他属性在严格模式下会 throw。生产版本没 seal 是因为生产 ref 对象没 getter/setter,无所谓额外属性。

这套复杂的 DEV 机制解释了为什么 React 的调试体验比很多框架好——框架在开发环境多花 CPU 换调试信号,警告不是泛泛而谈,而是”你的 ref 在 render 里被读取了,具体在哪一行”。这是一个库 vs 框架的分水岭:好的框架愿意为用户的 DX 付大量额外成本。

7.6.2 源码核对:updateRef 的一行——为什么 “什么都不做”

updateRef(ReactFiberHooks.js:1962-1965)的真实代码只有 3 行:

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

注意**initialValue 参数被完全忽略**。这个行为常让新手困惑——“我每次调 useRef(newValue) 传新的初值,为什么 ref.current 还是老值?“——因为 updateRef 根本不看 initialValue。这个参数只在 mount 时起作用

对 React 来说这条设计一致于 useState——mount 阶段用 initial,update 阶段返回已存的 state。但 useState 至少能通过 setState 改值;useRef 的值只能通过直接 mutate ref.current 来改。

这也解释了为什么用户不该依赖 “useRef 的 initialValue 被赋给 .current”——事实上赋值只发生一次。如果你需要”每次 render 都用最新 prop 初始化 ref”——应该在 render 体或 useEffect 里手动写 ref.current = newValue,不要指望 useRef(newValue) 这种写法。

React 把这条”初值只用一次”的语义固化在源码的”什么都不做”的 updateRef 里——以无为表达规则,这是 API 设计的一种高级形态。

7.7 useMemo 与 useCallback

useMemouseCallback 本质上是同一种缓存机制的两种表现形式:

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;

  if (prevState !== null && nextDeps !== null) {
    const prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      // 依赖没变,返回缓存的值
      return prevState[0];
    }
  }

  // 依赖变了,重新计算
  const nextValue = nextCreate();
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

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 (prevState !== null && nextDeps !== null) {
    const prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      // 依赖没变,返回缓存的回调
      return prevState[0];
    }
  }

  hook.memoizedState = [callback, nextDeps];
  return callback;
}

从实现上看,useCallback(fn, deps) 完全等价于 useMemo(() => fn, deps)。区别仅在于 useMemo 缓存的是函数的返回值,而 useCallback 缓存的是函数本身

7.7.1 何时使用 useMemo 和 useCallback

一个常见的误解是”到处使用 useMemouseCallback 可以提高性能”。实际上,每个 useMemo/useCallback 都有成本:

// ❌ 不必要的 useMemo:简单计算不需要缓存
function Component({ a, b }: { a: number; b: number }) {
  // 加法的计算成本远低于 useMemo 的依赖比较开销
  const sum = useMemo(() => a + b, [a, b]);
  return <div>{sum}</div>;
}

// ✅ 有意义的 useMemo:昂贵的计算
function Component({ items }: { items: Item[] }) {
  const sorted = useMemo(() => {
    // 排序是 O(n log n) 的操作,值得缓存
    return [...items].sort((a, b) => a.score - b.score);
  }, [items]);
  return <List items={sorted} />;
}

// ✅ 有意义的 useCallback:传递给 memo 组件的回调
function Parent({ id }: { id: string }) {
  const handleClick = useCallback(() => {
    fetchData(id);
  }, [id]);

  // 如果不用 useCallback,ExpensiveChild 每次都会重渲染
  return <ExpensiveChild onClick={handleClick} />;
}

const ExpensiveChild = React.memo(({ onClick }: { onClick: () => void }) => {
  // ... 昂贵的渲染逻辑
});

7.7.1 源码核对:areHookInputsEqual 的 DEV 警告体系

§7.7 讲了依赖比较。真实 areHookInputsEqual(ReactFiberHooks.js:439-486)的严格性远比教学版高——它在 DEV 下对两种反模式都发警告:

function areHookInputsEqual(nextDeps, prevDeps) {
  if (__DEV__) {
    if (ignorePreviousDependencies) {
      // Only true when this component is being hot reloaded.
      return false;
    }
  }

  if (prevDeps === null) {
    if (__DEV__) {
      console.error(
        '%s received a final argument during this render, but not during ' +
          'the previous render. Even though the final argument is optional, ' +
          'its type cannot change between renders.',
        currentHookNameInDev,
      );
    }
    return false;
  }

  if (__DEV__) {
    // Don't bother comparing lengths in prod because these arrays should be
    // passed inline.
    if (nextDeps.length !== prevDeps.length) {
      console.error(
        'The final argument passed to %s changed size between renders. The ' +
          'order and size of this array must remain constant.\n\n' +
          'Previous: %s\n' +
          'Incoming: %s',
        currentHookNameInDev,
        `[${prevDeps.join(', ')}]`,
        `[${nextDeps.join(', ')}]`,
      );
    }
  }

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}

三条值得读的细节:

1、HMR 路径:ignorePreviousDependencies 为 true 时直接返回 false。Hot Module Replacement 重新加载组件时,强制让所有 memo/effect 重新运行——因为依赖数组里可能引用了被 HMR 替换过的函数引用。这条 HMR 特殊路径在教学版里几乎从不提及,但它是 Fast Refresh 能保 state 的关键支撑之一(参见第 5 章 §5.5.3)。

2、“缺失依赖数组”警告:上次有 deps、这次没有(或反之)→ DEV 警告 “its type cannot change between renders.”。这条警告对应用户意外地在带 deps 和无 deps 之间切换的场景——语义完全不同(前者受控、后者每次 render 都 fire),React 把这个危险模式显式指出。

3、“数组长度变化”警告:一个 useMemo 的依赖数组从 [a, b] 变成 [a]——React 在 DEV 警告并在最大公共前缀上继续比较。prod 不检查长度是性能优化——React 团队假设你在开发时发现并修复了长度变化问题,prod 跑时不重复校验。

从这套警告体系能推断出一条 React 的 DX 哲学:开发时严格到近乎苛刻、生产时完全不收 CPU 税。这条原则贯穿 React 的每一个 DEV-only 分支。__DEV__ 在生产 bundle 里会被静态替换成 false、整个分支被 dead-code elimination 掉——零运行时代价

7.7.2 源码核对:shouldDoubleInvokeUserFnsInHooksDEV 的双调用

mountMemo/updateMemo(ReactFiberHooks.js:2259-2293)里出现了一个看似奇怪的模式:

if (shouldDoubleInvokeUserFnsInHooksDEV) {
  nextCreate();
}
const nextValue = nextCreate();

当 StrictMode 开启时,React 会故意调用 useMemo 的 create 函数两次——只保留第二次的结果。这是 StrictMode 故意暴露副作用的一种扩展:如果你的 useMemo create 函数有副作用(比如修改外部变量),连续两次调用会让副作用被观察到一次。

这条机制和 StrictMode 下组件函数被双重调用、effect 的 mount/unmount/mount 三段循环是同一条防御性哲学——让一切应该幂等的代码被强制测试幂等性。React 19 强化了这个机制(useMemo 也加入双调用)——因为 Concurrent Mode 下一个 render 可能被丢弃、那时用户的副作用就会被”白调用一次”——提前在 StrictMode 暴露这个可能。

这条机制的实际观察:console.log('memo compute') 在 StrictMode 里打印两次是正常的——这不是 bug,是 React 故意的测试。生产环境只调用一次。

7.8 useContext:没有 Hook 链表的 Hook

useContext 是一个特殊的 Hook——它不使用 Hook 链表来存储状态。它的实现直接读取 Context 的当前值:

function readContext<T>(context: ReactContext<T>): T {
  const value = context._currentValue;

  // 建立 Fiber 到 Context 的依赖关系
  const contextItem: ContextDependency<T> = {
    context,
    memoizedValue: value,
    next: null,
  };

  if (lastContextDependency === null) {
    currentlyRenderingFiber.dependencies = {
      lanes: NoLanes,
      firstContext: contextItem,
    };
    lastContextDependency = contextItem;
  } else {
    lastContextDependency = lastContextDependency.next = contextItem;
  }

  return value;
}

当 Context Provider 的值发生变化时,React 会遍历 Provider 的子树,找到所有依赖该 Context 的 Fiber 节点,并为它们标记更新:

function propagateContextChange<T>(
  workInProgress: Fiber,
  context: ReactContext<T>,
  renderLanes: Lanes
) {
  let fiber = workInProgress.child;

  while (fiber !== null) {
    const list = fiber.dependencies;
    if (list !== null) {
      let dependency = list.firstContext;
      while (dependency !== null) {
        if (dependency.context === context) {
          // 找到了依赖此 Context 的 Fiber
          if (fiber.tag === ClassComponent) {
            // 为 Class 组件创建一个强制更新
            const update = createUpdate(renderLanes);
            update.tag = ForceUpdate;
            enqueueUpdate(fiber, update);
          }
          // 标记更新 lane
          fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
          // 从当前节点一路向上标记 childLanes
          scheduleContextWorkOnParentPath(fiber.return, renderLanes);
          break;
        }
        dependency = dependency.next;
      }
    }
    fiber = fiber.child ?? fiber.sibling ?? /* 向上回溯找兄弟 */;
  }
}

7.8.1 useContext 的性能陷阱

useContext 的一个重要特性(也是常见的性能陷阱)是:只要 Provider 的 value 变化,所有消费该 Context 的组件都会重渲染,即使组件只使用了 value 中的一小部分。

// ❌ 性能问题:整个 value 对象每次渲染都是新引用
function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState('light');

  // 每次渲染都创建新对象 → 所有消费者都重渲染
  const value = { user, setUser, theme, setTheme };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

// ✅ 拆分 Context 或 memoize value
function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState('light');

  // 方案1:memoize value
  const value = useMemo(
    () => ({ user, setUser, theme, setTheme }),
    [user, theme]
  );

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}

// 方案2:拆分为独立的 Context
function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const value = useMemo(() => ({ user, setUser }), [user]);
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState('light');
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

7.8.1 源码核对:renderWithHooks 的 Dispatcher 选择五分支

§7.2 展示了 Dispatcher 机制的两张面孔(Mount / Update)。真实 renderWithHooks(ReactFiberHooks.js:488-582)里的选择逻辑有 5 个分支——Mount/Update 各有 DEV 变种,以及一个专门处理边角情况的 HooksDispatcherOnMountWithHookTypesInDEV:

if (__DEV__) {
  if (current !== null && current.memoizedState !== null) {
    ReactCurrentDispatcher.current = HooksDispatcherOnUpdateInDEV;
  } else if (hookTypesDev !== null) {
    // 上次 render 用过 Hook 但这次 current.memoizedState === null
    // 常发生在 "上次只用了 context"(不上链表)的 update 场景
    // 我们要走 Mount 的生产行为、但保留 DEV 的 Hook 顺序校验
    ReactCurrentDispatcher.current = HooksDispatcherOnMountWithHookTypesInDEV;
  } else {
    ReactCurrentDispatcher.current = HooksDispatcherOnMountInDEV;
  }
} else {
  ReactCurrentDispatcher.current =
    current === null || current.memoizedState === null
      ? HooksDispatcherOnMount
      : HooksDispatcherOnUpdate;
}

这里有一个深埋的设计难题:current.memoizedState === null 不一定代表”这是第一次 mount”。考虑一个只用 useContext 的组件——useContext 不上 Hook 链表、不写 memoizedState——第二次 render 时 current.memoizedState 仍然是 null。如果直接按 “null 等于 mount” 判断,React 会给这个 update 用 Mount Dispatcher——Hook 顺序校验会错乱。

React 的解法:在 DEV 里维护一个 hookTypesDev 数组——记录上次 render 每个 Hook 调用的类型。如果 hookTypesDev !== nullcurrent.memoizedState === null——说明是”上次只调了 non-stateful Hook”的 update——走 HooksDispatcherOnMountWithHookTypesInDEV,它行为上等同于 Mount Dispatcher、但额外校验 Hook 类型与上次一致。

这个边角 case 非常小众——用户几乎不会刻意触发它——但 React 在源码里写了完整处理路径 + 详细注释。这是一个框架在”正确性覆盖率”上的工程投入——把概率 < 1% 的场景也兜住,让 Hook 规则的校验在所有路径下都严格执行。

7.8.2 源码核对:Fast Refresh 通过 ignorePreviousDependencies 跨越 Hook 规则

renderWithHooks 的第 505-508 行有这么一段:

// Used for hot reloading:
ignorePreviousDependencies =
  current !== null && current.type !== workInProgress.type;

当 HMR 检测到 current.type !== workInProgress.type(类型变了)时,设置一个全局 flag 让 areHookInputsEqual 永远返回 false——让所有 useMemo/useEffect/useCallback 的 deps 比较失效、强制重新运行。

为什么要这么做?因为 HMR 替换了组件源码——用户在 useEffect 里引用的函数、在 useMemo 里计算的值,引用身份都变了,但依赖数组里的原始值可能没变(比如 [count] 还是 [count])。如果按正常 deps 比较,React 会认为”不需要重跑”——但实际上 effect 函数已经是新版本、不跑它 HMR 的意义就没了。

ignorePreviousDependencies 的存在让HMR 下所有 memo/effect 强制失效——与 §5.5.3 讲过的 isCompatibleFamilyForHotReloading(保 state)配对——state 保留、memo/effect 重跑——这是 Fast Refresh 的完整语义。

这条机制在普通 debug 时几乎不会被观察到——只在你真正修改组件源码、HMR 触发时生效。但理解它存在能帮你回答一些 “为什么我改代码后 useMemo 立刻重新计算了” 的困惑——答案不是 “React 识别到 deps 变了”,而是 “React 知道你改代码了、故意让所有 deps 比较失效”。

7.8.3 源码核对:didScheduleRenderPhaseUpdateDuringThisPass 的循环重入

renderWithHooks 第 586 行之后有一段关键循环:

let children = Component(props, secondArg);

// Check if there was a render phase update
if (didScheduleRenderPhaseUpdateDuringThisPass) {
  // Keep rendering until the component stabilizes (there are no more render
  // phase updates) or we hit the limit. (The original intent was to prevent
  // infinite loops in case there's a bug.)
  ...
}

Render phase update 发生时(用户在 render 里 setState),React 不会立刻走完 render、而是重跑组件函数直到 state 稳定——最多重跑 25 次(React 的硬编码上限)——防无限循环。

这条机制支持的一个合法模式:从 prop 派生 state。官方不推荐但有时候必须:

function MyInput({ defaultValue }) {
  const [value, setValue] = useState(defaultValue);
  const [lastDefault, setLastDefault] = useState(defaultValue);
  if (defaultValue !== lastDefault) {
    // 在 render 里 setState——React 会重跑这个组件一次
    setLastDefault(defaultValue);
    setValue(defaultValue);
  }
  // ...
}

这段代码在 render 里调 setState——React 的 render phase update 机制会识别并重跑组件。重跑时 lastDefault 已经是新值、if 不再命中——组件稳定输出。这比 useEffect + setState 少一次 commit cycle——性能上肉眼可见的差别。

25 次上限是 React 的兜底。如果你无限触发 render phase update(比如 setFoo(x => x + 1) 总是改变)——React 会抛 “Too many re-renders.” 错误。这条上限是”无限循环检测”的硬防御。

7.9 Hook 规则的技术根因

React 的官方文档说”只在顶层调用 Hook,不要在循环、条件或嵌套函数中调用”。这不是一个任意的约定,而是由 Hook 的底层实现决定的硬性限制。

核心问题在于:React 通过调用顺序来匹配 mount 阶段和 update 阶段的 Hook。updateWorkInProgressHook 的实现就是简单地将指针移动到链表的下一个节点:

function updateWorkInProgressHook(): Hook {
  // 从 current 树的 Hook 链表中获取下一个 Hook
  let nextCurrentHook: Hook | null;

  if (currentHook === null) {
    const current = currentlyRenderingFiber.alternate;
    nextCurrentHook = current !== null ? current.memoizedState : null;
  } else {
    nextCurrentHook = currentHook.next;
  }

  currentHook = nextCurrentHook;

  // 复用或创建对应的 workInProgress Hook
  const newHook: Hook = {
    memoizedState: currentHook!.memoizedState,
    baseState: currentHook!.baseState,
    baseQueue: currentHook!.baseQueue,
    queue: currentHook!.queue,
    next: null,
  };

  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = newHook;
    workInProgressHook = newHook;
  } else {
    workInProgressHook.next = newHook;
    workInProgressHook = newHook;
  }

  return workInProgressHook;
}

如果在条件语句中调用 Hook,调用顺序可能在不同渲染之间发生变化:

// ❌ 这段代码会导致 Hook 错位
function BuggyComponent({ isLoggedIn }: { isLoggedIn: boolean }) {
  const [name, setName] = useState('Guest');    // Hook 1: useState

  if (isLoggedIn) {
    useEffect(() => { /* ... */ });              // Hook 2: useEffect(条件性的!)
  }

  const [count, setCount] = useState(0);         // Hook 3: useState

  // 当 isLoggedIn 从 true 变为 false 时:
  // Mount: Hook1=useState, Hook2=useEffect, Hook3=useState
  // Update: Hook1=useState, Hook2=useState(!!!)  ← 错位了!
  // React 会尝试将 useState 的 update 结构应用到 useEffect 上
  // 结果:运行时错误或状态混乱
}
graph TD
    subgraph "Mount(isLoggedIn=true)"
        M1["Hook 1: useState('Guest')"]
        M2["Hook 2: useEffect"]
        M3["Hook 3: useState(0)"]
        M1 -->|next| M2
        M2 -->|next| M3
    end

    subgraph "Update(isLoggedIn=false)"
        U1["Hook 1: useState ✅"]
        U2["Hook 2: useState ❌<br/>期望 useEffect<br/>实际得到 useState"]
        U1 -->|next| U2
        style U2 fill:#FF6B6B
    end

图 7-3:条件调用 Hook 导致的链表错位

7.10 useId:服务端与客户端的一致性

useId 是 React 18 引入的一个看似简单但设计精妙的 Hook,它解决了 SSR 场景下的 ID 一致性问题:

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

  const root = getWorkInProgressRoot();
  const identifierPrefix = root.identifierPrefix; // 可配置的前缀

  let id: string;
  if (getIsHydrating()) {
    // SSR hydration:从树结构生成确定性 ID
    const treeId = getTreeId();
    id = ':' + identifierPrefix + 'R' + treeId;
  } else {
    // 客户端渲染:使用自增计数器
    const globalClientId = globalClientIdCounter++;
    id = ':' + identifierPrefix + 'r' + globalClientId.toString(32);
  }

  hook.memoizedState = id;
  return id;
}

useId 生成的 ID 有一个特殊格式(如 :R1::r0:),确保不会与用户自定义的 ID 冲突。在 SSR 场景下,它基于组件在树中的位置生成确定性的 ID,保证服务端和客户端渲染出相同的值。

7.11 Hook 的调试与开发者体验

在开发模式下,React 会做额外的检查来帮助开发者发现 Hook 使用错误:

// 开发模式下的 Hook 调用检查
if (__DEV__) {
  // 记录每次渲染中 Hook 的调用顺序
  const hookTypesDev: Array<HookType> = [];

  function mountHookTypesDev() {
    // mount 阶段记录调用顺序
    hookTypesDev.push('useState'); // 或 'useEffect' 等
  }

  function updateHookTypesDev() {
    // update 阶段验证调用顺序是否一致
    const hookName = hookTypesDev[hookTypesUpdateIndexDev++];
    if (hookName !== currentHookNameInDev) {
      console.error(
        'React has detected a change in the order of Hooks called by %s. ' +
          'This will lead to bugs and errors if not fixed. ' +
          'For more information, read the Rules of Hooks: ...',
        currentComponentName
      );
    }
  }
}

React 还利用 Dispatcher 切换来检测组件外部的 Hook 调用:当函数组件执行完毕后,Dispatcher 会被切换为 InvalidHooksDispatcher,此后任何 Hook 调用都会抛出清晰的错误信息。

7.10.1 源码核对:mountId 的 useId 实现 + getTreeId 的 2 进制字符串

§7.10 给了 useId 的高层原理,没有展开服务端和客户端如何生成一致 ID的具体机制。真实的 mountId(ReactFiberHooks.js 附近,随不同 React 版本变化)依赖两个配对机制:

1、TreeId——每个 Fiber 在树中有一个唯一的路径 ID,基于父 Fiber 和在兄弟节点中的位置。想象成一个二进制字符串——根节点是空,子节点根据”在父节点的第几个 child”编码。两个不同的 Fiber 有不同的 TreeId;SSR 和 Client 只要树结构一致,对应位置的 Fiber 的 TreeId 也一致。

2、localIdCounter——每个组件内部调的 useId 在同一个 Fiber 里依次拿 0, 1, 2…。第 N 个 useId 拿到 counter N。

useId 的最终 ID 是 TreeId + localIdCounter 的组合。服务端和客户端只要走完全相同的 render 路径、调相同数量的 useId,就能产出完全相同的 ID。这让 <label htmlFor={id}><input id={id}> 在 SSR 与 hydration 后都指向同一个 DOM 节点,不会出现 “SSR 和 CSR ID 不一致导致 hydration 警告” 的问题。

这套机制背后的一条假设:SSR 和 CSR 的 render 输出完全一致——任何一方多调一个 useId、多 render 一层 Fiber、走不同分支——ID 都会错乱。这也是为什么 React 推荐用户”在组件里用 useId、不要自己 Math.random”——只要相信框架、框架就能保证一致性;自己生成 ID 就得自己承担 SSR/CSR 对齐的责任。

这个”合作保证一致性”的设计模式在 React 的 SSR 架构里反复出现——框架和用户代码约定好一套规则,双方都遵守就能得到免费的跨端一致性。useId 只是一个具体例子。

7.11.0 源码定位索引

为便于读者按图索骥核实本章说法,列出引用的源码锚点:

小节源文件函数 / 行号
§7.1-2ReactFiberHooks.js整体
§7.3-7.3.1同上mountWorkInProgressHook:940-959
§7.3.2-3同上updateReducer / dispatchSetState:2748
§7.3.4同上环形链表 enqueueUpdate
§7.3.4.1同上dispatchSetState 完整:2748-2827 / InvalidNestedHooksDispatcherOnUpdateInDEV
§7.3.5同上updateWorkInProgressHook:961-1030
§7.3.6同上mountStateImpl:1721-1737
§7.4同上mountReducer:1165 / updateReducerImpl:1203
§7.5-5.3同上mountEffectImpl / updateEffectImpl
§7.5.4同上pushEffect:1838-1870
§7.6-6.2同上mountRef:1893-1959 / updateRef:1962-1965
§7.7-7.2同上mountMemo:2259 / updateMemo:2273 / areHookInputsEqual:439-486 / shouldDoubleInvokeUserFnsInHooksDEV
§7.8同上useContext(readContext 转发)
§7.8.1同上renderWithHooks:488-548 Dispatcher 选择
§7.8.2同上ignorePreviousDependencies:506
§7.8.3同上didScheduleRenderPhaseUpdateDuringThisPass:586
§7.9同上Hook 规则(综合性讨论)
§7.10同上useId 实现

源码版本:opensource/react commit 546fe4681。行号在更新版本后可能偏移,但函数名和数据结构应稳定。

7.11.1 本章与全书体系的呼应

Hooks 是 React 最小但最核心的 API 面——理解了本章,相当于理解了 React 函数组件时代的整套范式。但这一章不是孤立的知识点——它和本书其他章节的接合极其密集,梳理一下能让整个 React 内核的逻辑链变得透明:

与第 3 章(Fiber 架构)的绑定:Hook 链表挂在 Fiber.memoizedState 上——Hook 的生命周期跟 Fiber 一一对应。Fiber 复用时(§5.5)Hook 链表跟着复用;Fiber 重建时(type 变化)Hook 链表整条丢弃。本章反复出现的 “mountWorkInProgressHook / updateWorkInProgressHook” 实际就是”给当前正在构建的 WIP Fiber 添加/克隆 Hook”——WIP Fiber 是 Hook 链表的容器,Hook 链表是 WIP Fiber 的 payload

与第 5 章(Reconciliation)的延伸:§5.5.3 讲过”Fiber 复用条件”——elementType 相同、HMR 兼容、Lazy resolve 匹配。本章 §7.8.2 补了第四条:HMR 时 ignorePreviousDependencies 让 memo 重跑——这条让 state 保留、memo 重算的行为组合是 Fast Refresh 能工作的完整拼图。

与第 6 章(Commit)的交接:本章 §7.5.4 讲过 effect 挂载到 Fiber.updateQueue;第 6 章讲 commit 阶段如何遍历这条 updateQueue 调 effect.destroy → effect.create。Effect 的完整生命周期是 renderWithHooks 阶段 push 到队列 + commit 阶段按优先级遍历执行——两章合起来才是完整故事。

与第 8 章(React 19 新 Hooks)的过渡:本章讲的 useState/useReducer/useMemo/useCallback/useRef/useContext 是 React 16.8 的基石;第 8 章讲的 use/useActionState/useFormStatus/useOptimistic 是 React 19 的新成员。后者的实现都复用了本章的基础设施——useOptimistic 复用 updateReducerImpl 的 revertLane 分支、use 走独立的 thenableState 但绕不开 Hook 链表索引机制——新 Hook 是旧 Hook 基础设施的重新组合,不是另起炉灶

与第 9 章(并发模式)的互补:本章讲 Hook 的数据结构和 dispatch 路径;第 9 章讲 Hook 的优先级调度(Lane 模型)和中断恢复。Eager State 优化(§7.3.3)之所以能 bail out、render phase update(§7.8.3)之所以最多 25 次重跑、dispatchSetState 里 entangleTransitionUpdate 的调用——这些都在 Lane 的优先级框架里被精确定义。

这套”Hook 基础设施 + Fiber 容器 + Commit 执行 + Lane 调度”四层架构是 React 函数组件能运行的最小可行组合——砍掉任何一层整个系统就崩。读完本章再读第 8/9 章时,你会发现React 19 的所有 “新” 特性实际上是这四层的重新组合——没有新层、只有新组合。这种架构上的连续性是 React 作为一个演进了 8 年的大型前端框架最可贵的财富。

7.11.3 几个常见误区的源码级厘清

本章接近尾声时,再澄清几个业界流传但源码层面并不准确的说法:

误区一:useMemo 总是免费的缓存。真相是 useMemo 本身有开销——调 updateWorkInProgressHook 克隆 Hook、areHookInputsEqual 遍历 deps 数组做 Object.is 比较、返回缓存值。小型计算(x * 2)加 useMemo 反而比直接计算慢,因为 Hook 开销远大于运算。React 官方 docs 也明确说过:“Do not try to preemptively add useMemo or memoization.”——只对昂贵计算用 useMemo,默认不加

误区二:useCallback 让组件更快。真相是 useCallback 只在子组件用 React.memo 比较 props 时才有意义——否则 callback 引用的稳定性谁都不看。如果你的 callback 传给一个普通子组件(没 memo)或用在 useEffect 依赖里(而 effect 本来就应该跑),useCallback 什么都没优化、只增加了开销。

误区三:useRef 可以做 state 用。真相是 ref 的修改不触发 re-render——用 ref 存”当前 count”然后在 render 里读 countRef.current,UI 永远显示初始值。ref 适合存”render 之外访问的可变数据”——定时器 ID、socket 实例、上次的 props——不适合替代 state。

误区四:useContext 是性能杀手。真相是context 本身不慢——它的实现(readContext)只是一次 O(1) 查值。慢的是 context value 的引用变化导致所有消费者 re-render——和 context 机制无关,是引用稳定性问题。正确的解决方案是 useMemo 稳定 value、或把 state 和 dispatch 分成两个 context(消费 dispatch 的组件不会因 state 变而 re-render)。

误区五:函数组件比 class 组件快。真相是两者 render 性能相当——Fiber 架构下都是走同一个 reconciler 路径。函数组件的优势在代码组织(hooks 复用逻辑)而非性能。如果你听过”function 组件更快”,那是 React 15 时代的老数据——16.8 之后两者等价。

这五条误区在业界被反复复述、写进博客、甚至进了面试题库——但从本章讲的源码角度看,它们都站不住脚。读过源码的程序员在面试时能反驳这些误区,这是”会用”和”精通”的一条可见分界。

7.11.2 读完本章能回答的具体问题清单

作为本章的自测表,下列 13 个问题的答案都在本章的源码锚点里——如果你能不回源码说出原理,你对 Hooks 内核的掌握已经是”能教别人”级别:

  1. 为什么 Hook 不能在 if 里调?(§7.9——Hook 链表按顺序索引,多调少调都会让 updateWorkInProgressHook 抛 “Rendered more hooks”)
  2. useState(expensive())useState(() => expensive()) 差在哪?(§7.3.6——前者每次 render 都算、后者只算一次)
  3. setCount(1); setCount(1); 会触发几次 render?(§7.3.3——一次,Eager State 识别新旧值相同并 bail out)
  4. reducer 里能调 Hook 吗?(§7.3.4.1——不能,React 在 dispatch 时切 Dispatcher 到 Invalid,调 Hook 会立刻报错)
  5. 为什么 StrictMode 下 useMemo 的 create 函数执行两次?(§7.7.2——shouldDoubleInvokeUserFnsInHooksDEV 故意调用两次测试幂等)
  6. HMR 保 state 但 useMemo 重算是为什么?(§7.8.2——ignorePreviousDependencies 让 areHookInputsEqual 永远返回 false)
  7. useRef(newValue) 第二次传会把 current 改成 newValue 吗?(§7.6.2——不会,updateRef 忽略参数)
  8. 什么是 “Rendered more hooks than during the previous render.” 错误的根因?(§7.3.5——nextCurrentHook === null 但 WIP 需要继续走)
  9. setState(newValue, callback) 为什么没效果?(§7.3.4.1——React 函数式 setState 不支持 callback,DEV 下会有明确警告)
  10. render 里 setState 会无限循环吗?(§7.8.3——React 最多重跑 25 次,超过抛 “Too many re-renders.”)
  11. useEffectuseLayoutEffect 源码层差异是什么?(§7.5.2——只差 flags:PassiveEffect vs UpdateEffect)
  12. useReducer 和 useState 源码层的本质差异?(§7.4 + §7.4.1——useReducer 不走 Eager State 优化)
  13. useId 如何保证 SSR 和 CSR 一致?(§7.10.1——TreeId + localIdCounter 的组合,依赖两端 render 路径一致)

如果这 13 个问题你都能回答——你对 React Hooks 内核的理解已经超越 99% 的 React 使用者。Hooks 不是一组孤立 API,而是一套由链表、队列、Dispatcher、lane 和 effect flags 共同维持的状态机器;真正掌握它,意味着你能从一次业务异常倒推出底层状态迁移。

实操建议:把 ReactFiberHooks.js 这 4000+ 行源码在 VSCode 里打开、跟着本章提到的每一个函数跳转一遍,会让这些源码锚点在你脑海里形成立体的地图——以后写业务代码时,看到 useState、useEffect、useMemo,你会下意识地知道”这行背后 React 在做什么、开销是什么、有没有可能优化”。这种预判力就是从”会用 Hook”跃迁到”精通 Hook”的分水岭。

最后提一条读 React 源码的抓手:Hook 文件里很多代码看似重复(mount/update 两版、sync/concurrent 两版、DEV/prod 两版)——不要被表面吓退。抓两件事:数据结构演进(memoizedState / queue / baseQueue 在每个函数里的状态)和 Dispatcher 切换时机(§7.8.1 的 5 个分支)。理解这两条线,整个 Hook 系统的逻辑就串起来了。其余细节是层层叠加的边界保护——读的时候知道它们在干什么、不需要死记。

7.12 本章小结

Hooks 的设计是 React 中最精巧的工程之一。它用一条简单的单向链表和一个基于调用顺序的索引机制,为函数组件带来了完整的状态管理和副作用处理能力。

关键要点:

  1. Hook 链表是 Hooks 系统的基础:每个 Hook 对应一个 Hook 对象,通过 next 指针串成链表,挂在 Fiber 的 memoizedState
  2. Dispatcher 切换实现了同一 API 的不同行为:mount 阶段创建链表,update 阶段遍历链表
  3. useState 就是 useReducer:内部使用 basicStateReducer,两者共享更新队列和处理逻辑
  4. Eager State 是重要的性能优化:在没有其他待处理更新时,可以直接计算新状态并跳过调度
  5. useEffect 和 useLayoutEffect 的差异只在 flags:不同的 flags 决定了在 Commit 阶段的不同子阶段执行
  6. Hook 规则不是约定,是硬限制:调用顺序必须一致,否则链表会错位
  7. useRef 是最纯粹的”容器”:不参与任何更新机制,修改 current 完全是普通的 JS 操作

在下一章中,我们将探索 React 19 引入的新 Hooks 和 API——useuseFormStatususeOptimistic 等,看看它们如何在这个 Hook 体系之上构建出更强大的能力。


思考题

  1. 为什么 React 选择单向链表而不是数组来存储 Hook? 考虑在并发模式下,同一个组件可能需要同时维护多个”进行中”的 Hook 状态。链表和数组在这种场景下的表现有何差异?

  2. useState 的 Eager State 优化有一个限制条件:当前 Fiber 没有其他待处理的更新。 为什么需要这个条件?构造一个反例,说明在有待处理更新时进行 Eager State 计算可能得到错误的结果。

  3. useRef 不会触发重渲染,那 forwardRef + useImperativeHandle 是如何将子组件的方法暴露给父组件的? 追踪 useImperativeHandle 的源码实现,它本质上使用了哪个 Hook?

  4. 在一个使用了 3 个 useContext 的组件中,如果其中一个 Context 的值发生了变化,另外两个 Context 的值是否会被重新读取?propagateContextChange 和组件重渲染的流程来分析。