资讯详情 useEffect 正确用法指南:从依赖数组到竞态处理
📅 2026/10/16 15:19:26
useEffect 大概是 React Hooks 里被误解最多、滥用最多、出错之后也最难排查的一个 API。我见过不少项目组件里到处是 useEffect问起来要么是“我想监听某个变量一变就干点什么”要么是“初始化时拉一次数据”要么干脆是“模拟一下生命周期”结果页面一复杂请求重复发、状态不同步、闭包拿旧值各种问题全冒出来了。我打算把“什么时候真的该用 useEffect”这件事讲透先讲清楚它本质上是干什么的再列出一堆完全不该用它的情况接着把依赖数组和竞态这些硬骨头啃掉最后给出一套可以直接照抄的写法。适合刚接触 Hooks 的初中级开发者也适合已经写了很多 useEffect、但总觉得哪里不对劲的人。1. 先搞懂useEffect 到底管的是哪件事1.1 副作用这个概念为什么 React 要单独管React 官方一直强调组件应该是“纯函数”输入 props 和 state输出 UI。你可以把一次渲染想象成自动售货机——你投币、按键它只负责把商品递出来不应该在这期间同时发一封邮件、播放一段音乐、往数据库里写一条日志。真实业务里当然不可能这么干净你需要从服务端拉数据、监听窗口缩放、订阅 WebSocket、把数据写进 localStorage、初始化图表库。这些动作都发生在 React 的“纯粹渲染流程”之外所以它们有一个统一的名字副作用side effect。useEffect 要解决的就是“什么时候去做这些副作用才安全”。React 的约定是组件渲染并且 DOM 提交到屏幕之后再回头执行你交给它的回调。换句话说useEffect 不是一个普通的会立即执行的函数而是一个被 React 安排在“渲染完成后”运行的调度器。默认情况下它在浏览器绘制之后异步执行不会阻塞页面出现这也是它和后面要讲的 useLayoutEffect 最本质的区别。理解这一点你就能明白为什么数据请求、订阅、手动改 DOM 这些事天然属于它管——它们都需要等 DOM 真的存在了再操作而不是在渲染过程中做。1.2 真正需要 useEffect 的四类场景把各种业务形态压缩一下真正必须用 useEffect 的场景其实就那么几类我平时判断时基本是照这个分类去套。第一类和外部系统同步。网络请求、WebSocket 连接、浏览器事件监听这些都是 React 世界之外的东西。你的组件状态变了需要把变化告诉外部外部有消息推过来需要把消息变成组件状态。这一类是出现频率最高的。第二类操作 React 不直接管理的 DOM 或全局对象。比如根据数据变化修改 document.title给某个元素绑定非 React 的拖拽逻辑初始化图表、地图这类“自成一派”的第三方库。它们要求 DOM 节点已经真实存在而且通常需要在组件卸载时把实例销毁。第三类需要在渲染完成后、但不适合放在事件里的异步动作。比如页面进入某个区域时上报一个浏览埋点、播放一段音频、把当前筛选条件同步到地址栏。这些事和用户的具体操作可能有关联但触发时机往往依赖“某个状态已经渲染出来”这个前提。第四类建立和清理成对出现的资源。定时器、订阅、观察者模式下的注册凡是“先建立连接、将来一定要断开”的东西都应该交给 effect 去建立并且把清理逻辑放在 effect 的返回函数里。否则连接就会泄漏。后面讲订阅和定时器的时候我会给完整示例。这四类有个共同点它们都不是“纯计算”也无法在渲染函数内部直接完成必须让 React 在渲染流程之外给你一次机会。抓住这个共同点再去套具体需求基本不会跑偏。1.3 别再把它当生命周期钩子用现在网上大量旧教程还在用“useEffect 相当于 componentDidMount 加 componentWillUnmount”来教你这个类比是错的而且错得很隐蔽。生命周期强调的是“某个时刻触发一次”而 effect 的核心模型是“渲染完之后如果依赖变了就重新同步一次”。同一段 useEffect 代码在依赖变化时会被反复执行写在空数组里也不是因为它“只在挂载时执行”而是因为你告诉 React“没有任何东西值得让我重新同步”。这个思维差异会直接决定你的清理函数写得对不对。用生命周期思路的人容易写出“挂载时拉请求、卸载时取消监听”这种看似正确但中间过程全是洞的代码当请求参数变化时上一个请求可能还在飞新的请求已经发出去了最后返回的数据是旧参数的响应把新数据覆盖了。而用“同步”思路的人会天然想到既然我的依赖要变那我每次重新同步前应该先把上一次的连接和请求处理掉。useEffect 的清理函数其实就是“上一次同步的善后工作”而不是“卸载时的事后补救”。很多人卡在 useEffect 的困惑里本质上是卡在这套思维模型的转换上。接下来几章我把“不该用”和“怎么用”分开讲你会看得更清楚。2. 这些写法看似合理其实根本不该用 useEffect2.1 从 props 或 state 派生出来的数据直接在渲染里算新手最容易踩的坑就是把“数据变了我要算一个新值”写成“数据变了我要用 effect 更新另一个 state”。最典型的例子是排序后的列表// 错误示范多绕了一圈 const [visibleList, setVisibleList] useState(list); useEffect(() { setVisibleList([...list].sort((a, b) a.price - b.price)); }, [list]);这个写法的问题在于list 变化时组件要先带着旧的 visibleList 渲染一次然后 effect 更新 state再带着新的 visibleList 渲染第二次。完全可以直接在渲染期计算// 正确写法派生数据不需要 state const visibleList [...list].sort((a, b) a.price - b.price);只有当计算非常昂贵才需要加一层 useMemoconst visibleList useMemo( () [...list].sort((a, b) a.price - b.price), [list] );useMemo 只是缓存计算结果不是把计算挪到 effect 里。我看到不少项目把 filter、map、格式化、权限判断全部塞进 useEffect组件里堆了一堆 setState实际效果是每次渲染多跑一遍代码复杂度翻倍还随时可能因为漏掉依赖而产生旧值。这类“从现有数据直接推导”的场景永远优先考虑渲染期计算。2.2 用户事件里该做的事别丢给 effect用户点击“保存”按钮需要把表单数据提交给后端。正常思路是在点击事件里直接调用保存函数function handleSave() { saveData(formData); }但有些人会写成 effect 的形式useEffect(() { saveData(formData); }, [formData]);这样写的后果是用户每修改一个字段formData 变化一次组件就自动保存一次。哪怕你加了一个 submitted 标志位再来触发也无非是把问题绕得更远。事件本身就是“因”事件处理函数就是“果”中间不需要 useEffect 这个中转站。记住这个原则凡是能够确定“用户做了什么操作导致这件事发生”的就应该写在事件处理器里而不是用副作用去事后监听。发请求、跳转路由、关闭弹窗、重置表单这些动作用户能直接感知属于“响应动作”不属于“渲染后的额外同步”。2.3 兄弟组件之间的状态同步是状态设计问题两个兄弟组件一个搜索框一个搜索结果列表。有人会把搜索词放在搜索框自己的 state 里然后在父组件再用 useEffect 把搜索词同步过去再传给结果列表。这不仅是绕路还会引发不定时的闪烁和数据不一致。在 React 里组件间共享状态的标准做法是状态提升把 searchQuery 放到共同父组件的 state 里通过 props 下发给两个子组件。搜索框负责接收 value 和 onChange结果列表负责接收 query 并作为 props 驱动数据请求。props 流动本身就是响应式的直接改一个地方的 state另一个地方自然跟着变完全可以做到一条数据流不需要 useEffect 来“手动同步”。很多“状态不同步”问题根源是状态放在了错误的层级而不是 useEffect 写得太少。你应该去调整状态的位置而不是给副作用加码。2.4 一个反直觉的判断初始化也不一定要用 effect“页面一进来就要拉数据”是最常见的需求很多人直接把它写进 useEffect。但你需要先问一个关键问题这个拉取动作是不是必须等到渲染完成之后才能发起如果数据的来源是应用启动时就需要恢复的缓存完全可以用 useState 的初始化函数在组件第一次渲染前就准备好const [user, setUser] useState(() readFromCache());如果数据获取是由某个明确的动作触发的比如登录成功后拉用户信息那这段逻辑应该放在登录事件回调里而不是等登录状态变化之后再让 effect 去发请求。只有当你确实需要“页面已经画出来、用户已经能看到内容”这个时机去做事比如上报埋点、设置页面标题、初始化依赖 DOM 的图表useEffect 才是正确的选择。老实说很多人初始化时用 effect 只是因为习惯了并没有想过是否真的需要渲染后时机。3. 依赖数组核心机制也是最大的坑3.1 三种依赖形态分别代表什么useEffect 的第二个参数是依赖数组有三种写法对应三种完全不同的语义我建议彻底背下来。不传依赖数组也就是useEffect(() { ... })意思是“每次渲染后都执行”。这种写法在绝大多数业务场景里都是多余的会让你每次组件更新都白跑一遍 effect,假如里面有异步操作等于每个状态变化都触发一次请求。空数组[]意思是“挂载后执行一次卸载前如果有清理函数就执行清理”。它只会在初次挂载时建立连接不会因为任何 props 或 state 变化而重新同步。传了依赖项[a, b]意思是“当 a 或 b 发生变化时先执行上一次的清理函数再执行本次 effect”。这是最常用也最容易出错的形式因为依赖项的增删直接决定了 effect 何时“过期”和“重建”。需要特别记住的是空数组不代表“安全”它代表“永远用初始那次渲染的闭包”。如果你在 effect 里读取了一个后面会变化的 props 或 state它拿到的永远是初次渲染时的旧值。这也是很多闭包 bug 的根源。3.2 诚实写依赖为什么 lint 说你有错在 effect 内部凡是读取的响应式值——props、state、从 props 或 state 派生出来的值——都应该被列进依赖数组。这个规则有一个标准配套工具ESLint 的 exhaustive-deps 规则它是 React 官方推荐开启的。它会在你漏写依赖时给出警告这不是为了烦你而是在提前拦截一类非常难排查的 bug。遇到 lint 报依赖缺失正确的处理方式不是把责任推给规则也不是在文件顶部加一句// eslint-disable-next-line一关了之。你应该先想清楚这个值真的需要让 effect 响应它的变化吗如果是那就把它加进依赖数组如果不是说明这个值不应该出现在 effect 内部逻辑里把它移到 effect 外面或者用回调函数、ref 等方式绕开。还有一个很容易忽略的点如果 effect 内部使用了一个在组件内定义的函数这个函数没有用 useCallback 包裹那它每次渲染都是新的引用。把它放进依赖数组后会导致 effect 每次渲染后都执行。这时候的正确答案通常是把函数定义直接挪进 effect 内部而不是为了追求“函数引用稳定”而到处用 useCallback 包装。依赖数组的最终目标是让 effect 在执行前能清晰回答一个问题“什么东西变了我才需要重新干活”3.3 闭包吃掉了最新值闭包问题是 useEffect 最常见的翻车现场几乎每个 React 开发者都会遇到一次。经典例子是一个每秒加一的计时器function Counter() { const [count, setCount] useState(0); useEffect(() { const id setInterval(() { setCount(count 1); // 这里的 count 永远是第一次渲染时的 0 }, 1000); return () clearInterval(id); }, []); return div{count}/div; }因为依赖数组是空的effect 只执行一次setInterval 回调里捕获的 count 是首次渲染的 0。于是每次setCount(0 1)count 都变回 1永远卡在 1。原因就是闭包吃掉了旧值。修复方式是把依赖加进去或者干脆用函数式更新useEffect(() { const id setInterval(() { setCount(c c 1); // 告诉 React基于最新值计算 }, 1000); return () clearInterval(id); }, []);函数式更新是处理“依赖最新 state”最省事的方式。但如果你需要在回调里读取多个最新值或者要读取最新的 props只靠函数式更新就不够了。这时候更常见的手段是把你需要的值加入依赖数组让 effect 重新注册如果这个 effect 不希望频繁重新执行那就用一个 ref 来保存最新值const latestValueRef useRef(value); useEffect(() { latestValueRef.current value; });在旧回调里读取latestValueRef.current拿到的就是最新值。要记住一个原则闭包问题的本质是“我在什么时候捕获了这个值”而不是“这个值现在是什么”。搞清楚捕获时机问题就好解决。3.4 依赖数组本身解决不了的问题竞态依赖数组能处理“何时重新同步”但它管不了“上一次同步的收尾动作是否来得及”。最典型的场景是请求竞态用户快速切换选项第一次请求刚发出去第二次请求紧跟着发了出去。如果第一次请求返回得比第二次慢它携带的旧数据就会覆盖新数据界面上出现“选了 A 却显示 B 的内容”这种错乱。你没法用依赖数组来防止这件事因为依赖数组只是告诉你“该重新发请求了”。真正能兜底的是 effect 的清理函数。每次依赖变化、effect 重新执行之前React 会先执行上一次的清理函数。利用这个时机你可以告诉上一次请求“你已经过期了结果不要再写入 state”。这就是下一章要展开的内容。4. 实战几类副作用场景的标准写法4.1 数据请求竞态与取消数据请求是所有副作用场景里最需要谨慎处理的一种。先看一个基础版本根据 userId 获取用户详情function Profile({ userId }) { const [user, setUser] useState(null); const [loading, setLoading] useState(false); useEffect(() { let canceled false; setLoading(true); fetch(/api/users/${userId}) .then(res res.json()) .then(data { if (!canceled) setUser(data); }) .finally(() { if (!canceled) setLoading(false); }); return () { canceled true; }; }, [userId]); // ... }这里的核心是canceled标志。userId 变化时React 会先执行上一次清理函数把上一个请求的 canceled 置为 true于是即使旧请求晚返回也不会再去覆盖本次状态。组件卸载时同理清理函数也会执行不会出现卸载后 setState 的告警。如果你更喜欢现代的写法可以用 AbortController 真正取消请求useEffect(() { const controller new AbortController(); fetch(/api/users/${userId}, { signal: controller.signal }) .then(res res.json()) .then(data setUser(data)) .catch(err { // 注意abort 触发的错误名称是 AbortError需要单独判断 if (err.name ! AbortError) { // 处理真实错误 } }); return () controller.abort(); }, [userId]);两种方案都可行我建议团队统一一种约定。AbortController 更“正规”能真正取消请求节省带宽但要注意 abort 时 fetch 返回的 Promise 会进入 rejected 状态catch 里必须过滤 AbortError否则控制台会经常出现红色报错。布尔标志方案更简单直观新手不易踩坑。还有一点要记住不要在 useEffect 的回调里直接写 async 函数因为 React 要求 effect 的回调要么什么都不返回要么返回一个清理函数而 async 函数会返回 Promise。如果你确实想在 effect 里用 async可以用 IIFE 包一层但说实话面对复杂请求我更推荐把这些逻辑封装成自定义 Hook后面会讲。4.2 订阅外部事件记住清理监听 window 的 resize 事件、scroll 事件或者在组件内订阅某个事件总线这些都是典型的“外部系统同步”必须记得在清理阶段取消订阅function useWindowWidth() { const [width, setWidth] useState(window.innerWidth); useEffect(() { const handleResize () { setWidth(window.innerWidth); }; window.addEventListener(resize, handleResize); return () { window.removeEventListener(resize, handleResize); }; }, []); return width; }这里有个细节经常被忽略清理函数里的removeEventListener必须使用和addEventListener完全相同的函数引用。如果你把handleResize定义在 effect 外部并且它依赖了某些会变的值那你每次注册的都是新函数removeEventListener 就删不掉旧的。最简单的做法是直接在 effect 内部定义事件处理函数让注册与清理使用同一个引用。如果回调确实需要读取每次渲染后的最新值就按前一章说的用 ref 或依赖数组来处理不要为了图方便在外部定义函数然后又抱怨清理无效。4.3 定时器函数式更新加上条件清理定时器的正确姿势是在依赖变化时能停掉旧的、启动新的。比如一个支持运行/暂停的计时器function Timer() { const [seconds, setSeconds] useState(0); const [running, setRunning] useState(true); useEffect(() { if (!running) return; const id setInterval(() { setSeconds(s s 1); }, 1000); return () clearInterval(id); }, [running]); }当 running 从 true 变 false清理函数会清掉旧定时器因为if (!running) return也不会新建从 false 变 true会重新建一个 interval。整个逻辑完全由依赖数组驱动不需要额外写 stop 函数。这就是 useEffect 这种“同步”模型比手动生命周期好用的地方你不用记着一大堆收尾时机只要保证每次依赖变化时旧连接被拆掉、新连接被建起来剩下的交给 React。4.4 集成第三方库图表、地图第三方库的集成是 useEffect 最能发挥价值的地方之一。图表库、地图库、富文本编辑器这些库通常都有自己的数据模型、DOM 操作和销毁逻辑跟 React 的渲染流程是两套系统。你需要在挂载后做初始化在数据或配置变化时更新它在卸载时销毁它useEffect(() { const chart new MyChart(containerRef.current, { data: rawData, width: 600 }); // 数据变化时复用实例做更新 chart.updateData(rawData); return () { chart.destroy(); }; }, [rawData]);初始化线路体积较大、没有必要反复执行时可以把依赖拆开真正每次都需要的业务数据放进去容器或者基础配置只在挂载时执行一次。但要注意容器 ref 的 current 在首次依赖为空的 effect 执行时一定已经存在因为 effect 在 DOM 提交之后才运行。如果你发现 ref.current 是 null几乎可以肯定是 effect 依赖里混入了某个会改变的配置导致它在 DOM 尚未就绪的错误时机被重新执行了。这个细节排查起来非常隐蔽。4.5 把 effect 封装成自定义 Hook业务写多了你会发现直接用 useEffect 的地方其实越少越好。更高阶的用法是把它藏进自定义 Hook让调用方只面对语义化的函数。比如封装一个 useLocalStoragefunction useLocalStorage(key, initialValue) { const [value, setValue] useState(() { const saved localStorage.getItem(key); return saved ! null ? JSON.parse(saved) : initialValue; }); useEffect(() { localStorage.setItem(key, JSON.stringify(value)); }, [key, value]); return [value, setValue]; }页面里使用的时候就非常干净const [draft, setDraft] useLocalStorage(commentDraft, );关于“何时使用 useEffect”我最想推荐的一个工程实践是把 effect 的细节封装成一个又一个语义明确的自定义 Hook。useWindowSize、useDebouncedValue、useFetch、useLocalStorage、useScript……每个 Hook 内部怎么写都行内部到底用 useEffect 还是 useLayoutEffect对调用方完全透明。这样做的直接收益是业务组件里的 useEffect 肉眼可见地减少剩下的全是纯粹的 state 和 props 流动复杂度一下就降下来了。5. 新版本带来的新规矩5.1 React 18 StrictMode 双执行 effect别慌React 18 开始开发模式下如果启用了 StrictMode组件的 effect 在初次挂载时会先执行一次 setup然后立刻执行 cleanup再执行一次 setup。很多开发者第一次看到“请求发了两次”、“日志打了两遍”时以为是自己的代码写错了满世界找原因。这其实是 React 故意设计的“预演机制”提前暴露那些不可清理、不幂等的副作用。生产环境的 StrictMode 不会影响运行结果也就是说线上不会真的执行两次。这个机制给我们的启发很实在任何 useEffect 都必须做到“执行-清理-再执行”也不会出问题。如果做不到说明你的 effect 本身就有隐患,比如漏了清理函数、在 effect 里添加了全局事件没移除、请求没有取消。遇到 StrictMode 下双执行不要急着关 StrictMode,要去检查自己的清理逻辑是否完备。我在几个团队里都发现StrictMode 其实是一道免费的可靠性测试能逼着你把 effect 写干净。关掉它等于放弃了这个保障。5.2 React Compiler 解决了什么、没解决什么React Compiler 是 React 团队推出的自动记忆化方案它会在编译阶段分析组件的依赖关系自动注入 useMemo 和 useCallback 级别的优化。这意味着未来你写的很多手动 memo 代码可能会变得多余组件渲染性能的优化会交给编译器自动完成。但它不解决“何时用 useEffect”的问题。编译器能自动判断哪些计算可以缓存却不可能替你决定“什么时候应该跟外部系统同步”——因为副作用依赖的是业务语义、外部状态、生命周期这些信息编译器无法推断。useEffect 仍然需要人来判断而且随着 useMemo 和 useCallback 的使用频次下降依赖数组反而会因为“引用不稳定”产生的噪音变少判断起来更聚焦。说白了自动优化解决的是“渲染太快导致性能差”但解决不了“副作用时机不对导致逻辑错”。5.3 useLayoutEffect什么时候应该用它什么时候别碰useEffect 异步执行、在浏览器绘制之后运行useLayoutEffect 同步执行、在浏览器绘制之前运行。如果我们在 useLayoutEffect 里读取 DOM 的几何信息并立即调整样式用户看不到中间那个“错误的画面”但如果放到 useEffect 里就会先画一帧错的再画一帧对的出现肉眼可见的闪烁。所以当你需要读取 DOM 的尺寸、位置信息并马上基于它调整界面时选 useLayoutEffect其他所有情况默认用 useEffect 就好。很多人误以为 useLayoutEffect 能“更早拉数据”这是错的它仍然发生在 DOM 提交之后只是同步执行、会阻塞绘制。而且服务端渲染时它不会被运行如果要保证 SSR 页面的行为一致大多数时候都应该避开它。我见过有人因为在 effect 里测量宽度闪烁最后用 useLayoutEffect 解决的这种场景应该被记住但不要为了“快”而滥用它它并没有更快只是把时序从“画完之后”提前到了“画之前”。6. 高频问题排查实录6.1 最常见的无限循环是怎么来的useEffect 里无限循环十次有八次是下面这两种写法造成的。第一种在 effect 里 setState而这个 state 恰恰又是依赖项useEffect(() { setCount(count 1); }, [count]);每次渲染后effect 执行 setCountcount 变了触发渲染渲染后又执行 effect又 setCount……如此反复。这种问题的解法要么去掉依赖改用函数式更新要么把 setState 的动作从 effect 里拿出来。第二种更隐蔽依赖项是一个每次渲染都会新建的对象或函数const config { theme: dark }; useEffect(() { doSomething(config); }, [config]);config 每次渲染都是新引用React 判断依赖变化时用的是Object.is它认为“这次的 config 和上次不同”于是 effect 每次渲染后都执行。解决办法是用 useMemo 或 useCallback 稳定引用或者把对象定义直接挪到 effect 内部。排查这类问题的通用思路先在 effect 开头打一行日志打印依赖项的值和当前时间你能很清楚地看到是“依赖真的变了”还是“引用不稳定被误判”。6.2 请求发两次或者数据加载错乱请求发两次先不要怀疑代码逻辑。如果项目开了 StrictMode 且处于开发环境双调用是预期的不需要管生产环境不会这样。如果生产环境也重复那就检查 effect 依赖依赖项里是否有一个函数或对象每次都是新引用导致 effect 重复执行。还有一种常见情况是父组件因为某种原因重新挂载了子组件effect 也跟着重新执行两次这种要去查父组件的 key 或者条件渲染逻辑。数据加载错乱也就是“选了 B 却显示 A 的数据”几乎都是竞态问题。我说一个亲测有效的排查顺序第一步确认 effect 清理函数是否把旧的异步结果标记为失效第二步确认请求是否真的基于最新依赖发起第三步给 setData 加上校验只允许“本次请求仍然有效”时写入。照着这一步一步往下走绝大多数错乱问题都能定位到“上一次请求没被取消”这个点上。6.3 拿不到最新值的闭包问题闭包问题的典型表现是我在 effect 里注册了一个事件监听或定时器回调里读取某个 state结果读到的始终是首次渲染的值。参考 3.3 的讲解解决方式有三种按优先级排序能用函数式更新的就用函数式更新比如setCount(c c 1)能加依赖的就加依赖让 effect 重新注册回调两者都不合适时用 ref 保存最新值在回调里读取ref.current。我在排查一个奇怪问题时遇到过更特殊的场景effect 依赖数组里已经写了某个变量但回调里读到的还是旧值。原因是被依赖的变量和回调里读取的变量不是同一个引用——一个是 props 解构出来的一个是 props 本身。遇到这种“看起来没问题但其实有问题”的闭包最有效的招数是重新审视你在 effect 里到底捕获了什么、依赖数组里写的是什么两者必须完全对应。不要以为 lint 没报错就万事大吉lint 不能理解你的业务语义。6.4 调试 effect 的几个实用招调试副作用我常用的工具和方法如下。第一个是 React DevTools。打开 Profiler 录制交互选中组件观察每次 render 的原因和 effect 触发的依赖变化比瞎猜快得多。第二个是临时日志。在 effect 开头和清理函数里各打一行console.log带上效果说明和依赖值。这个方法很土但在定位复杂时序问题尤其是竞态和闭包时非常有效。比如你会在日志里清楚地看到“请求 A 取消了请求 B 开始”这样的顺序。第三个是减少变量。把 effect 体抽成一个独立函数手工传入所有依赖参数用普通函数调用去测试它是否每次都产出预期结果。这能帮你区分“effect 触发时机不对”和“effect 内部逻辑本身有错”。我最想强调的一点不要把调试重心放在“改依赖数组碰碰运气”上。依赖数组是 effect 的输入契约你应该像对待函数参数一样郑重地对待它。动不动就加依赖、删依赖、注释依赖会让代码变得更不可预测。凡是依赖数组动过之后问题“消失”但你说不清原因大概率埋下了更深的雷。真正靠谱的方式是先把场景归类到本章前面的几种典型模式里再对照标准写法修。我个人在实际操作中的体会是useEffect 不是一个“高级功能”也不需要你在每个组件里都秀一下。它更像一把需要谨慎使用的钳子专门用来连接 React 和它管不到的外部世界。如果你发现自己在用 useEffect 处理纯计算逻辑、事件回调还是状态同步那多半是没想清楚问题出在哪里。先把该去掉的 effect 去掉再思考剩下的几个到底该怎么写。判断顺序我一般固定为三问这件事是否只能发生在渲染完成后它是否涉及 React 之外的系统离开 effect 有没有更直接的数据流动方式三个问题问完答案基本就浮出水面了。用这种思路写代码组件会清爽很多页面出问题的概率也会明显下降。