Skip to main content

useEffect

概述

useEffect 是 React Hooks 体系里最常用的副作用 API。表面看是"组件渲染后跑一段逻辑",但底层涉及effect 节点收集、effectList 构建、commit 阶段调度、scheduler 异步任务四套机制。

useLayoutEffect 与之相似,但执行时机不同——这是面试高频考点,也是最容易写错的地方。

本篇按"收集 → 构建 effectList → 调度执行"的顺序拆解,重点回答:

  1. effect 信息存在哪
  2. 多个组件的 effect 怎么串成一个有序的 effectList
  3. useEffectuseLayoutEffect 到底什么时候执行?为什么一个会"闪烁"一个不会?

一、读源码前的三个前置概念

概念作用所在位置
Fiber flags每个 Fiber 节点上的标记位(HookHasEffect / HookPassive / HookLayoutfiber.flags
updateQueue单个 Fiber 上所有 effects 串成的循环链表fiber.updateQueue.lastEffect
effectList整个 Fiber Tree 上有 effect 的 fiber 节点组成的链表root.firstEffect → ... → root.lastEffect

关键认识:单个 Fiber 上存的是 effect(updateQueue),多个 Fiber 之间串成的是 fiber 节点(effectList)。这两个东西是不同维度,名字相似但别混


二、数据结构

2.1 effect 节点

挂在单个 Fiber 的 updateQueue 上的循环链表节点:

{
create: Function, // useEffect 传入的第一个参数(回调)
destroy: Function, // 回调 return 的函数(清理函数)
deps: any[], // 依赖项
next: Effect, // 指向下一个 effect
tag: string, // 区分 useEffect(HookPassive)还是 useLayoutEffect(HookLayout)
}

副作用在函数组件中体现为 use(Layout)Effect 的回调和销毁函数(统称"effect")。

2.2 两层链表关系

层级数据结构作用
单个 Fibereffect 循环链表(updateQueue收集这个 Fiber 上所有 effects
整个 Treefiber 节点链表(effectList按顺序串所有有 effect 的 fiber

单 Fiber 视角

useEffect-updateQueue.png

整个 Tree 视角

effect-list.jpeg

最终的 effectList 形如:

effect-list2.png


三、收集阶段(render 阶段)

render 阶段做的事:从组件函数里把 effect 收上来。mount 走一条路径,update 走另一条。

3.1 mount:mountEffectImpl

function mountEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = mountWorkInProgressHook(); // 创建 hook 节点
const nextDeps = deps === undefined ? null : deps;

// 1️⃣ 给 fiber 节点打 effectTag 标记
currentlyRenderingFiber.flags = fiberFlags;

// 2️⃣ 创建 effect 对象,挂到 hook.memoizedState
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
undefined, // mount 阶段没有 destroy
nextDeps,
);
}

mount 的特点不需要比较依赖(首次渲染肯定要跑一次)。HookHasEffect 表示"这次必跑"。

3.2 update:updateEffectImpl

function updateEffectImpl(fiberFlags, hookFlags, create, deps) {
const hook = updateWorkInProgressHook();
const nextDeps = deps === undefined ? null : deps;
let destroy = undefined;

if (currentHook !== null) {
// 1️⃣ 拿到上次的 effect,提取 destroy(清理函数)
const prevEffect = currentHook.memoizedState;
destroy = prevEffect.destroy;

// 2️⃣ 依赖项对比
if (nextDeps !== null) {
const prevDeps = prevEffect.deps;
// 依赖没变 → 复用 effect,不打 HookHasEffect
if (areHookInputsEqual(nextDeps, prevDeps)) {
pushEffect(hookFlags, create, destroy, nextDeps);
return; // ⚡ 提前返回
}
}
}

// 3️⃣ 依赖变了 → 打 HookHasEffect 标记
currentlyRenderingFiber.flags |= fiberFlags;
hook.memoizedState = pushEffect(
HookHasEffect | hookFlags,
create,
destroy,
nextDeps,
);
}

设计点HookHasEffect 是 commit 阶段判断"要不要执行这个 effect"的关键。只有打上 HookHasEffect 的 effect 才会真正执行回调,没打的话只更新 effect 对象不执行。这就是"依赖没变就不跑 effect"的实现。

mount vs update 的核心区别

  • mount:无须调用前一次 destroy,直接挂 effect,必跑
  • update:先取上一次的 destroy,比较依赖决定要不要跑

3.3 追加到循环链表 —— pushEffect

不管 mount 还是 update,最终都调用 pushEffect

function pushEffect(tag, create, destroy, deps) {
const effect = { tag, create, destroy, deps, next };

// 1️⃣ 拿到当前 fiber 的 updateQueue(effect 循环链表)
let componentUpdateQueue = currentlyRenderingFiber.updateQueue;
if (componentUpdateQueue === null) {
// updateQueue 为空:创建,自己指向自己形成环
componentUpdateQueue = createFunctionComponentUpdateQueue();
currentlyRenderingFiber.updateQueue = componentUpdateQueue;
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
// updateQueue 不为空:追加到链表尾部
const lastEffect = componentUpdateQueue.lastEffect;
if (lastEffect === null) {
componentUpdateQueue.lastEffect = effect.next = effect;
} else {
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
componentUpdateQueue.lastEffect = effect;
}
}
return effect;
}

3.4 构建 effectList —— completeUnitOfWork

单个 Fiber 的 effect 收集完了,React 还需要把整个 Tree 上有 effect 的 fiber 节点串成一条 effectList——commit 阶段就是沿着这条链表顺序执行的。

function completeUnitOfWork(unitOfWork) {
let completedWork = unitOfWork;
do {
const returnFiber = completedWork.return;

// 1️⃣ 子节点的 effect 上提到父节点
if (returnFiber !== null) {
if (returnFiber.firstEffect === null) {
returnFiber.firstEffect = completedWork.firstEffect;
}
if (completedWork.lastEffect !== null) {
// 连接兄弟节点之间的 effect
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork.firstEffect;
}
returnFiber.lastEffect = completedWork.lastEffect;
}

// 2️⃣ 当前 fiber 节点有 effect
const flags = completedWork.flags;
if (flags > PerformedWork) {
if (returnFiber.lastEffect !== null) {
returnFiber.lastEffect.nextEffect = completedWork;
} else {
returnFiber.firstEffect = completedWork;
}
returnFiber.lastEffect = completedWork;
}
}

// 3️⃣ 兄弟节点遍历,再返回父级
const siblingFiber = completedWork.sibling;
if (siblingFiber !== null) {
workInProgress = siblingFiber;
return;
}
completedWork = returnFiber;
workInProgress = completedWork;
} while (completedWork !== null);
}

设计点:effectList 是自下而上构建的(深度优先)。子节点处理完才处理父节点,保证 effect 执行顺序符合 JSX 树结构。


四、执行阶段(commit 阶段)

render 阶段负责"收集",commit 阶段负责"执行"。两者最大的区别是 useEffect 异步、useLayoutEffect 同步

4.1 useEffect —— 异步执行(DOM 渲染后)

useEffect 在 commit 阶段有 3 个执行点:

① 刚进入 commit 阶段 —— flushPassiveEffects

function commitRootImpl(root, renderPriorityLevel) {
// 进入 commit 阶段,先执行一次之前未执行的 useEffect
do {
flushPassiveEffects();
} while (rootWithPendingPassiveEffects !== null);

// ...
}

② beforeMutation 阶段 —— 异步调度 useEffect

function commitBeforeMutationEffects() {
while (nextEffect !== null) {
// ...
if ((flags & Passive) !== NoFlags) {
if (!rootDoesHavePassiveEffects) {
// 锁住调度状态,防止多次调度
rootDoesHavePassiveEffects = true;

// ⚡ 关键:用 scheduler 异步调度
scheduleCallback(NormalSchedulerPriority, () => {
flushPassiveEffects();
return null;
});
}
}
nextEffect = nextEffect.nextEffect;
}
}

设计点scheduleCallback 把 effect 执行放到 Scheduler 任务队列,等浏览器绘制(paint)之后才真正执行。所以 useEffect 不会阻塞 DOM 渲染

③ layout 阶段 —— 填充 effect 执行数组

function commitLifeCycles(finishedRoot, current, finishedWork, committedLanes) {
switch (finishedWork.tag) {
case FunctionComponent:
case ForwardRef:
case SimpleMemoComponent:
case Block: {
// layout 阶段填充 effect 执行数组
schedulePassiveEffects(finishedWork);
return;
}
}
}

填充逻辑:

function schedulePassiveEffects(finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
const { next, tag } = effect;
// ⚡ 关键:只有同时含 HookPassive 和 HookHasEffect 才处理
if (
(tag & HookPassive) !== NoHookEffect &&
(tag & HookHasEffect) !== NoHookEffect
) {
// 1️⃣ 销毁函数入 unmount 数组
enqueuePendingPassiveHookEffectUnmount(finishedWork, effect);
// 2️⃣ 创建函数入 mount 数组
enqueuePendingPassiveHookEffectMount(finishedWork, effect);
}
effect = next;
} while (effect !== firstEffect);
}
}

为什么先入 unmount 数组再入 mount 数组?因为 effect 的执行顺序是"先销毁旧的,再创建新的"。数组排队保证了顺序。

真正执行 —— flushPassiveEffectsImpl

function flushPassiveEffectsImpl() {
if (rootWithPendingPassiveEffects === null) return false;

// 1️⃣ 先执行所有销毁函数
const unmountEffects = pendingPassiveHookEffectsUnmount;
pendingPassiveHookEffectsUnmount = [];
for (let i = 0; i < unmountEffects.length; i += 2) {
const effect = unmountEffects[i];
const fiber = unmountEffects[i + 1];
const destroy = effect.destroy;
effect.destroy = undefined; // 防止重复执行
if (typeof destroy === "function") {
try {
destroy();
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
}

// 2️⃣ 再执行所有创建函数
const mountEffects = pendingPassiveHookEffectsMount;
pendingPassiveHookEffectsMount = [];
for (let i = 0; i < mountEffects.length; i += 2) {
const effect = mountEffects[i];
const fiber = mountEffects[i + 1];
try {
const create = effect.create;
effect.destroy = create(); // 把返回值存为新的 destroy
} catch (error) {
captureCommitPhaseError(fiber, error);
}
}
return true;
}

关键时序

render 完成 → commit 阶段(同步)→ DOM 更新 → 浏览器 paint

flushPassiveEffects 异步执行

4.2 useLayoutEffect —— 同步执行(DOM 渲染前)

useLayoutEffect 的实现更简单:不用数组缓存,直接在 effect 链表上操作。

① mutation 阶段 —— 卸载上一次的 effect

commitHookEffectListUnmount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListUnmount(tag, finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
const destroy = effect.destroy;
effect.destroy = undefined;
if (destroy !== undefined) destroy();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}

② layout 阶段 —— 执行本次 effect 创建

commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork);

function commitHookEffectListMount(tag, finishedWork) {
const updateQueue = finishedWork.updateQueue;
const lastEffect = updateQueue !== null ? updateQueue.lastEffect : null;
if (lastEffect !== null) {
const firstEffect = lastEffect.next;
let effect = firstEffect;
do {
if ((effect.tag & tag) === tag) {
const create = effect.create;
effect.destroy = create();
}
effect = effect.next;
} while (effect !== firstEffect);
}
}

关键时序

render 完成 → commit 阶段(同步)→ mutation 阶段(执行 destroy + 更新 DOM)

layout 阶段(执行 create)

浏览器 paint

4.3 对比:useEffect vs useLayoutEffect

维度useEffectuseLayoutEffect
执行时机浏览器 paint 之后DOM 更新完成、paint 之前
调度方式通过 scheduleCallback 异步调度同步执行(commit 阶段直接跑)
是否阻塞渲染❌ 不阻塞✅ 会阻塞
缓存方式用两个数组(unmount / mount)缓存直接遍历 updateQueue
等价类组件componentDidMount + componentDidUpdate(异步版)componentDidMount + componentDidUpdate(同步版)
典型场景数据请求、订阅、定时器DOM 测量、强制同步布局、动画
能否在服务端运行✅ 可以(render 阶段不执行)⚠️ 警告(SSR 阶段会触发)
// useLayoutEffect 经典用法:避免闪烁
function Tooltip() {
const ref = useRef();
const [position, setPosition] = useState({ top: 0, left: 0 });

useLayoutEffect(() => {
// 同步测量 DOM 并更新位置(在 paint 之前完成)
// 用户看不到"位置错乱 → 修正"的闪烁过程
const rect = ref.current.getBoundingClientRect();
setPosition({ top: rect.top, left: rect.left + 20 });
}, []);

return (
<div ref={ref} style={position}>
提示
</div>
);
}

选型经验

  • 默认用 useEffect(不阻塞渲染是它的最大价值)
  • 涉及到 修改 DOM、读取布局、动画 的场景考虑 useLayoutEffect
  • 不要为了"防止闪烁"乱用 useLayoutEffect——大多数情况加 CSS transition 就能解决

五、面试高频 Q&A

Q1: useEffectuseLayoutEffect 的区别是什么?

  • useEffect:通过 scheduler 异步调度,发生在 DOM 渲染之后,不会阻塞浏览器绘制
  • useLayoutEffect:在 commit 阶段同步执行,与 componentDidMount / componentDidUpdate 时机一致

默认建议使用 useEffect(不阻塞渲染),只有涉及 修改 DOM、测量布局、动画等场景才考虑 useLayoutEffect

简记useEffect 是 paint 之后跑,useLayoutEffect 是 paint 之前跑。

Q2: 同一个 effect 回调里多次 setState,会触发多次 render 吗?

不会。所有 setState 最终都会调用 scheduleUpdateOnFiber

// scheduleUpdateOnFiber
if (executionContext === NoContext) {
flushSyncCallbackQueue();
}

只有当 executionContextNoContext 时才会触发同步更新,其余都是批量更新。在 commit 的 beforeMutation 阶段会设置 executionContext |= CommitContext,标记已进入 commit 阶段,所以 effect 里的 setState 不会立刻同步刷新。

Q3: useEffect 是异步还是同步?

源码里 useEffect 通过 scheduleCallback 放到 Scheduler 任务队列,异步调度

注意:这里的"异步"不是说它跑在另一个线程,而是当前同步任务结束后再调度。React 18 之后 setState 的批处理变得更激进,effect 的行为也更可预测。

Q4: 为什么 useEffect 里能用 useState 的 setter 闭包?

因为 setter 引用是稳定的(见 useState Q7)。dispatchAction.bind 的闭包结果始终是同一个引用,不会变,所以 useEffect 不需要把 setter 加到依赖数组。

这也是 React 文档建议的:"所有依赖都列出来,但 setter 是个例外"。

Q5: 依赖项是空数组 [] 的 useEffect 什么时候执行?

  • mount 时:执行一次(mountEffectImpl 走路径,不比较依赖)
  • update 时永不执行areHookInputsEqual([], []) 为 true,不打 HookHasEffect,effect 进了 updateQueue 但永远不被执行)

这就是经典的"只跑一次"模式——等价于类组件的 componentDidMount

Q6: 怎么在 unmount 时做清理?

用 useEffect 回调的 return 函数

useEffect(() => {
const timer = setInterval(() => {...}, 1000);
return () => clearInterval(timer); // 卸载时清理
}, []);

源码对应:destroy 函数会被存到 effect.destroy 上,下次 effect 跑之前先调用 destroy(或组件 unmount 时调用)。