Skip to main content

第 15 章:useEffect / useLayoutEffect——effect 链表与执行时机

useEffect 和 useLayoutEffect 的区别,面试常考:一个异步、一个同步。本章看源码,把"为什么"落到机制上。

function App() {
useEffect(() => {
// 在浏览器画完这帧之后异步跑
}, [dep]);

useLayoutEffect(() => {
// 在浏览器画之前同步跑
}, [dep]);

return <div />;
}

先记住这一句:两个 hook 都只是登记一个 effect,真正执行在 commit 阶段。区别在给 effect 打的 flag:useLayoutEffect 打 Layout,同步跑在画面前;useEffect 打 Passive,异步跑在画面后。deps 相等时连登记都变"白登记",直接跳过。

一图流:一个 effect 的登记与执行​

effect 长什么样​

effect 是个对象(ReactFiberHooks.js:221):

type Effect = {
tag: HookFlags, // 要不要执行(HookHasEffect)
inst: EffectInstance, // 存清理函数 destroy
create: () => (() => void) | void, // 你的回调
deps: Array<mixed> | void | null, // 依赖数组
next: Effect, // 指向下一个 effect
};

它同时挂两个地方:hook 的 memoizedState 存一份(第 13 章的链表节点),fiber 的 updateQueue 也串一份(pushEffectImpl,2580)。后者是一个环链表(lastEffect 指向最新一个),commit 阶段就是沿着它把所有 effect 捞出来跑。

首次挂载时,mountEffectImpl(2615)无条件创建 effect 并设 HookHasEffect 标记:

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
// mount 阶段无条件打 HookHasEffect,保证第一次一定执行
currentlyRenderingFiber.flags |= fiberFlags; // Layout 或 Passive
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined, // inst(destroy 还没生成)
nextDeps,
);
}

pushEffect(2557)负责把 effect 串进 fiber 的环链表:

function pushEffect(tag, create, inst, deps) {
const effect = { tag, create, inst, deps, next: null };
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
// fiber 还没有 updateQueue:新建,effect 自己指向自己
componentUpdateQueue = { lastEffect: null };
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
effect.next = effect;
componentUpdateQueue.lastEffect = effect;
} else {
// 已有:新 effect 插到队首,保持环
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
effect.next = effect;
componentUpdateQueue.lastEffect = effect;
} else {
const firstEffect = lastEffect.next; // 原来的第一条
lastEffect.next = effect; // 旧尾指向新
effect.next = firstEffect; // 新指向旧首,闭合成环
componentUpdateQueue.lastEffect = effect;// 更新尾指针
}
}
return effect;
}

注意这个环和第 14 章 update 环的区别:update 环 pending 指向最新入队的,effect 环 lastEffect 也指向最新的,但消费时从 lastEffect.next(最早那条)开始,保证注册顺序等于执行顺序。

areHookInputsEqual:deps 怎么比​

areHookInputsEqual(ReactFiberHooks.js:454)就是逐个 Object.is:

function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null) {
return false; // 上一次没传 deps,每次渲染都判定"变了"
}
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
if (Object.is(nextDeps[i], prevDeps[i])) {
continue; // 这个元素没变,继续比下一个
}
return false; // 有一个变了,整个 deps 算"变了"
}
return true; // 全都没变
}

一个重要区别:prevDeps === null(上一次没传依赖数组)直接返回 false——每次渲染都执行。而传 [] 时 prevDeps = [],长度 0 vs 长度 0 返回 true,后续渲染全部跳过。所以"不传 deps"和"传 []"行为完全不同。

updateEffectImpl(2632)里怎么用这个函数:

if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushSimpleEffect(hookFlags, inst, create, nextDeps);
return; // deps 没变:登记 effect,但不打 HookHasEffect,执行阶段跳过它
}

currentlyRenderingFiber.flags |= fiberFlags; // deps 变了:给 fiber 打上要跑的标记
hook.memoizedState = pushSimpleEffect(HookHasEffect | hookFlags, ...);

areHookInputsEqual 就是一个逐个 Object.is 比较(454)。deps 相等就不执行,这就是依赖数组的作用。注意传 [] 不是"没有依赖",而是"一个都不比,永远相等",所以只在首次跑。而首次挂载(mountEffectImpl,2615)无条件打 HookHasEffect,保证第一次一定执行。

时机:Layout 同步,Passive 异步​

useLayoutEffect 的注册(2771)和 useEffect(2672)几乎一模一样,唯一的差别是传进去的 flag 不同:

  • useLayoutEffect → fiber.flags |= Layout,对应 commit 的 layout 阶段(第 18 章)。layout 阶段是 commit 的同步路径,在浏览器画帧之前跑完。这就是"同步、画面前"。
  • useEffect → fiber.flags |= Passive,对应的执行在 commit 之后,被调度成异步任务(commitPassiveMountEffects,第 4 章地图里那个"passive effect 异步跑")。这就是"异步、画面后"。

这些 flag 顺着第 9 章的 bubbleProperties 一路冒泡到根,commit 阶段按 subtreeFlags 找到所有该跑的 effect。

commit 阶段执行 effect 靠 commitHookEffectListMount(ReactFiberCommitWork.js:288):

function commitHookEffectListMount(flags, finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next; // 最早注册的 effect
let effect = firstEffect;
do {
if ((effect.tag & flags) === flags) {
const create = effect.create;
const inst = create(); // 👈 执行你的回调
effect.inst = inst; // inst 就是清理函数 destroy
}
effect = effect.next;
} while (effect !== firstEffect); // 遍历完整个环
}
}

它从 lastEffect.next(最早注册)开始遍历,逐个调 create(),把返回值(cleanup 函数)存回 effect.inst。卸载(或下次执行前)时调 commitHookEffectListUnmount(315),反过来读 effect.inst 并调用:

// 遍历环上所有 effect,调它们的 destroy
const destroy = effect.inst;
if (destroy !== undefined) {
destroy(); // 👈 执行你的清理函数
}

所以 effect 环上的 inst 就是你的 useEffect(() => { ... return cleanup; }) 里返回的清理函数。create → inst → next → create ... 环转一圈执行完。一套登记机制,两个 flag 分支,两种时机,就这回事。

动手实验​

  1. 看 deps 跳过:useEffect 依赖写 [count],在 areHookInputsEqual(ReactFiberHooks.js:454)打断点,count 没变的那次渲染,观察它返回 true、effect 没被标记执行。
  2. 对比执行顺序:一个组件里同时用 useEffect 和 useLayoutEffect 各打印一行,看控制台顺序。layout 那行在 paint 前,effect 那行在 paint 后。
  3. 扒 effect 环:在 pushEffectImpl(ReactFiberHooks.js:2580)打断点,看 fiber.updateQueue.lastEffect 的环链表怎么把多个 effect 串起来。

小结​

  • useEffect/useLayoutEffect 都只是登记,执行在 commit 阶段。
  • effect 是环链表节点,同时挂在 hook.memoizedState 和 fiber.updateQueue 上。
  • deps 用 Object.is 逐个比,相等就跳过执行([] 等于永不变化)。
  • Layout flag 同步跑画面前,Passive flag 异步跑画面后,一套机制两种时机。
  • 下一步:把常用 hooks 逐个拆完,第 16 章。