React useEffect 完全指南:依赖数组、清理函数与竞态处理
发布时间:2026/9/16 10:25:37 作者:尧图编辑部 阅读量:1,286

1. 为什么组件需要副作用useEffect诞生的背景先聊一个很多人刚学 React 时都会有过的困惑我只是想从接口拉个数据、往 localStorage 里写个值、给页面加个全局键盘监听为什么 React 非要单独搞一个 useEffect 出来不能直接在组件函数里写吗要回答这个问题得先接受 React 的一个核心设定组件函数必须是“纯”的。所谓纯函数就是给定相同的输入props 和 state就一定返回相同的 JSX 输出中途不修改任何外部状态。这个设定是 React 能高效渲染、能实现 concurrent 模式可中断渲染、能在未来做服务端组件等等一系列能力的根基。如果允许组件函数体内部直接操作 DOM、直接订阅外部事件、直接改全局变量那 React 在渲染过程中一旦被中断或重试这些操作就会重复执行甚至错乱整个渲染模型直接崩掉。那用户交互、网络请求、定时器、手动改 DOM 这类“不纯粹”的事情就总得有地方放。React 给出的答案就是把这些事放进 effect副作用里。useEffect 就是让组件和外部世界同步的官方通道。它的定位很明确组件渲染完成的“合适时机”把需要对外部世界产生影响的逻辑拿出来执行。再往回看Class 组件时代这类逻辑散落在三个生命周期里componentDidMount、componentDidUpdate、componentWillUnmount。同一个逻辑比如一个订阅你得在挂载时订阅、在更新时重新订阅、在卸载时取消订阅代码被拆成三段稍不留神就漏掉一个清理。useEffect 把这三个阶段统一成了同一个 API传一个 effect 函数React 会在挂载和更新后调用它如果 effect 函数返回一个清理函数React 会在下一次 effect 执行之前和组件卸载时调用它。三件事合并成一件这才是 Hooks 真正的价值所在——它不只是一个新 API而是把“关注点”重新聚合起来的思维方式。这篇文章我会按照从入门到进阶的顺序把 useEffect 的依赖数组机制、清理函数执行时机、闭包过期、数据请求竞态、与 useLayoutEffect 的区分这些内容全部过一遍。无论你是刚开始学 React 的新手还是在准备面试、想把自己的 useEffect 写法打磨得更加规范的老手这篇文章都能给你一个完整可用的参考。2. useEffect 核心机制依赖数组、执行时机与清理函数2.1 useEffect 的四种基本形态useEffect 的使用方式可以用一张表概括清楚所有的变体都逃不出下面四种形态形态代码写法执行时机无依赖数组useEffect(() {})每次渲染后都执行空依赖数组useEffect(() {}, [])只在组件挂载后执行一次有依赖数组useEffect(() {}, [a, b])挂载后执行一次a 或 b 变化后再执行带清理函数useEffect(() { return () {} }, [])effect 首次执行前无清理下次 effect 执行前或卸载时执行清理先说一个新手最容易误解的点useEffect 不是在渲染过程中同步执行的。渲染过程是纯函数阶段React 在完成 DOM 变更之后会在浏览器绘制完成后异步地调用 effect。这个执行时机上的差异非常关键它意味着你在 effect 里读取到的 DOM 已经是绘制后的状态但同时也意味着如果在 effect 里修改了 state会重新触发渲染。所以整个 useEffect 的执行循环是组件渲染 → 浏览器绘制完成 → effect 执行 → 如果 effect 里改了 state → 触发下一轮渲染 → 下一轮渲染完成后准备下一次 effect。很多时候当我们说“useEffect 和 componentDidMount 不一样”指的就是这种异步性。componentDidMount 是同步执行的在浏览器绘制之前就会触发而 useEffect 被推迟到了绘制之后。这也是为什么有些需要同步测量 DOM、同步调整布局的场景下React 官网会建议你使用 useLayoutEffect 而不是 useEffect。2.2 依赖数组到底在比较什么依赖数组是 useEffect 最容易踩坑的地方理解它的核心在于理解 React 对依赖项的对比方式。React 对依赖数组的处理逻辑是组件每次渲染完成后React 会把这一次传入的依赖数组和上一次渲染时传入的依赖数组做逐项比较比较的算法是Object.is。如果每一项都相同说明依赖没有变化那这次渲染就跳过 effect 不执行只要有一项不同就执行 effect先执行上一次的清理函数再执行新的 effect。这个概念要结合闭包来理解。组件每一次渲染都会生成一个独立的函数作用域effect 函数本身也是这一次渲染快照的一部分。你在 effect 内部访问到的 props、state都是这一次渲染时的那份值。依赖数组真正做的事情是告诉 React“我这个 effect 里用了这些外部变量你要帮我盯着只有这些变量变了才需要重新生成这个 effect。”这里会产生一个非常重要的推论依赖数组里必须把 effect 里用到的所有外部变量都列全。如果某个 state 或 props 在 effect 内部被读取了却没有写进依赖数组那 React 就不会因为它的变化重新执行 effect于是 effect 里读到的一直是某一次旧渲染的值这就是经典的“过期闭包”问题。我后面会专门用一整节来展开这个问题。2.3 clean up 函数的真实执行时机cleanup 函数的执行时机是面试里最爱问、实战里最容易写出 bug 的一个点。虽然很多教程会把总结说成“清理函数在组件卸载时执行”但要精确地说清理函数的执行时机实际上是两个第一个时机在下次 effect 执行之前。依赖数组里的值发生变化组件重新渲染React 要触发新一轮 effect 时会先把上一轮 effect 返回的清理函数调用掉然后再执行本轮 effect。这样做是为了防止逻辑重复叠加——比如你给 window 绑定了 keydown 监听如果不清理就再绑一次旧监听还留着回调就被触发了两次而且越积越多。第二个时机组件卸载时。组件要从页面上消失React 会执行最后一次 effect 返回的清理函数相当于给外部世界一个“收拾现场”的机会取消定时器、取消订阅、断掉 WebSocket 连接都在这时处理。注意一个细节如果没有返回清理函数那什么都不用做。这也是为什么在 useEffect 里做事件监听、订阅这类操作时一定要记得返回清理函数。下面是事件监听的典型写法function useKeyPress(targetKey: string) { const [pressed, setPressed] useState(false); useEffect(() { function handleDown(e: KeyboardEvent) { if (e.key targetKey) setPressed(true); } function handleUp(e: KeyboardEvent) { if (e.key targetKey) setPressed(false); } window.addEventListener(keydown, handleDown); window.addEventListener(keyup, handleUp); return () { window.removeEventListener(keydown, handleDown); window.removeEventListener(keyup, handleUp); }; }, [targetKey]); return pressed; }这里依赖数组传了targetKey当它变化时React 会先移除旧的监听再添加新的监听全程不会出现监听函数错乱的问题。2.4 StrictMode 下为什么 effect 会执行两次很多人在用 Create React App 或 Next.js 开发时会发现明明写的useEffect(() {}, [])console.log 却在开发环境打印了两次。第一反应往往是“我代码写错了”其实是 StrictMode 在“捣鬼”。React 18 之后开发模式下如果组件被React.StrictMode包裹React 会在挂载后故意执行一次效果清理再加一次重新执行也就是挂载 → effect 执行 → effect 清理 → effect 再次执行。这看起来非常反直觉但目的很纯粹帮你在开发阶段就暴露“忘记写清理函数”的问题。如果你的 effect 没有正确清理订阅、监听或者定时器这个双调用会立刻让问题浮出水面。这个行为只存在于开发环境生产环境不会发生所以不需要担心线上多跑一次的问题。我见过不少团队因为受不了双执行而直接删掉 StrictMode这是非常可惜的。StrictMode 的 double-invoke 不是 bug而是帮你提前发现 bug 的工具保留它、并让 effect 都具备正确的清理能力才是正确的处理方式。3. 依赖数组进阶闭包陷阱与引用类型问题3.1 useEffect 里读到的永远是一次渲染快照闭包陷阱是 useEffect 在实际开发里最容易让人头疼的问题。很多从 Class 组件转过来的老手脑子里的模型是“this.state 永远指向最新的 state 值”但函数组件里完全不是这么回事。函数组件每次渲染都会重新执行一遍组件函数每次执行都产生一套全新的局部变量和函数闭包。你在这次渲染里创建的函数它捕获的所有 state、props 都是这次渲染时的值永远不会被“更新”。举个例子下面这段代码你会以为每秒输出递增的 count实际上它永远输出 0function Counter() { const [count, setCount] useState(0); useEffect(() { const timer setInterval(() { console.log(count); // 永远输出 0 }, 1000); return () clearInterval(timer); }, []); return ( button onClick{() setCount(c c 1)}add/button ); }原因很简单effect 是在首次渲染时创建的闭包里捕获的 count 就是首次渲染的那个 0。之后每次点击虽然 count 变了、组件重新渲染了但 effect 的依赖数组是空数组React 判断依赖没变化就跳过了 effect 更新于是console.log(count)里读到的还是那个陈旧的闭包。解决这个问题的常规思路有两种。第一种是把 count 写进依赖数组count 一变就重建定时器但这会让定时器不停地被清掉重建并不是所有场景都合适。第二种是用 ref 来绕开闭包捕获因为 ref 是一个稳定的可变容器你在效应函数里读取ref.current任何时候拿到的都是最新值function Counter() { const [count, setCount] useState(0); const countRef useRef(count); useEffect(() { countRef.current count; }, [count]); useEffect(() { const timer setInterval(() { console.log(countRef.current); // 总是能读到最新的 count }, 1000); return () clearInterval(timer); }, []); return ( button onClick{() setCount(c c 1)}add/button ); }这里第一个 useEffect 用来把最新的 count 同步到 ref 里第二个 useEffect 里的定时器虽然闭包捕获的是第一次渲染的countRef对象但因为 ref 对象实例始终没变读countRef.current就能拿到最新值。这是处理“effect 需要读取最新值但不想频繁重建”时非常常用的一套组合拳。3.2 引用类型依赖导致死循环的排查思路闭包陷阱的反面是依赖数组里放了引用类型导致的无限循环。典型场景是 effect 里依赖了某个对象或数组而组件每次渲染都重新创建了这个对象function App() { const [count, setCount] useState(0); // 每次渲染都创建一个新的空对象引用地址都不同 const config { name: app }; useEffect(() { // 这里如果做了 setState 或请求操作会触发 re-render setCount(c c 1); }, [config]); return div{count}/div; }这里的问题是config每次渲染都是全新的对象引用React 用Object.is比较时发现“变了”于是 effect 执行setCount 又触发新一轮渲染新一轮又创建新 config死循环形成。遇到这种问题排查思路很固定一看依赖项是不是每次渲染都会重新创建的引用类型二看 effect 内部有没有触发状态更新。解决方案有三个层次。第一个层次最简单如果对象字段不复杂直接依赖基础类型字段而不是整个对象useEffect(() { // ... }, [config.name]);第二个层次是使用useMemo来缓存对象只有内部字段真正变化时才产生新引用const config useMemo(() { return { name: app }; }, []);第三个层次是使用useCallback来稳定函数引用这一招在处理“effect 要调用 props 里传入的函数”时尤其好用。比如父子组件通信父组件往子组件传了一个onLoad回调子组件把它写进 effect 依赖如果父组件没有用 useCallback 包裹每次渲染都生成新函数子组件 effect 就会反复触发。用 useCallback 固定回调引用配合子组件 effect 里的依赖才能控制住执行次数。3.3 exhaustive-deps让 ESLint 帮你盯住依赖说了这么多依赖数组的坑有一个非常有用的工具必须单独提一下那就是eslint-plugin-react-hooks里的exhaustive-deps规则。它做的事情简单粗暴从你 effect 函数体里自动提取被引用的外部变量然后和依赖数组做比对缺了什么就报 warning 或 error。这个规则的妙处在于它能把“依赖数组是否完整”这件事从手动维护变成自动化检查。很多人会觉得这个规则太啰嗦明明我知道自己在做什么加上某些变量只会让 effect 频繁触发为什么还要报错我的建议是先把这条规则打开把它的警告当成“你正在偏离正轨”的信号。当你确实想忽略某些依赖时先弄明白为什么它可以被忽略再谨慎地加 eslint-disable 注释而不是一上来就全局关掉。借助这个规则依赖数组的很多潜在 bug 能在编码阶段就被拦截掉比你自己靠搜索结果排查要高效得多。这也是我在审查团队代码时最看重的一条规则因为依赖数组相关的 bug往往是线上环境最难定位的问题类型之一。4. 数据请求与竞态useEffect 实战中最重要的一课4.1 一个搜索框引发的请求乱序问题说完了理论来看一个实际开发里最高频的场景用 useEffect 发接口请求。翻车频率最高的写法长这样function SearchResult({ keyword }) { const [data, setData] useState(null); const [loading, setLoading] useState(false); useEffect(() { setLoading(true); fetch(/api/search?q${keyword}) .then(res res.json()) .then(res { setData(res.data); setLoading(false); }); }, [keyword]); // 渲染逻辑... }这个写法的隐患在于当用户快速输入keyword 连续变化请求是异步的返回顺序可能和发起顺序不一致。比如我先搜“react”再搜“vue”但“vue”的请求先返回了setData(vue 的结果)把数据对了紧接着“react”的旧请求也返回了setData(react 的结果)把数据又盖了回去。页面上最终显示的搜索结果是“react 的结果”但 input 输入框里是“vue”。这种竞态问题在真实业务里会以各种形式出现不只是搜索还有分页切换、Tab 切换、筛选条件变化。要解决竞态最核心的思路是清理函数不仅要清理副作用还要清理掉“上一次请求的结果影响本次状态”的可能性。最经典的方案是在 effect 内部用一个局部布尔变量标记是否已过期useEffect(() { let ignore false; setLoading(true); fetch(/api/search?q${keyword}) .then(res res.json()) .then(res { if (!ignore) { setData(res.data); setLoading(false); } }) .catch(() { if (!ignore) setLoading(false); }); return () { ignore true; }; }, [keyword]);每次 keyword 变化上一次 effect 的 cleanup 就会执行把ignore置为 true这样旧请求即使成功返回也不会再进行 setState因为它的“时代已经过去了”。这是一个非常干净、非常经典的竞态处理模式面试里几乎必问项目里也应当成为默认写法。4.2 用 AbortController 主动中断请求Boolean 标记法能够防止“过期请求覆盖新结果”但它并不能真正中断网络请求请求还是发出去了浪费了流量和计算资源。如果你在写 Node.js 环境下的 fetch 或者前后端都归你管可以更进一步用AbortController真正地把旧请求“杀掉”。useEffect(() { const controller new AbortController(); setLoading(true); fetch(/api/search?q${keyword}, { signal: controller.signal, }) .then(res res.json()) .then(res { setData(res.data); setLoading(false); }) .catch((err) { // 如果是主动 abort 导致的错误如果不是的话再处理 if (err.name ! AbortError) { setLoading(false); } }); return () { controller.abort(); }; }, [keyword]);AbortController 是浏览器提供的原生能力不支持的话还有axios的CancelToken、unsafe等对应方案。fetch 在收到 abort 信号后会抛出一个AbortError的异常需要在 catch 里把它识别出来避免当成真正的错误去提示用户。这个方案比布尔标记法更进一步既防止了竞态又减少了无效请求。我在实际项目中采用的通常是一个结合版本cleanup 时既 abort 又加 ignore 标记双保险。因为不同团队封装的请求层对 abort 的处理表现不完全一致有的库会在 abort 后依然 resolve 而不是 reject这种情况下只有ignore标记才能兜底。具体方案可以按团队网络层情况取舍但核心原则不变effect 里的异步请求一定要能清理、要能防止竞态。4.3 依赖更新时组件还没卸载请求请求早于监听对象的场景还有一种容易被忽略的坑发生在依赖是异步获取的对象、而请求逻辑读取了这个对象某个字段时。比如地图场景初始化地图实例是个异步过程地图实例存到了 state然后一个组件要在地图加载完成后请求某个图层的点位数据useEffect(() { if (!mapInstance) return; mapInstance.on(click, handleClick); fetchLayerData(mapInstance.id).then(setPoints); return () { mapInstance.off(click, handleClick); }; }, [mapInstance]);当 mapInstance 第一次还是 null 时effect 直接 return不注册不请求一旦地图实例生成依赖变化effect 重新执行正常挂载监听和发请求如果组件卸载清理函数取消监听但没法撤销已经发出的请求。这里如果配合第 4.2 节讲到的 ignore 标记方式就可以在卸载或地图实例变化时防止旧请求把数据 set 到一个已经卸载的组件上。这种组合在真实项目中遇到的概率很高处理方式也可以直接套用之前的模式。5. useLayoutEffect 与 useEffect 怎么选白屏、闪烁与测量5.1 两者的执行时机差异到底在哪React 提供了另一个与 useEffect 极其相似的 HookuseLayoutEffect。两者 API 完全一样用法上也只是换了个函数名唯一的区别是执行时机。useEffect 是在浏览器完成绘制之后异步执行而 useLayoutEffect 是在 DOM 变更之后、浏览器绘制之前同步执行。这个时机的差别放在某些特定场景下会产生肉眼可见的差异。用一句话概括useLayoutEffect 的执行时机更接近 Class 组件里的 componentDidMount 和 componentDidUpdate它会在浏览器有机会绘制之前跑完因此如果在这里修改了 DOM 或同步调整了布局用户在本次渲染里直接看到的就是调整后的结果会少掉一次“先看到旧状态再闪到新状态”的过程。在我们做数据可视化大屏的时候经常遇到这个场景。大屏的响应式布局依赖窗口尺寸计算很多方案会用 vw/vh 单位做适配但在某些容器结构复杂、需要精确测量容器宽高再动态设置图表尺寸的情况下就需要在 DOM 挂载后立刻拿到真实的 offsetWidth、clientHeight。如果用 useEffect因为它在绘制之后才执行用户会先看到一帧默认尺寸的图表然后图表“跳”到正确的尺寸闪烁感非常明显。改用 useLayoutEffect 后测量和设置尺寸都在绘制前完成用户第一眼看到的就是正确尺寸观感明显更平滑。5.2 React Native 里为什么 useLayoutEffect 更重要还有一类场景在 React Native 开发中非常常见。React Native 的动画、页面转场、导航经常需要在一帧内同时完成布局计算和动画启动。如果动画参数依赖某个原生视图的布局尺寸而你用的是 useEffect存在拿不到最新值或启动晚一帧的问题。基础的解决方案就是把“读原生布局参与计算”的逻辑从 useEffect 迁到 useLayoutEffect保证在提交布局的这一帧内动画配置已经就绪。很多朋友在做 React Native 启动白屏优化时会发现除了原生侧启动时间、首屏 JS Bundle 加载这些因素之外JS 侧渲染完成后的第一次 useLayoutEffect 内如果做了耗时操作也会拖慢“首帧可交互”的时间点。排查启动白屏时如果你发现白屏时间主要是 JS 渲染阶段占用的那就值得检查一下项目里有多少个 useLayoutEffect、里面有没有同步请求或循环计算。不过要注意useLayoutEffect 因为同步执行如果内部逻辑太重反而会阻塞浏览器绘制让页面看起来卡顿。所以这个 Hook 的原则是能用 useEffect 尽量不用 useLayoutEffect只有在“必须保证绘制前完成”的场景下才用它。下面是一张可以直接作为选型参考的对比表维度useEffectuseLayoutEffect执行时机浏览器绘制完成后异步执行DOM 变更后、绘制前同步执行对视觉的影响可能会产生闪烁先旧后新绘制前已完成无闪烁对性能的影响不阻塞绘制性能开销小阻塞绘制内部别做重计算推荐使用场景绝大多数副作用、接口请求、事件监听测量 DOM、同步调整布局、动画前同步配置SSR 场景服务端不会执行客户端正常会在服务端渲染时产生警告需特殊处理5.3 服务端渲染与 useEffect 的两个注意点提到服务端渲染SSRuseEffect 有一个特别容易让新手困惑的特性在服务端渲染期间useEffect 不会执行。因为服务端只负责生成 HTML 字符串不存在浏览器绘制阶段所以 effect 被推迟到客户端水合后再执行。这意味着什么如果页面需要从 localStorage 读取某个值来渲染 UI这个值在服务端渲染出来的 HTML 里是缺失的客户端水合后又突然出现往往会导致 hydration mismatch 警告。处理这类问题的通行思路有两种一是把依赖 localStorage 的逻辑放到客户端挂载后的 effect 里再读读完后在二次 render 中渲染出来必要时用一个hasMounted标记来区分“服务端渲染快照”和“客户端真实内容”二是用外部状态管理方案比如把 localStorage 数据同步到 zustand 或 Redux store 中组件从 store 读值这样服务端和客户端第一次渲染读到的就是同一个 store 快照不会出现 mismatch。说到 zustand在写效果与 store 交互时也有一个常见误区在 effect 里直接修改 store 的 state。从数据流的角度看store 状态的改动应当由事件或业务逻辑触发而不是由组件的副作用来倒逼否则很容易出现组件渲染依赖 store 状态、store 状态又被 effect 修改、最终互相触发无限更新的死循环。更规范的做法是让事件处理器负责发起状态变更effect 只做“同步外部世界”的工作比如把 store 里的某个值同步到某个 SDK 实例、某个第三方播放器这种场景用 useEffect 才顺手。6. useEffect 高频问题排查速查表这一节把我在日常开发和面试中遇到的典型 useEffect 问题汇总成一张速查表每个问题都按“现象 → 原因 → 解法”组织可以直接当作排查手册使用。现象根本原因推荐解法effect 里的定时器读到的 state 永远是最初的值effect 闭包捕获了旧值依赖数组没包含该 state把 state 加进依赖数组或用 ref 保存最新值接口请求在开发环境执行两次React StrictMode 开发环境故意 double-invoke effect生产环境不受影响出现重复请求时应检查是否有清理逻辑请求返回顺序错乱导致数据不匹配异步竞态旧请求结果覆盖新请求在 cleanup 里置 ignore 标记或使用 AbortController 中断请求effect 内部 setState 导致无限循环依赖项是每次渲染都新建的引用类型用基础类型字段依赖、useMemo/useCallback 稳定引用依赖数组写少了值变了却没触发 effecteslint 的 exhaustive-deps 没开或没遵守打开规则按警告补齐依赖组件卸载后仍在 setState控制台警告异步请求或订阅在卸载后没有清理cleanup 里取消订阅/中断请求并加上 ignore 保护渲染后页面闪烁一下才显示正确布局useEffect 在绘制后执行导致首帧状态落后改用 useLayoutEffect 在绘制前完成测量或布局调整服务端渲染出现 hydration mismatchuseEffect 在服务端不执行导致首屏 HTML 与客户端不一致将依赖浏览器 API 的逻辑放入 effect 后二次渲染或先从 store 同步初始值父子组件同时有 effect 时执行顺序混乱effect 的执行顺序是子组件先于父组件理解顺序本身在独立逻辑中最小化跨组件副作用依赖这张表里的每一个条目都是我在不同项目里反复踩过、排查过、最后沉淀下来的经验。这里面的第 2 条和第 5 条是团队里出现频率最高的问题。很多刚接触 Hooks 的同事看到依赖数组报错的第一反应是“把那条规则禁掉”但实际上认真补全依赖数组之后往往能顺藤摸瓜发现组件设计上的不合理之处。7. 关于 useEffect 面试高频考点的最后补充最后再分享一个实际的体会。React 技术面试中 useEffect 几乎是必考知识点但面试官真正想考察的往往不是“你会不会用”而是“你对 React 渲染模型的理解有多深”。每次我面试候选人时都会围绕这三个问题层层深入第一个问题是“useEffect 的依赖数组为空时effect 会执行几次”能答对“开发环境两次、生产环境一次”的人已经不错第二个问题是“effect 里的闭包什么时候是过期的如何解决”能举出定时器例子说明解法的人有实战经验第三个问题是“如果依赖数组里有一个每帧都变化的值你有几种方式让 effect 不频繁触发”能想到用 ref 在读值这一层做文章的人对 React 的理解就比较到位了。这其实提示了一个学习方向不要死记 useEffect 的 API 表面而是把重点放在理解“渲染快照”“闭包捕获”“执行的清理顺序”这些底层机制上。一旦想通了组件每次渲染都是一次独立函数执行这一点useEffect 大半的坑你都能提前避开。我团队里带新人时我一般会让新人先写一个小项目做一个带搜索功能的列表、一个带定时器的组件、一个能同时运行多个订阅的页面全部用 useEffect 实现。做完这个项目自己对 useEffect 的信心会比看十篇教程都足。