陌上人如玉
公子世无双

8.2 副作用的清理

8.2 副作用的清理

一句话:凡是「建立了某样东西」的 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);
  }, []);
}

十、面试延伸

  1. 「为什么 Effect 一定要写清理?」
    因为 Effect 可能在依赖变化时多次执行(不只是「挂载一次」)。如果不清洗上一次建立的资源,会导致四类问题:① 重复回调(同一个事件触发多次处理);② 内存泄漏(定时器、订阅、实例一直存在);③ 状态错乱(旧定时器还在改过期的 state,或旧订阅覆盖新数据);④ 资源泄漏(GPU 上下文、WebGL 纹理、WebSocket 连接)。而且 StrictMode 会立刻把这些暴露出来(挂载 → 卸载 → 再挂载)。

  2. 「哪些副作用需要清理?」
    凡是「建立了某个持续存在的东西」都需要:① 事件监听(removeEventListener 或 AbortController);② 定时器(clearInterval/clearTimeout);③ 订阅(unsubscribe);④ Observer(disconnect/unobserve);⑤ 第三方实例(destroy/dispose + 解绑它自己的事件);⑥ 网络请求(AbortController.abort());⑦ 全局状态/样式(恢复原值,如 document.title、body.style.overflow)。核心口诀是「建立了什么就撤销什么」。

  3. 「为什么清理函数不只在卸载时执行?」
    因为它还负责「在依赖变化时撤销上一次的同步」——Effect 的模型是「同步」:依赖变了意味着「需要重新与外部系统对齐」,所以先「清理旧的同步」再「建立新的」。这正是 useEffect 比类组件的 componentDidUpdate 更安全的地方:你不需要在 componentDidUpdate 里手动比较前后值、也不会漏掉「撤销」这一步。所以清理逻辑不能假设「只会执行一次」。

  4. 「removeEventListener 为什么有时不生效?」
    两个原因:① 函数引用不同——必须传入「与添加时同一个函数引用」(所以不能用内联箭头函数,要把它提取成变量);② 选项不一致——addEventListener 用了 { capture: true } 或 true,removeEventListener 也必须传相同选项才能移除。此外还可以用 AbortController 统一管理:addEventListener(type, fn, { signal }),取消时只要 signal.abort(),完全避免这两类错误。

  5. 「怎么在『不重建订阅』的情况下读到最新的 state?」
    用 useRef 保存最新值:在没有依赖数组的 effect 里每次渲染同步 ref.current = value(这个操作不触发任何执行),然后在「只建立一次」的订阅/定时器回调里读 ref.current。这是 useInterval、useEventListener、useAutoSave 这类 Hook 的标准模式。另一种方式是「用函数式更新」(setCount(c => c + 1))——但它只适用于「新值仅依赖旧值」的场景。

  6. 「StrictMode 下『订阅两次』说明什么?」
    说明你的 Effect 没有写清理(或者清理不完整)。StrictMode 在开发环境故意执行「挂载 → 卸载 → 再挂载」,如果清理写得对,第一个订阅会被正确取消,运行时只剩一个(生产环境也只有一次)。正确应对是补齐清理逻辑,而不是用 ref 标志位绕过——因为「组件被快速卸载重挂载」在真实场景中确实会发生(如路由切换、条件渲染),不写清理的代码在生产环境同样有 bug。

  7. 「修改全局状态(如 document.title、body 样式)要注意什么?」
    要「保存原值并恢复」,而不是「置空」:① 多个组件可能同时修改同一个全局值(如多个页面都想改标题),栈式恢复才正确;② 置空会覆盖别人的设置(如主题系统的基础样式、其它组件的锁定);③ 用 ref 或局部变量保存原值,在清理函数里恢复。典型场景是「弹窗锁定滚动」(body.style.overflow)与「页面标题」。

  8. 「一个 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 只对应一类外部系统,便于复用与清理。

赞(0) 打赏
未经允许不得转载:陌上寒 » 8.2 副作用的清理

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫