
一句话:凡是「建立了某样东西」的 Effect,都必须「撤销它」——事件监听、定时器、订阅、Observer、第三方实例、请求、全局样式,漏掉任何一个都会导致重复回调、内存泄漏或状态错乱。
一、清理函数的两条规则
规则一:建立了什么,就撤销什么
addEventListener → removeEventListener / signal.abort()
setInterval → clearInterval
setTimeout → clearTimeout
subscribe → unsubscribe
observe → disconnect / unobserve
new ThirdParty() → destroy / dispose
fetch → AbortController.abort()
document.title = x → 恢复原值
body.style.overflow = 'hidden' → 恢复原值
规则二:清理的执行时机是「依赖变化后 + 卸载时」
⚠️ 所以清理逻辑不能假设「只会执行一次」
⚠️ 也不能在清理里访问「已卸载的 DOM 引用」(可能已被置为 null)
// ✅ 最小正确示例
useEffect(() => {
const onResize = () => setSize(getSize());
window.addEventListener('resize', onResize); // 建立
return () => {
window.removeEventListener('resize', onResize); // 撤销
};
}, []);
二、事件监听
// ✅ 方式一:手动移除
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
// ⚠️ 必须传「同一个函数引用」(所以不能是内联箭头)
// ✅ 方式二:AbortController 统一管理(推荐,多个监听时最省事)
useEffect(() => {
const ac = new AbortController();
window.addEventListener('resize', onResize, { signal: ac.signal });
window.addEventListener('scroll', onScroll, { signal: ac.signal });
document.addEventListener('keydown', onKeyDown, { signal: ac.signal });
return () => ac.abort(); // ⭐ 一次撤销全部
}, []);
// ⚠️ 闭包陷阱:监听回调里读 state 拿到的是旧值
useEffect(() => {
const onKeyDown = () => console.log(count); // ⚠️ 永远是最初的 count
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
// ✅ 解法一:把 count 加进依赖(会重建监听,通常可接受)
}, [count]);
// ✅ 解法二:用 ref 保存最新值(不重建监听)
const countRef = useRef(count);
useEffect(() => { countRef.current = count; });
useEffect(() => {
const onKeyDown = () => console.log(countRef.current); // ⭐ 最新值
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, []);
// ✅ 更好的封装:useEventListener(把清理与「最新值」都处理好)
function useEventListener<K extends keyof WindowEventMap>(
type: K,
handler: (e: WindowEventMap[K]) => void,
target: Window | Document | HTMLElement = window
) {
const handlerRef = useRef(handler);
useEffect(() => { handlerRef.current = handler; }); // 每次渲染同步最新回调
useEffect(() => {
const listener = (e: Event) => handlerRef.current(e as WindowEventMap[K]);
target.addEventListener(type, listener);
return () => target.removeEventListener(type, listener);
}, [type, target]); // ⭐ 只在 type 变化时重建
}
三、定时器
// ✅ setInterval
useEffect(() => {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]); // ⚠️ delay 变化时会「重建定时器」(计时重新开始)
// ✅ setTimeout
useEffect(() => {
const id = setTimeout(() => setVisible(false), 3000);
return () => clearTimeout(id);
}, []);
// ✅ setTimeout 的常见用途:防抖 / 自动隐藏
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => {
const id = setTimeout(onClose, 3000);
return () => clearTimeout(id); // ⭐ 组件卸载或 message 变化时取消
}, [message, onClose]);
return <div role="status">{message}</div>;
}
// ✅ useInterval(可动态调整间隔,且回调永远最新)
function useInterval(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => {
if (delay === null) return; // ⭐ 传 null 表示「暂停」
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
// 使用
function Timer() {
const [count, setCount] = useState(0);
const [running, setRunning] = useState(true);
useInterval(() => setCount((c) => c + 1), running ? 1000 : null); // ⭐ 暂停
return <button onClick={() => setRunning((r) => !r)}>{count}</button>;
}
⚠️ 定时器的三个要点:
① 必须清理(否则组件卸载后仍在跑 → 内存泄漏 + 对已卸载组件 setState)
② 间隔变化会「重建定时器」(如果你想「不重置计时」,需要额外的 ref 逻辑)
③ 回调里读 state 要用「函数式更新」或「ref 保存最新值」(闭包陷阱)
四、订阅
// ✅ 订阅模式(返回 unsubscribe 函数)
useEffect(() => {
const unsubscribe = store.subscribe((state) => setState(state));
return unsubscribe; // ⭐ 直接把返回的函数作为清理函数
}, [store]);
// ✅ 订阅 + 读取初始值(订阅后要「立即对齐一次」,否则可能错过订阅前的更新)
useEffect(() => {
const update = () => setSnapshot(source.getSnapshot());
update(); // ⭐ 订阅后先对齐一次
const unsubscribe = source.subscribe(update);
return unsubscribe;
}, [source]);
// ✅ 更好的做法:useSyncExternalStore(React 18+ 官方方案)
function useSource<T>(source: { subscribe: (cb: () => void) => () => void; getSnapshot: () => T }) {
return useSyncExternalStore(source.subscribe, source.getSnapshot, source.getSnapshot);
}
// ⭐ 它会正确处理「订阅、初始值、以及并发渲染下的一致性(防撕裂)」
// ✅ 事件总线(mitt)的订阅
useEffect(() => {
const handler = (payload: Payload) => { /* ... */ };
bus.on('some:event', handler);
return () => bus.off('some:event', handler); // ⚠️ 必须 off
}, []);
五、Observer
// ✅ IntersectionObserver(懒加载 / 无限滚动)
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) onIntersect();
}, { rootMargin: '200px' });
io.observe(el);
return () => io.disconnect(); // ⭐ 必须 disconnect
}, [onIntersect]);
// ✅ ResizeObserver(监听元素尺寸变化)
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
const rect = entry!.contentRect;
setSize({ width: rect.width, height: rect.height });
});
ro.observe(el);
setSize({ width: el.offsetWidth, height: el.offsetHeight }); // 先取一次初始值
return () => ro.disconnect();
}, []);
return [ref, size] as const;
}
// ✅ MutationObserver(监听 DOM 变化,用于集成非 React 的内容)
useEffect(() => {
const el = ref.current;
if (!el) return;
const mo = new MutationObserver((mutations) => {
onDomChange(mutations);
});
mo.observe(el, { childList: true, subtree: true, attributes: true });
return () => mo.disconnect();
}, [onDomChange]);
六、第三方实例
// ✅ 图表库(echarts)
useEffect(() => {
const chart = echarts.init(chartRef.current!);
chart.setOption(option);
return () => chart.dispose(); // ⭐ 必须 dispose
// ⚠️ 注意:这里只依赖 [](option 变化用单独的 effect 更新,避免「销毁重建」)
}, []);
useEffect(() => {
chartRef.current?.setOption(option, true); // 只更新 option
}, [option]);
// ✅ 地图 / 编辑器 / 播放器等
useEffect(() => {
const editor = new Editor(editorRef.current!, { value, theme });
const onSaved = (content: string) => onChange(content);
editor.on('saved', onSaved); // ⭐ 同时要解绑事件
return () => {
editor.off('saved', onSaved);
editor.destroy();
};
}, []);
⚠️ 第三方实例的三个要点:
① 「销毁」方法名各异(dispose / destroy / unmount / close),要看文档
② 别忘了「解绑它自己的事件」(否则回调里可能引用已卸载的组件)
③ 用 ref 保存实例(而不是 state)——它不需要触发渲染
七、请求与竞态
// ✅ 请求必须支持取消(AbortController)
useEffect(() => {
const controller = new AbortController();
api.get(url, { signal: controller.signal })
.then(setData)
.catch((err) => {
if (err.name === 'AbortError') return; // ⭐ 取消 → 静默
setError(err);
});
return () => controller.abort();
}, [url]);
// ✅ 无法取消时用「忽略标记」
useEffect(() => {
let cancelled = false;
fetchData().then((data) => {
if (cancelled) return; // ⭐ 忽略过期响应
setData(data);
});
return () => { cancelled = true; };
}, []);
八、全局样式的「恢复原值」
// ✅ 正确做法:保存原值再恢复(不要直接置空)
useEffect(() => {
if (!open) return;
const prevOverflow = document.body.style.overflow; // ⭐ 保存原值
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prevOverflow; // ⭐ 恢复原值
};
}, [open]);
// ✅ 另一个常见场景:修改 document.title
useEffect(() => {
const prevTitle = document.title;
document.title = `${count} 条消息`;
return () => {
document.title = prevTitle; // ⭐ 恢复(多页面共享时很重要)
};
}, [count]);
⚠️ 为什么要「恢复原值」而不是「置空」?
① 多个组件可能同时修改同一个全局样式(栈式恢复才正确)
② 置空会「覆盖掉别人的设置」(如主题系统设置的基础样式)
③ 用 ref 保存原值,能保证「谁改的谁还原」
九、常见坑
// ① 忘记清理(最常见)
useEffect(() => {
window.addEventListener('scroll', onScroll); // ❌ 没有清理
}, []);
// ② 清理时用了「不同的函数引用」
window.addEventListener('scroll', () => onScroll()); // ⚠️ 无法移除
return () => window.removeEventListener('scroll', () => onScroll());
// ③ 定时器没清理(组件卸载后仍在跑)
// ④ Observer 没 disconnect(内存泄漏 + 回调继续触发)
// ⑤ 第三方实例没 destroy(内存 + GPU 资源泄漏)
// ⑥ 请求没取消(竞态 + 已卸载组件 setState)
// ⑦ 全局样式「置空」而不是「恢复原值」(覆盖别人的设置)
// ⑧ 在 StrictMode 下「没写清理」→ 立刻暴露为「订阅两次、请求两次」
// ⑨ 用 ref 标志位「绕过」StrictMode 的双调用(掩盖真问题)
// ⑩ 清理函数里访问「已被置 null 的 ref」做 DOM 操作
return () => { ref.current?.focus(); }; // ⚠️ 已卸载
// ⑪ 一个 Effect 里建立多种资源但只清理了其中一部分
// ⑫ 订阅没有「立即对齐一次初始值」(错过订阅前的更新)
// ⑬ 在「事件监听的回调」里读 state(闭包陷阱)
// ⑭ 忘记「第三方库自己的事件」解绑(只 destroy 了实例)
// ⑮ 用「模块级变量」保存定时器 ID(多实例互相覆盖)→ 应该用 useRef
// ⑯ 清理函数「异步执行」(如放进了 setTimeout)→ 时序不可控
// ⑰ 在 SSR 中访问 window/document(没有清理的机会,直接报错)
// ⑱ 用 removeEventListener 时「忘了 capture 选项要一致」
el.addEventListener('click', fn, true);
el.removeEventListener('click', fn); // ⚠️ 选项不一致 → 移除失败
el.removeEventListener('click', fn, true); // ✅
// ⑮ 的对比
let timerId: number; // ❌ 模块级 → 多实例冲突
function Comp() {
useEffect(() => {
timerId = setInterval(tick, 1000);
return () => clearInterval(timerId);
}, []);
}
function Comp2() { // ✅ 实例级
const timerRef = useRef<number>();
useEffect(() => {
timerRef.current = window.setInterval(tick, 1000);
return () => clearInterval(timerRef.current);
}, []);
}
十、面试延伸
- 「为什么 Effect 一定要写清理?」
因为 Effect 可能在依赖变化时多次执行(不只是「挂载一次」)。如果不清洗上一次建立的资源,会导致四类问题:① 重复回调(同一个事件触发多次处理);② 内存泄漏(定时器、订阅、实例一直存在);③ 状态错乱(旧定时器还在改过期的 state,或旧订阅覆盖新数据);④ 资源泄漏(GPU 上下文、WebGL 纹理、WebSocket 连接)。而且 StrictMode 会立刻把这些暴露出来(挂载 → 卸载 → 再挂载)。 -
「哪些副作用需要清理?」
凡是「建立了某个持续存在的东西」都需要:① 事件监听(removeEventListener或AbortController);② 定时器(clearInterval/clearTimeout);③ 订阅(unsubscribe);④ Observer(disconnect/unobserve);⑤ 第三方实例(destroy/dispose+ 解绑它自己的事件);⑥ 网络请求(AbortController.abort());⑦ 全局状态/样式(恢复原值,如document.title、body.style.overflow)。核心口诀是「建立了什么就撤销什么」。 -
「为什么清理函数不只在卸载时执行?」
因为它还负责「在依赖变化时撤销上一次的同步」——Effect 的模型是「同步」:依赖变了意味着「需要重新与外部系统对齐」,所以先「清理旧的同步」再「建立新的」。这正是useEffect比类组件的componentDidUpdate更安全的地方:你不需要在componentDidUpdate里手动比较前后值、也不会漏掉「撤销」这一步。所以清理逻辑不能假设「只会执行一次」。 -
「
removeEventListener为什么有时不生效?」
两个原因:① 函数引用不同——必须传入「与添加时同一个函数引用」(所以不能用内联箭头函数,要把它提取成变量);② 选项不一致——addEventListener用了{ capture: true }或true,removeEventListener也必须传相同选项才能移除。此外还可以用AbortController统一管理:addEventListener(type, fn, { signal }),取消时只要signal.abort(),完全避免这两类错误。 -
「怎么在『不重建订阅』的情况下读到最新的 state?」
用useRef保存最新值:在没有依赖数组的 effect 里每次渲染同步ref.current = value(这个操作不触发任何执行),然后在「只建立一次」的订阅/定时器回调里读ref.current。这是useInterval、useEventListener、useAutoSave这类 Hook 的标准模式。另一种方式是「用函数式更新」(setCount(c => c + 1))——但它只适用于「新值仅依赖旧值」的场景。 -
「StrictMode 下『订阅两次』说明什么?」
说明你的 Effect 没有写清理(或者清理不完整)。StrictMode 在开发环境故意执行「挂载 → 卸载 → 再挂载」,如果清理写得对,第一个订阅会被正确取消,运行时只剩一个(生产环境也只有一次)。正确应对是补齐清理逻辑,而不是用ref标志位绕过——因为「组件被快速卸载重挂载」在真实场景中确实会发生(如路由切换、条件渲染),不写清理的代码在生产环境同样有 bug。 -
「修改全局状态(如
document.title、body样式)要注意什么?」
要「保存原值并恢复」,而不是「置空」:① 多个组件可能同时修改同一个全局值(如多个页面都想改标题),栈式恢复才正确;② 置空会覆盖别人的设置(如主题系统的基础样式、其它组件的锁定);③ 用ref或局部变量保存原值,在清理函数里恢复。典型场景是「弹窗锁定滚动」(body.style.overflow)与「页面标题」。 -
「一个 Effect 应该建立多少种资源?」
建议「一个 Effect 对应一类外部系统」——如果它同时做了「订阅 + 定时器 + 请求」,那么「依赖变化时会全部重建」,而且很难保证清理完整。更好的做法是拆成多个 Effect(每个负责一种同步),这样:① 依赖数组更精确(不会因为 A 变化而重建 B);② 清理逻辑更清晰(一一对应);③ 更容易复用(可以各自抽成自定义 Hook,如useInterval、useEventListener)。
一句话速记
清理的规则是「建立了什么就撤销什么」,时机是「依赖变化后 + 卸载时」(不能假设只执行一次);清单包括「事件监听(
removeEventListener要同引用同选项,或统一用AbortController+signal)、定时器(clearInterval/clearTimeout)、订阅(unsubscribe)、Observer(disconnect)、第三方实例(destroy/dispose+ 解绑它的事件)、请求(abort)、全局样式(保存并恢复原值)」;回调里读 state 要用「函数式更新」或「ref保存最新值」(useInterval/useEventListener的标准模式);StrictMode 暴露的「订阅两次/请求两次」= 你没写清理(补清理,别用 ref 绕过);一个 Effect 只对应一类外部系统,便于复用与清理。



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