Skip to main content

第 14 章:useState / useReducer——双缓存与 UpdateQueue

第 10 章说过,你拿到的 setCount 是一个"绑定好 fiber 和队列"的函数。本章追完整条链:setCount(c => c + 1) 从被调用,到新 state 出现在页面上,中间发生了什么。

先立个印象。useState 底层就是 useReducer,两者共用一套更新机制(updateState 内部直接转 updateReducer)。所以本章讲的是 reducer 通用机制,useState 只是把 basicStateReducer 当 reducer 用。

basicStateReducer(ReactFiberHooks.js:1248)是 React 里最短的 reducer:

function basicStateReducer(state, action) {
// 如果你传的是函数,就调用它;否则直接用值
return typeof action === 'function' ? action(state) : action;
}

这就是为什么 setCount(c => c + 1) 和 setCount(5) 两种写法都有效——reducer 内部判断 action 是不是函数,是就调、不是就赋值。

mountState(1805)做的事极其简单:调 mountReducer,传 basicStateReducer:

function mountState(initialState) {
const hook = mountWorkInProgressHook(); // 第 13 章的链表追加
if (typeof initialState === 'function') {
initialState = initialState(); // 惰性初始 state
}
hook.memoizedState = hook.baseState = initialState;
const queue = {
pending: null, // update 的环链表,初始空
lanes: NoLanes,
dispatch: null, // 绑好 fiber 的 setState 函数
lastRenderedReducer: basicStateReducer,
lastRenderedState: initialState,
};
hook.queue = queue;
const dispatch = (queue.dispatch =
dispatchSetState.bind(null, currentlyRenderingFiber, queue));
return [hook.memoizedState, dispatch];
}

注意 queue.dispatch 是通过 bind 把 fiber 和 queue 提前绑死的——所以每次渲染拿到的 setCount 都是同一个函数引用(dispatchSetState 签名是 (fiber, queue, action),前两个参数调用时不用传)。

先记住这一句:setState 做的事是把一次"更新"记进 hook 的队列,真正的计算留到 render 阶段。队列是环链表,双缓存(base / memoized)保证中断后能重算。

一图流:setCount 的一生​

入队:dispatchSetState​

setCount 的实现是 dispatchSetState。它先 requestUpdateLane 要一个 lane(优先级,第 23 章),再造一个 update 对象(3635):

const update = {
lane, // 这条更新的优先级
action, // 你传的东西:值或函数 c => c+1
eagerState: null,// 急切求值的缓存
next: null, // 指向下一条 update,串成环
};

update 是环链表的节点(enqueueUpdate)。入队时:

function enqueueUpdate(fiber, queue, update, lane) {
const pending = queue.pending;
if (pending === null) {
// 队列是空的:update 自己指向自己,形成单节点环
update.next = update;
} else {
// 队列非空:新 update 插到 pending 和 pending.next 之间
update.next = pending.next;
pending.next = update;
}
queue.pending = update; // pending 永远指向最新入队的 update
}

画出来:连续三次 setCount 后,queue.pending 指向最后一条 update,顺着 next 转一圈回到自己。1342 里消费时,React 会从 pending.next(最早那条)开始遍历,按入队顺序逐个跑 reducer。这就是为什么同一次渲染里连续三次 setState,三次都会生效——它们都老老实实排在环上等着被消费。

急切求值:setState 同值不渲染的秘密​

dispatchSetStateInternal(3629)里有个漂亮的优化:如果队列当前是空的(fiber.lanes === NoLanes),它当场用 reducer 算一遍新 state(3665):

const currentState = queue.lastRenderedState;
const eagerState = lastRenderedReducer(currentState, action);
update.hasEagerState = true;
update.eagerState = eagerState;
if (is(eagerState, currentState)) {
// Fast path. We can bail out without scheduling React to re-render.
enqueueConcurrentHookUpdateAndEagerlyBailout(fiber, queue, update);
}

关键在 3672:如果急切算出的新值和当前值相等,直接 bailout,连渲染都不调度。这就是为什么 setState(同一个值) 不触发重渲染。省掉了一次多余的 render。

render 阶段:updateReducer 算新 state​

真正算 state 的地方是 updateReducer(1294)。它拿到 hook,先做队列合并(1324):把这次渲染新到的 queue.pending 接进 baseQueue 后面,再把 queue.pending 清空。然后从 baseState 出发,沿着 baseQueue 一条条 update 跑 reducer:

function updateReducer(reducer, initialArg, init) {
const hook = updateWorkInProgressHook(); // 第 13 章的链表克隆
const queue = hook.queue;

// 1. 把 pending(新 update)合并进 baseQueue
if (queue.pending !== null) {
let first = queue.pending.next; // 最早的那条
let last = queue.pending; // 最新那条
if (baseQueue !== null) {
// 拼到 baseQueue 尾巴上,保持环链
last.next = baseQueue.next;
baseQueue.next = first;
}
hook.baseQueue = baseQueue = first;
queue.pending = null; // 清空 pending,消费完毕
}

// 2. 从 baseState 开始逐个跑 reducer
if (baseQueue !== null) {
let newState = hook.baseState;
let update = baseQueue.next;
do {
// 优先级不够的跳过,但保留在 baseQueue 上等下次
if (!isSubsetOfLanes(renderLanes, update.lane)) {
const clone = { ...update }; // 克隆,不消费
// ... 标记被跳过,保留在 baseQueue
} else {
newState = reducer(newState, update.action); // 消费 update
}
update = update.next;
} while (update !== null && update !== baseQueue.next);
}

hook.memoizedState = newState;
hook.baseState = baseState;
return [hook.memoizedState, queue.dispatch];
}

跳过逻辑是并发渲染的关键:一条 update 优先级不够(比如来自低优先级的 setState),updateReducer 不会把它丢掉,而是留在 baseQueue 上。下次渲染时 React 会从 baseState 出发,把 baseQueue 上的所有 update(包括上次被跳过的)全部重跑一遍。这就是第 24 章"中断重放"的机制——baseState + baseQueue 保证了不管被打断多少次,最后的结果都一致。

如果队列是空的,走快路径(1354):memoizedState = baseState,原样返回。

双缓存:base 和 memoized 为什么是两份​

hook 里有两份 state:baseState 和 memoizedState。

  • memoizedState:这次渲染算出来的最新值,直接进页面。
  • baseState + baseQueue:起点和未处理完的更新,留着给"中断重放"用。

为什么需要两份?第 24 章(并发渲染)会看到,一个低优先级渲染可能被打断。打断后重启时,React 不能从"算了半截的 memoizedState"继续,必须从 baseState 重放 baseQueue,才能保证结果一致。所以 baseState 永远记着"从哪开始算",memoizedState 是"这次算到哪"。useOptimistic(第 17 章)会显式利用这个差异。

动手实验​

  1. 断点看入队:在 dispatchSetState(ReactFiberHooks.js:3599)打断点,点按钮。观察 update 对象怎么造的,lane 和 action 是什么。
  2. 验证急切求值:setCount(c => c)(永远返回同一个值),在 dispatchSetStateInternal 的 is(eagerState, currentState) 那行打断点。会发现它当场算出相等,直接走了 bailout,组件根本没重渲染。
  3. 看队列环:连续点三次按钮(不渲染中间态),在 updateReducer(ReactFiberHooks.js:1294)打断点,看 baseQueue 上串着几条 update。

小结​

  • useState 就是 useReducer,共用一套 update 环链表 + reducer 机制。
  • setState 造 update 塞进 queue.pending,计算留到 render。
  • 急切求值:队列空时当场算,同值直接 bailout 不渲染。
  • render 时 updateReducer 合并 base + pending,从 baseState 逐个跑 reducer。
  • 双缓存:baseState 是"从哪算起",memoizedState 是"算到哪",中断重放靠 base。
  • 下一步:useEffect 的 effect 链表和执行时机,第 15 章。