第91课:副作用与 useEffect——依赖数组、清理函数、与生命周期对应关系、闭包陷阱
React 组件的主要职责是渲染 UI——根据 Props 和 State 计算并返回 React 元素。但真实世界的应用还需要与外部系统交互:发起网络请求、订阅数据源、操作 DOM、设置定时器。这些操作被称为副作用(Side Effects),因为它们会影响组件之外的世界,且在 React 的纯渲染流程之外执行。useEffect 是函数组件中管理副作用的统一入口——它涵盖了类组件中 componentDidMount、componentDidUpdate 和 componentWillUnmount 三个生命周期方法的功能。理解 useEffect 的依赖数组机制、清理函数的用途以及闭包陷阱的成因,是构建健壮 React 应用的关键。
1. useEffect 的基本模型
useEffect 接收两个参数:一个包含副作用逻辑的回调函数,和一个可选的依赖数组。React 在每次渲染完成后执行 useEffect 的回调。
1 | import { useEffect, useState } from 'react'; |
执行时机:useEffect 的回调在浏览器完成布局和绘制之后异步执行,不会阻塞浏览器更新屏幕。因此它适合进行网络请求、DOM 测量和手动 DOM 操作等不紧急的工作。
1.1 与 useLayoutEffect 的对比
React 还提供了 useLayoutEffect,其回调在所有 DOM 变更之后、浏览器绘制之前同步执行。它的执行时机与类组件的 componentDidMount 和 componentDidUpdate 相同。仅在需要读取布局信息(如元素的尺寸或位置)并同步更新 DOM 以避免闪烁时使用 useLayoutEffect;绝大多数情况使用 useEffect 即可。
2. 依赖数组:控制副作用的执行时机
useEffect 的第二个参数——依赖数组——决定了何时重新执行副作用。React 使用 Object.is 比较依赖数组的每一项。
2.1 不传依赖数组:每次渲染后都执行
1 | useEffect(() => { |
2.2 传入空数组 []:仅在首次挂载后执行
1 | useEffect(() => { |
这模拟了类组件的 componentDidMount。
2.3 传入具体依赖:仅在依赖变化时执行
1 | useEffect(() => { |
当 count 的值发生变化时,React 会先执行上一次效果的清理函数(如果有),然后执行新的效果。这模拟了 componentDidUpdate,但更为精确——你明确声明了副作用依赖哪些数据。
2.4 依赖数组的原则
React 官方建议不要在依赖数组中撒谎——所有在副作用函数内部引用的、来自组件作用域的变量(Props、State、由它们派生的值)都必须列在依赖数组中。ESLint 的 react-hooks/exhaustive-deps 规则会静态分析这一点并给出警告。
1 | function Profile({ userId }) { |
常见误区:如果你不想在依赖变化时重新执行副作用,正确的做法不是从数组中删除依赖,而是重构副作用逻辑——使用函数式更新、移动副作用到不同位置、或使用 useRef 存储不需要触发重新执行的值。
3. 清理函数:防止内存泄漏与状态更新错乱
useEffect 的回调函数可以返回一个函数,这个函数被称为清理函数(Cleanup Function)。React 在组件卸载之前、以及每次重新执行副作用之前,会调用上一次的清理函数。
1 | useEffect(() => { |
3.1 清理的场景
| 副作用类型 | 需要的清理操作 |
|---|---|
setInterval/setTimeout |
clearInterval/clearTimeout |
| 事件监听器 | removeEventListener |
| WebSocket 订阅 | ws.close() |
| 数据订阅 | unsubscribe() |
| 手动修改 DOM | 恢复原始状态(如移除注入的样式) |
3.2 避免对已卸载组件的状态更新
当组件卸载后,如果在异步操作(如 fetch)的回调中尝试调用 setState,React 会发出警告(内存泄漏)。清理函数本身不能取消 fetch,但可以通过标志变量或 AbortController 解决。
1 | useEffect(() => { |
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 | function Counter() { |
原因:useEffect 的回调函数捕获了首次渲染时的 count 值(0)。由于依赖数组为空,这个回调永远不会被重新创建,里面的 count 也永远是 0。这就是 React 的闭包陷阱(Stale Closure)。
5.2 解决方案一:将 count 加入依赖数组
1 | useEffect(() => { |
使用函数式更新 setCount(prev => prev + 1),就不需要依赖外部的 count 变量。但 console.log(count) 仍会打印旧值——因为 console.log 无法使用函数式更新。
5.3 解决方案二:使用 useRef 存储最新值
1 | function Counter() { |
通过 useRef 存储 count 的最新值,定时器回调可以始终读取 countRef.current 而无需将 count 加入依赖数组。
5.4 解决方案三:使用第三方库 useInterval
社区有封装好的 Hook(如 react-use 的 useInterval)可以正确处理定时器与 State 的同步问题。
6. 综合示例:实时搜索组件
1 | function SearchBox() { |
设计要点:
- 防抖:在副作用内部设置
setTimeout,每次重新执行时清理上一次定时器。 - 竞态控制:使用
AbortController取消上一次未完成的fetch,避免旧请求覆盖新结果。 - 清理函数:同时清理定时器和取消网络请求。
课后练习
一、概念自测(选择题 / 填空题)
(单选)
useEffect的回调函数在什么时机执行?
A. 在组件渲染之前同步执行。
B. 在浏览器完成布局和绘制之后异步执行。
C. 在 React 调度更新之前执行。
D. 仅在组件首次挂载时执行。(单选)
useEffect(() => { ... }, [])中的空依赖数组表示什么?
A. 副作用在每次渲染后都执行。
B. 副作用仅在组件挂载时执行一次。
C. 副作用仅在组件卸载时执行。
D. 副作用从不执行。(填空) 要在组件卸载时取消订阅或清理定时器,
useEffect的回调函数应______一个清理函数。(多选) 以下哪些是
useEffect依赖数组的正确使用方式?
A. 不传第二个参数:每次渲染后都执行。
B. 传入空数组[]:仅在挂载时执行一次。
C. 传入[count, name]:当count或name变化时执行。
D. 故意省略某些依赖以避免副作用触发过于频繁。
二、AI 编程任务:编写面向 AI 的提示词
场景:你需要实现一个窗口尺寸监听的自定义 Hook useWindowSize,并配合 useEffect 在一个组件中展示窗口的宽度和高度。要求如下:
useWindowSize返回{ width, height }对象,初始值为当前窗口的innerWidth和innerHeight。- 使用
useEffect监听window的resize事件,在组件卸载时移除事件监听。 - 在展示组件中,使用
useWindowSize并显示宽度和高度,当窗口尺寸变化时自动更新显示。 - 使用
useRef避免事件监听器因 State 变化而频繁重新注册。
任务要求:请写出一段完整的中文提示词,发送给 AI,使其生成符合上述要求的 React 自定义 Hook 和展示组件的代码。提示词中需明确指定 useEffect 的依赖数组、清理函数和 useRef 的用途。
三、Agent 模式下的提示词示例
你是一个资深前端开发 Agent。请创建一个 React 自定义 Hook
useWindowSize和一个使用它的展示组件。需要创建以下文件:
src/hooks/useWindowSize.js:
- 定义
useWindowSizeHook,使用useState初始化{ width: window.innerWidth, height: window.innerHeight }。- 使用
useRef存储尺寸状态(避免事件监听器闭包过期),但也可直接在事件回调中setState。- 使用
useEffect(空依赖数组[])注册window的resize事件监听器,回调中更新 State。返回清理函数removeEventListener。- 返回当前的
{ width, height }对象。src/components/WindowSizeDisplay.jsx:
- 调用
useWindowSize(),在 JSX 中显示Window size: {width} x {height}。- 所有代码使用 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() 取消未完成的网络请求。
课后练习答案
一、概念自测答案
B
- 解析:
useEffect在浏览器布局和绘制之后异步执行,不阻塞渲染。A 描述的是useLayoutEffect。
- 解析:
B
- 解析:空依赖数组表示副作用仅在组件挂载时执行一次,并在卸载时执行清理函数。
返回
- 解析:
useEffect回调通过return () => { ... }返回清理函数。
- 解析:
A、B、C
- 解析:D 错误,故意省略依赖是错误的做法,应该通过重构来正确管理依赖。
二、AI 编程任务参考答案(提示词示例)
示例提示词:
“请实现一个 React 自定义 HookuseWindowSize和展示组件。要求:
- Hook 返回
{ width, height },初始值从window.innerWidth/innerHeight获取。- 使用
useEffect(..., [])注册resize事件,清理函数removeEventListener。- 使用
useRef或直接在事件回调中更新 State。- 展示组件显示窗口尺寸。输出两个文件的完整代码。”