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,实际上它永远输出 0:
function 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 极其相似的 Hook:useLayoutEffect。两者 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,只有在“必须保证绘制前完成”的场景下才用它。
下面是一张可以直接作为选型参考的对比表:
| 维度 | useEffect | useLayoutEffect |
|---|---|---|
| 执行时机 | 浏览器绘制完成后异步执行 | DOM 变更后、绘制前同步执行 |
| 对视觉的影响 | 可能会产生闪烁(先旧后新) | 绘制前已完成,无闪烁 |
| 对性能的影响 | 不阻塞绘制,性能开销小 | 阻塞绘制,内部别做重计算 |
| 推荐使用场景 | 绝大多数副作用、接口请求、事件监听 | 测量 DOM、同步调整布局、动画前同步配置 |
| SSR 场景 | 服务端不会执行,客户端正常 | 会在服务端渲染时产生警告,需特殊处理 |
5.3 服务端渲染与 useEffect 的两个注意点
提到服务端渲染(SSR),useEffect 有一个特别容易让新手困惑的特性:在服务端渲染期间,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 稳定引用 |
| 依赖数组写少了,值变了却没触发 effect | eslint 的 exhaustive-deps 没开或没遵守 | 打开规则,按警告补齐依赖 |
| 组件卸载后仍在 setState,控制台警告 | 异步请求或订阅在卸载后没有清理 | cleanup 里取消订阅/中断请求,并加上 ignore 保护 |
| 渲染后页面闪烁一下才显示正确布局 | useEffect 在绘制后执行导致首帧状态落后 | 改用 useLayoutEffect 在绘制前完成测量或布局调整 |
| 服务端渲染出现 hydration mismatch | useEffect 在服务端不执行,导致首屏 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 的信心会比看十篇教程都足。