React 学习站
React›核心›核心

useEffect 与副作用

核心核心

一句话定义

useEffect 是让组件与渲染系统之外的世界保持同步的钩子:在每次提交并绘制后,React 按依赖数组执行(或在变化时先清理再重跑)你的 effect 函数。

为什么重要

副作用的边界决定代码正确性:数据请求、订阅、定时器、localStorage、改标题……都必须走 effect;而 90% 的「useEffect 疑难杂症」源于把不需要 effect 的事塞进了 effect。学会「何时不用」与「何时用」同样重要。

前置知识

  • kp-008 渲染/提交两阶段的时序。
  • kp-014 闭包快照(建议先读,可来回对照)。

核心概念

  • 副作用(Side Effect):影响渲染之外世界的操作——网络、订阅、定时器、全局存储、DOM API。
  • 依赖数组:[] 仅首次执行;[a, b] 在 a 或 b 变化后执行;不传则每次渲染后执行(慎用)。
  • 清理函数:effect 返回的函数,在「下次重跑前」和「组件卸载时」执行,负责回收订阅/定时器/请求。
  • 同步 vs 事件:「渲染导致的响应」放 effect;「用户操作导致的响应」放事件处理器——这是官方 split 逻辑。
  • useLayoutEffect:提交后、浏览器绘制前同步执行,用于测量 DOM/防闪烁;默认用 useEffect。

原理 / 机制

时序图:

text渲染(计算新虚拟DOM) → 提交(写入DOM)
  → [useLayoutEffect 同步执行] → 浏览器绘制
  → [useEffect 异步执行] → [清理函数先于重跑执行]

标准模式:订阅 + 清理

jsxfunction useOnlineStatus() {
  const [online, setOnline] = useState(navigator.onLine);

  useEffect(() => {
    function update() { setOnline(navigator.onLine); }
    window.addEventListener('online', update);
    window.addEventListener('offline', update);
    return () => {
      window.removeEventListener('online', update);
      window.removeEventListener('offline', update);
    };
  }, []); // 只订阅一次;更新走事件回调(外部事件驱动,不需要依赖)

  return online;
}

数据请求的「竞态」问题与修正:

jsxuseEffect(() => {
  let ignore = false;          // 竞态守卫
  fetch(`/api/search?q=${query}`)
    .then(r => r.json())
    .then(json => { if (!ignore) setData(json); });
  return () => { ignore = true; };  // 下一次请求开始时,使旧结果作废
}, [query]);

请求 A 慢于请求 B 返回时,没有守卫会把 A 的旧数据覆盖到新 query 上——这是 effect 数据请求第一大坑。

「不需要 effect」的三类典型(官方 You Might Not Need an Effect 的浓缩):

jsx// 1. 由 state/props 派生的值 → 渲染时直接算
const fullName = firstName + ' ' + lastName;   // 不要 useEffect+setState

// 2. 事件驱动的状态变化 → 放事件处理器
//    不要: [submitted, setSubmitted] + useEffect(() => { if (submitted) send() })
//    而是: 在提交按钮的 onClick 里直接 send()

// 3. 组件挂载时的"镜像到全局" → 例外才是 effect,且清理必须对称

直观类比

effect 像搬家后约水电:装修(渲染/提交)完成、人已入住(绘制)之后才去办(异步执行);每搬一次家(依赖变化重跑),要先退订旧服务(清理)、再订新服务。依赖数组是「搬家触发器」——你只声明「哪些变化意味着该重新同步」,React 负责在正确时机提醒你。

实例 / 案例

localStorage 持久化(学习路径第二阶段项目用到的形状):

jsxfunction usePersistentState(key, initial) {
  const [value, setValue] = useState(() => {
    // 惰性初始化:只在首渲染执行一次昂贵读取
    const raw = localStorage.getItem(key);
    return raw !== null ? JSON.parse(raw) : initial;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]); // value 变 → 同步写盘;key 变 → 写到新位置

  return [value, setValue];
}

注意与 kp-015 的衔接:这正是一个自定义 Hook 的雏形。

常见误区

  • 用 effect 做派生计算:useEffect(() => setTotal(a + b), [a, b]) —— 多一次渲染、可能闪旧值;直接渲染时算。
  • 依赖数组「为了让 lint 闭嘴」而删项:effect 读到的将是过期快照,产生最难查的 bug。正确动作是修复依赖或重构。
  • 清理不对称:订阅了没退订 → 泄漏;setTimeout 没 clear → 卸载后 setState 警告。
  • 在 effect 里改 state 导致循环:setX 后依赖里又有 x → 同步循环;确认这是「渲染派生」还是「事件驱动」,移回对应位置。
  • 把 effect 当生命周期方法用:[] 不是 "componentDidMount",而是「与 [] 中任何东西无关的同步」;心智不同,写法就不同。

自测题

  1. 写出 effect 完整执行时序(含清理、layout effect、绘制)。
  2. 为什么请求竞态需要 ignore 守卫?试着描述一次会出 bug 的时序。
  3. 判断并改写:「点击保存按钮后要弹 toast」——你用 useEffect 监听 saved 状态变化来弹,对吗?
  4. useEffect(() => {...}, []) 与「挂载时执行一次」的语义差别是什么?StrictMode 下前者会执行几次、为什么?
  5. 把 kp-010 的 usePersistentState 中 [key, value] 改成 [] 会发生什么?

与其他知识点的关系

  • 向前:依赖 kp-008 的提交时序。
  • 向后:kp-014 解释「依赖数组为什么必须诚实」(闭包);kp-015 把 effect 打包复用;kp-017 用 ref 存「effect 间共享的可变量」;kp-025 的 Actions 在表单场景取代一部分手写 effect。

延伸阅读

  • Synchronizing with Effects:<https://react.dev/learn/synchronizing-with-effects>
  • You Might Not Need an Effect(本篇最重要的延伸读物):<https://react.dev/learn/you-might-not-need-an-effect>
  • useEffect API:<https://react.dev/reference/react/useEffect>