WinddSnow

React-useEffect-and-Side-Effects

字数统计: 3.3k阅读时长: 13 min
2026/07/31

第91课:副作用与 useEffect——依赖数组、清理函数、与生命周期对应关系、闭包陷阱

React 组件的主要职责是渲染 UI——根据 Props 和 State 计算并返回 React 元素。但真实世界的应用还需要与外部系统交互:发起网络请求、订阅数据源、操作 DOM、设置定时器。这些操作被称为副作用(Side Effects),因为它们会影响组件之外的世界,且在 React 的纯渲染流程之外执行。useEffect 是函数组件中管理副作用的统一入口——它涵盖了类组件中 componentDidMountcomponentDidUpdatecomponentWillUnmount 三个生命周期方法的功能。理解 useEffect依赖数组机制清理函数的用途以及闭包陷阱的成因,是构建健壮 React 应用的关键。


1. useEffect 的基本模型

useEffect 接收两个参数:一个包含副作用逻辑的回调函数,和一个可选的依赖数组。React 在每次渲染完成后执行 useEffect 的回调。

1
2
3
4
5
6
7
8
9
10
import { useEffect, useState } from 'react';

function DocumentTitle({ title }) {
useEffect(() => {
// 副作用:操作 DOM(document.title 不在 React 树内)
document.title = title;
});

return <h1>{title}</h1>;
}

执行时机useEffect 的回调在浏览器完成布局和绘制之后异步执行,不会阻塞浏览器更新屏幕。因此它适合进行网络请求、DOM 测量和手动 DOM 操作等不紧急的工作。

1.1 与 useLayoutEffect 的对比

React 还提供了 useLayoutEffect,其回调在所有 DOM 变更之后、浏览器绘制之前同步执行。它的执行时机与类组件的 componentDidMountcomponentDidUpdate 相同。仅在需要读取布局信息(如元素的尺寸或位置)并同步更新 DOM 以避免闪烁时使用 useLayoutEffect;绝大多数情况使用 useEffect 即可。


2. 依赖数组:控制副作用的执行时机

useEffect 的第二个参数——依赖数组——决定了何时重新执行副作用。React 使用 Object.is 比较依赖数组的每一项。

2.1 不传依赖数组:每次渲染后都执行

1
2
3
useEffect(() => {
document.title = `Count: ${count}`;
}); // 没有第二个参数:每次渲染后都运行

2.2 传入空数组 []:仅在首次挂载后执行

1
2
3
4
useEffect(() => {
console.log('组件已挂载');
fetch('/api/users').then(res => res.json()).then(setUsers);
}, []); // 空数组:仅在 mount 时运行一次

这模拟了类组件的 componentDidMount

2.3 传入具体依赖:仅在依赖变化时执行

1
2
3
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]); // 仅在 count 变化时运行

count 的值发生变化时,React 会先执行上一次效果的清理函数(如果有),然后执行新的效果。这模拟了 componentDidUpdate,但更为精确——你明确声明了副作用依赖哪些数据。

2.4 依赖数组的原则

React 官方建议不要在依赖数组中撒谎——所有在副作用函数内部引用的、来自组件作用域的变量(Props、State、由它们派生的值)都必须列在依赖数组中。ESLint 的 react-hooks/exhaustive-deps 规则会静态分析这一点并给出警告。

1
2
3
4
5
6
7
8
9
function Profile({ userId }) {
const [user, setUser] = useState(null);

useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]); // ✅ userId 被正确列为依赖
}

常见误区:如果你不想在依赖变化时重新执行副作用,正确的做法不是从数组中删除依赖,而是重构副作用逻辑——使用函数式更新、移动副作用到不同位置、或使用 useRef 存储不需要触发重新执行的值。


3. 清理函数:防止内存泄漏与状态更新错乱

useEffect 的回调函数可以返回一个函数,这个函数被称为清理函数(Cleanup Function)。React 在组件卸载之前、以及每次重新执行副作用之前,会调用上一次的清理函数。

1
2
3
4
5
6
7
8
useEffect(() => {
const subscription = dataSource.subscribe(handleData);

return () => {
// 清理:取消订阅
subscription.unsubscribe();
};
}, [dataSource]);

3.1 清理的场景

副作用类型 需要的清理操作
setInterval/setTimeout clearInterval/clearTimeout
事件监听器 removeEventListener
WebSocket 订阅 ws.close()
数据订阅 unsubscribe()
手动修改 DOM 恢复原始状态(如移除注入的样式)

3.2 避免对已卸载组件的状态更新

当组件卸载后,如果在异步操作(如 fetch)的回调中尝试调用 setState,React 会发出警告(内存泄漏)。清理函数本身不能取消 fetch,但可以通过标志变量AbortController 解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
useEffect(() => {
let cancelled = false;

fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});

return () => {
cancelled = true; // 卸载时设置标志,忽略后续更新
};
}, [userId]);

4. 与类组件生命周期的对应关系

类组件生命周期 useEffect 等价写法
componentDidMount useEffect(fn, []) — 空依赖数组
componentDidUpdate useEffect(fn, [deps]) — 列出具体依赖
componentWillUnmount useEffect(() => { return cleanup }, []) — 清理函数
shouldComponentUpdate React.memo + useMemo + useCallback(非 useEffect 范畴)

关键差异:类组件的生命周期方法按照“挂载 → 更新 → 卸载”的时间线严格划分,而 useEffect 将关注点从时间点转移到数据依赖——你声明副作用和它的数据依赖之间的关系,React 自动处理执行和清理的时机。


5. 闭包陷阱:useEffect 中过期的 State 与 Props

5.1 问题复现

1
2
3
4
5
6
7
8
9
10
11
12
function Counter() {
const [count, setCount] = useState(0);

useEffect(() => {
const id = setInterval(() => {
console.log(count); // 始终打印 0
setCount(count + 1); // count 永远是初始值 0
}, 1000);

return () => clearInterval(id);
}, []); // 空依赖:此效果只运行一次
}

原因useEffect 的回调函数捕获了首次渲染时count 值(0)。由于依赖数组为空,这个回调永远不会被重新创建,里面的 count 也永远是 0。这就是 React 的闭包陷阱(Stale Closure)。

5.2 解决方案一:将 count 加入依赖数组

1
2
3
4
5
6
7
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1);
}, 1000);

return () => clearInterval(id);
}, []); // 空依赖 + 函数式更新

使用函数式更新 setCount(prev => prev + 1),就不需要依赖外部的 count 变量。但 console.log(count) 仍会打印旧值——因为 console.log 无法使用函数式更新。

5.3 解决方案二:使用 useRef 存储最新值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Counter() {
const [count, setCount] = useState(0);
const countRef = useRef(count);

useEffect(() => {
countRef.current = count; // 始终保持最新的 count
}, [count]);

useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // 总是获取最新值
setCount(prev => prev + 1);
}, 1000);

return () => clearInterval(id);
}, []);

return <h1>{count}</h1>;
}

通过 useRef 存储 count 的最新值,定时器回调可以始终读取 countRef.current 而无需将 count 加入依赖数组。

5.4 解决方案三:使用第三方库 useInterval

社区有封装好的 Hook(如 react-useuseInterval)可以正确处理定时器与 State 的同步问题。


6. 综合示例:实时搜索组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
if (query.trim() === '') {
setResults([]);
return;
}

setLoading(true);
const controller = new AbortController();

const timer = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: controller.signal,
});
const data = await res.json();
setResults(data);
} catch (err) {
if (err.name !== 'AbortError') {
console.error(err);
}
} finally {
setLoading(false);
}
}, 300); // 300ms 防抖

// 清理函数:取消上一次请求和定时器
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query]); // 依赖 query 变化重新执行

return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索..."
/>
{loading && <p>加载中...</p>}
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}

设计要点

  • 防抖:在副作用内部设置 setTimeout,每次重新执行时清理上一次定时器。
  • 竞态控制:使用 AbortController 取消上一次未完成的 fetch,避免旧请求覆盖新结果。
  • 清理函数:同时清理定时器和取消网络请求。

课后练习

一、概念自测(选择题 / 填空题)

  1. (单选) useEffect 的回调函数在什么时机执行?
    A. 在组件渲染之前同步执行。
    B. 在浏览器完成布局和绘制之后异步执行。
    C. 在 React 调度更新之前执行。
    D. 仅在组件首次挂载时执行。

  2. (单选) useEffect(() => { ... }, []) 中的空依赖数组表示什么?
    A. 副作用在每次渲染后都执行。
    B. 副作用仅在组件挂载时执行一次。
    C. 副作用仅在组件卸载时执行。
    D. 副作用从不执行。

  3. (填空) 要在组件卸载时取消订阅或清理定时器,useEffect 的回调函数应 ______ 一个清理函数。

  4. (多选) 以下哪些是 useEffect 依赖数组的正确使用方式?
    A. 不传第二个参数:每次渲染后都执行。
    B. 传入空数组 []:仅在挂载时执行一次。
    C. 传入 [count, name]:当 countname 变化时执行。
    D. 故意省略某些依赖以避免副作用触发过于频繁。

二、AI 编程任务:编写面向 AI 的提示词

场景:你需要实现一个窗口尺寸监听的自定义 Hook useWindowSize,并配合 useEffect 在一个组件中展示窗口的宽度和高度。要求如下:

  • useWindowSize 返回 { width, height } 对象,初始值为当前窗口的 innerWidthinnerHeight
  • 使用 useEffect 监听 windowresize 事件,在组件卸载时移除事件监听。
  • 在展示组件中,使用 useWindowSize 并显示宽度和高度,当窗口尺寸变化时自动更新显示。
  • 使用 useRef 避免事件监听器因 State 变化而频繁重新注册。

任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 React 自定义 Hook 和展示组件的代码。提示词中需明确指定 useEffect 的依赖数组、清理函数和 useRef 的用途。

三、Agent 模式下的提示词示例

你是一个资深前端开发 Agent。请创建一个 React 自定义 Hook useWindowSize 和一个使用它的展示组件。需要创建以下文件:

  1. src/hooks/useWindowSize.js
    • 定义 useWindowSize Hook,使用 useState 初始化 { width: window.innerWidth, height: window.innerHeight }
    • 使用 useRef 存储尺寸状态(避免事件监听器闭包过期),但也可直接在事件回调中 setState
    • 使用 useEffect(空依赖数组 [])注册 windowresize 事件监听器,回调中更新 State。返回清理函数 removeEventListener
    • 返回当前的 { width, height } 对象。
  2. src/components/WindowSizeDisplay.jsx
    • 调用 useWindowSize(),在 JSX 中显示 Window size: {width} x {height}
  3. 所有代码使用 JSDoc 注释,确保可直接运行。完成后列出所有文件内容。

四、面试真题与参考答案

题目(百度前端面试题):

请解释 React 中 useEffect 的闭包陷阱是如何产生的,如何通过 useRef 或函数式更新解决定时器中 State 过期的问题?同时说明 useEffect 的清理函数在什么时候被调用,它的主要用途是什么?

参考答案

闭包陷阱产生的原因是 useEffect 回调函数在创建时捕获了当次渲染的 State 和 Props 值。如果依赖数组为空,该回调仅在首次渲染时创建一次,其中的变量将永远是初始值。在定时器中读取这些变量就会得到过期的值。

解决方案包括:

  • 使用函数式更新 setState(prev => prev + 1),避免直接引用外部 State,从而无需将 State 加入依赖。
  • 使用 useRef 保存最新值,定时器回调通过 ref.current 读取——ref 对象在每次渲染中保持相同引用,但其 current 属性可随时更新。
  • 将 State 加入依赖数组,使效果每次随 State 变化而重新执行(会频繁清除和重建定时器,不适用于高频定时器)。

useEffect 的清理函数在两次执行之间(下一次效果执行前)以及组件卸载时调用。其主要用途是清理副作用产生的订阅、定时器、事件监听等资源,防止内存泄漏和状态更新到已卸载的组件。例如,clearInterval 清除定时器,removeEventListener 移除事件监听,AbortController.abort() 取消未完成的网络请求。


课后练习答案

一、概念自测答案

  1. B

    • 解析:useEffect 在浏览器布局和绘制之后异步执行,不阻塞渲染。A 描述的是 useLayoutEffect
  2. B

    • 解析:空依赖数组表示副作用仅在组件挂载时执行一次,并在卸载时执行清理函数。
  3. 返回

    • 解析:useEffect 回调通过 return () => { ... } 返回清理函数。
  4. A、B、C

    • 解析:D 错误,故意省略依赖是错误的做法,应该通过重构来正确管理依赖。

二、AI 编程任务参考答案(提示词示例)

示例提示词
“请实现一个 React 自定义 Hook useWindowSize 和展示组件。要求:

  • Hook 返回 { width, height },初始值从 window.innerWidth/innerHeight 获取。
  • 使用 useEffect(..., []) 注册 resize 事件,清理函数 removeEventListener
  • 使用 useRef 或直接在事件回调中更新 State。
  • 展示组件显示窗口尺寸。输出两个文件的完整代码。”
CATALOG
  1. 1. 第91课:副作用与 useEffect——依赖数组、清理函数、与生命周期对应关系、闭包陷阱
    1. 1.1. 1. useEffect 的基本模型
      1. 1.1.1. 1.1 与 useLayoutEffect 的对比
    2. 1.2. 2. 依赖数组:控制副作用的执行时机
      1. 1.2.1. 2.1 不传依赖数组:每次渲染后都执行
      2. 1.2.2. 2.2 传入空数组 []:仅在首次挂载后执行
      3. 1.2.3. 2.3 传入具体依赖:仅在依赖变化时执行
      4. 1.2.4. 2.4 依赖数组的原则
    3. 1.3. 3. 清理函数:防止内存泄漏与状态更新错乱
      1. 1.3.1. 3.1 清理的场景
      2. 1.3.2. 3.2 避免对已卸载组件的状态更新
    4. 1.4. 4. 与类组件生命周期的对应关系
    5. 1.5. 5. 闭包陷阱:useEffect 中过期的 State 与 Props
      1. 1.5.1. 5.1 问题复现
      2. 1.5.2. 5.2 解决方案一:将 count 加入依赖数组
      3. 1.5.3. 5.3 解决方案二:使用 useRef 存储最新值
      4. 1.5.4. 5.4 解决方案三:使用第三方库 useInterval
    6. 1.6. 6. 综合示例:实时搜索组件
    7. 1.7. 课后练习
      1. 1.7.1. 一、概念自测(选择题 / 填空题)
      2. 1.7.2. 二、AI 编程任务:编写面向 AI 的提示词
      3. 1.7.3. 三、Agent 模式下的提示词示例
      4. 1.7.4. 四、面试真题与参考答案
    8. 1.8. 课后练习答案
      1. 1.8.1. 一、概念自测答案
      2. 1.8.2. 二、AI 编程任务参考答案(提示词示例)