ARTICLE DETAIL

资讯详情

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

React Hooks精讲:useEffect执行时机、依赖数组与清理机制全解析

React Hooks精讲:useEffect执行时机、依赖数组与清理机制全解析 1. useEffect不是页面加载后执行理解这一句能少写一半bug先聊个最常见的场景。很多人第一次用useEffect是照着网上教程写请求数据function UserList() { const [list, setList] useState([]); useEffect(() { fetch(/api/users) .then(res res.json()) .then(data setList(data)); }, []); }跑起来没问题数据加载出来了于是很多人就把useEffect记成了页面加载后执行一次的地方。这个理解不算全错但会埋坑。等哪天依赖数组里加了state、props或者代码里出现setInterval、事件监听、订阅之类的东西问题就全冒出来了。我见过最典型的一个线上事故同事在useEffect里根据selectedId去拉详情依赖数组里写了[selectedId]然后在另一个事件回调里把selectedId改成了同一个值。结果不是不触发而是接口在短时间内反复请求后端直接打爆了日志。这不是useEffect的问题而是对触发时机的认知出了问题。useEffect的正确心智模型是组件渲染完成并提交到屏幕之后再执行副作用逻辑。这句话每个字都值得拆开是渲染完成之后不是渲染之前。提交到屏幕意味着useEffect拿到的是本次render的结果而不是上一次的。它和页面加载没有必然关系页面加载只是第一次render完成后顺带发生的一次而已。搞清楚这个再看useEffect的三种执行时机时机触发条件典型场景挂载后组件第一次渲染提交后初始化数据请求、注册全局事件更新后依赖项变化导致重新渲染并提交后根据props/id变化拉取数据、同步状态清理时机下一次副作用执行前、组件卸载时取消订阅、清除定时器、撤销请求换句话说useEffect的依赖数组是监听谁变了而不是规定什么时候跑一次。依赖变了组件重新渲染渲染提交后副作用就执行——哪怕值没变只要引用变了也会触发。1.1 从useEffect到useLayoutEffect差的不是功能而是时机很多React新手会困惑useEffect和useLayoutEffect到底有什么区别我在面试中遇到这个问题95%的候选人只能说出layout是同步的但说不清什么时候该用。实际开发里区别很实在。useEffect是异步执行的浏览器先绘制再跑你的副作用代码。大部分场景这么做没问题体验还更好因为不会阻塞用户看到内容。但有一种情况必须用useLayoutEffect你的副作用会直接改变DOM的视觉状态而且必须在浏览器绘制之前生效。比如读取DOM元素的尺寸然后调整样式、计算滚动条位置、同步设置某个元素的宽高等。拿tooltip定位举例等useEffect跑完再放位置用户会看到tooltip先出现在角落、然后跳到正确位置一闪一帧。改成useLayoutEffect浏览器还没来得及绘制位置已经摆好了就没有闪烁。判断标准很简单你的useEffect会不会让人眼看到先A后B的跳动会就用useLayoutEffect不会就用useEffect。1.2 一个生命周期的完整执行链路为了把机制讲透我画过一遍完整链路这里不用图直接描述组件render产生一个虚拟DOM树React把这次结果提交给真实DOM浏览器完成绘制。然后React检查useEffect的依赖数组首次挂载一定执行后续如果依赖项和上一次render时用的值不一样就执行一样就跳过。执行副作用之前如果有上一次的清理函数先跑清理函数再跑新副作用。举个完整的例子function Timer({ enable }) { const [count, setCount] useState(0); useEffect(() { if (!enable) { setCount(0); return; } const timer setInterval(() setCount(c c 1), 1000); return () clearInterval(timer); }, [enable]); return p{count}/p; }初始render完成后enable如果是false副作用执行但不做定时器工作enable变为true后组件重新render提交完成后先执行上一次的清理此处没有再执行新的副作用创建定时器enable再次变为false后重新render先cleanup掉旧timer再执行新的副作用把count归零因为enable为false所以直接return。整个链路里最关键的就是那个cleanup。很多人看到return () clearInterval(timer)不理解为什么能清理到旧的timer。答案是这个返回函数并不是下一次调用useEffect时执行而是下一次副作用执行之前执行——它永远清理的是上一次的效果。这个机制在竞态处理、事件监听、订阅管理中至关重要。说这些不是要考原理而是想说useEffect的用法本身很简单但使用场景千变万化。你只有理解了提交后依赖对比先清理再执行这条链路上手才不会云里雾里。2. 依赖数组是useEffect的灵魂为什么你写了依赖还是踩坑依赖数组的规则官方文档就几句话不传、传空数组、传具体值。但实际开发里最让人头秃的就是依赖数组。我接过的排查需求里useEffect相关的bug有八成出在这一块。先明确依赖数组的本质它不是这段代码的重跑条件而是这段代码使用的数据清单。React把每次render时的依赖值和上一次render时的依赖值做Object.is比较不同就执行副作用。这句话含义很深。它意味着你在副作用函数体里引用到的props、state、甚至函数理论上都应该出现在依赖数组里。漏了一个就会读到旧值——这就是传说中的闭包陷阱。2.1 闭包陷阱为什么依赖数组有值还读到旧数据最常见的报错场景function Counter() { const [count, setCount] useState(0); useEffect(() { const timer setInterval(() { console.log(count:, count); setCount(count 1); }, 1000); return () clearInterval(timer); }, []); }控制台会一直输出count: 0页面上的数字则变到1就停了。原因很简单useEffect只执行了一次而这个副作用函数闭包里captured的是第一次render时的count值0后面的render虽然产生了新的count但副作用函数没有重新创建。熟悉class组件的人会习惯每次render都重新创建一个方法方法里读this.state.props都是最新的。Hooks时代不是这样副作用函数是某一次render的快照它捕获的是那一刻的值。想读到最新值只有让副作用函数随依赖重新执行或者用ref绕过。解决方案很简单把count写进依赖数组副作用每次render后都重建闭包里就是最新的count。但如果不想让定时器反复重建改用ref是更好的方案const countRef useRef(0); useEffect(() { const timer setInterval(() { countRef.current 1; setCount(countRef.current); }, 1000); return () clearInterval(timer); }, []);这里的取舍逻辑是数据本身会变但定时器逻辑不需要重建——把会变的读取放到ref里把不变的逻辑保留在effect中。这是处理依赖数组里不能写但代码里又需要读的标准解法。2.2 无限循环的根源依赖变更导致的反复执行无限循环是useEffect另一个高发问题。写起来倒很简单useEffect(() { const newObj { name: test }; setState(prev ({ ...prev, extra: newObj })); }, [someState]);每次render都会创建一个新的{ name: test }对象每次这个对象的引用都不一样。如果newObj被塞进了state而state又出现在依赖数组里副作用再次执行再创建新对象再setState……无限循环。理解起来不复杂React的依赖对比是浅比较对象只看引用。你每次render创建一个新对象引用必然不同依赖必然变化effect必然重跑。所以依赖数组里写state的值不要写一个由派生计算出来的新对象。依赖数组里写props的属性可以但如果父组件传进来一个新对象字面量同样会导致无限循环。在effect里setState要格外小心看set的数据是否会参与依赖比较。经典解法是把对象拆开依赖数组写基础值const { userId, filterType } props; useEffect(() { fetch(/api/list?userId${userId}type${filterType}) .then(res res.json()) .then(data setList(data)); }, [userId, filterType]);2.3 useCallback和useMemo依赖数组的解药还是新坑这个不算新知识点但很多人理解反了。useCallback和useMemo解决的是同一件事给依赖数组提供一个稳定引用。const fetchData useCallback(async () { const resp await fetch(/api/user/${userId}); return resp.json(); }, [userId]); useEffect(() { fetchData().then(data setUser(data)); }, [fetchData]);这里fetchData被useCallback包了一层只要userId不变返回的函数引用就不变useEffect的依赖对比就不会误触发。如果不用useCallback每次render都产生一个新函数useEffect每次render后都要执行又回到无限循环的坑里。但useCallback不是万能的。它本身也有依赖数组也有闭包陷阱。比如上面的fetchData如果函数体里还读了别的state却没写进useCallback的依赖数组那函数内部读到的就是旧值useEffect即使重跑了也拿不到新数据。所以真正可靠的使用习惯是别急着包useCallback先想清楚依赖数组里该写什么。依赖数组写对了大部分场景根本不需要useCallback。给个排查依赖数组问题的清单我自己项目里都在用副作用函数体里出现的所有props、state、函数列出来。逐一判断这个值变了副作用需要重跑吗需要重跑的放进依赖数组不需要的考虑用ref或useCallback稳定引用。检查是否创建了新的对象/数组/函数字面量如果有要么抽离到组件外要么用useMemo/useCallback包起来。检查副作用里是否setState确认该state不在依赖链上导致循环。这个清单不是灵丹妙药但能解决绝大多数业务代码里useEffect的依赖问题。3. 异步请求与竞态处理useEffect里最容易被忽视的事后清理前面说了useEffect在渲染提交后运行这会带来一个直接后果副作用代码是异步世界里的事组件可能随时卸载也可能再次触发新一轮副作用。如果你在里面发请求没有做过期请求的处理轻则拿到过时数据覆盖页面重则组件卸载后setState报错。最常见的错误代码长这样useEffect(() { fetch(/api/user/${id}) .then(res res.json()) .then(data setUser(data)); }, [id]);表面看没问题输入新id重新fetch更新页面。但真实场景下用户可能快速切换id先请求id1再请求id2id2先返回页面显示2的数据过几百毫秒id1的请求也返回了setUser把页面数据又改回1。这就是竞态问题——后发的请求先回来先发的请求后回来最后显示的数据不是当前想要的。3.1 用一个布尔标志让过期请求闭嘴React 18之前最常用的方案是引入一个是否最新的标志useEffect(() { let canceled false; fetch(/api/user/${id}) .then(res res.json()) .then(data { if (!canceled) { setUser(data); } }) .catch(err { if (!canceled) { console.error(err); } }); return () { canceled true; }; }, [id]);核心思路是每次副作用重新执行或组件卸载时先把上一次请求的最终使用权作废。拿到数据先检查标志只有当前这次是一次最新的请求才允许setState。这个方案的优点是简单、不需要第三方库、任何React版本都能用。缺点是每个useEffect都要手写一套代码重复度高。而且canceled标志只能阻止setState没法真正中断网络请求流量和带宽还是浪费了。3.2 AbortController真正取消请求现代浏览器提供了一个原生的AbortController来取消fetch请求。在清理函数里调用abort()浏览器会直接终止网络请求不仅省带宽还能在React Native等场景中配合使用useEffect(() { const controller new AbortController(); fetch(/api/user/${id}, { signal: controller.signal }) .then(res res.json()) .then(data setUser(data)) .catch(err { if (err.name ! AbortError) { console.error(err); } }); return () controller.abort(); }, [id]);这样写每次id变化、组件卸载之前的请求都会被真正取消。注意catch里要判断err.name否则取消请求会抛一个AbortError被当成真实的错误处理控制台会出现一堆莫名其妙的报错。3.3 竞态处理的通用心智模型不管是布尔标志还是AbortController背后都是同一个模型副作用执行时如果上一次的副作用还没有收尾一定要给它一次收尾的机会——清理函数就是干这个的。应用的场景不只是请求。任何异步操作都可以套用setTimeout/setInterval清理函数里clear掉。WebSocket、EventSource清理函数里close掉。第三方库的订阅如store.subscribe清理函数里unsubscribe。地图实例、图表实例清理函数里销毁。用一句话总结useEffect里凡是可取消的东西都应该在清理函数里给它取消的机会。这不是锦上添花是基本的职业素养。我在Code Review时如果看到useEffect里有定时器、订阅、请求而没写清理函数一定打回。4. 四个特例场景useEffect在真实业务里的非典型用法讲完原理和常见坑我想分享四个我自己在实际项目中反复用到的useEffect场景。它们不是教科书上的标准用法但非常能体现useEffect的灵活性和边界。说实话把这些搞懂比背会十条规则都管用。第一个场景是响应props变化并同步子组件状态。有时候子组件内部有一个编辑状态父组件的数据一变子组件需要重置这个状态function EditPanel({ selectedUser }) { const [name, setName] useState(); useEffect(() { setName(selectedUser.name); }, [selectedUser]); return input value{name} onChange{e setName(e.target.value)} /; }这个useEffect做的事情和挂载后加载数据完全不同它是同步外部数据到内部状态。注意一个细节selectedUser是一个对象每次父组件render都可能产生新引用这会触发重置。如果你只想在用户真正改变时才重置可以把依赖拆成[selectedUser.name]这样对象变化但名字没变时不会重置用户的输入。第二个场景是清理全局事件监听。在class组件时代我们在componentDidMount里addEventListenercomponentWillUnmount里removeEventListener。Hooks时代用useEffectuseEffect(() { const handleKeyDown (e) { if (e.key Escape) { onClose(); } }; window.addEventListener(keydown, handleKeyDown); return () window.removeEventListener(keydown, handleKeyDown); }, [onClose]);这里有个容易忽略的点onClose如果是父组件每次render都重建的函数这个effect也会每次render都重建监听。性能损失不大但如果想避免可以在父组件把onClose用useCallback包一下或者这里直接用ref。实际项目中我更倾向于后者因为useCallback也会带来依赖管理的开销。第三个场景是组件挂载后执行一次的操作——比如埋点上报。空依赖数组是大家写埋点的标准姿势useEffect(() { trackEvent(page_view, { page: /home }); }, []);这个确实没问题但要注意React 18的StrictMode下会在开发环境执行两次副作用线上不受影响。如果你在开发环境看到埋点日志出现两次不用慌这是StrictMode故意的用来暴露清理函数缺失的问题。第四个场景和外部系统同步有关。比如有一个非React的图表库你需要把props的变化传给图表实例useEffect(() { chartRef.current.update({ data: chartData }); }, [chartData]);这个效果很纯粹chartData变了就把变化同步到图表系统。useEffect本质就是React和外部系统之间的桥梁。React官方文档甚至建议如果某个效果不能用外部系统非React来解释那就别用useEffect——这个建议其实非常有分量。4.1 useEffect和手动事件回调的边界最后想多说一句useEffect不是万能的不是所有状态变化后的副作用都该进useEffect。比如点击按钮后需要关闭弹窗并请求数据这事儿直接在onClick里做就好没必要写成useEffectconst handleConfirm async () { setOpen(false); await saveData(payload); refreshList(); };判断标准很直接**这个副作用是否必须等到渲染提交后才执行**如果是用户在某个交互点上的顺序操作直接在回调里按顺序写如果是响应state变化、且需要React先渲染再做事就用useEffect。5. 传统思维迁移从class组件的生命周期到useEffect的心智革命Hooks刚出来那会儿大量React开发者是从class组件转过来的总带着三个经典的心智模式迁移问题componentDidMount、componentDidUpdate、componentWillUnmount怎么对应useEffect。如果真的一一对应就会写出大量重复代码和bug。先说componentDidMount。很多人把空依赖数组的useEffect当成componentDidMount用useEffect(() { loadData(); }, []);这在组件只挂载一次、且不会卸载的场景下没问题。但如果你用了StrictMode、或者组件被父级条件渲染包裹后又重新挂载这个挂载和useEffect执行的真实次数就不一定一致。更严谨的说法是空依赖数组useEffect执行在组件第一次提交之后组件卸载后重新挂载它还会再执行一次。再看componentDidUpdate。class组件里想实现props.value变了就做某事要在componentDidUpdate里手动比较prevProps和this.propscomponentDidUpdate(prevProps) { if (prevProps.value ! this.props.value) { doSomething(); } }useEffect的依赖数组把这件事简化成了一个声明式依赖useEffect(() { doSomething(); }, [props.value]);语法上确实变简单了但隐藏了一个语义差异useEffect是提交后执行componentDidUpdate是更新后同步执行。差异不大但在某些特殊时序下会体现出来。用useLayoutEffect才能完美模拟componentDidUpdate的时机。最后是componentWillUnmount。class组件时代卸载清理和挂载逻辑分离写在两个方法里。useEffect把它们合成了一个函数里的一对兄弟副作用函数负责建立返回的清理函数负责拆除。这样的好处是模块化每个effect自我管理不会出现挂载时装了A事件、卸载时忘了移除A事件的错位问题。坏处是如果同一个组件有多个独立的副作用useEffect的写法会让清理逻辑和创建逻辑各自配对文件看着会变长但不影响可维护性。5.1 生命周期迁移的三个常见错误我刚从class转Hooks时踩过三个坑至今记忆深刻写出来帮大家避雷。第一个坑把多个副作用硬塞进一个useEffect里。useEffect(() { loadData(); window.addEventListener(resize, handleResize); return () window.removeEventListener(resize, handleResize); }, []);loadData其实只依赖props.id但为了一个useEffect我把resize监听和加载数据绑在了一起导致props.id变化时整个effect重跑resize监听被反复remove/add。正确做法是拆成两个useEffect各自管理各自的依赖。第二个坑滥用组件外变量。有些人为了让useEffect里读取到最新值把数据放在组件外部的模块级变量里let cachedData null; function Component({ id }) { useEffect(() { cachedData data-${id}; }, [id]); return p{cachedData}/p; }这在渲染阶段读取了副作用阶段才写入的值React无法保证它一定是最新的而且组件卸载后数据还残留在模块里多实例时相互污染。应该老老实实用useRef或useState。第三个坑在useEffect里获取DOM然后直接操作。useEffect本身允许操作DOM但如果你要做的是让DOM尺寸变化不影响useEffect里的样式计算用useLayoutEffect才是对的如果只是数据变了滚动一下某个节点useEffect够用。这两者还是有功能差异的。5.2 一个面试高频题的优秀回答模板这个标题很可能接下来会出现在React面试里。《useEffect怎么用》这句话背后面试官真正想考察的是你能否说清useEffect的执行时机、依赖数组的含义、闭包陷阱、清理函数机制以及什么时候该用什么时候不该用。我整理了一份参考答案大家可以背下来作为框架useEffect是React Hooks中处理副作用的核心API在渲染提交到屏幕后执行支持通过依赖数组控制执行次数。依赖数组是基于Object.is做的浅比较依赖变化时先执行上一次的清理函数再执行新副作用。依赖数组不是触发条件而是数据清单漏依赖会导致闭包读到旧值多余依赖会导致额外执行甚至无限循环。异步请求、定时器、订阅、事件监听等必须考虑清理否则会出现竞态、泄漏。不是所有状态变化后的动作都该用useEffect交互回调直接写代码派生状态优先用useMemo和外部系统同步才考虑useEffect。这套表述把API、原理、经验、边界全涵盖了面试官再追问细节就从上面几个部分里抽小点到深处聊。实际开发里这套框架也足以应对绝大多数useEffect相关的代码审查问题。6. SSR与服务端数据获取useEffect在React Native等场景下的避坑与替代方案React SSR是面试题和网络热词里反复出现的内容而且和useEffect有强关联。很多人以为SSR只是多了一套服务端代码但一到真写的时候就发现useEffect在服务端渲染时根本不执行。为什么想想useEffect的执行时机——它在渲染提交到屏幕之后才执行。服务端渲染不存在浏览器屏幕React在服务端只负责把组件树渲染成HTML字符串useEffect作为渲染后的副作用在服务端没有执行的环境。这是React的设计决定不是bug。这意味着你在组件里用useEffect做数据请求SSR时这些数据不会出现在首屏HTML里。用户看到的是没有数据的空壳页面等浏览器端hydrate完成后useEffect才跑然后再去请求数据再渲染出真实内容。对SEO、首屏体验、白屏时长的要求高的话这个方案是不行的。6.1 常见SSR数据预获取方案对比我整理过一版SSR下替代useEffect做数据获取的方案各有取舍方案原理优点缺点服务端数据注入在服务端fetch数据以props形式传给组件首屏完整、SEO好服务端代码耦合度高React Query/SWR数据请求逻辑放Hooks里SSR时用prefetch代码统一、缓存和状态管理完善学习成本略高仍需配置服务端预取getServerSidePropsNext.js页面级别服务端取数简单直接、适合页面数据只覆盖页面级不覆盖组件级状态组件级服务端取数框架自定义Hooks 全局缓存 服务端渲染前预取灵活适合复杂场景实现成本高、调试难如果项目是Next.js优先用getServerSideProps或Next 13的Server Components。如果是自己搭的SSR架构需要手动在服务端渲染前调用组件的静态方法取数再在客户端用预取数据已存在的标记跳过useEffect请求。这部分代码不好写建议直接用成熟的取数库。6.2 React Native里的useEffect特性和移动端语音输入的场景参考React Native开发中useEffect同样用于生命周期管理和异步处理但要特别注意两点。第一React Native没有浏览器DOM但useEffect的提交到屏幕语义仍然成立组件渲染完成后原生视图实际显示出来useEffect执行。所以如果你要做数据拉取后更新UI、进入页面后开始监听这些事写法跟Web没区别。第二React Native里有一堆原生模块的事件监听器比如AppState.addEventListener、Keyboard.addListener、NativeAppEventEmitter等。这些监听器如果在useEffect里注册一定要在cleanup里移除否则组件卸载后、组件树重新挂载时可能发生重复监听甚至内存泄漏。最近被问到的移动端项目语音输入功能也可以走这个模式。录音引擎启动、语音识别回调注册、状态机切换const [listening, setListening] useState(false); const [transcript, setTranscript] useState(); useEffect(() { if (!listening) return; const subscription SpeechRecognizer.addListener(result, (event) { setTranscript(event.text); }); SpeechRecognizer.start(); return () { subscription.remove(); SpeechRecognizer.stop(); }; }, [listening]);这段代码的要点在于启动听写之前先订阅回调停止时先移除订阅再停止引擎顺序不能反。否则你会在收尾阶段收到一条残留结果状态机直接错乱。这种和原生模块打交道的场景useEffect的清理函数是保命绳。7. React新生态里的useEffect并发渲染和StrictMode的双重考验React 18引入了并发渲染特性组件可能被中断渲染、恢复渲染、重新渲染渲染次数和最终提交结果之间不一定一一对应。这就对useEffect产生了一个影响useEffect的依赖值是在最初渲染时计算还是最终提交时计算答案是React会等到真正提交时才运行effects。并发渲染中途被丢弃的渲染结果不会产生副作用也不会执行effect的清理函数。换句话说React已经保证了effect的执行次数和commit次数一致。你不需要为并发渲染手动适配useEffect这个活儿框架替你干了。但StrictMode是一个需要适应的点。开发环境下StrictMode会让组件额外挂载一次再卸载一次useEffect会在开发环境刻意执行两遍。很多人刚升级就惊呼我的请求怎么发了两遍其实这是故意的用来暴露你不会写清理函数的问题。如果在开发环境你看到effect跑两遍第一件事去检查清理函数有没有写好而不是骂框架。7.1 在TS类型定义里理解useEffect的签名最后说一个TypeScript视角下的useEffect很多TS React开发者没仔细看过它的类型签名function useEffect( effect: () void | Destructor, deps?: React.DependencyList ): void;发现没有effect函数的返回值必须是void或者一个清理函数。你不能在effect里return一个Promise——哪怕是async箭头函数useEffect(async () { const data await fetchData(); setList(data); }, []);这行代码在TS下不会立刻报错但运行时会有问题async函数返回的是PromisePromise被当成effect的返回值React会把它当成一个下一步要执行的清理函数然后报错An effect function must not return anything besides a function, which is used for clean-up。正确做法是把async包在effect内部useEffect(() { let canceled false; async function load() { const data await fetchData(); if (!canceled) setList(data); } load(); return () { canceled true; }; }, []);依赖数组的类型是React.DependencyList实际上就是ReadonlyArrayunknown。这也意味着依赖数组里的值可以是任何类型包括对象、函数。前面说过的对象引用问题在TS里更隐蔽因为类型检查根本拦不住。另外React的eslint-plugin-react-hooks有一条内置规则exhaustive-deps专门检查useEffect依赖数组是否遗漏了函数体里用到的值。这条规则在开发阶段能帮你找出九成漏依赖的问题建议每个人都开启并当成硬约束。我个人遇到过很多次关闭这条规则写信任自己的依赖判断最后都在代码评审阶段被其他人抓到问题补回去。8. useEffect的常见面试题清单和实战排查工具既然热搜词里出现了react面试题和react面经这里直接整理一份useEffect相关的面试问答清单同时给一个排查线上问题的工具思路。两者配合既能应对面试也能用于实际开发。面试高频题基本围绕四个点执行时机、依赖数组、清理函数、闭包陷阱。逐一带上参考答案useEffect和useLayoutEffect的区别useEffect在浏览器绘制后异步执行useLayoutEffect在DOM变更后、浏览器绘制前同步执行。需要读取或修改DOM布局、避免闪烁时用useLayoutEffect其余用useEffect。为什么useEffect里不能直接写async函数因为async函数返回的是Promise不符合effect函数签名要求。应把async逻辑包在effect内部。依赖数组传空数组和传某个值的区别空数组组件挂载后执行一次卸载前清理一次不随任何值变化重跑传值该值变化后、重新渲染提交后执行且每次执行前清理上一次的effect。如何避免useEffect里的闭包陷阱把会变化的数据写进依赖数组如果数据结构稳定但需要读最新值用ref绕过用useCallback稳定函数引用。如果useEffect里setState导致无限循环怎么排查从依赖数组入手确认依赖值是基础类型还是引用类型检查effect里的setState是否导致了依赖引用变化检查是否有多余依赖。这里多说一句面试官往往不是考你背答案而是看你回答时的思路过程。我在面试中更愿意听到候选人说我先确认执行时机再列出依赖最后写cleanup而不是一股脑背书。8.1 线上effect问题排查的完整链路如果线上出现和useEffect相关的诡异问题我有一套固定的排查流程分享出来供参考第一步看控制台有没有警告。React会在依赖数组明显不完整或effect返回异常值时给出warning先把警告读一遍。第二步加日志。在effect函数开头和清理函数里分别console.log几条标记观察执行顺序和次数。这是最土但最有效的方法useEffect(() { console.log(effect run with id , id); return () console.log(cleanup with id , id); }, [id]);对照日志能立刻判断出是effect根本没执行、effect执行了但cleanup没执行还是effect执行次数过多。第三步用React DevTools的Profiler看渲染次数和提交顺序。如果effect执行次数和渲染次数对得上问题出在依赖变化频率过高如果对不上从代码逻辑里找。第四步如果是数据请求相关打开Network面板检查请求数。配合竞态排查章节提到的布尔标志或AbortController看请求是否有canceled。第四步的技巧在于很多useEffect导致的bug其实不是useEffect逻辑错了而是需求变更后别人修改了依赖数组把不该拆的拆了、该加的漏了。排查时别只盯useEffect要从状态流全局去看。这套流程我用了一年多基本上一个问题半小时内能定位且不依赖运气。9. 个人经验总结我useEffect的日常使用清单文章看到这里理论、案例、坑都聊了个遍。写到最后分享几个我个人在实际开发中反复用到、也反复踩坑总结出来的useEffect铁律算是给大家一个可以直接照抄的清单。第一个铁律能不写useEffect就不写。useEffect不是非得用的。派生状态用普通的const filteredList list.filter(...)就行事件回调里能处理的就事件回调处理父组件能传下来的就传下来。useEffect用多了代码的数据流会变得很难跟。第二个铁律useEffect里的代码一定是副作用。往外部系统写数据、发请求、改DOM这些是副作用由props计算新值这不是副作用是渲染的一部分。如果你发现effects函数体里大量都是计算说明设计有问题。第三个铁律任何useEffect都有清理函数除非你真的想清楚了不需要。定时器、订阅、请求、手动DOM操作几乎都有清理需求。哪怕是弹个Toast这种业务也会遇到组件卸载后Toast还挂着的问题。第四个铁律依赖数组不要靠猜。打开eslint规则exhaustive-deps把缺失依赖当成编译错误来看待。虽然有时候依赖确实可以省略但那需要配合useMemo或useCallback的稳定引用而不是靠省略逃避。第五个铁律把useEffect看作同步外部系统的工具。React官方文档的那句useEffect is a way to synchronize your component with an external system其实是最好的概括。想通这句话useEffect的每一个API设计都合理了提交后执行因为外部系统需要看到的是最新UI依赖变化重建因为外部系统需要随状态同步清理函数先用后建因为外部系统需要平滑过渡。关于useEffect能聊的东西很多但核心就这么些。写博客的今天我重新看了一遍自己两年前写的useEffect代码发现当年的很多巧妙用法其实都是理解不深时的补救措施。搞清楚执行时机、依赖数组、清理机制这三件事useEffect真的不难难的是你愿不愿意花一天时间把原理吃透。
返回列表