陌上人如玉
公子世无双

2.3 useEffect 深入

本文于 2026-10-12 11:26 更新,部分内容具有时效性,如有失效,请留言

2.3 useEffect 深入

一句话:useEffect 的语义是「把这个组件与某个外部系统同步起来」——它在渲染提交、浏览器绘制之后异步执行,通过依赖数组控制何时重新同步,通过清理函数保证「切换前先撤销上一次的同步」。

一、效应(Effect)的正确理解

useEffect 不是「生命周期钩子」,而是「同步外部系统」的工具。

它的模型是:
  ① 组件渲染 → 需要与某个外部系统对齐
  ② 执行 effect → 建立同步(订阅、连接、写入 DOM…)
  ③ 依赖变化或卸载 → 先执行清理(撤销上一次同步),再执行新的 effect

外部系统包括:
  网络请求、订阅、定时器、浏览器 API(title / localStorage / ResizeObserver)、
  第三方库实例、WebSocket、DOM 操作…
useEffect(() => {
  // ① 建立同步
  const ws = new WebSocket(url);
  ws.onmessage = handleMessage;

  // ② 返回清理函数
  return () => ws.close();
}, [url]);            // ③ 依赖变化时重新同步

二、依赖数组的三种写法

// ① 不写依赖数组:每次渲染后都执行(几乎总是错的)
useEffect(() => {
  console.log('每次渲染都执行');
});

// ② 空数组:只在挂载后执行一次(清理在卸载时)
useEffect(() => {
  console.log('仅挂载时执行');
  return () => console.log('仅卸载时执行');
}, []);

// ③ 有依赖:挂载时 + 依赖变化时执行
useEffect(() => {
  console.log('id 变化时执行', id);
  return () => console.log('id 变化前 / 卸载时执行', id);
}, [id]);
写法 执行时机 常见错误
无依赖数组 每次渲染后 忘记写依赖,导致无限循环
[] 仅挂载 + 卸载清理 用了外部变量但不写依赖(闭包陷阱)
[a, b] 挂载 + a 或 b 变化时 漏依赖 / 多依赖(对象引用不稳定)
// ⚠️ 无依赖数组 + setState = 无限循环
useEffect(() => {
  setCount(count + 1);        // ❌ 每次渲染都执行 → 又触发渲染 → 无限循环
});

// ⚠️ 依赖是「每次渲染都新建的对象」→ 每次都执行
useEffect(() => {
  doSomething(options);       // options 是对象字面量 → 引用总是新的
}, [options]);

// ✅ 三种解法
useEffect(() => {
  doSomething({ a: 1 });
}, []);                       // ① 把常量移出依赖

const opts = useMemo(() => ({ a: 1 }), []);
useEffect(() => { doSomething(opts); }, [opts]);   // ② useMemo 稳定引用

useEffect(() => {
  doSomething({ a: id });
}, [id]);                     // ③ 依赖「原始值」而不是对象

三、执行时机与清理时机

完整流程:

初次挂载:
  ① 执行组件函数(render phase)→ 返回元素
  ② commit 阶段:把 DOM 更新到屏幕
  ③ 浏览器绘制(paint)
  ④ 执行 useEffect 的 effect 函数(异步,paint 之后)
     ← ⭐ 注意:这里的「异步」是指「不阻塞绘制」,但它仍在本次事件循环的微任务/任务中

依赖变化时:
  ① 渲染 + commit(DOM 已更新)
  ② 浏览器绘制
  ③ 执行【上一次的清理函数】
  ④ 执行【本次的 effect 函数】

卸载时:
  ① 执行【最后一次的清理函数】

⚠️ 顺序要点:
  · 同一个组件内的多个 useEffect:按【声明顺序】执行
  · 不同组件之间:子组件的 effect 先于父组件执行
  · 清理函数的执行顺序:与 effect 相反(后声明的先清理)
// 用日志验证时机(值得自己跑一遍)
function Demo({ id }) {
  console.log('① render', id);

  useLayoutEffect(() => {
    console.log('② useLayoutEffect', id);
    return () => console.log('② cleanup layout', id);
  }, [id]);

  useEffect(() => {
    console.log('③ useEffect', id);
    return () => console.log('③ cleanup effect', id);
  }, [id]);

  return 
{id}
; } // 首次挂载输出: // ① render // ② useLayoutEffect // ③ useEffect // id 从 1 变 2 时输出: // ① render 2 // ② cleanup layout 1 // ② useLayoutEffect 2 // ③ cleanup effect 1 // ③ useEffect 2

四、与类组件生命周期的对应

类组件 函数组件等价写法
componentDidMount useEffect(() => {...}, [])
componentDidUpdate useEffect(() => {...})(无依赖)或 [deps]
componentWillUnmount useEffect(() => () => {...}, []) 的清理函数
shouldComponentUpdate React.memo + 自定义比较函数
componentDidCatch ❌ 只能用类组件(Error Boundary)
getSnapshotBeforeUpdate useLayoutEffect(在 DOM 更新后、绘制前读取)
// 「componentDidMount + componentWillUnmount」的等价写法
useEffect(() => {
  // 挂载时执行(对应 componentDidMount)
  const timer = setInterval(tick, 1000);

  return () => {
    // 卸载时执行(对应 componentWillUnmount)
    clearInterval(timer);
  };
}, []);

// ⚠️ 但注意:useEffect 的模型不是「事件」(挂载/卸载),而是「同步」
// 当依赖变化时,它会「先清理、再执行」——这比 componentDidUpdate 更安全

重要区别:类组件的 componentDidMount / componentDidUpdate 是两个独立的时机,你需要在 componentDidUpdate 里手动比较前后值;而 useEffect 是「一个同步过程」,依赖变化时会自动「先撤销、再建立」,不需要手动比较。

五、什么时候「不该」用 Effect(重要)

// ❌ ① 派生数据:不需要 Effect,直接算
function Bad({ firstName, lastName }) {
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);     // ❌ 多余的一次渲染
  }, [firstName, lastName]);
  return 
{fullName}
; } // ✅ function Good({ firstName, lastName }) { const fullName = `${firstName} ${lastName}`; // ✅ 渲染时直接算 return
{fullName}
; } // ❌ ② 响应「某个事件」:应该写在事件处理里 function Bad2({ productId }) { useEffect(() => { if (productId) analytics.track('view', productId); // ❌ 「用户点了」是事件 }, [productId]); } // ✅ 在触发的地方调用 function Good2({ productId, onSelect }) { const handleClick = () => { analytics.track('view', productId); onSelect(productId); }; return ; } // ❌ ③ 重置/调整状态:优先用 key 让组件重建 function Bad3({ userId }) { const [comment, setComment] = useState(''); useEffect(() => { setComment(''); // ❌ 用户切换时清空草稿 }, [userId]); } // ✅ 用 key 强制重建(更符合「不同用户 = 不同组件实例」的语义) // ❌ ④ 通知父组件变化:让「数据流」更清晰 function Bad4({ onChange, value }) { useEffect(() => { onChange(value); // ❌ 容易造成循环 }, [value, onChange]); } // ✅ 在「导致 value 变化的那个事件处理」里调用 onChange

官方总结的「不一定需要 Effect」的四种情况:

场景 应该怎么做
派生数据 渲染时直接计算(必要时 useMemo)
响应事件 写在事件处理函数里
重置状态 用 key 让组件重新挂载
部分 props 变化时的调整 渲染时计算,而不是用 Effect 同步

判断标准:「这段代码是因为『渲染』而运行,还是因为『某个交互』而运行?」 前者才可能需要 Effect;后者应该写在事件处理里。

六、Effect 的合法用法(与外部系统同步)

// ① 订阅(数据源 / 事件总线)
useEffect(() => {
  const unsubscribe = store.subscribe(handleChange);
  return unsubscribe;
}, [store]);

// ② 浏览器事件监听
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);
// 或用 AbortController 统一管理
useEffect(() => {
  const ac = new AbortController();
  window.addEventListener('resize', handleResize, { signal: ac.signal });
  window.addEventListener('scroll', handleScroll, { signal: ac.signal });
  return () => ac.abort();
}, []);

// ③ 定时器
useEffect(() => {
  const id = setInterval(tick, 1000);
  return () => clearInterval(id);
}, [delay]);      // ⚠️ delay 变化时重新建立定时器

// ④ 与第三方库同步(图表、地图、编辑器)
useEffect(() => {
  const chart = echarts.init(ref.current!);
  chart.setOption(option);
  return () => chart.dispose();
}, []);

// ⑤ 修改浏览器标题 / 读取或写入 DOM 之外的存储
useEffect(() => {
  document.title = title;
}, [title]);

// ⑥ 数据请求(配合取消)
useEffect(() => {
  const ac = new AbortController();
  setState({ status: 'loading' });

  fetchData(id, { signal: ac.signal })
    .then((data) => setState({ status: 'success', data }))
    .catch((e) => {
      if (e.name !== 'AbortError') setState({ status: 'error', error: e.message });
    });

  return () => ac.abort();
}, [id]);

// ⑦ Observer
useEffect(() => {
  const io = new IntersectionObserver(onIntersect);
  io.observe(ref.current!);
  return () => io.disconnect();
}, []);

七、依赖数组的规范

// ✅ 依赖「用到的每一个响应式值」
useEffect(() => {
  console.log(a, b);           // 用到了 a 和 b
}, [a, b]);                     // 依赖必须写全

// ⚠️ 把整个对象作为依赖(引用不稳定 → 每次都执行)
useEffect(() => {
  doSomething(user);
}, [user]);                     // user 每次渲染都是新对象?

// ✅ 依赖具体的字段
useEffect(() => {
  doSomething(user.id);
}, [user.id]);

// ✅ 需要「最新值但不想重新执行」时用 useRef
const latestUser = useRef(user);
useEffect(() => {
  latestUser.current = user;     // 每次渲染更新 ref(不触发执行)
});

useEffect(() => {
  const timer = setInterval(() => {
    console.log(latestUser.current);   // 用 ref 拿最新值
  }, 1000);
  return () => clearInterval(timer);
}, []);                           // 依赖写 [] 但拿得到最新值

// ✅ 函数依赖:用 useCallback 稳定,或移到 Effect 内部
// 方案一:把函数移到内部(推荐,除非它需要在别处复用)
useEffect(() => {
  function handle() { /* 用到 a, b */ }
  window.addEventListener('keydown', handle);
  return () => window.removeEventListener('keydown', handle);
}, [a, b]);                       // 依赖变成函数用到的值

// 方案二:用 useCallback 稳定函数引用
const handle = useCallback(() => { /* ... */ }, [a, b]);
useEffect(() => {
  window.addEventListener('keydown', handle);
  return () => window.removeEventListener('keydown', handle);
}, [handle]);
关于 exhaustive-deps 的建议:
① 它的警告【几乎总是对的】——不要习惯性 disable
② 如果加上依赖会导致「无限循环」,通常说明:
   · 依赖里有「每次渲染都新建的对象/函数」→ 用 useMemo/useCallback 稳定
   · 或者这段逻辑本来就不该放在 Effect 里(见第五节)
③ 需要「跳过某次执行」时,用 ref 标志位,而不是删依赖

八、常见坑

// ① 无依赖数组 + setState → 无限循环
useEffect(() => { setX(x + 1); });

// ② 漏依赖导致闭包陷阱
useEffect(() => {
  const id = setInterval(() => console.log(count), 1000);   // ⚠️ count 永远是初始值
  return () => clearInterval(id);
}, []);                            // ✅ 应该写 [count],或用 useRef

// ③ 依赖是「新对象」
useEffect(() => { ... }, [{ a: 1 }]);      // ⚠️ 字面量对象每次都是新引用

// ④ 忘记清理
useEffect(() => {
  const ws = new WebSocket(url);           // ⚠️ 没关闭 → 泄漏 + StrictMode 下开两个
}, [url]);

// ⑤ 在 Effect 里「同步状态」而不是「同步外部系统」
useEffect(() => {
  setFiltered(list.filter(...));           // ⚠️ 应直接计算或用 useMemo
}, [list]);

// ⑥ 用 Effect 初始化「只做一次」的数据
useEffect(() => {
  setInitialValue(compute());              // ⚠️ 应该用 useState 的惰性初始化
}, []);

// ⑦ 在清理函数里访问已卸载的 DOM
return () => { ref.current?.focus(); };    // ⚠️ 此时 DOM 可能已卸载

// ⑧ 依赖数组里放「不稳定的函数」
useEffect(() => { fetchData(); }, [fetchData]);   // ⚠️ fetchData 每次渲染都是新函数

// ⑨ 异步函数直接作为 effect(会返回 Promise)
useEffect(async () => { await fetchData(); }, []);   // ❌ 不能返回 Promise
// ✅
useEffect(() => {
  let cancelled = false;
  (async () => {
    const data = await fetchData();
    if (!cancelled) setData(data);
  })();
  return () => { cancelled = true; };
}, []);

// ⑩ 认为 Effect「在渲染前执行」
// → 实际是在【绘制之后】异步执行(需要绘制前执行用 useLayoutEffect)

面试延伸

  1. 「useEffect 的执行时机是什么?」
    它在「渲染提交 + 浏览器绘制之后」异步执行(不阻塞绘制)。依赖变化时的顺序是「渲染 → commit → 绘制 → 执行上一次的清理函数 → 执行本次 effect」;卸载时执行最后一次清理。同一组件内多个 useEffect 按声明顺序执行,子组件的 effect 先于父组件;清理函数的顺序与声明相反。

  2. 「依赖数组不写、写 []、写 [a] 有什么区别?」
    不写 → 每次渲染后都执行(几乎总是错的,配合 setState 会无限循环);[] → 只在挂载后执行一次,清理在卸载时执行;[a] → 挂载时执行,且 a 变化时“先清理再执行”。第三种是最常用的形式,它把「同步」与「重新同步」统一在了一个模型里。

  3. 「什么时候不该用 useEffect?」
    四种情况:① 派生数据——渲染时直接算(需要时用 useMemo),不要用 Effect 同步到 state;② 响应某个事件——写在事件处理函数里(Effect 是「因为渲染而运行」的,不是「因为交互」);③ 重置状态——用 key 让组件重新挂载更符合语义;④ 部分 props 变化时的调整——在渲染时计算即可。判断标准是「这段逻辑因渲染而跑,还是因交互而跑」。

  4. 「清理函数什么时候执行?」
    两个时机:① 依赖变化后、下一次 effect 执行前(用于「撤销上一次的同步」);② 组件卸载时(用于释放资源)。这正是 useEffect 比 componentDidUpdate 更安全的地方——它自动处理了「先撤销、再建立」的逻辑,不需要你手动比较新旧值。注意:清理函数里不要访问已卸载的 DOM 引用(可能已经是 null)。

  5. 「为什么 useEffect 里不能直接写 async 函数?」
    因为 useEffect 的返回值必须是「清理函数或无返回值」,而 async 函数总是返回 Promise,React 会把它当作清理函数来调用,导致报错(An effect function must not return anything besides a function)。正确写法是「在 effect 内部定义一个 async 函数并立即调用」,同时用「取消标记」或 AbortController 处理竞态。

  6. 「依赖数组漏了值会怎样?」
    会产生闭包陷阱(stale closure):effect 里捕获的是当次渲染的变量快照,后续变化不会反映进去。典型表现是「setInterval 里拿到的永远是初始值」「事件处理器读到旧 state」。解法:① 补齐依赖(首选);② 用 useRef 保存最新值;③ 用函数式更新(setX(p => ...))避免读旧值。不要靠删依赖来「消除警告」,那是在掩盖 bug。

  7. 「为什么加了依赖会导致无限循环?」
    因为依赖里有「每次渲染都新建的对象/函数」(如对象字面量、内联箭头函数、useSelector 返回的新对象)。effect 执行 → 修改 state → 重新渲染 → 对象又是新引用 → 依赖「变了」→ 再执行 → 死循环。解法:① 把常量提到模块作用域;② 用 useMemo / useCallback 稳定引用;③ 依赖「原始值」而不是对象(user.id 而不是 user);④ 反思这段逻辑是否本就不该用 Effect。

  8. 「怎么在 Effect 里拿到『最新值』但又不重新执行?」
    用 useRef:每次渲染把最新值写入 ref.current(在没有依赖数组的 effect 里更新 ref 不会触发任何执行),然后在需要的地方读 ref.current。这是「事件订阅 / 定时器里读最新 props」的标准解法。另一种方式是「用 useEffectEvent(实验中)」——它的语义就是「读最新值但不作为依赖」。

一句话速记

useEffect 的语义是「与外部系统同步」(不是生命周期钩子);依赖数组三种写法:不写(每次渲染)、[](仅挂载)、[deps](变化时「先清理再执行」);清理函数在「依赖变化后」与「卸载时」执行;能不用就不用——派生数据直接算、事件逻辑写在处理函数里、重置状态用 key;漏依赖 = 闭包陷阱(用 useRef 或补齐依赖解决,不要靠删依赖消警告);依赖里有新对象/新函数会导致无限循环。

赞(0) 打赏
未经允许不得转载:陌上寒 » 2.3 useEffect 深入

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫