
一句话:手写
useEffect的核心是「用Object.is逐个比较依赖数组」——不同则「先执行上次的清理函数,再执行本次的 effect」;所有 effect 都在「组件渲染完成后统一执行」。
一、目标与难点
要实现的三件事:
① 【依赖比较】——只有依赖变化时才重新执行
② 【清理时机】——依赖变化时「先清理上次,再执行本次」;卸载时执行清理
③ 【执行时机】——所有 effect 在「组件函数执行完(渲染完成)之后」统一执行
(不能在组件函数执行过程中执行,否则「渲染没结束就做副作用」)
难点:
· 依赖数组的比较需要「记住上一次的依赖」
· 清理函数需要「被记住」,在下一次执行前调用
· 多个 effect 要「按声明顺序」在渲染后依次执行
二、最简实现(无依赖数组)
// 用与 useState 相同的「槽位」机制
let hookStates = [];
let hookIndex = 0;
let pendingEffects = []; // ⭐ 收集本次渲染产生的 effect,渲染后统一执行
function useEffect(effect, deps) {
const index = hookIndex++;
const prev = hookStates[index]; // { deps, cleanup }
// ① 判断是否需要执行
const hasChanged = prev === undefined || !depsEqual(prev.deps, deps);
if (hasChanged) {
pendingEffects.push({
index,
effect,
deps,
cleanup: prev?.cleanup, // ⭐ 上次的清理函数
});
}
}
function depsEqual(a, b) {
// 不传依赖数组 → 每次都执行(返回 false 表示「不相等」)
if (a === undefined || b === undefined) return false;
if (a.length !== b.length) return false;
return a.every((item, i) => Object.is(item, b[i])); // ⭐ 逐项 Object.is
}
// ② 在「组件函数执行完」之后统一 flush(这是关键)
function scheduleEffects() {
queueMicrotask(() => {
pendingEffects.forEach(({ index, effect, deps, cleanup }) => {
cleanup?.(); // ⭐ 先执行上次的清理
const result = effect(); // 再执行本次 effect
hookStates[index] = {
deps,
cleanup: typeof result === 'function' ? result : undefined, // 记住清理函数
};
});
pendingEffects = [];
});
}
三、完整实现(含卸载清理)
// ===== 运行时状态 =====
let hooks = []; // 每个槽位:{ deps, cleanup }
let cursor = 0;
let effectQueue = []; // 本次渲染要执行/调度的 effect
let unmountQueue = []; // 组件卸载时要执行的清理
let isMounted = false;
// ===== 渲染流程 =====
function render(Component, container) {
cursor = 0; // ⭐ 游标归零
effectQueue = [];
const vnode = Component(); // 1. 执行组件函数(收集 effect)
commitToDOM(vnode, container); // 2. 更新 DOM
flushEffects(); // 3. ⭐ 渲染后统一执行 effect
}
// ===== useEffect 的「调度阶段」 =====
function useEffect(effect, deps) {
const index = cursor++;
const prev = hooks[index];
const depsChanged =
prev === undefined || // 首次渲染
deps === undefined || // 不传依赖 → 每次都执行
!depsEqual(prev.deps, deps); // 依赖变化
if (depsChanged) {
effectQueue.push({ index, effect, deps, prev });
}
}
// ===== useEffect 的「执行阶段」(渲染后) =====
function flushEffects() {
const queue = effectQueue;
effectQueue = [];
queue.forEach(({ index, effect, deps, prev }) => {
// ① 先执行「上一次的清理函数」
if (typeof prev?.cleanup === 'function') {
prev.cleanup();
}
// ② 再执行本次 effect
const cleanup = effect();
// ③ 记住「依赖」与「清理函数」,供下次比较/调用
hooks[index] = {
deps,
cleanup: typeof cleanup === 'function' ? cleanup : undefined,
};
});
}
// ===== 卸载:执行所有清理 =====
function unmount() {
hooks.forEach((hook) => {
if (typeof hook?.cleanup === 'function') hook.cleanup();
});
hooks = [];
}
⭐ 三个关键设计:
① `effectQueue` + `flushEffects()` ——「收集」与「执行」分离
→ 保证「所有 effect 都在渲染完成后执行」(而不是在执行组件函数的过程中)
② `hooks[index] = { deps, cleanup }` —— 同时记住「依赖」与「清理函数」
→ 依赖用于「是否需要重跑」,清理函数用于「重跑前撤销」
③ 清理的执行顺序是「**先清理上一次,再执行本次**」
→ 与 React 一致(这也是 useEffect 比 componentDidUpdate 更安全的原因)
// ✅ 验证:执行顺序
function Demo({ id }) {
useEffect(() => {
console.log('effect', id);
return () => console.log('cleanup', id);
}, [id]);
useEffect(() => {
console.log('effect2', id);
return () => console.log('cleanup2', id);
}, [id]);
return { /* jsx */ };
}
// 首次挂载:effect 1 → effect2 1
// id 变为 2:cleanup 1 → effect 2 → cleanup2 1 → effect2 2
四、多个 effect 的执行顺序
⭐ 真实 React 的顺序规则(手写实现也应尽量一致):
① 同一个组件内:【按声明顺序】执行
(因为 effectQueue 是按 cursor 递增 push 的)
② 不同组件之间:子组件的 effect 【先于】父组件
(因为 commit 阶段是「由下往上」收集 effect 的)
③ 清理函数的顺序:与「执行顺序」相反(后进先出)
⚠️ 这一条在手写实现里通常简化处理
④ useLayoutEffect 与 useEffect:
· layout effect 在「DOM 更新后、绘制前」同步执行
· passive effect(useEffect)在「绘制后」异步执行
→ 两者在同一个组件内,layout 先于 passive
// ✅ 若想模拟「清理顺序相反」,可以倒序遍历清理(简化版)
function flushEffects() {
const queue = effectQueue;
effectQueue = [];
// ① 先「倒序」执行所有清理(后进先出)
for (let i = queue.length - 1; i >= 0; i--) {
const { prev } = queue[i];
if (typeof prev?.cleanup === 'function') prev.cleanup();
}
// ② 再「顺序」执行所有 effect
queue.forEach(({ index, effect, deps }) => {
const cleanup = effect();
hooks[index] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
});
}
五、useLayoutEffect 的差异(同步执行)
// ✅ useLayoutEffect:不在「微任务」里执行,而是「同步」在 DOM 更新后立即执行
function useLayoutEffect(effect, deps) {
const index = cursor++;
const prev = hooks[index];
const depsChanged = prev === undefined || deps === undefined || !depsEqual(prev.deps, deps);
if (!depsChanged) return;
// ⭐ 同步执行(不推入 effectQueue)
if (typeof prev?.cleanup === 'function') prev.cleanup();
const cleanup = effect();
hooks[index] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
}
// ⚠️ 注意:真实实现是「收集起来在 commit 的 layout 阶段统一执行」,
// 这里为了简单直接同步执行(效果类似:都在「绘制前」完成)
六、常见坑
① 在「组件函数执行过程中」直接执行 effect
→ 副作用发生在「渲染未完成」时(可能被中断/丢弃,见 4.3)
✅ 必须「收集 → 渲染后统一执行」
② 忘记「保存清理函数」→ 下次执行前无法撤销上一次的订阅/定时器
③ 忘记「保存依赖」→ 无法判断「是否需要重跑」
④ 用 `===` 比较依赖(数组与对象永远不相等,且 NaN/-0 判断不对)
✅ 逐项 `Object.is`
⑤ 依赖数组长度变化时不处理(真实 React 会警告)
⑥ 把「依赖为 undefined(不传)」与「依赖为 [] 」混为一谈
· 不传 deps → 每次渲染都执行
· deps = [] → 只在挂载时执行一次
⑦ 忘记「卸载时执行清理」(内存泄漏)
⑧ 认为「清理只会在卸载时执行」(依赖变化时也会执行)
⑨ 在清理里访问「已被置 null 的 DOM ref」
⑩ 认为「手写实现与真实 React 等价」
→ 真实实现还有:passive effect 的调度(绘制后异步)、
effect 链表、优先级、StrictMode 的双执行、
以及「渲染被丢弃时丢掉已收集的 effect」
// ✅ 关于「渲染被丢弃」的处理(真实 React 会做,手写版可简化)
function render(Component, container) {
cursor = 0;
effectQueue = [];
try {
const vnode = Component();
commitToDOM(vnode, container);
flushEffects();
} catch (err) {
effectQueue = []; // ⭐ 渲染失败 → 丢弃本次收集的 effect
throw err;
}
}
七、完整可运行版本
<!doctype html>
<html>
<body>
<div id="root"></div>
<script>
// ---------- 运行时 ----------
let hooks = [];
let cursor = 0;
let effectQueue = [];
let root = null;
let Component = null;
let scheduled = false;
function h(type, props, ...children) {
return { type, props: { ...props, children: children.length === 1 ? children[0] : children } };
}
function depsEqual(a, b) {
if (a === undefined || b === undefined) return false; // 不传 → 每次都执行
if (a.length !== b.length) return false;
return a.every((x, i) => Object.is(x, b[i]));
}
function useState(initial) {
const i = cursor++;
if (hooks[i]?.state === undefined && !hooks[i]?.initialized) {
hooks[i] = { ...(hooks[i] ?? {}), state: typeof initial === 'function' ? initial() : initial, initialized: true };
}
const setState = (v) => {
const prev = hooks[i].state;
const next = typeof v === 'function' ? v(prev) : v;
if (Object.is(prev, next)) return;
hooks[i] = { ...hooks[i], state: next };
schedule();
};
return [hooks[i].state, setState];
}
function useEffect(effect, deps) {
const i = cursor++;
const prev = hooks[i];
const changed = prev === undefined || !depsEqual(prev?.deps, deps);
if (changed) effectQueue.push({ i, effect, deps, prev });
}
function flushEffects() {
const queue = effectQueue;
effectQueue = [];
queue.forEach(({ i, effect, deps, prev }) => {
if (typeof prev?.cleanup === 'function') prev.cleanup(); // 先清理
const cleanup = effect(); // 再执行
hooks[i] = { ...(hooks[i] ?? {}), deps, cleanup: typeof cleanup === 'function' ? cleanup : undefined };
});
}
// ---------- 渲染 ----------
function schedule() {
if (scheduled) return;
scheduled = true;
queueMicrotask(() => { scheduled = false; render(Component, root); });
}
function render(C, container) {
root = container; Component = C;
cursor = 0;
effectQueue = [];
const vnode = C(); // 1. 组件函数(收集 effect)
container.innerHTML = ''; // 2. 提交 DOM
container.appendChild(mount(vnode));
flushEffects(); // 3. ⭐ 渲染后统一执行 effect
}
function mount(vnode) {
if (vnode == null || typeof vnode === 'boolean') return document.createTextNode('');
if (typeof vnode === 'string' || typeof vnode === 'number') return document.createTextNode(String(vnode));
const el = document.createElement(vnode.type);
const { children, ...props } = vnode.props ?? {};
for (const [k, v] of Object.entries(props)) {
if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
else el.setAttribute(k, String(v));
}
(Array.isArray(children) ? children : [children]).forEach((c) => el.appendChild(mount(c)));
return el;
}
// ---------- 用户代码 ----------
function Timer() {
const [count, setCount] = useState(0);
const [running, setRunning] = useState(true);
useEffect(() => {
if (!running) return;
const id = setInterval(() => setCount((c) => c + 1), 1000);
console.log('[effect] 启动定时器');
return () => { clearInterval(id); console.log('[cleanup] 清理定时器'); }; // ⭐ 清理
}, [running]);
useEffect(() => {
document.title = `计数 ${count}`;
return () => { document.title = '原来的标题'; }; // ⭐ 恢复
}, [count]);
return h('div', null,
h('p', null, `count = ${count}`),
h('button', { onClick: () => setRunning((r) => !r) }, running ? '暂停' : '继续')
);
}
render(Timer, document.getElementById('root'));
</script>
</body>
</html>
⭐ 跑起来观察:
① 点「暂停」时,控制台会打印「[cleanup] 清理定时器」→ 说明依赖变化时执行了清理
② 计数变化时,document.title 被更新,且「下一次变化前」会先恢复原标题
八、面试延伸
- 「手写一个
useEffect」
核心三点:① 依赖比较——把「上次的依赖数组」存在 Hook 槽位里,用Object.is逐项比较(不传依赖数组表示「每次都执行」);② 收集与执行分离——在组件函数执行时只把需要执行的 effect 推进一个队列,等「组件函数执行完、DOM 提交后」统一执行(不能在渲染过程中执行,否则副作用发生在「渲染未完成」时);③ 清理函数——保存 effect 返回的函数,在下一次执行前与卸载时调用,且顺序是「先清理上次、再执行本次」。 -
「为什么 effect 要在渲染后统一执行,而不是遇到就执行?」
因为「执行组件函数(render 阶段)必须是纯的」——它可能被中断、重放或丢弃(见 4.3)。如果在「渲染过程中」就执行副作用:① 副作用可能被执行多次(重放);② 已经执行的副作用无法撤销(丢弃时);③ 渲染还没结束就改了 DOM/外部状态,行为不可预测。所以 React 的做法是「收集起来,commit 之后再执行」——这也是useEffect(绘制后)与useLayoutEffect(绘制前)时机的来源。 -
「依赖数组怎么比较?」
用Object.is逐项比较,而不是===(也不是JSON.stringify)。原因:①===对数组/对象总是 false(所以「数组比较」必须逐项);②Object.is能正确处理NaN(Object.is(NaN, NaN) === true)与-0/+0(Object.is(-0, +0) === false),而===在这两个边界上会判错。另外要区分三种情况:不传依赖(每次都执行)、传[](只挂载时执行)、传[a, b](依赖变化时执行)。 -
「清理函数什么时候执行?」
两个时机:① 依赖变化后、下一次 effect 执行前(用于「撤销上一次的同步」);② 组件卸载时(用于释放资源)。顺序上,React 是「先执行所有清理(倒序,后进先出),再执行所有 effect(顺序)」。关键理解是「清理不只发生在卸载」——它在每次「重新同步」前都会执行,这正是useEffect比componentDidUpdate更安全的原因(不需要手动比较前后值)。 -
「手写实现里怎么区分『不传依赖』和『传空数组』?」
通过「deps === undefined」判断:不传时deps是undefined→ 判定为「每次都执行」(depsEqual直接返回false);传[]时deps是「空数组」→ 判定为「只在挂载时执行」(因为「空数组与空数组」是相等的,所以之后不再执行)。这个区分很关键——很多实现错误地把两者当成一回事。 -
「为什么要保存『上一次的清理函数』?」
因为「清理」是「撤销上一次建立的资源」——如果每次执行 effect 都新建一个定时器/订阅而不清理旧的,就会「资源累积」(定时器越来越多、订阅重复触发)。所以要:① 在 effect 被收集时带上prev(上一次的deps与cleanup);② 在 flush 时先调用prev.cleanup()、再执行本次 effect、并保存本次返回的cleanup。这个「保存-调用」的循环就是清理机制的实现。 -
「
useLayoutEffect在手写实现里怎么处理?」
区别只在「执行时机」:useEffect把 effect 推进队列、在「微任务里(绘制后)」执行;useLayoutEffect则「同步在 DOM 提交后立即执行」(绘制前)。手写时最简单的做法是「不推队列,直接同步执行」(同时执行上次的清理)。真实 React 更精细:它把 layout effect 收集到 draft queue,在 commit 的 layout 阶段统一同步执行;passive effect 则调度到绘制后。 -
「手写实现与真实 React 的差距在哪?」
五处:① 调度精细化——真实的 passive effect 是「绘制后异步调度」(用 Scheduler),还支持「渲染被丢弃时丢弃已收集的 effect」;② effect 链表与优先级——真实实现把 effect 挂在 fiber 的更新队列上,支持优先级与中断;③ StrictMode 的双执行——真实的开发模式会「double invoke」(挂载 → 卸载 → 再挂载);④useLayoutEffect/useInsertionEffect的不同时机;⑤ 错误处理——effect 里的错误会被捕获并交给 Error Boundary(部分场景)。手写的目的是「理解依赖比较与清理机制」,不是复刻实现。
一句话速记
手写
useEffect的核心是「把上次的{ deps, cleanup }存在 Hook 槽位里」:用Object.is逐项比较依赖(不传依赖 = 每次都执行、[]= 只挂载一次,两者要区分);在组件函数执行时只『收集』effect,等渲染提交后『统一执行』(因为 render 必须纯、可能被中断/重放);执行时「先调用上次的清理、再执行本次 effect、并保存本次返回的 cleanup」,卸载时执行所有清理;useLayoutEffect的差异只是「同步在绘制前执行」;手写版缺失的细节有「passive effect 的异步调度、effect 链表与优先级、StrictMode 双执行、渲染丢弃时丢弃 effect」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!