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. 这些写法看似合理,其实根本不该用 useEffect
2.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);只有当计算非常昂贵,才需要加一层 useMemo:
const 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,让调用方只面对语义化的函数。比如封装一个 useLocalStorage:
function 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 执行 setCount,count 变了,触发渲染,渲染后又执行 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 有没有更直接的数据流动方式?三个问题问完,答案基本就浮出水面了。用这种思路写代码,组件会清爽很多,页面出问题的概率也会明显下降。