Dive into React——Hooks 原理


考点 2.1:Hooks 链表结构(memoizedState)

第 0 段:直觉锚定

想象你在组装一条手链。每个珠子就是一个 Hook 调用(useStateuseEffectuseMemo......),珠子之间用线串起来,形成一条单向链表 。手链的 挂在组件的 Fiber 节点上(fiber.memoizedState),next 指向 null

关键规则是:串珠子的顺序永远不能变 。第一次渲染串了 5 颗珠子(state → effect → ref → memo → callback),那之后每次重新渲染,必须按完全相同的顺序 再串一遍。React 不记"第 N 个珠子叫什么名字",它只认位置------"第 2 颗珠子就是上次第 2 颗珠子的接班人"。如果你某次渲染把第 2 颗珠子跳过了,后面所有珠子都会对错位,这就是 React 报错 "Rendered more hooks than during the previous render" 的根因。


第 1 段:问题背景

函数组件没有 this,状态存在哪?

Class 组件有 this.statethis.props,天然可以存储状态。函数组件就是个纯函数,每次调用都是全新的局部变量,没有任何地方可以跨渲染保持数据

React 需要解决两个问题:

  1. 存储:函数组件的状态存放在哪里?
  2. 寻址:每次渲染时,第 N 次 Hook 调用怎么找到上次对应的那个状态?

答案:存在 Fiber 上,用链表顺序寻址

React 把每个 Hook 的状态封装成一个 Hook 对象 ,挂在对应 Fiber 节点的 memoizedState 字段上,通过 next 指针串成链表。

为什么要用链表而不是数组? 因为链表天然表达"一个接一个"的顺序语义,next 指针的遍历过程和 Hook 调用顺序完全一致。React 不需要额外维护索引计数器------每次调用 Hook 函数时,内部就顺着 next 往前走一格。用数组当然也能实现,但链表在克隆/复用时更方便(更新时逐节点从 current 树克隆,无需整体拷贝)。


第 2 段:核心数据结构

Hook 对象的完整结构

源码位于 ReactFiberHooks.js:194

typescript 复制代码
Hook {
  memoizedState: any              // 当前 Hook 的"记忆值"(不同 Hook 类型存不同东西)
  baseState: any                  // 更新计算的基础状态(用于 useState/useReducer 的计算)
  baseQueue: Update | null        // 尚未处理完的更新链表(优先级被跳过的更新暂存处)
  queue: UpdateQueue | null       // 更新队列(dispatch 触发的 action 都排在这里)
  next: Hook | null               // 指向下一个 Hook
}

memoizedState 在不同 Hook 中存什么

Hook 类型 memoizedState 存储内容 源码位置
useState 当前 state 值(如 0"hello" ReactFiberHooks.js:1910
useReducer 当前 state 值 同上(共用 mountStateImpl 逻辑)
useEffect Effect 对象 {tag, inst, create, deps, next} ReactFiberHooks.js:2623
useMemo [cachedValue, depsArray] ReactFiberHooks.js:2931
useCallback [callbackFn, depsArray] ReactFiberHooks.js:2898
useRef {current: initialValue} ReactFiberHooks.js:2604

注意: memoizedState 这个名字在 Fiber 节点层面和 Hook 层面含义不同:

  • fiber.memoizedState → 对于函数组件,指向 Hook 链表的头节点
  • hook.memoizedState → 这个 Hook 自己存储的值

链表在 Fiber 上的位置

scss 复制代码
FiberNode (函数组件)
  ├── memoizedState ──→ [Hook#1: useState] ──→ [Hook#2: useEffect] ──→ [Hook#3: useRef] ──→ null
  │                       ↑ 链表头                                                 ↑ 链表尾
  ├── updateQueue ──→ FunctionComponentUpdateQueue { lastEffect, events, stores, ... }
  │                        ↑ 注意:effect 链表的头尾由 updateQueue.lastEffect 管理,
  │                           不在 Hook 链表本身上
  └── ...

一个容易混淆的点: useEffect 的 Hook 对象中 memoizedState 存的是单个 Effect 对象。但组件可能有多个 effect,它们通过 Effect.next 串成环形链表 ,由 fiber.updateQueue.lastEffect 指向尾部(即尾部.next 回到头部)。这和 Hook 链表是两条不同的链表------Hook 链表管理"第几个 Hook",Effect 链表管理"哪些 Effect 需要执行"。


第 3 段:运行流程

首次渲染(mount)------链表的构建

入口: renderWithHooks()ReactFiberHooks.js:502

ini 复制代码
// 每次渲染前,先把工作区的 Hook 链表清空
workInProgress.memoizedState = null;   // ← 第 526 行
workInProgress.updateQueue = null;
​
// 根据 current 树的状态,选择 mount 或 update 的 dispatcher
ReactSharedInternals.H =
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount     // 首次渲染,用 mount 版 Hook
    : HooksDispatcherOnUpdate;   // 更新渲染,用 update 版 Hook

然后组件函数开始执行。假设组件是:

scss 复制代码
function App() {
  const [count, setCount] = useState(0);      // Hook#1
  const [name, setName] = useState("React");  // Hook#2
  useEffect(() => { /* ... */ }, [count]);    // Hook#3
  return <div>{count}</div>;
}

每次调用 Hook 函数时 ,内部都会调用 mountWorkInProgressHook()(第 979 行):

csharp 复制代码
function mountWorkInProgressHook(): Hook {
  const hook = {
    memoizedState: null,
    baseState: null,
    baseQueue: null,
    queue: null,
    next: null,
  };
​
  if (workInProgressHook === null) {
    // 第一个 Hook:链表头挂在 fiber.memoizedState 上
    currentlyRenderingFiber.memoizedState = workInProgressHook = hook;
  } else {
    // 后续 Hook:追加到链表尾部
    workInProgressHook = workInProgressHook.next = hook;
  }
  return workInProgressHook;
}

构建过程:

ini 复制代码
调用 useState(0):
  workInProgressHook === null
  → fiber.memoizedState = hook1
  → hook1.memoizedState = 0, hook1.next = null
​
调用 useState("React"):
  workInProgressHook === hook1
  → hook1.next = hook2
  → hook2.memoizedState = "React", hook2.next = null
​
调用 useEffect(...):
  workInProgressHook === hook2
  → hook2.next = hook3
  → hook3.memoizedState = Effect{...}, hook3.next = null
​
最终结果:
  fiber.memoizedState → hook1 → hook2 → hook3 → null

更新渲染(update)------链表的克隆

更新时调用的是 updateWorkInProgressHook()(第 1000 行)。它的逻辑是从 current 树的 Hook 链表逐个克隆到 workInProgress 树

ini 复制代码
function updateWorkInProgressHook(): Hook {
  // 1. 从 current 树找到对应的 Hook
  if (currentHook === null) {
    nextCurrentHook = currentlyRenderingFiber.alternate.memoizedState;  // current 树链表头
  } else {
    nextCurrentHook = currentHook.next;  // 往前走一格
  }
​
  // 2. 克隆 current Hook 的数据,创建新的 Hook 节点
  const newHook = {
    memoizedState: currentHook.memoizedState,  // 复用上次的值
    baseState: currentHook.baseState,
    baseQueue: currentHook.baseQueue,
    queue: currentHook.queue,
    next: null,
  };
​
  // 3. 挂到 workInProgress 的链表上
  if (workInProgressHook === null) {
    currentlyRenderingFiber.memoizedState = workInProgressHook = newHook;
  } else {
    workInProgressHook = workInProgressHook.next = newHook;
  }
​
  currentHook = nextCurrentHook;
  return workInProgressHook;
}

关键洞察: 更新时 React 不是原地修改 Hook 链表,而是逐个克隆。这样 workInProgress 树拥有自己的 Hook 链表副本,渲染中途即使被中断,current 树的链表也不会被破坏。

错误检测:Hook 数量不匹配

源码第 1044 行:

javascript 复制代码
if (nextCurrentHook === null) {
  throw new Error('Rendered more hooks than during the previous render.');
}

如果这次渲染调用的 Hook 数量比上次多,currentHook.next 已经是 null 了,再调用 Hook 就会走进这个分支------这就是 React 检测 Hook 规则违规的机制。


第 4 段:设计动机与权衡

为什么不用数组而用链表

维度 数组 链表(React 选择的)
随机访问 O(1) O(n)
顺序遍历 O(n),需要索引计数器 O(n),next 指针自然推进
克隆单个节点 需要拷贝整个数组或用 index 映射 只克隆当前节点,O(1)
内存布局 连续内存,缓存友好 分散在堆上,缓存不友好

React 选择链表的原因:

  1. 克隆更高效:更新时逐个克隆 current Hook,不需要一次性拷贝整个数组
  2. 不需要索引 :遍历就是 hook = hook.next,比维护 hooks[index] 的心智模型更简单
  3. Hook 数量不固定:不同组件的 Hook 数量不同,链表天然适应任意长度

为什么"不能在条件语句里用 Hook"

这直接源于链表顺序寻址的设计。假设:

scss 复制代码
function App({ show }) {
  const [a, setA] = useState(0);     // Hook#1
  if (show) {
    const [b, setB] = useState(1);   // Hook#2(条件性)
  }
  const [c, setC] = useState(2);     // Hook#3 或 Hook#2
}

第一次渲染 show=true:链表是 a → b → c → null(3 个节点) 第二次渲染 show=false:链表变成 a → c → null(2 个节点)

updateWorkInProgressHook 从 current 树取 currentHook.next 时,期望拿到的是 b 的数据,实际拿到的却是 c------链表对位错乱,React 会报错或者用错数据。

Hook 链表与 Fiber 双缓存的关系

回顾考点 1.3 的双缓冲机制:React 维护两棵 Fiber 树(current 和 workInProgress)。Hook 链表也是双份的:

  • current.memoizedState → 上一次渲染的 Hook 链表
  • workInProgress.memoizedState → 本次渲染正在构建的 Hook 链表

更新时,updateWorkInProgressHookcurrent 链表逐个克隆到 workInProgress 链表。如果渲染中途被中断,workInProgress 的半成品链表直接丢弃,current 的链表完好无损。下次恢复时从头重新克隆。


第 5 段:次级误解和边界

误解 1:"fiber.memoizedState 和 hook.memoizedState 是同一个东西"

不是。 它们虽然同名,但作用层级不同:

  • fiber.memoizedState → 对函数组件来说,是 Hook 链表的头指针
  • hook.memoizedState → 单个 Hook 存储的具体值

对 class 组件来说,fiber.memoizedState 存的是 this.state,跟 Hook 没有任何关系。

误解 2:"每次渲染的 Hook 对象都是新建的"

mount 时是新建的,update 时是克隆的。updateWorkInProgressHook 第 1050 行:

yaml 复制代码
const newHook = {
  memoizedState: currentHook.memoizedState,  // 浅拷贝
  baseState: currentHook.baseState,
  baseQueue: currentHook.baseQueue,
  queue: currentHook.queue,   // ← queue 是引用共享,不是拷贝!
  next: null,
};

注意 queue引用共享 的------current 和 workInProgress 的同一个 Hook 共用同一个更新队列。这是因为 dispatch 函数(setState)在 bind 时绑定了这个 queue,如果 clone 了 queue,dispatch 就找不到新 action 了。

边界:组件没有任何 Hook 会怎样?

源码第 543-544 行的注释已经解释了:

arduino 复制代码
// Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used.
// Non-stateful hooks (e.g. context) don't get added to memoizedState

如果一个函数组件只用了 useContext(不会创建 Hook 节点)而没用任何 stateful Hook,那 fiber.memoizedState 始终为 null。React 通过检查 current.memoizedState === null 来判断 mount/update 就会失效------它会误以为每次都是首次渲染。这是 React 的一个已知边界情况。

边界:useEffect 的 Effect 链表和 Hook 链表是两条独立的链表

这是一个高频混淆点:

less 复制代码
Hook 链表(管理"第几个 Hook"):
  fiber.memoizedState → [useState hook] → [useEffect hook] → null
​
Effect 链表(管理"哪些 effect 需要执行"):
  fiber.updateQueue.lastEffect → Effect#3 ⇄ Effect#1 ⇄ Effect#2 ⇄ (回到 Effect#3)
  ↑ 环形链表,通过 lastEffect.next 找到头节点

每个 useEffect Hook 的 memoizedState 指向一个 Effect 对象,这些 Effect 对象通过自己的 next 指针形成环形链表,挂在 fiber.updateQueue.lastEffect 上。在 commit 阶段,React 遍历的是 Effect 环形链表,不是 Hook 链表。


现在我们知道了函数组件的所有 Hook 状态以链表形式挂在 fiber.memoizedState 上,通过调用顺序寻址 。但 useState 返回的 setState(也就是 dispatch)是怎么做到"调用后触发更新"的?它和 Fiber 节点之间是怎么绑定的?这就是下一个考点「dispatch 的 bind 机制」要拆解的事情。


考点 2.2:dispatch 的 bind 机制

第 0 段:直觉锚定

想象你给一个快递员发了一张预填好寄件人和收件地址的面单。面单上已经写死了"从哪寄、寄到哪个仓库",快递员只需要在"物品"一栏填上你给他的东西就行。

dispatchSetState.bind(null, fiber, queue) 就是这张预填面单。React 把 fiber(哪个组件)和 queue(哪个 Hook 的更新队列)提前绑死,返回的 dispatch 函数对外只暴露一个参数------action(你要设置的新值)。组件拿到的 setCount(1) 背后,fiberqueue 早已被闭包封印进去了。


第 1 段:问题背景

useState 返回的 setState 到底是什么

scss 复制代码
const [count, setCount] = useState(0);

setCount 看起来就是一个普通函数,调用它就能触发组件更新。但问题是:一个普通函数怎么知道要更新哪个组件的哪个 state?

React 的解法是:bind 提前把"身份信息"绑进去

为什么不用闭包而用 bind

技术上闭包也能实现同样效果:

arduino 复制代码
// 闭包方案(假设)
const setCount = (action) => dispatchSetState(fiber, queue, action);
​
// bind 方案(React 实际使用的)
const setCount = dispatchSetState.bind(null, fiber, queue);

两者效果等价。React 选择 bind 可能是因为:

  1. bind 是引擎原生实现,比手动闭包包装少一层函数调用
  2. 语义更清晰------"把前两个参数固定住,只留 action 给调用者"

第 2 段:核心数据结构

dispatch 的创建过程

源码位于 ReactFiberHooks.js:1922-1933

ini 复制代码
function mountState<S>(initialState): [S, Dispatch] {
  const hook = mountStateImpl(initialState);  // 创建 Hook 节点,初始化 state
  const queue = hook.queue;
​
  // 关键行:bind 绑定
  const dispatch = dispatchSetState.bind(
    null,
    currentlyRenderingFiber,  // 第1个参数:当前 Fiber 节点
    queue,                     // 第2个参数:这个 Hook 的更新队列
  );
​
  queue.dispatch = dispatch;   // 把 dispatch 也存到 queue 上
  return [hook.memoizedState, dispatch];
}

bind 做了什么:

scss 复制代码
dispatchSetState 的原始签名:
  dispatchSetState(fiber, queue, action)
                         ↑       ↑      ↑
                       组件身份  Hook身份  用户传入的新值
​
bind(null, fiber, queue) 之后:
  dispatch(action)    ← 对外只剩这一个参数

更新队列(UpdateQueue)的结构

csharp 复制代码
// ReactFiberHooks.js:1911-1917
const queue: UpdateQueue = {
  pending: null,              // 指向最新的 Update(环形链表)
  lanes: NoLanes,             // 优先级
  dispatch: null,             // 创建后立即赋值为 bind 后的 dispatch
  lastRenderedReducer: basicStateReducer,  // 上次渲染用的 reducer
  lastRenderedState: initialState,         // 上次渲染的 state(用于比较)
};

Update 对象的结构

当调用 setCount(1) 时,React 创建一个 Update 对象(ReactFiberHooks.js:3634):

csharp 复制代码
const update = {
  lane,                    // 优先级
  revertLane: NoLane,
  gesture: null,
  action,                  // ← 你传入的 1(或函数 (prev) => prev + 1)
  hasEagerState: false,    // 是否已急切计算过新 state
  eagerState: null,        // 急切计算的结果
  next: null,              // 指向下一个 Update(环形链表)
};

第 3 段:运行流程

dispatchSetState 的完整调用链

当你调用 setCount(1) 时:

scss 复制代码
setCount(1)
  │
  ▼
dispatchSetState(fiber, queue, 1)     ← bind 解开,三个参数齐全
  │
  ├─ ① requestUpdateLane(fiber)       ← 获取一个优先级 lane
  │
  ├─ ② dispatchSetStateInternal()     ← 创建 Update 对象并入队
  │     │
  │     ├─ 创建 Update { action: 1, lane, ... }
  │     │
  │     ├─ 判断:是否在渲染阶段(isRenderPhaseUpdate)?
  │     │    ├─ Yes → enqueueRenderPhaseUpdate(渲染阶段的更新,特殊处理)
  │     │    └─ No  → 进入下面的流程
  │     │
  │     ├─ 【急切计算优化】检查是否可以提前算出新 state
  │     │    条件:fiber.lanes === NoLanes(当前无待处理更新)
  │     │    ├─ 用 lastRenderedReducer(currentState, action) 算出 eagerState
  │     │    ├─ 如果 is(eagerState, currentState) === true
  │     │    │    → 新旧 state 一样,直接 bailout(不调度更新!)
  │     │    └─ 否则,正常入队
  │     │
  │     └─ enqueueConcurrentHookUpdate(fiber, queue, update, lane)
  │          → 把 Update 入队到 queue.pending(环形链表)
  │          → 返回 root
  │
  └─ ③ scheduleUpdateOnFiber(root, fiber, lane)  ← 调度更新
       → 进入 Fiber 的调度流程(workLoop → beginWork → ...)

关键细节:急切计算(Eager Evaluation)

这是 React 的一个性能优化。源码 ReactFiberHooks.js:3648-3677

ini 复制代码
// 如果当前 fiber 没有任何待处理的更新(lanes === NoLanes)
if (fiber.lanes === NoLanes && (alternate === null || alternate.lanes === NoLanes)) {
  const lastRenderedReducer = queue.lastRenderedReducer;
  const currentState = queue.lastRenderedState;
  const eagerState = lastRenderedReducer(currentState, action);
​
  update.hasEagerState = true;
  update.eagerState = eagerState;
​
  if (is(eagerState, currentState)) {
    // 新旧 state 相同 → 不触发重新渲染!
    enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
    return false;  // 没有调度更新
  }
}

含义: 如果 setCount(currentCount)(新值和旧值相同),React 在 dispatch 阶段就能提前算出来,直接跳过整个渲染流程。不需要等到 beginWork 的 bailout 检查。

useReducer 的 dispatch 对比

useReducer 的 dispatch 也是 bind 机制,但绑定的是 dispatchReducerAction 而不是 dispatchSetState

arduino 复制代码
// useState 的 dispatch
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
​
// useReducer 的 dispatch
const dispatch = dispatchReducerAction.bind(null, currentlyRenderingFiber, queue);

两者区别:

  • dispatchSetState:action 直接就是新值(或更新函数),内部用 basicStateReducer 处理
  • dispatchReducerAction:action 传给用户提供的 reducer 函数处理

但绑定模式完全一样。


第 4 段:设计动机与权衡

为什么 bind(null, fiber, queue) 而不是 bind(this, ...)

第一个参数传 null 是因为 dispatchSetState 内部不使用 this。React 不用面向对象的方式组织这段代码,所有上下文通过参数传递。bind 的第一个参数是 thisArg,传 null 表示忽略。

为什么 dispatch 的引用是稳定的

看这段代码:

ini 复制代码
// mountState 中
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
queue.dispatch = dispatch;
return [hook.memoizedState, dispatch];

dispatchmountState 时创建一次,然后存在 queue.dispatch 上。更新时不会再重新 bind

arduino 复制代码
// updateState 中(简化)
function updateState<S>(): [S, Dispatch] {
  const hook = updateWorkInProgressHook();
  const queue = hook.queue;
  // ...
  return [hook.memoizedState, queue.dispatch];  // ← 直接复用 queue.dispatch
}

这意味着 setCount 的引用在组件的整个生命周期内始终不变 (同一个函数对象)。这就是为什么你不需要把 setState 包在 useCallback 里------它天然就是稳定引用。

为什么 queue 是引用共享的

回顾考点 2.1:update 时克隆 Hook 对象,但 queue 是引用共享的:

arduino 复制代码
const newHook = {
  memoizedState: currentHook.memoizedState,
  queue: currentHook.queue,  // ← 引用,不是拷贝
};

原因就在这里:queue.dispatch 是 bind 时绑定了这个 queue 对象。如果克隆了 queue(新建一个对象),dispatch 上的闭包还指向旧的 queue,新的 action 就入不了队了。所以 queue 必须是同一个对象引用,跨渲染共享。


第 5 段:次级误解和边界

误解 1:"setState 是异步的"

不准确。 setState(即 dispatch)本身是同步执行的------它同步创建 Update 对象、入队、调用 scheduleUpdateOnFiber。但 scheduleUpdateOnFiber 不一定立即触发渲染。在 Concurrent Mode 下,更新可能被调度到稍后执行;在 Legacy Mode 的生命周期内调用时,React 会批量处理(batching)。

所谓"setState 是异步的",指的是渲染(re-render)是延迟的,不是 setState 本身是异步的。

误解 2:"每次渲染都会创建新的 dispatch"

不会。 dispatch 只在 mount 时通过 bind 创建一次,之后存在 queue.dispatch 上。update 时直接复用。这就是 useState 返回的 setter 引用稳定的原因。

边界:在渲染阶段调用 setState 会怎样?

源码 isRenderPhaseUpdate(fiber) 检测到这种情况后,走特殊路径 enqueueRenderPhaseUpdate,不会调用 scheduleUpdateOnFiber。而是标记一个标志位,等当前渲染结束后再处理。这是 React 防止渲染阶段无限循环的机制------但如果你在渲染阶段无条件调用 setState(没有终止条件),仍然会死循环。

边界:bind 绑定的 fiber 是 current 还是 workInProgress?

ini 复制代码
const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);

currentlyRenderingFiberworkInProgress------当前正在构建的 Fiber 节点。但 dispatchSetState 内部通过 fiber.alternate 可以访问到 current 树。所以无论用户在什么时候调用 dispatch,React 都能找到正确的更新入口。


现在我们知道了 dispatch 通过 bind 把 fiber 和 queue 封闭在内,对外只暴露 action 参数,并且 queue 引用在组件生命周期内保持稳定。但 mount 时创建 Hook 的逻辑和 update 时克隆 Hook 的逻辑是两套完全不同的代码路径------React 是怎么切换这两条路径的?这就是下一个考点「mount vs update 两条路径」要拆解的内容。


考点 2.3:mount vs update 两条路径

第 0 段:直觉锚定

想象你开一家奶茶店。开业第一天 (mount),你要做全套:选地段、装修、买设备、招人、定菜单------一切从零开始。之后每天开门营业(update),你只需要看看昨天的数据、调整今天的备料------大部分基础设施已经在了,做增量更新就行。

React 的 Hook 也是这个逻辑。useState 在首次渲染时要创建 Hook 对象、初始化 state、创建 queue、bind dispatch。但更新渲染时,这些都已经存在了------只需要从 current 树克隆 Hook、处理队列里的 update、算出新 state。

React 怎么知道该走哪条路?答案是一个策略模式(Dispatcher) :在渲染开始时切换一个全局的"调度器对象",同一个 useState 调用,背后指向的是完全不同的函数。


第 1 段:问题背景

为什么需要两条路径

回顾考点 2.1:mount 时 mountWorkInProgressHook() 创建新 Hook 节点,update 时 updateWorkInProgressHook() 从 current 树克隆。这两个函数的行为完全不同。

如果只用一个函数处理两种情况,代码会充满 if (isMount) ... else ... 分支。React 的做法更优雅:在组件函数执行之前,就把 useState 这个"函数名"指向不同的实现 。组件代码写 useState(0) 完全不知道自己走的是 mount 还是 update 路径。

Dispatcher 是什么

Dispatcher 就是一个纯对象,每个属性对应一个 Hook API:

yaml 复制代码
// ReactFiberHooks.js:3898
const HooksDispatcherOnMount = {
  useState: mountState,
  useEffect: mountEffect,
  useMemo: mountMemo,
  useRef: mountRef,
  // ...
};
​
// ReactFiberHooks.js:3926
const HooksDispatcherOnUpdate = {
  useState: updateState,
  useEffect: updateEffect,
  useMemo: updateMemo,
  useRef: updateRef,
  // ...
};

组件代码里的 useState,实际上调用的是 ReactSharedInternals.H.useState。React 在渲染前把 H 切换成 mount 或 update dispatcher,就完成了路径切换。


第 2 段:核心数据结构

Dispatcher 的切换机制

源码 ReactFiberHooks.js:559-563

sql 复制代码
ReactSharedInternals.H =
  current === null || current.memoizedState === null
    ? HooksDispatcherOnMount      // current 树不存在 或 current 无 Hook → mount
    : HooksDispatcherOnUpdate;    // 否则 → update

判断条件有两个:

  1. current === null:这个 Fiber 从未渲染过(首次挂载)
  2. current.memoizedState === null:Fiber 存在,但从未使用过任何 stateful Hook(如只用了 useContext

React 包如何桥接到 Dispatcher

useState 等函数在 packages/react/src/ReactHooks.js 中定义:

csharp 复制代码
export function useState<S>(initialState) {
  const dispatcher = resolveDispatcher();
  return dispatcher.useState(initialState);
}

resolveDispatcher() 返回的就是 ReactSharedInternals.H------当前激活的 Dispatcher。所以用户代码调用的 useState(0) 本质上是:

css 复制代码
用户代码: useState(0)
  → resolveDispatcher().useState(0)
  → HooksDispatcherOnMount.useState(0)   // mount 时
    = mountState(0)
  或
  → HooksDispatcherOnUpdate.useState(0)  // update 时
    = updateState(0)

三条路径,不是两条

实际上除了 mount 和 update,还有第三条路径------rerender

ini 复制代码
// ReactFiberHooks.js 中搜索 rerender
HooksDispatcherOnRerender = {
  useState: rerenderState,
  useEffect: rerenderEffect,
  // ...
};

rerender 发生在渲染阶段的更新 (render phase update)------组件在执行过程中调用了 setState,导致当前渲染作废、需要立即重新执行。这种情况不回到 schedule 流程,而是在当前渲染中直接重跑组件。

updateStatererenderState 的区别:

csharp 复制代码
// ReactFiberHooks.js:1936
function updateState<S>(initialState): [S, Dispatch] {
  return updateReducer(basicStateReducer, initialState);
}
​
// ReactFiberHooks.js:1942
function rerenderState<S>(initialState): [S, Dispatch] {
  return rerenderReducer(basicStateReducer, initialState);
}

底层分别走 updateReducerrerenderReducer,区别在于如何处理 Update 队列(rerender 时不重新入队,直接消费已有的更新)。


第 3 段:运行流程

完整的 Dispatcher 切换时序

ini 复制代码
beginWork 遇到函数组件 Fiber
  │
  ▼
renderWithHooks(current, workInProgress, Component, ...)
  │
  ├─ ① 清空 workInProgress 的 Hook 状态
  │     workInProgress.memoizedState = null
  │     workInProgress.updateQueue = null
  │
  ├─ ② 选择 Dispatcher
  │     current === null || current.memoizedState === null?
  │       → Yes: ReactSharedInternals.H = HooksDispatcherOnMount
  │       → No:  ReactSharedInternals.H = HooksDispatcherOnUpdate
  │
  ├─ ③ 执行组件函数
  │     Component(props)
  │       → 调用 useState(0)  → dispatcher.useState(0)
  │       → 调用 useEffect(fn) → dispatcher.useEffect(fn)
  │       → ...
  │       每次调用都通过 dispatcher 间接路由到 mount* 或 update* 实现
  │
  ├─ ④ 检查是否有渲染阶段更新
  │     if (didScheduleRenderPhaseUpdateDuringThisPass)
  │       → renderWithHooksAgain(...)  // 重跑组件
  │
  └─ ⑤ finishRenderingHooks()
        清理全局变量(currentHook、workInProgressHook 等)

mount 路径:useState 的完整流程

scss 复制代码
mountState(0)
  │
  ├─ mountStateImpl(0)
  │     ├─ mountWorkInProgressHook()   → 创建 Hook 节点,挂到链表尾
  │     ├─ hook.memoizedState = hook.baseState = 0
  │     └─ 创建 UpdateQueue { pending: null, ... }
  │
  ├─ dispatchSetState.bind(null, fiber, queue)   → 创建 dispatch
  ├─ queue.dispatch = dispatch
  │
  └─ return [0, dispatch]

update 路径:useState 的完整流程

scss 复制代码
updateState(0)
  │
  └─ updateReducer(basicStateReducer, 0)
       │
       ├─ updateWorkInProgressHook()
       │     从 current 树克隆 Hook 到 workInProgress 树
       │
       └─ updateReducerImpl(hook, currentHook, reducer)
             │
             ├─ 取出 pending 队列,合并到 baseQueue
             │
             ├─ 遍历 baseQueue,逐个处理 Update:
             │     ├─ 优先级不够 → skip(保留在 baseQueue 中)
             │     ├─ 优先级足够 + hasEagerState → 直接用 eagerState
             │     └─ 优先级足够 + 无 eagerState → reducer(state, action)
             │
             ├─ 计算出 newBaseState 和 newState
             │
             ├─ hook.memoizedState = newState
             │   hook.baseState = newBaseState
             │
             └─ return [newState, queue.dispatch]  ← dispatch 复用,不重建

updateReducerImpl 的核心:Update 队列处理

这是 update 路径最复杂的部分。源码 ReactFiberHooks.js:1302-1540

当多个 setState 连续调用时,Update 对象排成一个环形链表 (通过 queue.pending 指向最新节点,pending.next 指向最老节点)。updateReducerImpl 遍历这个环形链表:

ini 复制代码
// 简化后的核心逻辑
const first = baseQueue.next;
let newState = baseState;
let update = first;
​
do {
  const updateLane = update.lane;
​
  if (优先级不够) {
    // 跳过,保留在新的 baseQueue 中
    // 这样下次更高优先级的渲染时可以重新处理
  } else {
    // 有足够优先级,处理这个 update
    if (update.hasEagerState) {
      newState = update.eagerState;       // 用急切计算的结果
    } else {
      newState = reducer(newState, update.action);  // 正常计算
    }
  }
​
  update = update.next;
} while (update !== null && update !== first);  // 环形链表,回到起点就停

关键设计:被跳过的 Update 不会丢失。 它们被保存到新的 baseQueue 中,下次更高优先级渲染时会重新处理。这就是为什么低优先级更新不会覆盖高优先级更新。


第 4 段:设计动机与权衡

为什么用 Dispatcher 模式而不是 if-else

方案 优点 缺点
每个函数里 if (isMount) 直观 每个 Hook 函数都要分支,代码重复
Dispatcher 对象切换 关注点分离,mount/update 实现完全独立 多一层间接调用
策略类 / 继承 面向对象风格 JS 中过于重量级

Dispatcher 模式的核心优势:mount 和 update 的实现可以完全独立演进 。比如 mountRef 创建 {current: value}updateRef 直接返回 hook.memoizedState------两者逻辑差异极大,用 if-else 会非常臃肿。

为什么 rerender 是第三条路径

渲染阶段更新(组件函数执行中调用 setState)需要特殊处理:

  1. 不能再调 scheduleUpdateOnFiber(会导致无限循环)
  2. 需要在当前渲染中立即消费新的 Update
  3. rerenderReducer 的实现跳过了 scheduleUpdateOnFiber,直接处理 pending queue

useState 和 useReducer 共享 updateReducer

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

useState 的 update 路径实际上调用的是 updateReducer,只不过用了内置的 basicStateReducer

csharp 复制代码
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

这就是为什么 setState 既接受值(setState(1))也接受函数(setState(prev => prev + 1))------basicStateReducer 帮你做了分发。useReducer 则用你自定义的 reducer。


第 5 段:次级误解和边界

误解 1:"mount 和 update 的判断依据是组件是否第一次渲染"

不完全对。 判断条件是 current === null || current.memoizedState === null。即使组件已经渲染过,如果上次渲染没有使用任何 stateful Hook(只用了 useContext),current.memoizedState 仍然是 null,React 会走 mount 路径。

源码第 543 行的注释明确说了这一点:

arduino 复制代码
// Using memoizedState to differentiate between mount/update only works if at least one stateful hook is used.
// Non-stateful hooks (e.g. context) don't get added to memoizedState.

误解 2:"Dispatcher 切换是 React 独有的发明"

Dispatcher 本质上是策略模式(Strategy Pattern) + 全局状态切换。在 JS 生态中并不罕见------Express 的中间件、Redux 的 store enhancer 都用了类似思路。React 的独特之处在于用它来区分同一 API 的不同实现,且切换时机由框架内部精确控制。

边界:Strict Mode 下的双重调用

源码第 613-626 行:

scss 复制代码
if (shouldDoubleRenderDEV) {
  setIsStrictModeForDevtools(true);
  try {
    children = renderWithHooksAgain(workInProgress, Component, props, secondArg);
  } finally {
    setIsStrictModeForDevtools(false);
  }
}

Strict Mode 下组件会执行两次,但不是切换两次 Dispatcher 。第二次执行走的是 renderWithHooksAgain,它复用第一次执行建立的 Hook 链表,不会重新创建。这是考点 3.6 会详细展开的内容。

边界:Suspense 恢复后的 Dispatcher

当组件 throw Promise 触发 Suspense,稍后恢复渲染时,React 可能需要切换 Dispatcher。因为 Suspense 恢复后组件可能处于 mount 状态(第一次成功渲染),需要确保用正确的 Dispatcher。这涉及 renderWithHooksAgain 和 rerender 路径的交互,是边界情况。


现在我们知道了 React 通过 Dispatcher 对象切换来路由同一 Hook 调用到 mount/update/rerender 三条完全不同的实现路径,mount 创建一切从零,update 克隆并处理 Update 队列 。但 useEffect 在 mount 和 update 中的行为有何不同?它的回调到底在什么时机执行?这就是下一个考点「useEffect 的执行时机」要拆解的内容。


考点 2.4:useEffect 的执行时机

第 0 段:直觉锚定

想象你在装修房子。有三种装修工:

  1. 水电工 (useInsertionEffect):在你搬进来之前,趁墙还露着,赶紧把线管埋好。必须在刷漆、铺地板之前干完。
  2. 精装修工 (useLayoutEffect):你刚把家具搬走、新家具还没进门这一瞬间。他在空房子里搞装修------你看不见这个过程(浏览器还没绘制),但你回来时一切已经就绪。
  3. 保洁阿姨 (useEffect):你已经搬进来了,正在正常生活。阿姨趁你不在家的时候来打扫------不影响你的日常生活(浏览器已经完成绘制),但如果阿姨干活很慢,下次你约的朋友来访可能会迟到(阻塞下一次渲染)。

useEffect 就是那位保洁阿姨------在浏览器绘制完成后,以低优先级异步执行


第 1 段:问题背景

useEffect 的回调到底什么时候执行

这是面试高频题,也是最容易答错的地方。先给结论:

php 复制代码
setState → render 阶段(计算新 Fiber 树)→ commit 阶段
                                              │
                                              ├─ ① Mutation:操作 DOM(同步)
                                              ├─ ② Layout Effects:执行 useLayoutEffect(同步)
                                              │         ← 此时 DOM 已更新,但浏览器尚未绘制
                                              ├─ 浏览器绘制(Paint) ← 不受 React 控制
                                              └─ ③ Passive Effects:执行 useEffect(异步/延迟)
                                                        ← 此时浏览器已经绘制完毕

关键点:useEffect 的 create 回调不在 commit 阶段同步执行,而是被调度到一个单独的微任务/宏任务中

为什么这么设计

如果 useEffect 和 useLayoutEffect 一样同步执行,那所有 effect 都会阻塞浏览器绘制。但大部分 effect(数据获取、日志上报、事件订阅)并不需要阻塞绘制。延迟执行可以让浏览器更快地把新 UI 呈现给用户。


第 2 段:核心数据结构

Effect 对象的 tag 标记

Effect 对象有一个 tag 字段,用位标记(bitmask)标识类型和状态:

ini 复制代码
tag 的可能值(来自 ReactHookEffectTags.js):
​
HookPassive    = 0b100   (4)  → useEffect
HookLayout     = 0b010   (2)  → useLayoutEffect
HookInsertion  = 0b001   (1)  → useInsertionEffect
HookHasEffect  = 0b1000  (8)  → "需要执行"标记

mount 时如何标记

scss 复制代码
// ReactFiberHooks.js:2622-2628
function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
  const hook = mountWorkInProgressHook();
  currentlyRenderingFiber.flags |= fiberFlags;    // ← 标记 Fiber:我有 passive effect
  hook.memoizedState = pushSimpleEffect(
    HookHasEffect | hookFlags,   // ← tag 同时包含"类型"和"需要执行"
    createEffectInstance(),
    create,
    nextDeps,
  );
}
  • fiberFlags = Passive(对于 useEffect)→ 标记到 fiber.flags
  • HookHasEffect | HookPassive → 标记到 effect.tag

为什么需要两个标记? fiber.flags 告诉 React "这个组件有某种 effect 要处理",effect.tag 告诉 React "具体是哪种 effect、是否真的需要执行"。

update 时 deps 不变的短路逻辑

ini 复制代码
// ReactFiberHooks.js:2644-2657
if (currentHook !== null) {
  if (nextDeps !== null) {
    const prevDeps = currentHook.memoizedState.deps;
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      // deps 没变 → 不需要执行这个 effect
      hook.memoizedState = pushSimpleEffect(
        hookFlags,           // ← 注意:没有 HookHasEffect!
        inst,
        create,
        nextDeps,
      );
      return;               // ← 直接返回,不标记 fiber.flags
    }
  }
}
// deps 变了,才走下面的逻辑,打上 HookHasEffect 标记
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(HookHasEffect | hookFlags, inst, create, nextDeps);

核心机制:deps 不变时,effect.tag 只有 HookPassive(4),没有 HookHasEffect(8)。commit 阶段检查的是 HookHasEffect,所以不会执行。

Effect 环形链表

pushEffectImplReactFiberHooks.js:2579)把 Effect 串成环形链表:

ini 复制代码
function pushEffectImpl(effect) {
  const componentUpdateQueue = currentlyRenderingFiber.updateQueue;
  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;
}
less 复制代码
updateQueue.lastEffect → Effect#3
                          │
                Effect#3.next → Effect#1 → Effect#2 → Effect#3(回到头)
                                ↑ 头              ↑ 尾

第 3 段:运行流程

commit 阶段 effect 执行的完整时序

scss 复制代码
commitRootImpl()
  │
  ├─ ─ ─ ─ ─ ─ commit 阶段开始 ─ ─ ─ ─ ─ ─
  │
  ├─ ① BeforeMutation 阶段
  │     └─ getSnapshotBeforeUpdate(class 组件)
  │
  ├─ ② Mutation 阶段
  │     ├─ 遍历 effectList,执行 DOM 操作(插入/更新/删除)
  │     ├─ 执行 useInsertionEffect 的 unmount + mount
  │     │    commitHookEffectListUnmount(HookInsertion | HookHasEffect)
  │     │    commitHookEffectListMount(HookInsertion | HookHasEffect)
  │     └─ 执行 useLayoutEffect 的 unmount
  │          commitHookLayoutUnmountEffects(HookLayout | HookHasEffect)
  │
  ├─ ③ 切换 Fiber 树:root.current = finishedWork
  │
  ├─ ④ Layout 阶段
  │     ├─ 执行 useLayoutEffect 的 mount
  │     │    commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect)
  │     ├─ 执行 componentDidMount / componentDidUpdate
  │     └─ 执行 ref 更新(safelyAttachRef)
  │
  ├─ ─ ─ ─ ─ ─ 浏览器绘制(Paint) ─ ─ ─ ─ ─
  │
  ├─ ⑤ 调度 Passive Effects
  │     scheduleCallback(NormalPriority, () => {
  │       flushPassiveEffects()  ← 这是一个独立的调度任务
  │     })
  │
  └─ commit 阶段结束,主线程释放
       │
       ▼(稍后,在独立的调度任务中)
  flushPassiveEffects()
    ├─ 先执行所有 useEffect 的 unmount(destroy)
    │    commitHookPassiveUnmountEffects(finishedWork, HookPassive | HookHasEffect)
    │
    └─ 再执行所有 useEffect 的 mount(create)
         commitHookPassiveMountEffects(finishedWork, HookPassive | HookHasEffect)

commitHookEffectListMount 的核心逻辑

源码 ReactFiberCommitEffects.js:141

ini 复制代码
function commitHookEffectListMount(flags, finishedWork) {
  const updateQueue = finishedWork.updateQueue;
  const lastEffect = updateQueue.lastEffect;
  if (lastEffect !== null) {
    const firstEffect = lastEffect.next;
    let effect = firstEffect;
    do {
      if ((effect.tag & flags) === flags) {  // ← 位运算检查:tag 是否包含所有要求的标记
        const create = effect.create;
        const inst = effect.inst;
        destroy = create();            // 执行 create 回调
        inst.destroy = destroy;        // 把 destroy 函数存起来,下次 unmount 用
      }
      effect = effect.next;
    } while (effect !== firstEffect);  // 环形链表,回到起点就停
  }
}

(effect.tag & flags) === flags 是关键判断:

  • flags = HookPassive | HookHasEffect = 0b1100

  • 如果 effect.tag = HookPassive = 0b0100(deps 没变,没有 HookHasEffect)

    • 0b0100 & 0b1100 = 0b0100 ≠ 0b1100跳过
  • 如果 effect.tag = HookHasEffect | HookPassive = 0b1100(deps 变了)

    • 0b1100 & 0b1100 = 0b1100执行

useEffect 的 unmount(destroy)何时执行

lua 复制代码
场景1:组件卸载
  → 下次渲染时该 Fiber 被删除
  → Mutation 阶段:commitHookPassiveUnmountEffects
  → 先执行 destroy,再移除 DOM
​
场景2:deps 变化
  → flushPassiveEffects 中:
    先 unmount(执行旧 destroy) → 再 mount(执行新 create)
​
场景3:deps 不变
  → effect.tag 没有 HookHasEffect → 跳过,不执行 destroy 也不执行 create

第 4 段:设计动机与权衡

三种 Effect 的时序对比

Hook 时机 阻塞绘制 典型用途
useInsertionEffect Mutation 阶段,DOM 操作前 是(但极快) CSS-in-JS 注入样式规则
useLayoutEffect Layout 阶段,DOM 更新后、绘制前 读取 DOM 布局、同步修改 DOM
useEffect 绘制后,异步调度 数据获取、事件订阅、日志

为什么 useEffect 不阻塞绘制

因为 React 通过 scheduleCallback(Scheduler 包)把 passive effects 调度到一个独立的回调中。这个回调在浏览器完成当前帧的绘制后、下一个空闲时段执行。如果 effect 执行时间过长,不会阻塞当前帧的绘制,但可能影响下一帧。

为什么 unmount 在 mount 之前

markdown 复制代码
// flushPassiveEffects 的执行顺序
1. commitHookPassiveUnmountEffects → 先执行旧 destroy
2. commitHookPassiveMountEffects  → 再执行新 create

这保证了 cleanup 逻辑先于新的 setup 逻辑运行。如果反过来(先 create 再 destroy),新 effect 可能依赖的 DOM 状态已经被 destroy 破坏了。

为什么用环形链表而不用数组

环形链表的好处是 lastEffect.next 就是头节点,O(1) 找到遍历起点。追加新 effect 也是 O(1)(修改 lastEffect 指针和两个 next 引用)。遍历时 effect !== firstEffect 自然终止。


第 5 段:次级误解和边界

误解 1:"useEffect 是异步的"

不够精确。 useEffect 的调度 是异步的(通过 scheduleCallback),但回调执行 本身是同步的------只是被放在一个稍后的任务中。不是说回调内部有 await 或者用了 Web Worker。

误解 2:"useEffect 在每次渲染后都会执行"

不一定。 只有当 effect.tag 包含 HookHasEffect 时才会执行。deps 不变的情况下,mount 路径打了 HookHasEffect,但 update 路径通过 areHookInputsEqual 检测后不打 HookHasEffect,commit 阶段就会跳过。

误解 3:"useEffect 的 destroy 在 DOM 移除之前执行"

passive effect 的 destroy 不是在 DOM 移除前执行的。 它在下一轮 passive effect flush 时执行------也就是在下一次渲染的 commit 之后。此时 DOM 可能已经改变了。

真正在 DOM 移除前执行的是 useLayoutEffect 的 destroy (Mutation 阶段)。这就是为什么如果你需要在 DOM 节点被移除前做清理(比如断开 MutationObserver),应该用 useLayoutEffect 而不是 useEffect

边界:strict mode 下的双重执行

开发模式 + StrictMode 下,React 会对每个 effect mount → unmount → mount 执行一次,帮助你发现缺少 cleanup 的问题。这不是 bug,是故意的设计(考点 3.6 会展开)。

边界:flushSync 内的 useEffect

如果在 flushSync 回调中触发更新,React 会在同步 commit 后立即同步刷新所有 passive effects ,而不是延迟到下一个任务。这会打破"不阻塞绘制"的保证,是 flushSync 需要谨慎使用的原因之一。


现在我们知道了 useEffect 的 create 回调在浏览器绘制后异步执行,通过 HookHasEffect 位标记控制是否执行,deps 不变时跳过 。但 useLayoutEffectuseEffect 共享了几乎全部基础设施,唯一的区别就是执行时机------这个区别到底有多大影响?这就是下一个考点「useLayoutEffect vs useEffect」要拆解的内容。


考点 2.5:useLayoutEffect vs useEffect

第 0 段:直觉锚定

继续用装修的比喻。你刚换了一幅画(DOM 更新完毕)。

useLayoutEffect 就像你站在画前面,趁朋友还没看到(浏览器还没拍照),赶紧把画扶正------你朋友走进来时,看到的就是完美状态。但代价是你扶正的时候,朋友被挡在门外等着(阻塞绘制)。

useEffect 就像你朋友已经看到画挂歪了(浏览器拍了一张歪的照片给他看了),你等朋友走了再把画扶正------朋友下次再来时画才是正的。好处是朋友不需要在门口等你。

核心区别就一个:在浏览器绘制前还是绘制后执行。 其他所有行为(deps 比较、destroy/create 顺序、Effect 链表结构)完全一样。


第 1 段:问题背景

为什么 React 要提供两种 Effect

useLayoutEffect 是为了解决一个实际问题:读取或同步修改 DOM 布局

典型场景:tooltip 定位。你需要先渲染 DOM 元素,读取它的 getBoundingClientRect(),然后根据位置设置 tooltip 的 top/left。如果用 useEffect

复制代码
渲染 DOM → 浏览器绘制(用户看到 tooltip 闪到错误位置)→ useEffect 执行修正位置 → 再次绘制

用户会看到一次"闪烁"(flash)。用 useLayoutEffect

复制代码
渲染 DOM → useLayoutEffect 读取位置并修正 → 浏览器绘制(用户直接看到正确位置)

没有任何闪烁。

三种 Effect 的完整时序回顾

上一节已经给出了时序图,这里聚焦 layout 和 passive 的执行位置差异

sql 复制代码
Mutation 阶段:
  ├─ 操作 DOM(插入/更新/删除)
  ├─ useInsertionEffect unmount + mount
  └─ useLayoutEffect unmount(destroy)  ← DOM 操作之后
​
Layout 阶段:
  ├─ useLayoutEffect mount(create)     ← 浏览器绘制之前
  ├─ componentDidMount / componentDidUpdate
  └─ ref 更新
​
═══════════════ 浏览器绘制 ═══════════════
​
Passive 阶段(异步):
  ├─ useEffect unmount(destroy)
  └─ useEffect mount(create)

第 2 段:核心数据结构

源码层面的差异------只有两个参数不同

useLayoutEffectuseEffect 的 mount/update 实现调用的是同一个 mountEffectImpl / updateEffectImpl,唯一区别是传入的参数:

arduino 复制代码
// useEffect --- ReactFiberHooks.js:2686
mountEffectImpl(
  PassiveEffect | PassiveStaticEffect,  // fiberFlags
  HookPassive,                           // hookFlags
  create,
  deps,
);
​
// useLayoutEffect --- ReactFiberHooks.js:2781
mountEffectImpl(
  UpdateEffect | LayoutStaticEffect,    // fiberFlags  ← 不同!
  HookLayout,                           // hookFlags   ← 不同!
  create,
  deps,
);

两个参数的含义:

  • fiberFlags :打在 fiber.flags 上的标记,告诉 commit 阶段"这个 Fiber 有哪种副作用"
  • hookFlags :打在 effect.tag 上的标记,用来区分 effect 类型

fiberFlags 对 commit 阶段的影响

sql 复制代码
useEffect:
  fiber.flags |= PassiveEffect
  → commit 阶段看到 Passive 标记 → 把这个 Fiber 加入 pendingPassiveEffects
  → 异步调度 flushPassiveEffects → 绘制后才执行
​
useLayoutEffect:
  fiber.flags |= UpdateEffect
  → commit 阶段看到 Update 标记 → 在 Layout 子阶段同步执行

hookFlags 对遍历过滤的影响

commit 阶段遍历 Effect 环形链表时:

scss 复制代码
// Layout 阶段
commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect);
// 遍历时:if ((effect.tag & (HookLayout | HookHasEffect)) === (HookLayout | HookHasEffect))
​
// Passive 阶段
commitHookPassiveMountEffects(finishedWork, HookPassive | HookHasEffect);
// 遍历时:if ((effect.tag & (HookPassive | HookHasEffect)) === (HookPassive | HookHasEffect))

位运算过滤保证了 layout 阶段只执行 HookLayout 的 effect,passive 阶段只执行 HookPassive 的 effect,互不干扰。

Effect 对象的 tag 编码

一个组件同时使用 useEffectuseLayoutEffect 时,Effect 链表中会有不同 tag 的节点:

less 复制代码
updateQueue.lastEffect → Effect#2 (HookLayout)
                          │
        Effect#2.next → Effect#1 (HookPassive) → Effect#2(环形)

每个 Effect 的 tag:

  • useEffect 产生的:HookPassive | HookHasEffect = 0b1100(deps 变时)或 HookPassive = 0b0100(deps 不变时)
  • useLayoutEffect 产生的:HookLayout | HookHasEffect = 0b1010(deps 变时)或 HookLayout = 0b0010(deps 不变时)

第 3 段:运行流程

完整时序对比(同一组件同时使用两种 Effect)

ini 复制代码
render 阶段:
  组件执行,调用 useEffect(fn1, [a]) → 创建 Effect#1 {tag: HookPassive|HookHasEffect}
  组件执行,调用 useLayoutEffect(fn2, [b]) → 创建 Effect#2 {tag: HookLayout|HookHasEffect}
  Effect 环形链表:Effect#1 ⇄ Effect#2
​
commit 阶段:
  │
  ├─ Mutation 子阶段:
  │    ├─ DOM 操作(appendChild / updateProperties / removeChild)
  │    │
  │    ├─ 遍历 Effect 链表,查找 HookLayout | HookHasEffect
  │    │    Effect#1: tag = 0b1100, (0b1100 & 0b1010) = 0b1000 ≠ 0b1010 → 跳过
  │    │    Effect#2: tag = 0b1010, (0b1010 & 0b1010) = 0b1010 → 执行 destroy(fn2 上次的 cleanup)
  │    │
  │    └─ 同样处理 useInsertionEffect 的 unmount + mount
  │
  ├─ 切换 Fiber 树:root.current = finishedWork
  │
  ├─ Layout 子阶段:
  │    ├─ 遍历 Effect 链表,查找 HookLayout | HookHasEffect
  │    │    Effect#2: tag = 0b1010 → 执行 create(fn2)
  │    │    inst.destroy = fn2 的返回值(cleanup 函数)
  │    │
  │    └─ ref 更新
  │
  ├─ ─ ─ 浏览器绘制(Paint) ─ ─ ─
  │
  └─ 调度 Passive Effects(scheduleCallback)
       │
       ▼(异步执行)
  flushPassiveEffects:
    ├─ 遍历 Effect 链表,查找 HookPassive | HookHasEffect
    │    Effect#1: tag = 0b1100 → 先执行 destroy(fn1 上次的 cleanup)
    │    Effect#1: tag = 0b1100 → 再执行 create(fn1)
    │    inst.destroy = fn1 的返回值
    └─ 完成

对比:如果 deps 不变

ini 复制代码
update 时 deps 未变化:
  useEffect:        effect.tag = HookPassive(无 HookHasEffect)→ 0b0100
  useLayoutEffect:  effect.tag = HookLayout(无 HookHasEffect)→ 0b0010
​
遍历时的判断:
  Layout 阶段:(0b0010 & 0b1010) = 0b0010 ≠ 0b1010 → 跳过 ✓
  Passive 阶段:(0b0100 & 0b1100) = 0b0100 ≠ 0b1100 → 跳过 ✓
​
两种 Effect 都不执行。

第 4 段:设计动机与权衡

为什么共享 mountEffectImpl / updateEffectImpl

看代码,三种 Effect 的 mount/update 逻辑几乎完全一样------创建 Hook、打标记、推入 Effect 链表。唯一的区别就是 fiberFlagshookFlags 两个参数。共享实现避免了三份重复代码。

useLayoutEffect 的性能代价

lua 复制代码
使用 useLayoutEffect 时:
  Mutation → Layout Effect destroy → Layout Effect create → 浏览器绘制
                                                      ↑
                                              如果 create 很慢,阻塞绘制
​
使用 useEffect 时:
  Mutation → 浏览器绘制 → Passive Effect destroy → Passive Effect create
              ↑
         不被 effect 阻塞

所以 React 官方建议:99% 的情况用 useEffect,只在需要同步读取/修改 DOM 布局时才用 useLayoutEffect

SSR 中的差异

useLayoutEffect 在服务端渲染时会触发 React 警告:

vbnet 复制代码
Warning: useLayoutEffect does nothing on the server...

因为服务端没有 DOM,也没有浏览器绘制周期,layout effect 无法执行。React 官方建议 SSR 场景下用 useEffect 替代,或使用社区库 use-isomorphic-layout-effect(自动在服务端降级为 useEffect)。


第 5 段:次级误解和边界

误解 1:"useLayoutEffect 比 useEffect 快"

不是。 两者执行的是同一个 create 回调,速度取决于你写了什么代码。区别只在执行时机------useLayoutEffect 在绘制前同步执行(可能阻塞绘制),useEffect 在绘制后异步执行(不阻塞但用户可能看到中间状态)。

误解 2:"useLayoutEffect 和 componentDidMount 等价"

基本等价,但有细微差异。 componentDidMount 在 Layout 阶段同步执行,和 useLayoutEffect 的 create 回调在同一时机。但 componentDidUpdateuseLayoutEffect 的 destroy/create 顺序不同:

  • class 组件:先 componentDidUpdate,再处理子组件的 layout effects
  • 函数组件:先处理完所有 layout effects 的 destroy,再执行所有 create

边界:useLayoutEffect 中触发 setState

在 useLayoutEffect 的 create 回调中调用 setState,会同步触发一次额外渲染(不是异步调度)。因为此时还在 commit 阶段,React 会立即处理这个更新,在浏览器绘制前完成。这意味着用户不会看到中间状态,但组件会多渲染一次。

如果这个额外渲染又触发了 layout effect 中的 setState,就会形成无限循环------React 会对嵌套更新次数设置上限来防止这种情况。

边界:useInsertionEffect 是什么

三种 Effect 中的第三个,执行时机最早(Mutation 阶段,DOM 操作之前)。它设计给 CSS-in-JS 库用------在 DOM 插入前注入 <style> 标签,避免无样式内容闪烁(FOUC)。普通开发者几乎不需要直接使用它。

它的 fiberFlags 是 UpdateEffect,hookFlags 是 HookInsertion。mount/update 同样调用 mountEffectImpl / updateEffectImpl,只是参数不同。


现在我们知道了 useLayoutEffect 和 useEffect 共享同一套基础设施,仅通过 fiberFlags 和 hookFlags 两个参数区分,导致 commit 阶段在不同的子阶段执行 。但 useMemouseCallback 不涉及 Effect 链表,它们用的是完全不同的机制来避免重复计算------这就是下一个考点「useMemo / useCallback 的依赖比较」要拆解的内容。


考点 2.6:useMemo / useCallback 的依赖比较

第 0 段:直觉锚定

想象你有个习惯:出门前看一眼手机备忘录上的购物清单

  • useMemo:如果清单没变(牛奶、面包、鸡蛋------和上次一模一样),你直接用上次已经买好的那袋东西,不用再去超市跑一趟。只有清单变了,你才重新出门采购。
  • useCallback:如果清单没变,你直接把上次写的那张"买菜路线图"递给家人,不用重新画一张一模一样的。

两者做的事情本质相同:避免重复计算/重复创建 。区别只是"缓存的是什么"------useMemo 缓存计算结果 ,useCallback 缓存函数引用。而且 useCallback 其实就是 useMemo 的语法糖。


第 1 段:问题背景

为什么需要这两个 Hook

函数组件每次渲染都会从头执行一遍。所有局部变量、函数、计算表达式都会重新创建/重新求值。

javascript 复制代码
function App({ items, onClick }) {
  // 每次渲染都重新创建函数
  const handleClick = () => { /* ... */ };
​
  // 每次渲染都重新计算
  const sorted = items.sort((a, b) => a.id - b.id);
​
  // 如果传给子组件,子组件每次都会重新渲染(因为引用变了)
  return <List data={sorted} onClick={handleClick} />;
}

如果 ListReact.memo 包裹的组件,即使 items 没变,sorted 是新数组(引用不同),List 还是会重新渲染。useMemo/useCallback 就是为了解决这类"无意义的新建"。


第 2 段:核心数据结构

memoizedState 的存储格式

两者在 Hook 的 memoizedState 中存的都是 [value, deps] 二元组:

less 复制代码
// mountMemo --- ReactFiberHooks.js:2931
hook.memoizedState = [nextValue, nextDeps];
//                           ↑           ↑
//                      缓存的值      deps 数组

// mountCallback --- ReactFiberHooks.js:2898
hook.memoizedState = [callback, nextDeps];
//                           ↑          ↑
//                      缓存的函数    deps 数组

源码对比:mount 路径

ini 复制代码
// mountCallback --- ReactFiberHooks.js:2895
function mountCallback<T>(callback: T, deps): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  hook.memoizedState = [callback, nextDeps];  // 直接存 [函数, deps]
  return callback;
}

// mountMemo --- ReactFiberHooks.js:2916
function mountMemo<T>(nextCreate: () => T, deps): T {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const nextValue = nextCreate();             // 先执行计算函数
  hook.memoizedState = [nextValue, nextDeps]; // 存 [结果, deps]
  return nextValue;
}

关键差异在 mount 阶段: useMemo 会立即调用 nextCreate() 拿到结果,useCallback 直接存传入的函数(不调用)。

源码对比:update 路径

ini 复制代码
// updateCallback --- ReactFiberHooks.js:2902
function updateCallback<T>(callback: T, deps): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];  // deps 没变 → 返回旧函数
    }
  }
  hook.memoizedState = [callback, nextDeps];  // deps 变了 → 存新函数
  return callback;
}

// updateMemo --- ReactFiberHooks.js:2935
function updateMemo<T>(nextCreate: () => T, deps): T {
  const hook = updateWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;
  const prevState = hook.memoizedState;
  if (nextDeps !== null) {
    const prevDeps = prevState[1];
    if (areHookInputsEqual(nextDeps, prevDeps)) {
      return prevState[0];  // deps 没变 → 返回旧值,不调用 nextCreate
    }
  }
  const nextValue = nextCreate();             // deps 变了 → 重新计算
  hook.memoizedState = [nextValue, nextDeps];
  return nextValue;
}

update 阶段两者的逻辑结构完全一样areHookInputsEqual 比较新旧 deps → 相同就返回缓存的旧值 → 不同就更新。唯一的区别是"更新"时 useMemo 要调用 nextCreate()useCallback 直接用传入的新函数。


第 3 段:运行流程

useMemo 的完整生命周期

scss 复制代码
首次渲染(mount):
  useMemo(() => computeExpensiveValue(a, b), [a, b])
    │
    ├─ mountWorkInProgressHook() → 创建 Hook 节点
    ├─ nextCreate() → 执行 computeExpensiveValue(a, b),得到 nextValue
    ├─ hook.memoizedState = [nextValue, [a, b]]
    └─ return nextValue

更新渲染(update),a 和 b 不变:
  useMemo(() => computeExpensiveValue(a, b), [a, b])
    │
    ├─ updateWorkInProgressHook() → 从 current 克隆 Hook
    ├─ prevState = hook.memoizedState → [oldValue, [a, b]]
    ├─ areHookInputsEqual([a, b], [a, b]) → true
    ├─ return prevState[0]  → 直接返回 oldValue
    └─ nextCreate 没有被调用!省掉了 computeExpensiveValue 的执行

更新渲染(update),a 或 b 变了:
  useMemo(() => computeExpensiveValue(a, b), [a, b])
    │
    ├─ updateWorkInProgressHook() → 从 current 克隆 Hook
    ├─ prevState = hook.memoizedState → [oldValue, [oldA, oldB]]
    ├─ areHookInputsEqual([a, b], [oldA, oldB]) → false
    ├─ nextValue = nextCreate() → 重新计算
    ├─ hook.memoizedState = [nextValue, [a, b]]
    └─ return nextValue

useCallback 和 useMemo 的等价关系

scss 复制代码
// useCallback 的实现本质上等价于:
useCallback(fn, deps)
≈ useMemo(() => fn, deps)

但 React 没有用 useMemo 实现 useCallback,而是写了独立实现。原因是 useCallback 不需要调用 nextCreate() ,直接存函数引用即可,少一层函数调用开销。

deps 为 null / undefined 的行为

ini 复制代码
const nextDeps = deps === undefined ? null : deps;
  • useMemo(fn)deps = undefinednextDeps = null
  • useMemo(fn, null)deps = nullnextDeps = null

nextDeps === null 时:

csharp 复制代码
if (nextDeps !== null) {   // ← false,跳过比较
  // ...
}
// 直接走"更新"分支,每次都重新计算

不传 deps 或传 null = 每次渲染都重新计算 ,和不用 useMemo 一样。React 的 ESLint 规则 react-hooks/exhaustive-deps 会警告这种情况。


第 4 段:设计动机与权衡

useMemo / useCallback 不等于 "自动性能优化"

markdown 复制代码
useMemo 的开销:
  1. 创建 Hook 节点(或克隆)
  2. 创建 deps 数组
  3. areHookInputsEqual 逐个比较 deps
  4. 存储 [value, deps] 二元组

如果 computeExpensiveValue 本身很快(比如简单的属性访问),
useMemo 的开销反而 > 直接计算的开销。

何时值得用:

  • 计算确实昂贵(大数组排序、复杂过滤、深度克隆)
  • 值作为 React.memo 组件的 props,引用稳定性影响子组件是否重渲染
  • 值作为其他 Hook 的 deps,避免不必要的 effect 重执行

何时不必用:

  • 简单的属性访问(items.lengthuser.name
  • 原始值的计算(a + bcount * 2
  • 组件本身就很轻,重渲染代价极低

useCallback 的真正价值

useCallback(fn, deps) 缓存的不是"函数的计算结果"(函数本身就是结果),而是函数引用的稳定性。它的价值几乎只体现在一个场景:

复制代码
函数作为 props 传给 React.memo 子组件
  → 如果函数引用每次都变,memo 失效,子组件白重渲染
  → useCallback 保持引用稳定,memo 才能生效

如果没有 React.memouseCallback 完全没有意义------函数重建了,传给普通子组件,子组件本来就会重渲染。

为什么 React 未来可能自动优化 useMemo

React 编译器(React Compiler)的目标就是自动判断哪些值需要缓存 ,开发者不再需要手动写 useMemo / useCallback。编译器通过静态分析,在编译阶段自动插入缓存逻辑。这也是为什么 React 官方文档现在说 useMemo 是"性能优化而非语义保证"。


第 5 段:次级误解和边界

误解 1:"useCallback 缓存了函数的闭包变量"

不是。 useCallback(fn, deps) 中的 fn 是每次渲染新创建的函数(包含最新的闭包变量)。如果 deps 变了,hook.memoizedState 被更新为新的 [fn, deps]。如果 deps 没变,返回的是第一次(或上次 deps 变化时)传入的那个函数 ,那个函数的闭包捕获的是当时的变量值

javascript 复制代码
function App({ count }) {
  // count 变了但 deps 是 [],返回的永远是第一次的函数
  // 那个函数的闭包中 count 永远是初始值
  const fn = useCallback(() => console.log(count), []);
}

useCallback 缓存的是"那个时刻的函数",不是"最新的函数"。 这是 stale closure 问题的常见来源。

误解 2:"useMemo 保证引用不变"

不保证。 useMemo 是一个性能优化提示 ,不是语义保证。React 文档明确说明:React 可能会选择"遗忘"已缓存的值并重新计算。未来 React 编译器可能自动去掉你写的 useMemo,用更优的缓存策略替代。

如果你需要语义保证 引用不变(比如作为 useRef 的初始值、作为 Map 的 key),应该用 useRef

边界:deps 比较用的是 Object.is

useEffect 一样,areHookInputsEqual 逐个位置用 Object.is 比较。这意味着:

scss 复制代码
const obj = { a: 1 };
useMemo(() => obj.a, [obj]);  // 每次 obj 引用变了都会重新计算

useMemo(() => obj.a, [obj.a]); // 值不变就跳过

对象/数组作为 deps 时,即使"内容一样",引用不同也会触发重新计算。解决方案是把 deps 拆成原始值,或者用 useRef 手动比较。


现在我们知道了 useMemo 和 useCallback 通过 [value, deps] 二元组缓存结果/函数引用,update 时用 areHookInputsEqualObject.is 逐个比较)判断是否命中缓存 。但 useRef 完全不走 deps 比较逻辑------它甚至没有 mount/update 的区别,它的本质是什么?这就是下一个考点「useRef 的本质」要拆解的内容。


考点 2.7:useRef 的本质

第 0 段:直觉锚定

想象你在办公室有一个私人抽屉。抽屉上没有锁、没有标签、没有变化检测。你往里面放什么、什么时候换东西,没人管------公司不监听抽屉内容的变动。

useRef 就是函数组件的私人抽屉。{ current: 值 } 就是那个抽屉。你可以在任何地方读它写它(渲染阶段、effect 回调、事件处理),React 完全不关心你改了什么------它不会因此触发重渲染,不会比较新旧值,不会通知任何人。


第 1 段:问题背景

函数组件的"可变状态"困境

函数组件中,有两种"跨渲染持久"的数据需求:

  1. 驱动渲染的数据 (state)------变了就要重新渲染 → useState
  2. 不驱动渲染的数据 (可变引用)------变了不需要重新渲染 → useRef

典型场景:

  • 保存 DOM 引用(ref={divRef}
  • 保存定时器 ID(setInterval 返回值)
  • 保存上一次渲染的值(手动实现 usePrevious
  • 保存一个跨渲染稳定的可变容器

如果用 useState 存定时器 ID,每次 setState 都会触发无意义的重渲染。useRef 解决的就是"我需要跨渲染持久存一个值,但我不希望它的变化触发渲染"。


第 2 段:核心数据结构

源码------可能是 React 中最短的 Hook 实现

csharp 复制代码
// mountRef --- ReactFiberHooks.js:2602
function mountRef<T>(initialValue: T): {current: T} {
  const hook = mountWorkInProgressHook();
  const ref = {current: initialValue};
  hook.memoizedState = ref;
  return ref;
}

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

就这么短。 全部逻辑加起来 6 行有效代码。

和其他 Hook 的对比

维度 useState useEffect useMemo useRef
mount 做了什么 创建 state + queue + dispatch 创建 Effect + 打 flags 调用 nextCreate + 存 value, deps 创建 {current: value}
update 做了什么 克隆 Hook + 处理 Update 队列 比较 deps + 打/不打 flags 比较 deps + 返回旧值或重算 直接返回 memoizedState
有 deps 比较吗 有(eager bailout) 有(areHookInputsEqual) 有(areHookInputsEqual) 没有
有 queue 吗
有 Effect 链表吗
变化会触发渲染吗 N/A 不会(但也不检测变化) 不会
initialValue update 时用到吗 用到(作为 fallback) 用到(作为 create 参数) 用到(作为 nextCreate) 完全忽略

updateRef 为什么忽略 initialValue

csharp 复制代码
function updateRef<T>(initialValue: T): {current: T} {
  const hook = updateWorkInProgressHook();
  return hook.memoizedState;  // ← initialValue 参数完全没被使用
}

initialValue 出现在函数签名中是为了mountRef 保持相同的调用接口 (Dispatcher 要求 useStateuseRef 等的参数列表一致)。但 update 时直接返回已有的 ref 对象,初始值没有意义。


第 3 段:运行流程

useRef 的完整生命周期

sql 复制代码
首次渲染(mount):
  const divRef = useRef(null)
    │
    ├─ mountWorkInProgressHook() → 创建 Hook 节点
    ├─ ref = {current: null}
    ├─ hook.memoizedState = ref
    └─ return {current: null}

更新渲染(update),假设渲染之间你做了 divRef.current = divElement:
  const divRef = useRef(null)
    │
    ├─ updateWorkInProgressHook()
    │     → 从 current 树克隆 Hook
    │     → newHook.memoizedState = currentHook.memoizedState  ← 引用共享!
    │
    └─ return hook.memoizedState  → 返回的是 mount 时创建的同一个对象
                                     ↓
                                   {current: divElement}  ← 你之前修改的值还在

为什么 ref 的修改能跨渲染保持

关键在于 updateWorkInProgressHook 的克隆逻辑(考点 2.1):

arduino 复制代码
const newHook = {
  memoizedState: currentHook.memoizedState,  // ← 浅拷贝:引用同一个 ref 对象
  // ...
};

currentHook.memoizedState{current: ...} 对象的引用。克隆时这个引用被复制到 newHook 上。所以 mount 创建的那个 {current: ...} 对象在组件的整个生命周期中始终是同一个对象 。你改它的 current 属性,下次渲染时读到的就是新值。

为什么 ref 的修改不触发重渲染

因为 React 完全不监听 ref 的变化 。没有任何代码在 ref.current 被修改时调用 scheduleUpdateOnFiber。对比 useState

ini 复制代码
setState(1):
  → dispatchSetState → 创建 Update → 入队 → scheduleUpdateOnFiber → 触发渲染

ref.current = 1:
  → 就是普通的 JavaScript 属性赋值
  → 没有任何 React 代码介入
  → 不会触发任何东西

第 4 段:设计动机与权衡

useRef 为什么不用 Proxy / getter-setter 拦截

理论上 React 可以用 Proxy 或属性描述符拦截 ref.current 的读写,在写入时自动触发渲染。React 选择不这么做的原因:

  1. 性能:每次属性赋值都经过 Proxy 拦截,有额外开销
  2. 可预测性 :如果 ref.current = x 突然触发了重渲染,开发者会很困惑------"我只是给一个引用赋值,怎么页面刷新了?"
  3. 灵活性:ref 的设计初衷就是"可变但不触发渲染的逃逸舱",如果自动触发渲染就失去了存在意义

useRef 作为"所有 Hook 问题"的逃生舱

useRef 是 React Hooks 中最灵活的底层原语。很多自定义 Hook 的底层都依赖 useRef:

scss 复制代码
// usePrevious --- 保存上一次渲染的值
function usePrevious(value) {
  const ref = useRef();
  useEffect(() => { ref.current = value; });
  return ref.current;
}

// useInterval --- 稳定的定时器
function useInterval(callback, delay) {
  const savedCallback = useRef(callback);
  useEffect(() => { savedCallback.current = callback; });
  useEffect(() => {
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

// 实例变量(类似 class 组件的 this.xxx)
const instanceVar = useRef(0);
instanceVar.current++;  // 不会触发渲染,但值确实变了

useRef 和 class 组件实例属性的等价关系

class 组件 函数组件 触发渲染
this.state useState
this.xxx(实例属性) useRef
this.props 函数参数 props N/A

useRef 本质上就是函数组件的 this------一个跨渲染持久、可自由修改、不触发渲染的可变容器。


第 5 段:次级误解和边界

误解 1:"useRef 创建的对象在每次渲染都是新的"

不是。 mount 时创建一次 {current: value},之后所有渲染都返回同一个对象 。因为 updateRef 直接返回 hook.memoizedState,而 hook.memoizedState 是从 current Hook 浅拷贝过来的引用。

误解 2:"useRef 的返回值是稳定的"

对象引用是稳定的,但 current 属性的值不是。 每次 ref.current = newValue 之后,你拿到的值就变了。如果你把 ref.current 作为其他 Hook 的 deps:

ini 复制代码
const countRef = useRef(0);
countRef.current++;

useEffect(() => {
  console.log("effect");
}, [countRef.current]);  // ← ref.current 变了但 React 不知道!

这段 effect 不会重新执行。 因为 deps 比较发生在渲染阶段 (调用 useEffect 时),此时 countRef.current 的值和上次渲染结束时的值相同(你在渲染阶段修改了它,但 React 在渲染开始时已经"记住"了旧值)。

更危险的是:在渲染阶段读写 ref.current 本身就是反模式------React 可能并发执行多次渲染,ref 的值可能不可预测。

边界:ref 在并发模式下的安全风险

React 18 的并发渲染可能多次调用组件函数(但只提交一次)。如果组件在渲染阶段修改 ref:

csharp 复制代码
function App() {
  const ref = useRef(0);
  ref.current++;  // ← 渲染阶段修改 ref
  return <div>{ref.current}</div>;
}

并发模式下 React 可能调用 App() 两次或更多次,ref.current 会累加多次。但最终只有一次被提交到 DOM。这意味着 ref.current 的值可能和用户看到的不一致

正确做法:ref 的写入应该只在 effect(commit 后)或事件回调中进行,不要在渲染阶段修改。

边界:ref callback vs ref object

useRef 返回的是 ref object({current: ...})。React 还支持 ref callback:

javascript 复制代码
<div ref={(node) => { /* 用 node 做点什么 */ }} />

ref callback 在每次 DOM 节点变化时调用(传入新节点),卸载时调用一次(传入 null)。和 ref object 不同,callback 能让你在 ref 变化时立即执行逻辑,不需要 useEffect。


现在我们知道了 useRef 的本质就是一个挂载在 Hook 链表上的普通 JavaScript 对象 {current: value},React 完全不监听它的变化,这是它作为"可变逃逸舱"的核心设计 。但 useContext 走了一条完全不同的路径------它甚至不创建 Hook 节点,也不走 mount/update 的标准流程。它的机制是什么?有什么性能陷阱?这就是下一个考点「useContext 原理与性能陷阱」要拆解的内容。


考点 2.8:useContext 原理与性能陷阱

第 0 段:直觉锚定

想象你的公司有一个全员广播系统。CEO 在广播里说"今年的主题色改成蓝色了"------所有开着广播的员工立刻听到,立刻换上蓝色工服。

useContext 就是这个广播的接收端。CEO 是 Context.Provider,广播内容是 context._currentValue,"开着广播的员工"是所有调用了 useContext(MyContext) 的组件。

关键问题在于:广播没有定向功能 。CEO 只说了一句话,但所有人都会听到、都会行动------哪怕你的工位在仓库、根本不需要接触客户,你也被迫换了工服。这就是 useContext 的性能陷阱:没有选择性订阅,只有全量通知


第 1 段:问题背景

useContext 和其他 Hook 的根本区别

到目前为止我们学的所有 Hook 都遵循同一个模式:mount 时创建 Hook 节点 → update 时克隆 → 处理数据。但 useContext 不走这套流程

看 Dispatcher 的定义:

arduino 复制代码
// HooksDispatcherOnMount --- ReactFiberHooks.js:3898
{
  useContext: readContext,    // ← mount 和 update 用的是同一个函数!
  useState: mountState,      // ← mount 和 update 不同
  useEffect: mountEffect,
  // ...
}

// HooksDispatcherOnUpdate --- ReactFiberHooks.js:3926
{
  useContext: readContext,    // ← 同一个 readContext,没有 mount/update 区分
  useState: updateState,
  useEffect: updateEffect,
  // ...
}

useContext 没有 mount/useCallback 的两条路径。 它每次都做同样的事:读 context._currentValue,然后把当前组件注册为这个 context 的依赖。

为什么 useContext 不创建 Hook 节点

回顾考点 2.1:mountWorkInProgressHook() 创建的 Hook 节点挂在 fiber.memoizedState 上。但 useContext 不调用 mountWorkInProgressHook / updateWorkInProgressHook,它走的是完全不同的机制------fiber.dependencies


第 2 段:核心数据结构

ReactContext 对象

csharp 复制代码
// packages/react/src/ReactContext.js
const context: ReactContext<T> = {
  $$typeof: REACT_CONTEXT_TYPE,
  _currentValue: defaultValue,      // 主渲染器的当前值
  _currentValue2: defaultValue,     // 副渲染器的当前值(如 React Native)
  Provider: { $$typeof: REACT_PROVIDER_TYPE, _context: context },
  Consumer: { $$typeof: REACT_CONSUMER_TYPE, _context: context },
};

_currentValue 是 context 的"全局状态"------任何时刻只有一个值 ,所有消费者读的都是同一个引用。当 Provider 的 value prop 变化时,React 直接修改 _currentValue

fiber.dependencies ------ context 依赖链表

readContextForConsumerReactFiberNewContext.js:576)在组件读取 context 时,把当前组件注册为依赖:

ini 复制代码
function readContextForConsumer(consumer, context) {
  const value = context._currentValue;  // 直接读全局值

  const contextItem = {
    context: context,
    memoizedValue: value,
    next: null,
  };

  if (lastContextDependency === null) {
    // 第一个 context 依赖:创建新的依赖链表
    consumer.dependencies = {
      lanes: NoLanes,
      firstContext: contextItem,
    };
    consumer.flags |= NeedsPropagation;
  } else {
    // 后续 context 依赖:追加到链表
    lastContextDependency = lastContextDependency.next = contextItem;
  }

  return value;
}

关键:context 依赖存在 fiber.dependencies 上,不是 fiber.memoizedState(Hook 链表)上。 这就是为什么 useContext 不受 Hook 链表顺序约束------它根本不在 Hook 链表里。

依赖链表的结构

less 复制代码
Fiber (消费组件)
  ├── memoizedState → [useState Hook] → [useEffect Hook] → null    ← Hook 链表
  │
  └── dependencies → {                    ← Context 依赖
                        lanes: NoLanes,
                        firstContext → ContextItem#1 → ContextItem#2 → null
                                       {context, memoizedValue, next}
                                       {context, memoizedValue, next}
                      }

两套完全独立的数据结构: Hook 链表管 useState/useEffect 等,dependencies 链表管 useContext。


第 3 段:运行流程

完整的 Context 更新传播流程

scss 复制代码
用户代码:<MyContext.Provider value={newValue}>
  │
  ▼
Provider 的 value prop 变化
  │
  ├─ ① React 修改 context._currentValue = newValue
  │     (直接赋值,全局生效)
  │
  ├─ ② propagateContextChanges()
  │     从 Provider 对应的 Fiber 开始,向下遍历子树
  │     │
  │     ├─ 遇到消费组件(fiber.dependencies.firstContext !== null)
  │     │    ├─ 检查依赖链表中是否有匹配的 context
  │     │    ├─ 匹配到 → 标记这个 Fiber 需要重新渲染
  │     │    │         fiber.lanes |= renderLanes
  │     │    └─ 不匹配 → 跳过
  │     │
  │     └─ 遇到 Provider → 检查是否"屏蔽"了该 context
  │          (同名 context 的嵌套 Provider 会创建独立作用域)
  │
  ├─ ③ scheduleUpdateOnFiber()
  │     把被标记的消费组件加入渲染调度
  │
  └─ ④ 消费组件重新渲染
        调用 readContext() → 读到新的 context._currentValue

组件渲染时 readContext 做了什么

css 复制代码
组件函数执行到 useContext(MyContext):
  │
  ├─ dispatcher.useContext(MyContext)
  │     = readContext(MyContext)
  │       = readContextForConsumer(currentlyRenderingFiber, MyContext)
  │
  ├─ ① value = MyContext._currentValue    ← 读当前值
  │
  ├─ ② 创建 ContextItem {context, memoizedValue: value, next: null}
  │
  ├─ ③ 挂到 fiber.dependencies 链表上    ← 注册依赖
  │
  └─ ④ return value                       ← 返回当前值给组件使用

每次渲染都会重新注册依赖。 prepareToReadContext(第 535 行)在组件渲染开始时重置 lastContextDependency = null,然后 readContext 逐个重建依赖链表。

为什么 mount 和 update 用同一个函数

因为 readContext 每次做的事情完全一样:

  1. _currentValue
  2. 注册依赖
  3. 返回值

不存在"首次需要初始化,后续需要复用/比较"的区别。context 的值永远是"全局最新值",不需要像 useState 那样维护历史状态。


第 4 段:设计动机与权衡

性能陷阱:无选择性订阅

scss 复制代码
<MyContext.Provider value={{ theme: 'blue', fontSize: 14 }}>
  <Header />       ← 用了 useContext(MyContext),只用 theme
  <Content />      ← 用了 useContext(MyContext),只用 fontSize
  <Footer />       ← 没用 useContext(MyContext)
</MyContext.Provider>

当 Provider 的 value 变了(比如 { theme: 'blue', fontSize: 15 }):

  • Header 只关心 theme,没变,但被迫重新渲染
  • Content 关心 fontSize,变了,需要重新渲染 ✓
  • Footer 没用 context,不重新渲染 ✓

问题根源:useContext 的粒度是整个 context 对象,不是对象中的某个字段。 只要 value 引用变了(哪怕内容只改了一个字段),所有消费者都会重新渲染。

为什么 React 不做细粒度订阅

性能代价 vs 实现复杂度的权衡:

方案 优点 缺点
当前方案(全量通知) 实现简单,API 简洁 消费者全部重渲染
选择性订阅(只通知用到的字段) 精确更新 需要 Proxy 或 selector,实现复杂
拆分多个 Context 精确更新 API 复杂,Context 嵌套地狱

React 选择了第一种------简单但粗暴。官方建议的优化手段是拆分 Context

xml 复制代码
// 不好:一个 Context 包所有
<ThemeContext.Provider value={{ theme, fontSize, locale }}>
  ...
</ThemeContext.Provider>

// 好:拆成多个独立 Context
<ThemeContext.Provider value={theme}>
  <FontSizeContext.Provider value={fontSize}>
    <LocaleContext.Provider value={locale}>
      ...
    </LocaleContext.Provider>
  </FontSizeContext.Provider>
</ThemeContext.Provider>

这样改 fontSize 不会触发只读 theme 的组件重渲染。

另一个常见陷阱:Provider value 内联创建

javascript 复制代码
// 问题代码:每次渲染都创建新对象
function App() {
  return (
    <MyContext.Provider value={{ theme: 'blue' }}>
      <Child />
    </MyContext.Provider>
  );
}

每次 App 渲染,value 都是新对象({} 引用不同),React 认为 value 变了 → 所有消费者重渲染。

修复: 把 value 提到组件外或用 useMemo

javascript 复制代码
const VALUE = { theme: 'blue' };  // 组件外常量

function App() {
  return (
    <MyContext.Provider value={VALUE}>
      <Child />
    </MyContext.Provider>
  );
}

第 5 段:次级误解和边界

误解 1:"useContext 会创建 Hook 节点"

不会。 readContext 不调用 mountWorkInProgressHook / updateWorkInProgressHook,所以它不在 Hook 链表上。这也是为什么源码注释(考点 2.3 提到过)说:

less 复制代码
// Non-stateful hooks (e.g. context) don't get added to memoizedState,
// so memoizedState would be null during updates and mounts.

只用 useContext 的组件,fiber.memoizedState 始终为 null,React 无法通过 memoizedState 判断是 mount 还是 update。

误解 2:"React.memo 能阻止 context 导致的重渲染"

不能。 React.memo 只做 props 比较,不影响 context 的传播 。即使组件被 memo 包裹且 props 没变,只要它消费的 context value 变了,它仍然会重新渲染。

scss 复制代码
React.memo(Component) 阻止的是:
  父组件重渲染 → props 不变 → 子组件不重渲染 ✓

React.memo(Component) 阻止不了的是:
  context value 变 → 消费该 context 的 memo 组件 → 仍然重渲染 ✗

误解 3:"useContext 是响应式的"

不是。 useContext 不是像 Vue 的 computed 或 MobX 的 autorun 那样自动追踪依赖、精确响应变化。它的工作方式是:

  1. 渲染时读一次全局值
  2. Provider 变了 → 标记消费者需要重渲染 → 下次渲染时读新值

没有 proxy、没有 getter/setter 拦截、没有细粒度依赖追踪。 本质上就是"全局变量 + 全量广播"。

边界:context 变化传播会被 Provider 屏蔽

嵌套的同名 Provider 会创建独立作用域:

xml 复制代码
<ThemeContext.Provider value="blue">
  <Outer />              ← 读到 "blue"
  <ThemeContext.Provider value="red">
    <Inner />            ← 读到 "red"
  </ThemeContext.Provider>
</ThemeContext.Provider>

外层 Provider 从 "blue" 改成 "green" 时,Outer 重渲染,但 Inner 不受影响------因为它的最近 Provider 是内层的 "red"propagateContextChanges 遍历到内层同名 Provider 时会停止向下传播。


现在我们知道了 useContext 不走 Hook 链表,而是通过 fiber.dependencies 注册依赖,Provider 变化时全量通知所有消费者,这就是它的性能陷阱根源 。但 React 还有一个和 useState 密切相关的 Hook------useReducer,它的 dispatch 机制和 useState 共享底层,但提供了更灵活的状态更新方式。这就是下一个考点「useReducer 与 useState 的关系」要拆解的内容。


考点 2.9:useReducer 与 useState 的关系

第 0 段:直觉锚定

想象你在餐厅点餐。

useState 就像你直接跟厨师喊指令:"加个蛋!"、"换成微辣!"、"再加一碗饭!"。每条指令都是独立的话,厨师记在脑子里(或者忘了)。

useReducer 就像你填了一张标准化表单 。表单上只有几个固定选项:{type: 'ADD_EGG'}{type: 'SPICY', level: 'mild'}{type: 'ADD_RICE'}。厨师拿到表单后,查一本操作手册 (reducer 函数),手册里写着"收到 ADD_EGG 就加蛋,收到 SPICY 就调辣度"。所有逻辑集中在手册里,厨师不需要自己判断。

本质上,useState 是 useReducer 的语法糖。React 源码里就是这样实现的。


第 1 段:问题背景

useState 和 useReducer 各自适合什么场景

维度 useState useReducer
状态类型 独立的原始值/简单对象 复杂状态机、多个子值互相关联
更新逻辑 直接设值或用函数 统一的 reducer 函数集中处理
可测试性 逻辑分散在各处 reducer 是纯函数,独立测试
调试 setState 之间无关联 可以打日志看 action 流

经典例子:一个表单有 name、email、address、phone 四个字段。用 useState 需要四个独立的 state,或者一个 state 对象但每次更新都要展开。用 useReducer 可以用一个 reducer 统一管理所有字段的更新逻辑。


第 2 段:核心数据结构

useState 是 useReducer 的语法糖------源码证据

mount 路径:

ini 复制代码
// useState 的 mount --- ReactFiberHooks.js:1922
function mountState<S>(initialState): [S, Dispatch] {
  const hook = mountStateImpl(initialState);
  const queue = hook.queue;
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

// useState 的 mountStateImpl 内部 --- ReactFiberHooks.js:1911
const queue = {
  pending: null,
  lanes: NoLanes,
  dispatch: null,
  lastRenderedReducer: basicStateReducer,  // ← 内置 reducer
  lastRenderedState: initialState,
};

对比 useReducer 的 mount

ini 复制代码
// useReducer 的 mount --- ReactFiberHooks.js:1256
function mountReducer<S, I, A>(reducer, initialArg, init?): [S, Dispatch<A>] {
  const hook = mountWorkInProgressHook();
  const initialState = init !== undefined ? init(initialArg) : initialArg;
  hook.memoizedState = hook.baseState = initialState;
  const queue = {
    pending: null,
    lanes: NoLanes,
    dispatch: null,
    lastRenderedReducer: reducer,  // ← 用户自定义的 reducer
    lastRenderedState: initialState,
  };
  hook.queue = queue;
  const dispatch = dispatchReducerAction.bind(null, currentlyRenderingFiber, queue);
  queue.dispatch = dispatch;
  return [hook.memoizedState, dispatch];
}

差异只有三处:

差异点 useState useReducer
lastRenderedReducer basicStateReducer(内置) 用户传入的 reducer
绑定的 dispatch 函数 dispatchSetState dispatchReducerAction
初始值处理 typeof initialState === 'function' 则调用 支持 init(initialArg) 惰性初始化

basicStateReducer------useState 的内置 reducer

csharp 复制代码
// ReactFiberHooks.js:1251
function basicStateReducer<S>(state: S, action: BasicStateAction<S>): S {
  return typeof action === 'function' ? action(state) : action;
}

这就是为什么 setState 既接受值也接受函数:

scss 复制代码
setCount(1);                // action = 1 → typeof 1 !== 'function' → return 1
setCount(prev => prev + 1); // action = function → return function(prevState)

basicStateReducer 帮你做了这个分发。

update 路径的共享

csharp 复制代码
// ReactFiberHooks.js:1936
function updateState<S>(initialState): [S, Dispatch] {
  return updateReducer(basicStateReducer, initialState);
}

// ReactFiberHooks.js:1942
function rerenderState<S>(initialState): [S, Dispatch] {
  return rerenderReducer(basicStateReducer, initialState);
}

update 和 rerender 时,useState 直接调用 updateReducer/rerenderReducer,只是把 reducer 固定为 basicStateReducer 这就是"useState 是 useReducer 的语法糖"在源码层面的直接证据。


第 3 段:运行流程

两条 dispatch 路径的对比

scss 复制代码
用户调用 setState(1):
  dispatchSetState(fiber, queue, 1)
    ├─ requestUpdateLane(fiber)         ← 获取优先级
    ├─ 创建 Update {action: 1, ...}
    ├─ 【急切计算优化】
    │    eagerState = queue.lastRenderedReducer(state, 1)
    │                  = basicStateReducer(state, 1)
    │                  = 1
    │    if (is(eagerState, state)) → bailout
    ├─ 否则 enqueueConcurrentHookUpdate
    └─ scheduleUpdateOnFiber

用户调用 dispatch({type: 'INCREMENT'}):
  dispatchReducerAction(fiber, queue, {type: 'INCREMENT'})
    ├─ requestUpdateLane(fiber)         ← 获取优先级
    ├─ 创建 Update {action: {type: 'INCREMENT'}, ...}
    ├─ 没有急切计算优化!← 关键区别
    ├─ enqueueConcurrentHookUpdate
    └─ scheduleUpdateOnFiber

关键区别:dispatchSetState 有急切计算优化(eager evaluation),dispatchReducerAction 没有。

原因在于:useStatebasicStateReducer 逻辑极简(直接返回 action 或调用 action 函数),在 dispatch 阶段急切计算几乎零成本。而 useReducer 的 reducer 是用户自定义的,可能很重,React 不敢在 dispatch 阶段就调用它------万一 reducer 有副作用(虽然不该有),在 dispatch 阶段执行就出问题了。

updateReducerImpl------两者共享的状态计算核心

render 阶段处理 Update 队列时,updateStateupdateReducer 最终都走到 updateReducerImpl

arduino 复制代码
// ReactFiberHooks.js:1302
function updateReducerImpl(hook, current, reducer) {
  const queue = hook.queue;
  queue.lastRenderedReducer = reducer;  // 更新为最新的 reducer

  // 合并 pending queue 和 baseQueue
  // 遍历 Update 环形链表
  // 逐个处理:优先级够的执行 reducer(state, action)
  //           优先级不够的保留在 baseQueue

  // 最终:
  hook.memoizedState = newState;
  hook.baseState = newBaseState;
  return [newState, queue.dispatch];
}

无论是 useState 还是 useReducer,状态计算都是 (newState = reducer(newState, action)) useState 传入的 reducer 是 basicStateReducer,useReducer 传入的是用户自定义 reducer。


第 4 段:设计动机与权衡

为什么 React 要同时提供 useState 和 useReducer

  1. API 简洁性const [count, setCount] = useState(0)const [count, dispatch] = useReducer((s, a) => a, 0) 简洁得多。简单状态用 useState,复杂状态用 useReducer,各得其所。

  2. useState 的急切优化 :因为 basicStateReducer 是 React 内部函数,React 确保它没有副作用,可以在 dispatch 阶段安全地提前计算。这个优化让 setState(sameValue) 可以在 dispatch 阶段直接 bailout,连 render 阶段都不进。

  3. dispatch 函数签名不同

    • useStatesetState:接受新值或 updater 函数
    • useReducerdispatch:接受 action 对象

    两种签名服务于不同的心智模型。

useReducer 的隐藏优势:稳定的 dispatch 引用

useStatesetState 引用是稳定的(考点 2.2 讲过)。useReducerdispatch 同样稳定------它也是 bind 一次后存在 queue.dispatch 上复用。

useReducer 有一个额外好处:reducer 函数本身在组件外部定义 ,不需要作为 deps 传入 useEffect/useCallback。对比:

scss 复制代码
// useState + useEffect:updater 函数需要作为 deps
function Counter() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setCount(c => c + 1), 1000);
    return () => clearInterval(id);
  }, []); // ← setCount 是稳定的,不传也行,但 ESLint 会警告
}

// useReducer + useEffect:dispatch 是稳定的,不需要 deps
function Counter() {
  const [count, dispatch] = useReducer((c, a) => c + 1, 0);
  useEffect(() => {
    const id = setInterval(() => dispatch({type: 'inc'}), 1000);
    return () => clearInterval(id);
  }, []); // ← dispatch 稳定,reducer 在外部定义,不需要任何 deps
}

惰性初始化------useReducer 独有的特性

scss 复制代码
// useState 的惰性初始化
const [state, setState] = useState(() => computeExpensiveValue());

// useReducer 的惰性初始化(第三个参数)
const [state, dispatch] = useReducer(reducer, initialArg, init);
// init(initialArg) 只在 mount 时调用,update 时跳过

useReducer 的惰性初始化是三个参数的设计,比 useState 的单函数参数更灵活------可以传入一个初始参数 initialArg 和一个初始化函数 init,两者分离。


第 5 段:次级误解和边界

误解 1:"useReducer 比 useState 性能更好"

不一定。 两者共享同一套 updateReducerImpl 状态计算逻辑,核心循环完全一样。唯一的性能差异是 useState 有急切计算优化,而 useReducer 没有。所以对于"设了相同的值"的场景,useState 反而更快。

误解 2:"useReducer 的 dispatch 和 useState 的 setState 是同一个函数"

不是。 useState 绑定 dispatchSetStateuseReducer 绑定 dispatchReducerAction。虽然两者做类似的事(创建 Update、入队、调度),但 dispatchSetState 多了急切计算优化的逻辑。

边界:useState 的 setState 接受函数时不是 reducer

ini 复制代码
setCount(prev => prev + 1);

这个函数叫 updater function,不是 reducer。区别:

  • reducer:(state, action) => newState,接收 state 和 action 两个参数
  • updater:(prevState) => newState,只接收 prevState 一个参数

basicStateReducer 内部帮你做了分发:如果是函数就当 updater 调用,如果是值就直接用。

边界:useReducer 可以在 render 阶段 dispatch

如果 reducer 返回和当前 state 相同的值(Object.is 判断),React 会 bailout 这个组件------和 useState 的 bailout 行为一致。但如果 reducer 返回新值,会触发渲染阶段更新(rerender),走 rerenderReducer 路径。


问题考核

题目 1: 假设组件内有两个 useState 和一个 useEffect。请描述 fiber.memoizedState 指向的数据结构长什么样。如果此时 useEffect 的 deps 没变,commit 阶段遍历 Effect 链表时,React 是怎么跳过这个 effect 的?

题目 2: useStatesetState 为什么不需要用 useCallback 包裹?请从源码层面解释 dispatch 引用稳定的原理,以及为什么 update 路径中 queue 必须是引用共享而不是克隆。

题目 3: useContext 不走 Hook 链表(fiber.memoizedState),那它把依赖信息存在哪里?当 Provider 的 value 变化时,React 怎么找到所有消费了这个 context 的组件并触发它们重渲染?这个机制有什么性能陷阱?

相关推荐
光影少年4 小时前
react的useMemo 如何优化?
前端·react.js·掘金·金石计划
YFF菲菲兔4 小时前
React 核心流程总述
react.js
三木檾5 小时前
从 5 个文件读完一个生产级 AI Chatbot——Vercel AI Chatbot 源码拆解
ai编程·源码阅读·next.js
光影少年5 小时前
react状态管理
前端·react.js·前端框架
珎珎啊5 小时前
React 和 Vue 3的区别
前端·vue.js·react.js
Bigger6 小时前
mini-cc 终端 UI:用 React 写 CLI 是什么体验
前端·react.js·ai编程
吹个口哨写代码7 小时前
IIS 部署 Vue/React 单页应用 (SPA) 刷新页面 404/403.18 报错原因及终极解决方案
前端·vue.js·react.js
喵个咪18 小时前
基于 Taro 的 Headless CMS 多端前端架构:技术解析与二次开发导引
前端·react.js·taro