跳到主要内容

useEffect 三种用法,在 commit 阶段的挂载时机(源码级辨析)

· 阅读需 7 分钟

useEffect 是渲染后异步执行的副作用」——这句话对,但不完整。同样是 useEffectuseEffect(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 变了 / 首次挂载才带上)

「跑不跑」是两层门共同决定的:

  1. fiber 层:fiber 的 flags 是否含 Passive
  2. 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);
}

注意两个差异点,后面全靠它们:

  1. mount 打 PassiveStaticEffect,update 不打PassiveStatic 是「子树含 useEffect」的静态标记,用于让 React 快速判断「这棵子树有没有被动 effect」,只在挂载时烙一次。
  2. 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 === nullupdateEffectImplnextDeps !== null 为 false,跳过比较,每次都打 flag + HookHasEffect。等价于「每次渲染后都重新订阅」。

4.2 useEffect(fn, []):只在挂载时跑一次

deps 传了 []。update 时 areHookInputsEqual([], []) 恒为 true(逐项比较,空数组没有任何不等的项),于是永远走提前 return——再也不会重新执行。这就是「挂载副作用」的标准写法(如订阅一次、初始化一次)。

4.3 useEffect(fn, [a, b]):deps 变了才跑

只有 ab 与上次浅比较不相等时,才打 flag 重跑;否则跳过。

4.4 cleanup 的时机:先 unmount 后 mount

「重跑」其实是两步:先执行上一次的 cleanup,再执行本次的 create。这在被动阶段里是先清后挂ReactFiberWorkLoop.jsflushPassiveEffectsImpl):

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);
}

两个本质区别:

  1. HookLayout 而不是 HookPassive → 它被 commitLayoutEffectsLayout 阶段同步执行ReactFiberCommitWork.js),浏览器绘制之前。
  2. cleanup 时机也不同useLayoutEffect 的 cleanup 在 Mutation 阶段就跑了(commitHookLayoutUnmountEffectscommitMutationEffectsOnFiber 里被调用),而 mount 在 Layout 阶段——「清」和「挂」跨了两个子阶段,中间隔着 DOM 变更。

对比:

useEffectuseLayoutEffect
Hook tagHookPassiveHookLayout
执行阶段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 + Update flag,改在 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 调度)。