ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

React useEffect 闭包陷阱:三次真实踩坑与根治方案

React useEffect 闭包陷阱:三次真实踩坑与根治方案 被 useEffect 的闭包坑过三次之后我特别理解那种“代码看着哪儿都对跑起来就是不对”的憋屈感。今天不聊 React Hooks 的高深原理就讲讲我在这三个真实业务场景里是怎么一步步踩进闭包陷阱的以及最后靠哪套思路把自己捞了出来。如果你是刚接触 Hooks 的新手这篇能帮你少走至少三个大坑如果你已经被坑过那咱们刚好可以对一下症状。React 组件从类组件转到函数组件之后useEffect 成了处理副作用的主力但它也是社区里翻车率最高的 Hook 之一。闭包陷阱、依赖项数组、无限循环、拿到过期值几乎每个前端都在这里踩过。我这三次踩坑一次比一次隐蔽但它们的本质都是同一个东西闭包捕获了旧渲染的快照。搞懂这一点比记住一百条“最佳实践”都管用。1. 先扒开闭包陷阱的真面目1.1 闭包不是 React 的锅是 JavaScript 的基本属性很多人一看到“闭包陷阱”四个字就头疼觉得这是什么高深概念。其实闭包就是 JavaScript 里一个很基础也很朴素的行为函数在创建的时候会记住它所在作用域里的变量。不管这个函数之后被丢到哪里执行它都带着当初记住的那份“记忆”。举个最简单的例子function makeCounter() { let count 0; return function () { count; return count; }; } const counter makeCounter(); console.log(counter()); // 1 console.log(counter()); // 2外层函数 makeCounter 执行完后count 变量按理说“销毁”了但内部返回的那个匿名函数还握着它的引用所以每次调用 counter 都能让 count 递增。这就是闭包。React 函数组件里也一样。每次渲染组件函数都会从头到尾执行一遍useState 返回的 state 值是这次渲染的独立常量props 也是这次渲染传入的 props。useEffect 里写的回调函数本质就是“这次渲染”创造出来的一个普通函数它也闭包住了这次渲染的 state、props。用个生活化的比喻每次渲染就像给页面拍了一张照片照片里的 state、props 是定格的。useEffect 的回调是贴在照片背面的一张便签便签写的东西只属于这张照片。哪怕后来页面变成了下一张照片那张旧便签也不会自动改写。1.2 useEffect 的依赖项数组不是装饰品useEffect 的第二个参数——依赖项数组很多人一开始都当成“可选参数”或者“性能优化开关”来用。今天我必须纠正这个认知依赖项数组是 React 决定“什么时候重新执行副作用”的唯一依据。它的工作方式是这样每次渲染都会生成一个新的 effect 回调函数React 把回调存起来然后把依赖项数组里的值和上次渲染的依赖项做对比用的是 Object.is 比较。只要有任何一项变了React 就先执行上一次的清理函数再执行新的 effect 回调如果一项都没变React 直接跳过不执行。而闭包陷阱就藏在这里如果你的依赖项数组写得不完整比如明明回调里读了 count依赖项却写了个空数组那么你执行的就是“第一次渲染”里创建的那个回调。那个回调闭包住的 count 永远是初始值 0。说白了大部分“useEffect 不更新”“总是拿到旧值”的坑都是因为“执行的回调”和“你期望的回调”不是同一个渲染周期里创造出来的。2. 第一次踩坑定时器里的幽灵变量2.1 一个轮播图自动播放功能我第一次被坑是在一个很常规的轮播图组件里。需求是每隔 2 秒自动切换到下一张图。当时我的代码大概是这个样子function Carousel({ images }) { const [activeIndex, setActiveIndex] useState(0); useEffect(() { const timer setInterval(() { console.log(activeIndex, activeIndex); setActiveIndex(activeIndex 1); }, 2000); return () clearInterval(timer); }, []); return ( div span第 {activeIndex} 张/span img src{images[activeIndex]} alt / /div ); }这代码问题一眼就能看出来依赖项是空数组effect 只在首次渲染执行setInterval 里的回调闭包住了首次渲染的 activeIndex也就是 0。于是每隔 2 秒执行的都是setActiveIndex(0 1)activeIndex 从 0 变成 1 后就再也不动了。但如果真写进一个大型组件里一开始根本不会发现“永远停在第二张”因为组件还伴随其他交互你可能会误以为是样式问题或者图片加载问题。我在本地跑的时候console 面板里刷出来的全是 0那一刻的困惑记忆犹新。2.2 我当时是怎么一步步定位的最初的排查我甚至怀疑过是不是 setState 在某些场景下有 batch 合并问题。我在 setInterval 里加了大量 console.log发现打印出来的 activeIndex 永远是 0而不是“上一次点击后更新到的新值”。真正让我茅塞顿开的是一个问题setInterval 到底注册过几次答案是只有一次。因为它写在空依赖项的 effect 里React 只在挂载时执行一次。而那个始终不更新的 activeIndex正是“挂载时那次渲染”的 activeIndex。想通之后最直接的解法是改成函数式更新useEffect(() { const timer setInterval(() { setActiveIndex((prev) (prev 1) % images.length); }, 2000); return () clearInterval(timer); }, []);函数式更新的精髓在于我不需要读取外层的 activeIndexReact 会把最新的 state 作为参数传给更新函数。这样闭包里就不存在“过期变量”的问题了。这次踩坑给我留下的教训我后来经常在团队分享里讲只要你看到 setTimeout、setInterval、Promise、事件监听这类异步回调里直接读取了 state请立刻停下来问自己——这个回调是谁创建的它记得的是哪一次渲染的值3. 第二次踩坑依赖项数组里的自做聪明3.1 依赖一个对象引用结果掉进无限循环第一次踩坑之后我学乖了开始认真写依赖项。但过度认真也有问题第二个坑就这么来的。当时我做一个图表组件父组件会传一个 config 对象进来子组件要根据 config 去拉数据。我心想依赖项应该把 config 整个写进去这样只要配置变了就重新请求function Chart({ config }) { useEffect(() { fetchData(config.chartId).then((data) renderChart(data)); }, [config]); // 这个依赖项写完整了吗 }看起来没毛病吧config 确实在 effect 里被读取了也写进依赖项了。但父组件那边随手写的传参方式成了定时炸弹Chart config{{ chartId: 1 }} /父组件每次重新渲染这个{ chartId: 1 }都是一个全新的对象引用。React 用 Object.is 比较依赖项新对象和旧对象引用不同哪怕内容完全一样也判定为“改变了”。于是 effect 每次都执行每次又触发 setState图表组件重新渲染父组件也可能跟着重渲染然后新的 config 又来了——死循环。我当时排查这个问题的时候现象非常吓人页面的接口请求次数在 Network 面板里肉眼可见地疯涨浏览器标签页都开始卡顿。我一度以为是对接的后端回调有问题后来才恍然不是接口的问题是我把一个对象引用写进了依赖项。3.2 函数写不写依赖项我当年选择了忽略 lint这类问题的标准解法很简单依赖项不要写整个对象写成它的原始属性useEffect(() { fetchData(config.chartId).then((data) renderChart(data)); }, [config.chartId]); // 只依赖原始值但事情没这么简单。另一个相似场景很快找上门我在 effect 里调用了组件里定义的一个函数 fetchListlint 工具立刻开始报警告。function fetchList() { // 读取了组件里的 props 和 state } useEffect(() { fetchList(); }, []);eslint-plugin-react-hooks 的 exhaustive-deps 规则明确告诉我fetchList 是一个应该写进依赖项的函数。我当时觉得这个 lint 很烦人“我又不会错关掉不就行了”于是直接在文件末尾加了解禁注释。结果上线后某个权限相关的功能出现了诡异行为用户切换角色后页面列表仍然是旧角色能看到的数据。为什么因为 fetchList 在每次渲染时都是一个新的函数而我的 effect 只执行了一次闭包捕获的是第一次渲染时创建的 fetchList。而这个 fetchList 闭包住的 props 里用户角色是旧的。正确做法是给函数套上 useCallback把函数引用稳定下来const fetchList useCallback(() { // 里面读取的 props、state 写进 useCallback 的依赖 }, [userRole]); useEffect(() { fetchList(); }, [fetchList]);这次踩坑让我彻底记住了eslint 的 exhaustive-deps 规则不是噪音它就是把“闭包陷阱”这个看不见的风险提前暴露出来的探针。跟它对着干短期省事长期遭殃。如果你确实不想把函数变成依赖也有替代方案把函数定义直接写进 effect 内部或者通过 ref 读取最新值。最忌讳的就是一边在 effect 里读它一边又不把它写进依赖项然后靠 disable 注释掩盖问题。4. 第三次踩坑闭包陷阱和异步竞态的组合拳4.1 带防抖的搜索框出现了“后发先至”第三个坑最隐蔽因为它不报错、不循环、页面大部分时候看起来都是对的只有特定交互顺序才会露馅。场景是一个搜索页面用户输入关键词前端做 500ms 防抖然后请求后端搜索接口。代码大概是function SearchPage() { const [keyword, setKeyword] useState(); const [results, setResults] useState([]); useEffect(() { const timer setTimeout(() { fetch(/api/search?q${keyword}) .then((res) res.json()) .then((data) setResults(data)); }, 500); return () clearTimeout(timer); }, [keyword]); // 渲染 results 列表 }我测试的时候输入“react”然后马上改成“vue”注意这里有个微妙的时序第一次输入 react 后500ms 防抖结束请求 A 发出去了紧接着用户改成 vue上一个 effect 的清理函数执行把 setTimeout 清了但那个已经发出去的 fetch 请求是没法用 clearTimeout 取消的于是请求 A 继续等待响应。接着新的 effect 创建等 500ms 后请求 B 也发出去了。如果网络正常请求 B 通常比 A 晚发出、也早回来结果没问题。但如果某个瞬间网络抖动请求 A 的响应比请求 B 更晚到达结果就变成了输入框显示的是 “vue”页面列表展示的却是 “react” 的搜索结果。这个场景的本质还是闭包请求 A 的回调闭包住了 keyword 为 “react” 那次渲染它根本意识不到自己已经是“上一任 effect 的遗物”。它只知道响应到了就去 setResults。4.2 标准解法清理函数里的取消标志竞态问题的标准解法是在 effect 的清理函数里置一个布尔标志位响应回来先检查标志位已经失效就直接丢弃useEffect(() { let cancelled false; const timer setTimeout(() { fetch(/api/search?q${keyword}) .then((res) res.json()) .then((data) { if (!cancelled) { setResults(data); } }); }, 500); return () { cancelled true; clearTimeout(timer); }; }, [keyword]);当 keyword 从 react 变成 vueReact 在执行新 effect 之前会先清理上一个 effect把 cancelled 置为 true。请求 A 的响应晚到也没关系cancelled 已经是 truesetResults 被跳过数据不会被错误覆盖。再现代一点的方案是使用 AbortController直接把还没有完毕的请求取消掉useEffect(() { const controller new AbortController(); const timer setTimeout(() { fetch(/api/search?q${keyword}, { signal: controller.signal, }) .then((res) res.json()) .then((data) setResults(data)) .catch((err) { // 需要判断是不是 abort 导致的错误避免误报 if (err.name ! AbortError) { console.error(err); } }); }, 500); return () { controller.abort(); clearTimeout(timer); }; }, [keyword]);标志位简单直观适合绝大多数场景AbortController 更干净能真正释放网络资源但要额外处理 AbortError代码稍多。我个人习惯是数据拉取类请求优先用 AbortController普通回调类逻辑用 cancelled 标志位。这个坑教会我的是别把“防抖请求”想得太简单。只要涉及异步回调 组件状态就必须考虑“这个回调还在不在有效期”。闭包陷阱不只会让你拿到旧值还会让你把旧请求的结果写到新界面上。5. 三种通用解法从根上避开闭包陷阱5.1 依赖项书写的黄金法则踩过三次坑以后我把 useEffect 的写法沉淀成了一套自己的规则现在基本不会再犯低级错误。原则只有一条effect 回调里读取了哪个响应式值就应该把哪个响应式值写进依赖项。所谓响应式值就是会随渲染变化的东西props、state、context、以及由它们衍生出来的函数或引用。这条原则听起来简单执行起来难因为很多人会下意识地“觉得自己不需要响应某个变化”。如果确实不希望 effect 响应某个值的变化正确的做法不是把它从依赖项里删掉而是把它存进 ref 里。我在后文会讲 ref 方案。还有两个常见场景要注意能依赖原始值就别依赖对象本身。比如 config.chartId 优于 configrow.id 优于 row。对象引用一变effect 就执行你很多时候只关心里面的值变没变。函数依赖用 useCallback 或 useMemo 稳定引用或者干脆把定义挪进 effect 内部。记住了lint 报警不是让你烦的它在替你挡子弹。如果依赖项真的很多说明你有可能把太多逻辑塞进了一个 effect。可以考虑拆分成多个 useEffect各自只关心自己的依赖。5.2 useCallback 和 useMemo稳定的引用是解药useCallback 和 useMemo 是配合 useEffect 的一对好搭档。它们的本质都是缓存依赖不变时返回上一次创建的引用。什么时候需要用 useCallback我给一个非常具体的判断标准当这个函数会被某个 effect 调用或者会作为 props 传给子组件时就该考虑。function ProductList({ category }) { const fetchProducts useCallback(() { return fetch(/api/products?category${category}).then((res) res.json() ); }, [category]); useEffect(() { fetchProducts().then(setProducts); }, [fetchProducts]); }这里关键的一点是useCallback 的依赖数组里写了 category如果 category 没变fetchProducts 的引用就是稳定的effect 不会因为函数引用变化而反复执行。如果 category 变了useCallback 返回新函数effect 也跟着重新执行语义完全正确。但也有反面教训有些同学为了“性能优化”把所有函数都包一层 useCallback依赖数组写了一大串反而把自己绕晕。useCallback 不是免费的它有记忆成本也会增加阅读负担。只有函数作为 effect 依赖或子组件 props 时才值得用。你要是拿它包裹一个压根不传给任何人的纯函数那就是自己给自己找罪受。5.3 useRef那个随时可变的“小盒子”useRef 是我个人觉得最优雅的兜底方案。它返回一个可变对象current 属性随意读写不会因为重新渲染而丢失。换句话说ref 绕开了“每次渲染都有独立快照”的限制像一个可以随身翻新的小本子。经典的“ref 同步最新值”模式是这样function Timer() { const [count, setCount] useState(0); const countRef useRef(count); // 每次渲染后把最新 count 同步到 ref useEffect(() { countRef.current count; }, [count]); useEffect(() { const id setInterval(() { console.log(最新 count, countRef.current); }, 1000); return () clearInterval(id); }, []); return div{count}/div; }第一次的轮播图如果我们想用 ref 解决也是一样的思路setInterval 里通过 ref 读取最新值而不是直接读 state。这里给大家一个经验判断如果你只是想“更新状态”优先用函数式更新setCount((prev) prev 1)简单可靠。如果你需要在异步回调里“读取当前最新值”去做判断比如判断当前是否已经登录、当前配置是否允许发送请求那就用 ref 同步模式。如果某个值你根本不想触发 UI 更新ref 天生就是为它准备的。不过要注意ref 不是银弹。ref 的更新不会触发重新渲染如果你把本该放在 state 里的东西放进 ref界面不会自动刷新反而引发新问题。我见过有人把 loading 状态放进 ref结果页面不响应之后又跑来找我排查那已经不是闭包陷阱了是状态设计问题。5.4 自定义 Hook 封装把坑直接填平第三次踩坑之后我干脆把定时器和轮询逻辑封装成一个自定义 Hook团队里所有人统一使用从源头避免手写 setInterval 踩闭包坑。一个比较通用的 useInterval 是这样的function useInterval(callback, delay) { const savedCallback useRef(callback); // 每次渲染后保存最新的 callback useEffect(() { savedCallback.current callback; }); useEffect(() { if (delay null) { return; } const id setInterval(() { savedCallback.current(); }, delay); return () clearInterval(id); }, [delay]); }用法useInterval(() { setCount((prev) prev 1); }, 1000);这个封装的核心原理就是利用 ref 绕开闭包陷阱让 setInterval 里的回调永远调用“最新一次的 callback”。好处是团队成员再也不用纠结依赖项怎么写了只要保证自身逻辑正确定时器永远不会过期。同样的思路还可以封装请求竞态、轮询、事件监听等场景。遇到反复出现的坑与其每次都小心翼翼不如抽成公共设施一行代码解决。6. 实战排查速查依赖项相关的坑位清单6.1 一张表看懂常见症状和出路这几年的经验我整理成了一个速查表团队新同学进来我都会先丢给他们这份清单症状可能原因快速定位方法推荐解法effect 只在首次执行后不再触发依赖项数组缺失或写错在 effect 开头打印依赖值补全所有被读取的响应式值effect 无限循环执行依赖了引用类型且引用每次变化打印 effect 执行次数依赖原始属性值或用 useMemo 稳定引用异步回调里总是拿到旧 state回调闭包捕获了旧渲染的变量在回调里打印变量值与预期对比函数式更新或用 ref 读取最新值lint 提示函数依赖缺失组件内函数每次渲染都是新引用看 eslint 警告的具体位置useCallback 包裹函数或把函数定义挪进 effect防抖/请求结果被旧响应覆盖异步竞态旧回调未被标记失效打印请求参数和响应时间清理函数设置 cancelled 标志或 AbortController表格里每一行都是我这三年里在真实项目里摔过的跟头。你遇到问题的时候不妨先按“症状”对号入座再根据“快速定位方法”去确认能省下大量翻代码的时间。6.2 几个调试技巧和一点心态上的建议排查闭包陷阱最土的办法往往最有效打日志。在 useEffect 开头打印“effect run”在清理函数里打印“effect cleanup”在异步回调里打印关键变量。把三条日志串起来你就能清楚看到这次 effect 是哪次渲染的清理函数有没有按预期执行异步回调读取的是哪一版变量。React DevTools 里的 Components 面板也很有用你可以选中组件查看当前的 props 和 state有时候一眼就能发现闭包读取的值和面板上显示的最新值对不上。如果你发现自己在一个组件里同时维护了五六个 useState互相之间有引用关系闭包问题会变得特别难缠。这时候不妨想想 useReducer把关联状态集中到一个 reducer 里通过 dispatch 更新状态减少“在异步环境里分散读取 state”的机会。心态上我想多说两句。闭包陷阱之所以反复咬人不是因为它难而是因为我们的直觉错了。大脑默认“代码哪儿写着当前运行到这儿值就是最新的”但 React 函数组件的真相是“每次渲染都是独立宇宙”。一旦接受这个设定看到 setInterval 里的 state 你自然会警惕看到依赖项数组你会自然去扫描函数体。另外提一句React 官方也在探索新的 API 来缓解这类问题比如把不稳定的值交给 effect 内部来读取而不是硬塞进依赖项数组。但不管未来 API 怎么演进闭包这个语言特性不会消失理解它的运行机制永远是最保险的做法。最后分享一个我的固定思考流程被坑了三次之后我给自己定了一个规矩每次写 useEffect 之前先回答三个问题。第一这个 effect 要响应什么变化第二effect 回调里读了哪些 props、state、函数第三这些值在处理异步逻辑时我希望它取的是创建时的旧值还是执行时的最新值想清楚这三个问题闭包陷阱基本就找不到你了。比如定时器场景我希望读取的是执行时的最新值那就用函数式更新或 ref请求场景我希望读取的是触发请求那一刻的关键参数那就把它作为依赖项老老实实写进去同时用清理函数标记过期回调。这三次踩坑给我的收获远比教训大。现在我写 useEffect 的速度变快了代码也更稳了更重要的是我再也不会觉得闭包是什么神鬼莫测的东西——它就是一个需要尊重的特性。你理解它它就是你手里最趁手的工具你不理解它它就躲在每个 setTimeout 后面给你挖坑。
返回列表