掌握 React useEffect:核心概念、使用技巧与常见陷阱

当你写出无泄漏、无多余渲染、无过期闭包的 effect 时,React 函数组件的开发体验会变得前所未有的流畅。希望这篇指南能帮你跨过 useEffect 的常见门槛,写出更可靠的 React 应用。

前言

useEffect 是 React 函数组件中最常用、也最容易写出 bug 的 Hook 之一。它让我们能在函数组件中执行副作用(side effects),从而与外部世界交互。本文将系统地梳理 useEffect 的核心知识、典型场景、常见误区以及最佳实践,帮助你真正驾驭它。


1. 什么是副作用?为什么需要 useEffect?

React 的核心原则之一是 组件必须是纯函数:对于相同的 props 和 state,组件应该渲染出相同的 UI。但在实际应用中,我们不可避免地需要做一些"不纯"的事情,例如:

  • 从服务器获取数据
  • 手动操作 DOM
  • 订阅外部事件(如键盘、滚动、WebSocket)
  • 设置定时器
  • 打印日志或发送埋点

这些操作会影响组件以外的世界,被称为副作用 。在类组件中,我们通过 componentDidMountcomponentDidUpdatecomponentWillUnmount 来管理副作用;而在函数组件中,所有副作用都统一交给 useEffect 处理。


2. 基本语法

jsx 复制代码
useEffect(setup, dependencies?)
  • setup :一个函数,用于执行副作用逻辑。它可以返回一个清理函数(cleanup),在组件卸载或下一次副作用执行前调用。
  • dependencies(可选) :一个数组,包含所有在副作用中使用到的、来自组件内部的响应式值(如 props、state、以及由它们派生的变量)。React 会使用 Object.is 对依赖项进行浅比较,只有依赖项发生变化时,副作用才会重新执行。

3. 依赖项数组的三种形态

3.1 不传第二个参数

jsx 复制代码
useEffect(() => {
  console.log('每次渲染后都会执行');
});

每次组件完成渲染(包括首次渲染和每次更新)后,副作用都会执行。这种用法很少见,容易导致性能问题或无限循环,通常需要明确指定依赖项。

3.2 传入空数组 []

jsx 复制代码
useEffect(() => {
  console.log('仅在组件挂载时执行一次');
}, []);

副作用只在组件首次挂载后运行一次,清理函数也只在卸载时运行。这类似于 componentDidMount + componentWillUnmount 的组合。

3.3 传入具体的依赖项

jsx 复制代码
useEffect(() => {
  console.log(`count 变为 ${count}`);
}, [count]);

只有当 count 发生变化时,副作用才会重新执行。这是最推荐、最精细的控制方式。


4. 副作用的清理

很多副作用需要在组件卸载或者下一次副作用执行前被清理掉,比如清除定时器、取消订阅、中止网络请求等。useEffect 通过让 setup 函数返回一个清理函数来实现这一点。

jsx 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    console.log('ticking...');
  }, 1000);

  // 返回清理函数
  return () => {
    clearInterval(timer);
    console.log('清除定时器');
  };
}, []);

执行时机:

  • 组件卸载时,执行上一次副作用的清理函数。
  • 依赖项变化、即将执行下一次副作用之前,会先执行上一次的清理函数。

这也意味着:每一次副作用都能拿到"属于它自己"的 state 和 props ,避免了类组件中常见的"需要在 componentDidUpdate 里手动比较"的问题。


5. 典型使用场景

5.1 数据获取

jsx 复制代码
useEffect(() => {
  let cancelled = false;

  async function fetchData() {
    const res = await fetch(`/api/user/${userId}`);
    const json = await res.json();
    if (!cancelled) {
      setUser(json);
    }
  }

  fetchData();

  return () => {
    cancelled = true; // 防止组件卸载后设置状态
  };
}, [userId]);

注意:useEffect 的回调不能直接声明为 async ,因为异步函数默认返回 Promise,而 useEffect 期望返回一个清理函数或 undefined。因此需要在内部定义异步函数并调用。

5.2 事件监听

jsx 复制代码
useEffect(() => {
  const handleResize = () => setWindowSize(window.innerWidth);
  window.addEventListener('resize', handleResize);

  return () => window.removeEventListener('resize', handleResize);
}, []);

5.3 手动操作 DOM(如聚焦输入框)

jsx 复制代码
useEffect(() => {
  inputRef.current?.focus();
}, []);

如果 DOM 操作不涉及外部观察者,也不需要清理。

5.4 订阅外部 Store(如 Redux、Zustand、EventEmitter)

jsx 复制代码
useEffect(() => {
  const unsubscribe = externalStore.subscribe(handleChange);
  return unsubscribe;
}, []);

6. 常见误区与陷阱

6.1 遗漏依赖项,导致"闭包陷阱"

jsx 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count); // 永远打印初始值 0
  }, 1000);
  return () => clearInterval(timer);
}, []); // 注意:依赖数组为空,但内部使用了 count

因为副作用被"固化"在了初始渲染的闭包中,count 永远是第一次渲染时的值。正确的做法是将 count 加入依赖:

jsx 复制代码
useEffect(() => {
  const timer = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(timer);
}, [count]);

或者使用函数式更新 setCount(c => c + 1) 来避免直接读取 count

6.2 将对象、数组、函数直接作为依赖,引发无限循环

jsx 复制代码
const options = { page: 1, size: 10 };

useEffect(() => {
  fetchData(options);
}, [options]); // 每次渲染 options 都是新对象,导致副作用无限执行

React 对依赖项进行浅比较,而每次渲染都会创建新的引用类型值。解决方法:

  • 改为使用基础类型值作为依赖;
  • 使用 useMemouseCallback 保持引用稳定;
  • 或者采用比较 ref 的方式来跳过不必要的执行(慎用)。

6.3 在 useEffect 中根据 state 计算另一个 state

jsx 复制代码
// ❌ 错误:用 effect 同步派生状态
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [fullName, setFullName] = useState('');

useEffect(() => {
  setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);

这种写法不仅多余,还会导致额外的渲染。派生状态应该在渲染期间直接计算,而不是通过 effect:

jsx 复制代码
const fullName = `${firstName} ${lastName}`;

如果计算开销大,可以用 useMemo

6.4 严格模式下的"双重调用"

在 React 18 的严格模式(<StrictMode>)下,effects 会在开发环境中执行两次 (setup → cleanup → setup),目的是帮助你发现未正确清理的副作用。这不是 bug,生产环境下仍然只执行一次。因此,请务必为所有需要清理的副作用编写清理函数。

6.5 异步 effect 的写法错误

jsx 复制代码
// ❌ 直接传递 async 函数
useEffect(async () => {
  const data = await fetchSomething();
  setData(data);
}, []);

正确做法见 5.1 节,在内部定义并调用异步函数。


7. useEffect 与 useLayoutEffect

  • useEffect 在浏览器完成布局和绘制之后异步执行,不会阻塞屏幕更新,适用于大多数副作用。
  • useLayoutEffect 在 DOM 变更之后、浏览器绘制之前同步执行,适用于需要同步读取/修改 DOM 布局的场景(例如测量元素尺寸、避免闪烁)。

两者语法完全相同,但选择上应优先使用 useEffect,只在必要时切换为 useLayoutEffect


8. 最佳实践

  1. 一个 useEffect 只做一件事

    把不相关的逻辑拆分成多个 useEffect,每个都有自己的依赖数组,让代码更清晰、更易维护。

  2. 始终填写完整的依赖数组

    不要故意省略依赖来"优化"执行时机,这会为 bug 埋下伏笔。使用 eslint-plugin-react-hooksexhaustive-deps 规则可以自动补全和检查。

  3. 优先使用函数式更新和稳定引用

    当副作用依赖的状态变更非常频繁时,考虑使用 setState(prev => ...) 来避免在依赖中引入 state;对于函数或对象,用 useMemo / useCallback 保持其稳定性。

  4. 关注清理逻辑

    任何创建了订阅、定时器、绑定了事件的副作用都应该返回清理函数。

  5. 提取自定义 Hook

    通用、可复用的副作用逻辑可以封装为自定义 Hook(如 useFetchuseEventListeneruseInterval),让组件代码更简洁。


9. 总结

useEffect 用声明式的方式将副作用与组件状态绑定起来,让我们可以更清晰地表达"在什么情况下需要做什么事"。掌握它的关键在于:

  • 理解依赖数组的工作原理与比较机制;
  • 为每一个副作用负责到底(包括清理);
  • 避免用 effect 实现可以用纯计算解决的问题;
  • 利用工具和规则保持代码的健壮性。
相关推荐
XD7429716361 小时前
科技早报晚报|2026年5月12日:GUI Agent、编程会话工作台与 npm 安装门禁,今晚更值得做的 3 个技术机会
前端·科技·npm·供应链安全·ai agent·开发者工具
前端那点事1 小时前
Vue3 新趋势:10个高阶实用操作|性能优化+开发提效+避坑指南
前端·vue.js
small_white_robot1 小时前
idek-2022 web 全wp——持续更新
开发语言·前端·javascript·网络·安全·web安全·网络安全
漫游的渔夫1 小时前
从 if-else 乱麻到状态机:前端开发者该怎么理解多 Agent 协作?
前端·人工智能·typescript
前端那点事1 小时前
90%前端只会皮毛!JSON.parse/stringify高阶用法、数据规则、避坑终极指南
前端·vue.js
需要坚持的人1 小时前
让 SVG 不再“丢字变形”:一次思维导图导出文字转 Path 的实战优化
前端·vue.js·svg
sp421 小时前
NativeScript 5.1:直接集成 Objective-C 代码
前端·javascript
UXbot1 小时前
AI一次生成iOS和Android双端原型功能详解
android·前端·ios·kotlin·交互·swift
我是卡卡啊1 小时前
View 绘制深度分析:HWUI · RenderThread · SurfaceFlinger
前端