useEffect 三种用法,在 commit 阶段的挂载时机(源码级辨析)
「useEffect 是渲染后异步执行的副作用」——这句话对,但不完整。同样是 useEffect,useEffect(fn)、useEffect(fn, [])、useEffect(fn, [a]) 在 commit 阶段触发的时机和条件完全不同;再叠上 useLayoutEffect,时机又变了。
本文从 React 源码(open/react)出发,把「不同使用方式 → 不同 flag → commit 阶段不同挂载时机」这条链讲清楚。
一、先给结论:commit 阶段的三个子阶段
React 的 commit 阶段不是「一口气改完 DOM 再跑副作用」,它分三个与 effect 相关的子阶段,先后顺序严格:
对应源码里 commitRootImpl 的三步(ReactFiberWorkLoop.js):
commitMutationEffects(root, finishedWork, lanes); // ① Mutation
commitLayoutEffects(finishedWork, root, lanes); // ② Layout(同步)
// ③ Passive 不在这里跑,而是调度成一个异步任务
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects(); // 绘制后异步执行 useEffect
return null;
});
一句话记:useLayoutEffect 在 ② 同步执行(绘制前),useEffect 在 ③ 异步执行(绘制后)。而「这次到底跑不跑」,由挂载/更新时的 flag 决定。
二、useEffect 的本质:一个 effect 链表 + 两个 gate
每个函数组件的 hooks 里,effect 被压进一条循环链表(updateQueue.lastEffect)。每个 effect 对象带一个 tag,其中两个 bit 最关键:
| 位 | 含义 |
|---|---|
HookPassive / HookLayout / HookInsertion | 它是哪一类 effect(决定在哪个子阶段跑) |
HookHasEffect | 这次提交它要不要真的执行(deps 变了 / 首次挂载才带上) |
「跑不跑」是两层门共同决定的:
- fiber 层:fiber 的
flags是否含Passive。 - effect 层:effect 的
tag是否含HookHasEffect。
两层都满足,commitPassiveMountOnFiber 才会真正执行它(ReactFiberCommitWork.js):
if (flags & Passive) {
commitHookPassiveMountEffects(
finishedWork,
HookPassive | HookHasEffect, // 只有同时带这两个 bit 的 effect 才跑
);
}
三、mount vs update:flag 的不同打法是关键
看 useEffect 的实现(ReactFiberHooks.js):
function mountEffect(create, deps) {
// 首次挂载:打 PassiveEffect | PassiveStaticEffect
mountEffectImpl(
PassiveEffect | PassiveStaticEffect,
HookPassive,
create,
deps,
);
}
function updateEffect(create, deps) {
// 更新:只打 PassiveEffect,不带 PassiveStaticEffect
updateEffectImpl(PassiveEffect, HookPassive, create, deps);
}
注意两个差异点,后面全靠它们:
- mount 打
PassiveStaticEffect,update 不打。PassiveStatic是「子树含 useEffect」的静态标记,用于让 React 快速判断「这棵子树有没有被动 effect」,只在挂载时烙一次。 - mount 一定带
HookHasEffect(首次必然执行),update 则要看 deps。
再看 updateEffectImpl 里 deps 比较这段(ReactFiberHooks.js):
function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
const effect = hook.memoizedState;
const inst = effect.inst;
if (currentHook !== null) {
if (nextDeps !== null) {
const prevDeps = currentHook.memoizedState.deps;
if (areHookInputsEqual(nextDeps, prevDeps)) {
// deps 没变 → 只登记 effect,不打 HookHasEffect、不打 fiber flag
hook.memoizedState = pushSimpleEffect(hookFlags, inst, create, nextDeps);
return; // ★ 直接返回,什么都不发生
}
}
}
// deps 变了 → 才打 fiber flag + HookHasEffect
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushSimpleEffect(
HookHasEffect | hookFlags,
inst,
create,
nextDeps,
);
}
areHookInputsEqual 返回 true(deps 没变)时提前 return:不打 fiber.flags,effect 的 tag 里也没有 HookHasEffect。于是两层门的第二层直接关死——即使 commit 走到被动阶段,这个 effect 也不会执行。
四、三种用法 → 三种结果
把上面的 flag 逻辑套进三种写法:
| 写法 | mount 时 | update 时(deps 未变) | update 时(deps 变了) |
|---|---|---|---|
useEffect(fn) 无 deps | 执行 | 每次都执行(nextDeps === null,跳过比较,直接打 flag) | —— |
useEffect(fn, []) 空数组 | 执行 | 永不执行([] === [] 恒等,areHookInputsEqual 恒 true) | —— |
useEffect(fn, [a, b]) | 执行 | 不执行 | 执行 |
4.1 useEffect(fn):每次 commit 后都跑
没传 deps 时 nextDeps === null,updateEffectImpl 里 nextDeps !== null 为 false,跳过比较,每次都打 flag + HookHasEffect。等价于「每次渲染后都重新订阅」。
4.2 useEffect(fn, []):只在挂载时跑一次
deps 传了 []。update 时 areHookInputsEqual([], []) 恒为 true(逐项比较,空数组没有任何不等的项),于是永远走提前 return——再也不会重新执行。这就是「挂载副作用」的标准写法(如订阅一次、初始化一次)。
4.3 useEffect(fn, [a, b]):deps 变了才跑
只有 a 或 b 与上次浅比较不相等时,才打 flag 重跑;否则跳过。
4.4 cleanup 的时机:先 unmount 后 mount
「重跑」其实是两步:先执行上一次的 cleanup,再执行本次的 create。这在被动阶段里是先清后挂(ReactFiberWorkLoop.js 的 flushPassiveEffectsImpl):
commitPassiveUnmountEffects(root.current); // ① 先跑上一轮的 cleanup(destroy)
commitPassiveMountEffects(root, root.current, lanes, transitions, ...); // ② 再跑本轮 create
所以 useEffect(fn, [count]) 里,count 每次变化,顺序永远是:上一次的 cleanup → 这一次的 fn。
五、useLayoutEffect:换一个 Hook tag,就换一个阶段
useLayoutEffect 的实现几乎一样,差别只在 flag 和 hook 类型(ReactFiberHooks.js):
function mountLayoutEffect(create, deps) {
let fiberFlags = UpdateEffect | LayoutStaticEffect; // 注意:是 Update + LayoutStatic
// dev + StrictEffectsMode 下额外 MountLayoutDevEffect
return mountEffectImpl(fiberFlags, HookLayout, create, deps);
}
function updateLayoutEffect(create, deps) {
return updateEffectImpl(UpdateEffect, HookLayout, create, deps);
}
两个本质区别:
HookLayout而不是HookPassive→ 它被commitLayoutEffects在 Layout 阶段同步执行(ReactFiberCommitWork.js),浏览器绘制之前。- cleanup 时机也不同:
useLayoutEffect的 cleanup 在 Mutation 阶段就跑了(commitHookLayoutUnmountEffects在commitMutationEffectsOnFiber里被调用),而 mount 在 Layout 阶段——「清」和「挂」跨了两个子阶段,中间隔着 DOM 变更。
对比:
| useEffect | useLayoutEffect | |
|---|---|---|
| Hook tag | HookPassive | HookLayout |
| 执行阶段 | Passive(③) | Layout(②) |
| 相对绘制 | 绘制后,异步 | 绘制前,同步 |
| cleanup 时机 | Passive 阶段(先清后挂) | Mutation 阶段清、Layout 阶段挂 |
| 典型用途 | 订阅、请求、日志 | 测量 DOM、同步改布局避免闪烁 |
六、被动 effect 为什么是「异步」的
useEffect 不阻塞绘制,靠的是把 flushPassiveEffects 包成一个 Normal 优先级的调度任务(ReactFiberWorkLoop.js):
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
flushPassiveEffectsImpl 里会先 throw 一个保护:不允许在 render/commit 过程中 flush 被动 effect,保证它一定发生在本次 commit 结束之后。这就是「useEffect 永远在浏览器 paint 之后、异步执行」的源码依据——useLayoutEffect 走的是同步的 commitLayoutEffects,所以没有这一层调度。
七、一张表总结
commit 阶段
├─ Mutation:改 DOM + useInsertionEffect + useLayoutEffect 的 cleanup
├─ Layout(同步、绘制前):useLayoutEffect 的 create
└─ Passive(异步、绘制后):useEffect 的 cleanup → create
「这次 effect 跑不跑」由两层门决定:
- fiber flag:mount 打
Passive;update 时 deps 变了才打,没变不打。 - effect tag:带
HookHasEffect才跑;deps 没变时 update 只登记不带这个 bit。
三种用法落到 flag 上:
useEffect(fn):每次 update 都打 flag → 每次重跑。useEffect(fn, []):只有 mount 打 flag → 只跑一次。useEffect(fn, [deps]):deps 变才打 flag → 变了才重跑。useLayoutEffect:换HookLayout+Updateflag,改在 Layout 阶段同步执行。
小结
把「useEffect 什么时候执行」想清楚,本质是把三个问题拆开看:它属于哪一类 effect(Hook tag)、这次该不该执行(HookHasEffect + deps 比较)、以及落在 commit 的哪个子阶段(Layout vs Passive)。源码里没有魔法,只有 flag 的叠加和一个 areHookInputsEqual 的提前 return。
附:文中源码出处均在
open/react/packages/react-reconciler/src/下——ReactFiberHooks.js(effect 挂载/更新)、ReactFiberFlags.js(Passive / LayoutStatic 等 flag)、ReactFiberCommitWork.js(commit 三阶段)、ReactFiberWorkLoop.js(flushPassiveEffects 调度)。
