1. 从“副作用”说起为什么React需要useEffect如果你写过一段时间的React尤其是从类组件转向函数组件你肯定对useEffect这个Hook又爱又恨。爱它是因为它让函数组件拥有了处理“副作用”的能力让组件逻辑变得前所未有的清晰和集中恨它是因为它的依赖数组、清理函数、执行时机这些概念稍不留神就会写出bug比如无限循环、内存泄漏或者状态更新不及时。那么到底什么是“副作用”在React的语境下我们可以把它理解为**“所有在React渲染流程之外与外部世界进行交互的操作”**。这听起来有点抽象我举几个你每天都在写的例子从API获取数据、手动操作DOM元素、订阅一个事件比如窗口的resize事件、设置或清除定时器setInterval/setTimeout、记录日志到分析工具等等。这些操作都有一个共同点它们不是简单地根据props和state计算并返回JSX它们会“影响”到React世界之外的东西或者从外部世界“读取”信息。在useEffect出现之前我们只能在类组件的生命周期方法里处理这些事在componentDidMount里发起请求和订阅在componentDidUpdate里根据变化更新在componentWillUnmount里清理。这种方式把同一件事的逻辑拆分到了三个不同的地方代码跳来跳去维护起来很头疼。useEffect的设计哲学就是把这三件事合而为一“在组件渲染到屏幕之后根据依赖项的变化执行一些副作用并在下次执行前或组件卸载时进行清理。”一个Hook一个地方搞定所有。所以当你写下useEffect(() { ... }, [deps])时你其实是在告诉React“嘿等我这次渲染画到屏幕上之后帮我跑一下这段代码。并且只有当我列在[deps]里的那些值真的变了你才需要重新跑它。” 这种声明式的写法让副作用逻辑与渲染逻辑解耦是React函数组件范式的一次巨大飞跃。2. useEffect的核心心智模型渲染、提交与副作用要真正用好useEffect不能只死记硬背语法必须理解React底层的工作流程。React的渲染分为两个主要阶段渲染Render和提交Commit。渲染阶段是“纯计算”阶段。React会调用你的函数组件或类组件的render方法根据当前的props和state计算出下一次更新应有的虚拟DOM树。这个过程必须是“纯净”的不能有副作用。因为React可能会出于性能考虑中断、重启或并发地执行多个渲染如果渲染过程中夹杂了副作用比如直接修改DOM、发起网络请求就会导致UI状态不一致这是绝对禁止的。提交阶段是“影响真实世界”的阶段。当React计算出新的虚拟DOM树后它会与上一次的渲染结果进行比较Diffing计算出需要实际应用到真实DOM上的最小变更集。然后React会同步地、不可中断地执行这些DOM更新把新的UI“提交”到屏幕上。只有在这个提交阶段完成之后屏幕才真正反映了最新的渲染结果。useEffect的执行时机就紧跟在提交阶段之后。你可以把它想象成一次渲染的“售后回调”。React保证了在useEffect执行时DOM已经更新完毕你可以安全地基于最新的DOM进行测量、操作或者执行那些依赖于最新UI状态的副作用。这个心智模型引出了useEffect的一个黄金法则useEffect内的代码不应该直接影响下一次渲染。它应该去安排一些在“未来”某个时刻执行的任务比如网络请求返回后设置状态而不是同步地、立即地改变状态从而触发另一次渲染。如果你在useEffect里同步地setState并且这个状态变化没有正确的依赖项约束就极易引发无限渲染循环。2.1 依赖数组useEffect的“开关”与“记忆”依赖数组[deps]是useEffect的灵魂也是最容易出错的地方。它的作用很简单React会将当前这次渲染的依赖项值与上一次渲染时的值进行浅比较Object.is。只有至少有一个值发生了变化才会在本次提交后重新执行副作用。这里有几个关键点需要深入理解“一切从依赖数组出发”原则如果你的副作用函数内部使用了某个在组件作用域内声明的值props、state、函数或其他变量而这个值可能会在多次渲染间发生变化那么原则上它就必须出现在依赖数组里。这是React的规则也是保证副作用逻辑与当前渲染“同步”的基础。ESLint的react-hooks/exhaustive-deps规则就是为此而生强烈建议你开启并遵守它。依赖项变化触发重新执行当依赖项变化旧的副作用会先被清理执行清理函数如果有的话然后新的副作用才会执行。这确保了订阅、定时器等资源总是绑定到最新的值上不会出现闭包陷阱导致的过期值问题。空数组[]的含义空数组意味着“这个副作用不依赖于任何会变化的值”。因此它只会在组件首次挂载Mount后执行一次并且在组件卸载Unmount时清理一次。这模拟了类组件中componentDidMount和componentWillUnmount的组合。常用于只需要执行一次的初始化操作如数据获取、全局事件监听。没有依赖数组如果你省略了第二个参数那么useEffect会在每一次组件渲染包括首次之后都执行。这很少是你想要的行为除非副作用真的需要在每次UI更新后都运行比如根据DOM变化更新一个第三方图表库。大多数情况下这会导致性能问题或无限循环。注意依赖数组的浅比较有时会让你踩坑。如果依赖项是一个对象或数组即使内容没变但每次渲染都创建了一个新的引用{}或[]React也会认为依赖变了从而重复执行副作用。这时你需要用useMemo或useCallback来稳定引用。3. 从基础到实战useEffect的四种经典模式理解了原理我们来看具体怎么用。useEffect的使用可以归纳为几种经典模式掌握它们就能应对90%的场景。3.1 模式一挂载时执行卸载时清理这是最基础的模式对应类组件的componentDidMountcomponentWillUnmount。useEffect(() { // 1. 挂载后执行的操作 console.log(组件已挂载开始订阅事件或初始化第三方库); const handleResize () setWindowWidth(window.innerWidth); window.addEventListener(resize, handleResize); // 2. 返回一个清理函数 return () { console.log(组件即将卸载清理订阅或资源); window.removeEventListener(resize, handleResize); }; }, []); // 空依赖数组是关键核心要点清理函数CleanupuseEffect可以返回一个函数React会在下次执行该副作用之前以及组件卸载时调用它。这是防止内存泄漏的生命线务必为每一个需要清理的副作用事件监听、定时器、订阅都提供清理函数。执行时机清理函数会在下一次副作用执行前运行以确保旧的监听/订阅被移除后再建立新的。这保证了逻辑的连贯性。3.2 模式二依赖特定状态/属性变化时执行这是最常用的模式用于在某个值变化时执行副作用比如根据ID变化获取数据。const [userId, setUserId] useState(1); const [userData, setUserData] useState(null); useEffect(() { // 当 userId 变化时获取新用户数据 if (!userId) return; let isCancelled false; // 标志位用于处理竞态条件 const fetchUser async () { try { const response await fetch(/api/users/${userId}); const data await response.json(); if (!isCancelled) { // 只有当前请求未被取消时才更新状态 setUserData(data); } } catch (error) { if (!isCancelled) { console.error(获取用户失败:, error); } } }; fetchUser(); // 清理函数在 userId 变化或组件卸载时取消未完成的请求 return () { isCancelled true; }; }, [userId]); // 依赖项userId核心要点与避坑竞态条件Race Condition这是数据获取场景下的经典陷阱。如果用户快速切换userId比如从1切到2再切回1网络请求返回的顺序是不确定的。后发起的请求可能先返回如果直接setState会导致状态显示的是旧ID对应的数据。使用一个可变的标志位如isCancelled或AbortController来取消过期请求是标准做法。依赖项必须完整副作用函数里用到了setUserData但为什么依赖数组里没有它因为setState函数以及从useState、useReducer返回的dispatch函数在组件的整个生命周期内是稳定不变的React保证其引用不变所以可以安全地省略。但如果你用到了其他自定义函数或变量就必须加上。3.3 模式三每次渲染后都执行这种模式较少使用通常用于与React渲染流程深度集成的第三方库或者需要同步DOM变化的场景。const [scrollTop, setScrollTop] useState(0); useEffect(() { // 每次渲染后都更新一个外部库例如一个动画库的状态 // 假设 someExternalLib.sync 需要最新的DOM信息 someExternalLib.sync(document.getElementById(my-element)); // 注意这里没有依赖数组意味着每次渲染后都执行 });警告无依赖数组的useEffect性能开销很大极易导致无限循环如果副作用内同步设置了状态。使用前务必三思并考虑是否能用useLayoutEffect或其他模式替代。3.4 模式四基于前一个状态执行副作用有时我们不仅需要知道状态变了还需要知道它从什么值变成了什么值。虽然useEffect本身不直接提供“上一次的props或state”但我们可以通过useRef来手动实现一个“值的历史记录器”。const [count, setCount] useState(0); const prevCountRef useRef(); useEffect(() { // 访问上一次渲染时的值 const prevCount prevCountRef.current; if (prevCount ! undefined prevCount ! count) { console.log(计数从 ${prevCount} 变为了 ${count}); // 可以在这里执行一些依赖于“变化”本身的逻辑 } // 更新ref为下一次渲染做准备 prevCountRef.current count; }, [count]); // 依赖项是 count原理useRef返回的对象在整个组件生命周期内保持不变.current属性可变且其变化不会触发重新渲染。因此我们可以用它来存储上一次渲染时的值在副作用中进行比较。这是一种非常实用的高级模式。4. 进阶useEffect的陷阱、优化与替代方案即使理解了模式实际开发中依然遍布荆棘。下面是我总结的几个高频陷阱和应对策略。4.1 无限循环依赖项的“幽灵更新”这是新手最常见的噩梦。症状是页面卡死控制台疯狂打印。// 错误示例经典的无限循环 const [count, setCount] useState(0); useEffect(() { setCount(count 1); // 副作用设置状态触发重新渲染... }, [count]); // 依赖项 count 变了再次执行副作用... 循环开始根因分析副作用执行 → 调用setCount→count状态更新 → 组件重新渲染 → 依赖项[count]变化 → 副作用再次执行 → 无限循环。解决方案检查依赖项是否必要上面的例子中setCount可能本意是初始化那应该用[]空依赖或者用函数式更新setCount(c c 1)来避免直接依赖count。使用函数式更新当新状态依赖于旧状态时使用setState(prevState newState)形式。这样你就不需要将state作为依赖项。useEffect(() { const intervalId setInterval(() { setCount(c c 1); // 不依赖外部的 count 变量 }, 1000); return () clearInterval(intervalId); }, []); // 空依赖安全稳定依赖项引用如果依赖项是对象或函数确保它们引用稳定。// 每次渲染都创建新的 config 对象导致依赖项总在变 const config { enabled: true }; useEffect(() { ... }, [config]); // ✅ 使用 useMemo 稳定引用 const config useMemo(() ({ enabled: true }), []); useEffect(() { ... }, [config]); // 每次渲染都创建新的 fetchData 函数 const fetchData () { ... }; useEffect(() { fetchData(); }, [fetchData]); // ✅ 使用 useCallback 稳定函数引用 const fetchData useCallback(() { ... }, []); useEffect(() { fetchData(); }, [fetchData]);4.2 过时闭包依赖项缺失的恶果当副作用函数引用了某个状态或prop却没有将其列入依赖数组时就会形成“过时闭包”。副作用函数捕获的是它创建时的变量值而不是最新的值。const [count, setCount] useState(0); useEffect(() { const intervalId setInterval(() { console.log(count); // ❌ 永远打印 0 }, 1000); return () clearInterval(intervalId); }, []); // 缺少 count 依赖为什么总是0副作用函数在组件首次挂载时创建它“记住”了那一刻count的值是0。即使后续count变成了1、2、3...定时器回调里访问的仍然是那个被“闭包”起来的、最初的0。解决方案老老实实把用到的变量都加到依赖数组里。如果加了之后导致不必要的执行比如上面的定时器会每秒重建那就需要重构代码。对于定时器常见的做法是使用useRef来保存一个可变的、最新的值或者使用函数式更新。// 方案1使用ref保存最新值 const [count, setCount] useState(0); const countRef useRef(count); countRef.current count; // 每次渲染后更新ref useEffect(() { const intervalId setInterval(() { console.log(countRef.current); // ✅ 总是最新值 }, 1000); return () clearInterval(intervalId); }, []); // 方案2如果副作用逻辑允许避免直接引用会变的值4.3 何时不用useEffect寻找更优解useEffect不是万能的滥用它会让组件逻辑变得晦涩且低效。React官方文档也强调很多场景有更好的选择。场景一基于Props或State的派生状态// 不推荐用useEffect同步状态 const [fullName, setFullName] useState(); useEffect(() { setFullName(${firstName} ${lastName}); }, [firstName, lastName]); // ✅ 推荐在渲染过程中直接计算 const fullName ${firstName} ${lastName}; // 或者使用 useMemo 进行记忆化如果计算开销大 const fullName useMemo(() ${firstName} ${lastName}, [firstName, lastName]);原因useEffect中的状态更新会触发一次额外的、不必要的渲染。而直接计算或使用useMemo则是在当前渲染周期内完成更高效、更直接。场景二处理用户事件// 不推荐用useEffect响应状态变化来执行事件逻辑 const [searchText, setSearchText] useState(); useEffect(() { if (searchText) { fetchResults(searchText); } }, [searchText]); // ✅ 推荐直接在事件处理函数中执行 const handleSearchChange (event) { const text event.target.value; setSearchText(text); if (text) { fetchResults(text); } };原因用户输入是离散的事件。用useEffect响应searchText变化会导致每次按键都触发请求还需要防抖逻辑。而事件处理函数是更精确、更符合心智模型的地方。场景三初始化昂贵的一次性计算// 不推荐用useEffect设置初始化状态 const [expensiveData, setExpensiveData] useState(null); useEffect(() { const data calculateExpensiveThing(props.id); setExpensiveData(data); }, [props.id]); // ✅ 推荐使用 useState 惰性初始化 const [expensiveData, setExpensiveData] useState(() { return calculateExpensiveThing(props.id); // 此函数仅在初始渲染时调用一次 });原因useState的惰性初始化函数只在组件首次挂载时执行一次比useEffect更早、更直接且不会触发额外的渲染。4.4 useLayoutEffect当你需要同步测量或操作DOMuseLayoutEffect的API和useEffect一模一样唯一的区别是执行时机。useEffect在浏览器绘制Paint之后异步执行。不会阻塞浏览器更新屏幕。useLayoutEffect在React完成DOM更新之后但在浏览器绘制之前同步执行。会阻塞浏览器绘制。什么时候用useLayoutEffect当你需要同步地读取DOM布局如元素尺寸、位置并紧接着进行DOM操作如根据读取的值设置样式、滚动位置时为了避免用户看到闪烁比如元素先出现在A位置然后瞬间跳到B位置就必须使用useLayoutEffect。const ref useRef(null); useLayoutEffect(() { // 在这里DOM已经更新但浏览器还没画出来 const { width } ref.current.getBoundingClientRect(); // 同步地根据width设置一些样式用户不会看到中间状态 if (width 500) { ref.current.style.fontSize 20px; } }, [someDependency]);经验法则默认总是使用useEffect除非你明确知道需要测量或操作DOM并且遇到了视觉闪烁问题再考虑换用useLayoutEffect。因为同步执行会阻塞渲染过度使用可能影响性能。5. 架构思考将副作用从组件中抽离当组件内的useEffect逻辑变得复杂时为了保持组件的纯净和可测试性我们可以考虑将副作用逻辑抽离成自定义Hook。这是React Hook最强大的能力之一——逻辑复用。假设我们有一个通用的数据获取逻辑// useFetch.js - 自定义数据获取Hook import { useState, useEffect } from react; function useFetch(url, options {}) { const [data, setData] useState(null); const [error, setError] useState(null); const [isLoading, setIsLoading] useState(false); useEffect(() { // 定义获取数据的函数 const fetchData async () { setIsLoading(true); setError(null); // 重置错误 try { const response await fetch(url, options); if (!response.ok) { throw new Error(HTTP error! status: ${response.status}); } const result await response.json(); setData(result); } catch (err) { setError(err.message); } finally { setIsLoading(false); } }; fetchData(); // 注意这里依赖了 url 和 options它们变化时会重新获取 }, [url, options]); // 一个更健壮的实现可能会用 useMemo/useCallback 稳定 options // 返回状态和方法 return { data, error, isLoading }; } // MyComponent.js - 使用自定义Hook的组件 function MyComponent({ resourceId }) { const apiUrl /api/data/${resourceId}; const { data, error, isLoading } useFetch(apiUrl); if (isLoading) return div加载中.../div; if (error) return div错误: {error}/div; return div数据: {JSON.stringify(data)}/div; }通过自定义HookuseFetch我们将数据获取的副作用逻辑状态管理、错误处理、加载状态完全封装了起来。组件变得极其简洁只关心要请求什么URL和如何展示结果。这个Hook还可以轻松地加入竞态处理、缓存、重试等高级功能而所有使用它的组件都能自动受益。这种“关注点分离”的架构让组件回归其本质——描述UI而将复杂的副作用逻辑交给专门的自定义Hook去管理极大地提升了代码的可维护性和可测试性。当你发现多个组件在使用相似的useEffect逻辑时就是考虑抽离成自定义Hook的最佳时机。