
1. useEffect 到底帮你干了什么1.1 useEffect 的“副作用”到底是什么我记得刚学 React 那会儿最大的困惑就是组件明明只是返回一段 JSX为什么数据还能自己变后来才明白渲染只是 React 世界的一半另一半是副作用Side Effect。所谓副作用其实就是“渲染之外的一切事情”。比如从接口拉数据以后 setState监听浏览器的 resize / scroll 事件操作 DOM比如让输入框自动聚焦启动一个定时器每 5 秒轮询一次订阅某个全局状态比如事件总线、WebSocket这些操作不直接参与 UI 计算但它们会“影响”组件之外的世界或者被这个世界影响。而在 React 的函数组件里你没法像 class 组件那样写componentDidMount/componentDidUpdate/componentWillUnmount三个生命周期方法函数组件只负责“根据 props 和 state 生成 UI”。于是 React 提供给了我们useEffect它就是用来承接这些“副作用”的钩子。我们可以把 React 函数组件想象成一个流水线输入 props / state → 计算 UI → 输出 DOM → 运行 useEffect。useEffect 是在页面真实渲染到浏览器之后才执行的这保证了它不会阻塞渲染也不会在渲染过程中制造混乱。1.2 useEffect 的运行时机渲染完成之后才干活很多新手会把 useEffect 和“渲染过程中执行”搞混这其实是最大的一处误区。React 的执行顺序是这样的组件函数被调用计算出新的虚拟 DOM。React 对比新旧虚拟 DOM决定哪些地方要更新。React 把更新提交到真实 DOM。浏览器绘制页面。useEffect 的回调函数才在这个时候执行。也就是说useEffect 里的代码一定是在“屏幕更新之后”才跑的。如果你在 useEffect 里读取 DOM 的尺寸、坐标、滚动位置是一定能拿到最新值的。这一点和我当年在 class 组件里写componentDidMount的感受基本一致只是名字和写法都变了。这里有一个非常值得注意的细节useEffect 的默认行为是“每次渲染完成之后都执行一次”。它不像componentDidMount只执行一次也不像componentDidUpdate只负责更新。它更接近“挂了也管、更新也管、卸载也管”的全能选手。这个特性让它强大但也让它容易坑人——因为只要你稍微不注意回调就会莫名其妙执行很多次。2. 依赖数组控制 useEffect 执行次数的关键2.1 为什么要有第二个参数useEffect 的基本写法是useEffect(() { console.log(每次渲染后都执行); });如果不传第二个参数回调会在每一次渲染完成后都执行。对于大部分业务场景来说这是没必要的浪费。你只是拉一次数据结果每次输入框敲一个字都要重新拉服务端压力直接翻倍。React 为了解决这个问题设计了依赖数组dependency array。它告诉 React只有在依赖项发生变化的时候这个 effect 才需要重新执行。useEffect(() { console.log(只有当 count 变化时我才会执行); }, [count]);这时你可能会问那不是和componentDidUpdate里手动判断prevProps.count ! count差不多吗确实思想是相通的。但在 useEffect 里依赖数组的可读性和可维护性好太多——代码一眼就能看出这个副作用到底关心什么。2.2 空依赖数组的真面目如果你希望副作用只执行一次也就是实现“挂载后执行一次”的效果可以传一个空数组useEffect(() { fetchData(); }, []);空数组意味着这个 effect 不依赖任何值。所以 React 认为它永远不需要重新执行只在首次挂载后跑一次。但是这里埋着一个大坑如果你在空数组里读取了某个 state 或 prop那么读取到的是首次渲染时的旧值。以后这个值再怎么变你都不会知道。比如const [userId, setUserId] useState(1); useEffect(() { fetchUser(userId); // 这里如果传了空数组userId 永远拿到 1 }, []);这个代码看起来人畜无害但实际上只要 userId 变成 2、3、4这个 effect 都不会重新触发。解决办法只有两个把 userId 加进依赖数组或者改用 ref 去保存最新值。初学者最常在这翻车调试的时候以为自己代码写错了其实是闭包把旧值给“冻住”了。2.3 依赖数组的对比逻辑React 判断依赖是否变化用的不是深比较而是Object.is 级别的浅比较。这意味着基本类型数字、字符串、布尔值直接比较值是否相同。对象、数组、函数比较的是引用地址而不是内容。所以你会看到这种神奇的现象明明是同一个对象只是每次渲染都重建了一遍结果 effect 每次都执行了。const config { page: 1 }; // 每次渲染都新建对象 useEffect(() { loadList(config); }, [config]); // 每次都执行因为 config 引用变了这其实是 React 官方设计如此不是 bug。但如果你没理解它就会写出“无限循环请求数据”的代码。这一点我在后面的调试章节会专门展开。3. 清理函数卸载时收手的优雅方案3.1 为什么需要清理副作用做了一半组件却卸载了——这种场景在单页应用里太常见了。比如用户进入详情页接口数据还没回来他直接点击返回按钮跑路了。这时候如果接口才回包并 setStateReact 会警告甚至导致内存泄漏。在 useEffect 里你可以返回一个cleanup 函数React 会在组件卸载时自动调用它。也可以在依赖变化、effect 重新执行之前先调用上一次的 cleanup。useEffect(() { let timer setInterval(() { console.log(还在跑); }, 1000); return () { clearInterval(timer); // 组件卸载时定时器被清掉 }; }, []);这个模式最典型的场景是清理定时器取消 WebSocket 连接取消订阅事件调用 AbortController 中止请求3.2 清理时机到底是什么时候很多人以为 cleanup 只在组件卸载时执行其实不对。React 的执行规则是首次挂载时执行 effect不执行 cleanup。依赖变化effect 重新执行前先执行上一次的 cleanup再执行新的 effect。组件卸载时执行最后一次 cleanup。这意味着 cleanup 不只是“临终告别”它还担任着“旧任务撤销者”的角色。所以你在 cleanup 里不仅要清理定时器还要考虑把上一次的请求结果作废避免 Race Condition。举个实际场景搜索框输入关键词每次输入都发请求。如果没有 cleanup 防抖快速输入三个字会同时发出三个请求而最后显示哪个结果全看接口返回顺序。React 17 以后虽然在严格模式下会主动模拟卸载再挂载来帮你暴露 cleanup 遗漏问题但真正的数据竞争还是要靠 AbortController 或者标识位去处理。useEffect(() { let isCancel false; fetchData(keyword).then((data) { if (!isCancel) { setData(data); } }); return () { isCancel true; }; }, [keyword]);这个技巧我几乎每天都在用。它能够保证只有“最后一次”请求的结果才会被 setState之前的请求全部被忽略。比单纯用缓存或者防抖都稳。4. 依赖数组与闭包陷阱为什么我的 effect 拿不到最新值4.1 闭包捕获的是渲染快照useEffect 的回调函数是在某个特定渲染中创建的。它捕获的是那一次渲染时的 props、state 和所有变量的值。这个值就像拍了一张照片渲染完成后就不变了。const [count, setCount] useState(0); useEffect(() { const timer setInterval(() { console.log(count); // 永远打印 0 }, 1000); }, []);你以为每秒都会打印最新的 count结果打印的一直是 0。原因就是setInterval 创建时把 count 值 0 捕获进闭包之后不管 count 怎么变闭包里的 count 都是 0。解决方案之一是给 useEffect 加上依赖useEffect(() { const timer setInterval(() { console.log(count); }, 1000); return () clearInterval(timer); }, [count]); // count 每次变化都会重新创建定时器另一种方案是使用useRef保存最新值const countRef useRef(count); countRef.current count; // 每次渲染后更新 ref useEffect(() { const timer setInterval(() { console.log(countRef.current); // 永远拿最新值 }, 1000); }, []);4.2 闭包陷阱最常见于异步请求和定时器凡是“延迟执行”的代码都很容易踩闭包陷阱。比如setTimeout 回调setInterval 回调Promise.then 回调事件回调函数因为这些代码并不在渲染阶段立即执行而是在将来的某个时刻执行。到那时如果闭包捕获的还是旧值就会出现“看到的数字不对”的问题。我们团队有个同事写过一段代码点击按钮后1 秒钟后弹出当前输入框内容。因为他在 setTimeout 里直接用了输入框的 value 变量而 value 是从 props 传进来的。结果每次输入后点击弹出来的都是上一次输入的内容。排查很久才发现是闭包把旧值捕获了。这个案例非常典型建议大家在写异步回调时先问问自己这个值是不是从依赖数组里传进来的如果不是它是不是被旧渲染捕获了4.3 依赖数组到底该填什么这条我非常想强调依赖数组的意义是“告诉 React 这个 effect 使用了哪些外部值”而不是“我想让 effect 什么时候去跑”。很多新手会反过来理解把依赖数组当成一个“触发开关”。比如 effect 里明明用了user和list但他只写了[user]。结果数据不对又觉得 React 有问题。真正规范的做法是把 effect 里用到的所有 props 和 state 都写进依赖数组。如果用了多个就填多个。如果不想频繁触发优先调整逻辑比如改用 ref而不是删依赖。这也就是 React 官方 eslint-plugin-react-hooks 里exhaustive-deps规则检查的内容。按照它的提示去补依赖99% 的时候都不会出错。5. 实战案例一个带防抖的搜索请求5.1 场景需求与分析光说不练假把式我挑一个非常常见的场景来完整捋一遍搜索框输入关键词防抖 500ms 后请求接口展示结果列表同时考虑竞态和数据过期。这个需求里隐藏的核心点有三个每次输入都要更新输入框内容这是纯 UI 渲染。输入停止 500ms 后才发请求这是防抖。请求回来后把结果渲染到列表里这是异步状态更新。我们会在这一个组件里同时用到 useState、useEffect、useRef把 useEffect 的几种用法全部串起来。5.2 第一部分输入框与基础状态清晰起见先把基础结构写出来import { useState, useEffect, useRef } from react; function SearchBox() { const [keyword, setKeyword] useState(); const [result, setResult] useState([]); const [loading, setLoading] useState(false); const timerRef useRef(null); // 输入框变化只做 setState不在这里触发请求 const handleInput (e) { setKeyword(e.target.value); }; return ( div input value{keyword} onChange{handleInput} placeholder输入关键词搜索 / {loading ? p加载中.../p : ( ul {result.map((item) li key{item.id}{item.name}/li)} /ul )} /div ); }注意我没有把请求逻辑直接写进 handleInput。因为如果写在事件回调里每次输入都会触发一次请求还要手动管防抖。这不是 React 推荐的思路——React 的哲学是“数据和渲染驱动副作用”而不是“事件驱动副作用”。5.3 第二部分用 useEffect 实现防抖请求核心代码来了useEffect(() { if (!keyword.trim()) { setResult([]); return; } // 先清掉上一次的定时器 if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current setTimeout(() { setLoading(true); fetch(/api/search?q${keyword}) .then((res) res.json()) .then((data) { setResult(data.list); setLoading(false); }); }, 500); // 组件卸载或 keyword 变化时清理上一个定时器 return () { clearTimeout(timerRef.current); }; }, [keyword]);这段代码有几个关键点防抖逻辑放在 useEffect 里而不是事件里好处是不管是通过输入、粘贴、清空只要 keyword 一变都会走同一套逻辑。先清上一次定时器再加新的实现“重新计时”的效果。cleanup 里再次清定时器保证组件卸载或者 keyword 变化时不会出现上一次的定时器残留。setLoading 放在 fetch 之前请求开始时显示加载态。这个写法基本已经是业界标准的“useEffect 防抖请求”模板了。直接照着抄完全没问题。5.4 第三部分处理竞态与组件卸载上面代码在大多数情况下能跑但它还不够稳。如果用户在请求发出后立刻切换关键词上一次请求回来会把旧结果覆盖到新结果上屏幕上出现错乱数据。更严重的是如果组件已经卸载还在 setState控制台会报“Cant perform a React state update on an unmounted component”。我用一个布尔标识位来解决useEffect(() { if (!keyword.trim()) { setResult([]); return; } let ignore false; // 本次请求是否还有效 if (timerRef.current) { clearTimeout(timerRef.current); } timerRef.current setTimeout(() { setLoading(true); fetch(/api/search?q${keyword}) .then((res) res.json()) .then((data) { if (!ignore) { setResult(data.list); setLoading(false); } }); }, 500); return () { clearTimeout(timerRef.current); ignore true; // 当 keyword 变化或组件卸载时旧请求结果被忽略 }; }, [keyword]);这里ignore是重中之重。每次 effect 重新执行都会创建一个新的ignore变量初始为false。当 cleanup 执行时它把旧的那次的ignore置为true。这样即便旧请求晚回来也无法再更新 state。5.5 最终效果与心得完整跑通后这个搜索组件能做到输入过程中不发请求停 500ms 后才发连续输入时只发最后一次请求组件卸载后不会有 setState 警告快速切换关键词时旧请求结果不会覆盖新结果。我实际测试下来的体感是在每个输入框场景里这套模式几乎不需要再改什么。哪怕是做富文本编辑器、筛选条件联动、分页表格搜索逻辑都是一样的只是把 fetch 换成你要调用的函数而已。6. 常见问题排查与性能优化笔记6.1 常见问题速查表症状原因解决方案useEffect 执行了无数次依赖数组里有对象/数组/函数每次渲染引用都变useMemo / useCallback 稳定引用或使用 ref 保存组件卸载后报 setState 警告异步回调里没有做状态判空用 ignore 标志位或用 AbortController 中止请求定时器拿不到最新值闭包捕获了旧渲染的 state用 ref 存最新值或把依赖加进数组effect 只在第一次执行空依赖数组[]导致不随更新触发检查是否有需要变化的依赖补进去页面每次都会闪一下 loadingeffect 挂在尾部但没有防抖加防抖或拆分状态避免短暂 loading 闪烁useEffect 里请求了两次数据开发模式React StrictMode 在开发环境故意挂载两次这是 React 设计的用于暴露清理遗漏不用纠结这些问题的根源几乎都集中在“依赖数组”和“闭包”两个概念上。只要把这两个理解透useEffect 80% 的坑都能自己排查掉。6.2 性能优化不要把所有逻辑塞进一个 useEffect一个常见的坏味道就是把请求、日志、埋点、事件绑定全塞进同一个 useEffect。这样做的后果是任何一个依赖变了所有逻辑都会跟着重跑一遍浪费资源。更合理的做法是拆分成多个 useEffect各管各的事// 负责请求数据 useEffect(() { loadData(); }, [filter]); // 负责埋点 useEffect(() { trackEvent(filter_changed, filter); }, [filter]); // 负责订阅 useEffect(() { const off on(resize, handleResize); return off; }, []);每个 useEffect 遵循“一个职责”的原则依赖自然清晰排查问题也方便。不是说 useEffect 拆得越碎越好而是让“相关的东西尽量聚在一起无关的东西彻底分开”。6.3 开发模式下的双重调用如果你用的是 React 18 的 StrictMode你会发现在开发环境下useEffect 的挂载和卸载会被故意执行两次。这不代表你的代码有问题而是 React 在帮你“彩排”先卸载一次考察你的 cleanup 是不是真的收干净了再重新挂载。很多人第一次遇到会懵以为是代码写错了甚至有人直接把 StrictMode 删掉。我的建议是保留 StrictMode让它检查。只有开发模式下暴露出的 cleanup 缺失在线上才可能变成内存泄漏。平时多花 10 秒看看这些“警告”线上能省很多事。6.4 一个小技巧如果你需要“读取最新值但不想触发执行”有时候我们想要一个效果定时器里能拿到最新 state但又不能因为 state 变化而重建定时器。这时候 useRef 就是最优解。const latestValue useRef(value); latestValue.current value; // 每次渲染同步 useEffect(() { const timer setInterval(() { // 这里可以放心读 latestValue.current }, 1000); return () clearInterval(timer); }, []);看起来就和前面的“闭包陷阱”解法一样。实际项目中做轮询、做倒计时、做“读秒器”的时候这个模式几乎天天用。核心思想就是ref 相当于一个不会触发渲染的仓库你只管往里存、往外取。7. 我踩过的那些坑和一些习惯7.1 三个印象深刻的翻车案例先说第一个。有次我在做一个筛选联动面板需要根据选中的类别重新请求列表。当时我把整个filter对象直接放进依赖数组然后在请求里读它的字段。代码看起来没问题但每次筛选一改effect 会跑两遍。原因是setFilter的时候我把对象整个换了但 React 的浅比较认为它变了。后来改成把filter.categoryId单独抽出来放进依赖数组问题立刻消失。第二个案例是定时器导致的闭包陷阱。我做了一个“剩余时间倒计时”用 setInterval 每秒减一。因为读取的是初始值倒计时永远停在同一个数字。这块花了半天才想明白原来 setInterval 的回调捕获了初始渲染的 state完全不知道后续变化。后来用 ref 保存时间戳问题就没了。其实只要记住一句话定时器里的 state 要看最新值用 ref。第三个案例是我们项目里有个组件useEffect 里订阅了全局事件但忘了在 cleanup 里退订。结果用户切页面再回来订阅重复叠加每次切一次多一条监听最后页面卡得不行。排查起来很费劲因为控制台不报错。后来我把所有“订阅类”的 useEffect 统一写成“返回退订函数”的格式才算根治。7.2 我现在写 useEffect 的习惯我给自己定了几个规矩分享给大家每个 useEffect 开头先问自己我不写这个副作用会怎样如果答案是不会怎样那就不写。依赖数组不是装饰品是把所有用到的外部值都写进去。如果写着写着觉得依赖太多、太乱说明这个副作用该拆了。凡是“持续存在”的东西事件监听、定时器、订阅必须写 cleanup。凡是“将来才发生”的东西请求回调、延时执行必须做 ignore 保护。尽量不在 useEffect 里写同步逻辑。能算出来的值就写在渲染主体里不需要交给副作用。这些规矩听起来简单但每一条都有真实事故支撑。照做之后我几乎没有再被 useEffect 的开销和记忆泄漏坑过。7.3 useEffect 和 useLayoutEffect 怎么选最后提一嘴很容易搞混的两兄弟。useEffect 是异步执行浏览器绘制完成后才跑。useLayoutEffect 是同步执行在 DOM 更新后、浏览器绘制前跑。如果要在页面绘制前测量 DOM、修改样式、避免闪屏用 useLayoutEffect 更合适。日常绝大多数场景比如请求数据、埋点、订阅用 useEffect 就够了它的异步特性对性能更友好。我自己实际用 useLayoutEffect 的场景很有限基本只在“读取滚动条位置并同步设置某个元素高度”的时候会用到。如果你发现页面有闪烁或跳动先考虑 useLayoutEffect如果只是拿数据就别动它useEffect 更稳。8. 一个可以继续扩展的思路useEffect 的价值不只在 React 项目里它那套“渲染完成后做副作用的时机”思想其实在很多框架里都有类似映射比如 Vue 的 watchEffect、Solid 的 createEffect。理解了 React 的 useEffect再看这些框架的副作用处理你会发现套路是一样的声明一个响应式依赖当依赖变化时重新执行副作用。如果你还没试过把那上面的搜索请求再往前推一步我建议你试试接入AbortController替代之前的布尔 ignore这样请求如果超时或中途取消后端也能收到明确的取消信号网络层面上更干净。这个改动适合你项目请求链路比较成熟、不想靠一个布尔值扛全部责任的时候做。再或者你可以把 header、footer、网络请求、日志上报都用自定义 Hook 封装。一个 useFetch 配上 useDebounce后面每个列表页都能直接复用省下来的时间自己去喝茶。README 和业务代码都不需要多解释团队里其他人一看函数名就懂。9. 最后分享一个实用经验那次倒计时 bug 解完之后我对自己定了一条铁律凡是 useEffect 里出现的 state我都要强迫自己看一遍依赖数组。如果依赖数组里没它我一定会停一下问自己是不是故意的。如果是故意用 ref 绕开我会在代码旁边写一行注释说明原因防止同事和未来的我看得一头雾水。这条铁律帮我少踩了很多坑。说真的React 的 useEffect 其实不难难的是在“何时触发”和“为何触发”之间保持清醒。依赖数组是 React 给你的承诺书闭包是你自己的责任书。前者要如实填写后者要亲手收尾两者都做到位useEffect 就是你的好帮手而不是那个总在深夜刷新你世界的“周公”。