☰
React Hooks精讲:useEffect执行时机、依赖数组与清理机制全解析
2026/9/26 7:17:03 网站建设 项目流程

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。

给个排查依赖数组问题的清单,我自己项目里都在用:

  1. 副作用函数体里出现的所有props、state、函数,列出来。
  2. 逐一判断:这个值变了,副作用需要重跑吗?
  3. 需要重跑的,放进依赖数组;不需要的,考虑用ref或useCallback稳定引用。
  4. 检查是否创建了新的对象/数组/函数字面量,如果有,要么抽离到组件外,要么用useMemo/useCallback包起来。
  5. 检查副作用里是否setState,确认该state不在依赖链上导致循环。

这个清单不是灵丹妙药,但能解决绝大多数业务代码里useEffect的依赖问题。

3. 异步请求与竞态处理:useEffect里最容易被忽视的"事后清理"

前面说了useEffect在渲染提交后运行,这会带来一个直接后果:副作用代码是异步世界里的事,组件可能随时卸载,也可能再次触发新一轮副作用。如果你在里面发请求,没有做"过期请求"的处理,轻则拿到过时数据覆盖页面,重则组件卸载后setState报错。

最常见的错误代码长这样:

useEffect(() => { fetch(`/api/user/${id}`) .then(res => res.json()) .then(data => setUser(data)); }, [id]);

表面看没问题,输入新id,重新fetch,更新页面。但真实场景下用户可能快速切换id:先请求id=1,再请求id=2,id=2先返回,页面显示2的数据,过几百毫秒id=1的请求也返回了,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里addEventListener,componentWillUnmount里removeEventListener。Hooks时代用useEffect:

useEffect(() => { 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里做就好,没必要写成useEffect:

const 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.props:

componentDidUpdate(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的执行时机、依赖数组的含义、闭包陷阱、清理函数机制,以及"什么时候该用什么时候不该用"。

我整理了一份参考答案,大家可以背下来作为框架:

  1. useEffect是React Hooks中处理副作用的核心API,在渲染提交到屏幕后执行,支持通过依赖数组控制执行次数。
  2. 依赖数组是基于Object.is做的浅比较,依赖变化时先执行上一次的清理函数,再执行新副作用。
  3. 依赖数组不是"触发条件"而是"数据清单",漏依赖会导致闭包读到旧值;多余依赖会导致额外执行甚至无限循环。
  4. 异步请求、定时器、订阅、事件监听等必须考虑清理,否则会出现竞态、泄漏。
  5. 不是所有"状态变化后的动作"都该用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代码统一、缓存和状态管理完善学习成本略高,仍需配置服务端预取
getServerSideProps(Next.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函数返回的是Promise,Promise被当成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,实际上就是ReadonlyArray<unknown>。这也意味着依赖数组里的值可以是任何类型,包括对象、函数。前面说过的对象引用问题,在TS里更隐蔽,因为类型检查根本拦不住。

另外,React的eslint-plugin-react-hooks有一条内置规则exhaustive-deps,专门检查useEffect依赖数组是否遗漏了函数体里用到的值。这条规则在开发阶段能帮你找出九成漏依赖的问题,建议每个人都开启并当成硬约束。我个人遇到过很多次,关闭这条规则写"信任自己的依赖判断",最后都在代码评审阶段被其他人抓到问题补回去。

8. useEffect的常见面试题清单和实战排查工具

既然热搜词里出现了react面试题和react面经,这里直接整理一份useEffect相关的面试问答清单,同时给一个排查线上问题的工具思路。两者配合,既能应对面试也能用于实际开发。

面试高频题基本围绕四个点:执行时机、依赖数组、清理函数、闭包陷阱。逐一带上参考答案:

  1. useEffect和useLayoutEffect的区别?useEffect在浏览器绘制后异步执行;useLayoutEffect在DOM变更后、浏览器绘制前同步执行。需要读取或修改DOM布局、避免闪烁时用useLayoutEffect,其余用useEffect。

  2. 为什么useEffect里不能直接写async函数?因为async函数返回的是Promise,不符合effect函数签名要求。应把async逻辑包在effect内部。

  3. 依赖数组传空数组和传某个值的区别?空数组:组件挂载后执行一次,卸载前清理一次,不随任何值变化重跑;传值:该值变化后、重新渲染提交后执行,且每次执行前清理上一次的effect。

  4. 如何避免useEffect里的闭包陷阱?把会变化的数据写进依赖数组;如果数据结构稳定但需要读最新值,用ref绕过;用useCallback稳定函数引用。

  5. 如果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真的不难,难的是你愿不愿意花一天时间把原理吃透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询