深入理解React的useEffect:从闭包陷阱到依赖数组的完整避坑指南 写 useEffect 的博文最怕的就是把它讲成“API 说明书”三个参数、两个参数、一个参数然后给几个例子就完事。但实际上你在真实项目里遇到的几乎所有怪问题——数据重复请求、状态不同步、死循环、闭包陷阱、白屏闪烁……根子都在 useEffect 的心智模型上。这一篇我梳理了从底层机制到实战踩坑的完整链路希望能帮你看穿副作用这件事。1. 为什么说 useEffect 是 React 组件里最容易出 bug 的地方1.1 渲染函数必须“纯净”但应用不可能没有副作用React 组件在函数式组件时代本质上就是“一个根据输入props、state计算输出UI的纯函数”。纯函数的含义是同样的输入必然得到同样的输出并且在计算过程中不产生任何外部影响。这在数学上是完美的模型但真实应用不可能只在屏幕上画一个静态的框——你要请求数据、要监听窗口大小、要设置定时器、要操作 localStorage、要手动给 canvas 画图……这些行为在 React 的术语里都被叫作“副作用”side effect。副作用一旦没有统一管理机制就会散落在组件的任意角落。比如你直接在渲染函数主体里发请求function ProductList({ categoryId }) { fetch(/api/products?categoryId${categoryId}) .then(res res.json()) .then(data setProducts(data)) return (/* UI */) }这段代码在初次挂载时跑一次在更新时也会跑一次而且 setState 又会触发重新渲染重新渲染又发请求……这是一个会自动加速的恶性循环。如果没有 useEffect 这类机制把副作用绑定到“渲染完成之后”并控制其触发时机React 应用根本没法稳定运行。useEffect 解决的核心问题就是在“渲染完成后”和“特定依赖发生变化后”这两个时间窗口安全地执行副作用并提供了清理cleanup能力来防止内存泄漏和状态错乱。它是 React 从类组件生命周期时代进化到函数组件时代的关键里程碑。1.2 类组件时代用“生命周期”硬凑函数组件里为什么换了思路在 class 组件时代副作用被硬塞进了几个生命周期函数componentDidMount 处理挂载后的数据请求componentDidUpdate 处理更新后的同步操作componentWillUnmount 做清理。这种设计有很明显的割裂感——同一个数据请求的逻辑要被拆到三个方法里而且容易漏掉 componentDidUpdate 里的比对逻辑。class 组件还有个很让人头疼的问题大量不相关的代码被迫堆在同一个生命周期方法里由于 JS 的 this 指向问题方法之间还要花大量精力绑定。useEffect 把“挂载后执行”“更新后执行”“卸载前清理”这几种时机压缩到了一个统一 API 里。它不是生命周期的替代品而是更贴近“渲染结果”这一心智模型的机制——每一轮渲染都有自己的 effect 副本当渲染产出变化时React 负责安排旧 effects 的清理和新 effects 的执行。我的理解要点useEffect 不是“监听状态变化后执行”而是“每一轮渲染完成后如果依赖发生变化则同步执行”。把“渲染周期”作为思考的锚点而不是“某个状态值的变化事件”很多坑就可以提前避开。2. useEffect 的三个关键参数从底层机制讲到代码示例2.1 callback、deps、cleanup 到底分别负责什么useEffect 的函数签名是useEffect(callback, deps)它接收两个参数但在大多数场景里 callback 本身又会返回一个清理函数所以实际交互的是三件事。第一个参数 callback 是副作用的主体它会在“合适的时机”被 React 调用。React 保证这个 callback 一定是在浏览器完成布局和绘制之后才执行不会阻塞视觉更新。你们可以把它理解成 React 给应用安装的一个“监工”你的 UI 已经渲染出来了现在你可以去做那些改变外部世界的事情发请求、改标题、操作 DOM。第二个参数 deps 是依赖数组。React 会在每次渲染完成后把这次传入的 deps 和上次渲染时的 deps 逐个用 Object.is 比较只要有一个不同就会执行本次的 callback。如果第二个参数缺省不传那么每一轮渲染后都会执行副作用就成了“无差别攻击”如果传入空数组 []那么只在挂载后执行一次之后除非组件卸载否则不会再执行。第三个元素 cleanup 清理函数它会在以下时机被调用组件卸载时下一次执行 callback 之前React 会先把上一个 effect 清理掉再执行最新的依赖变化导致旧 effect 失效时。清理函数是 useEffect 里最容易被初学者遗忘的部分但对于定时器、事件监听、订阅连接来说没有清理就等于在平台上堆垃圾。下面这段代码我特意把三个部分的配合关系写全也是实际开发中“监听元素尺寸”的典型做法import { useState, useEffect, useRef } from react function TrackSize() { const divRef useRef(null) const [size, setSize] useState({ width: 0, height: 0 }) useEffect(() { const node divRef.current if (!node) return const observer new ResizeObserver(entries { const cr entries[0].contentRect setSize({ width: cr.width, height: cr.height }) }) observer.observe(node) // 这个 return 的函数就是 cleanup return () { observer.disconnect() } }, []) // 空数组挂载时建立监听卸载时自动断开 return ( div ref{divRef} style{{ width: 50%, resize: both, overflow: hidden }} 当前尺寸{size.width} x {size.height} /div ) }挂载后创建 ResizeObserver 并观察 div卸载前断开观察。这里不用在 componentDidUpdate 里做任何额外的事情因为 ResizeObserver 本身就是持续回调机制effect 只需要负责“建立”和“销毁”。这个模式可以直接迁移到 window.resize 监听、scroll 监听、WebSocket 连接等场景。2.2 与 class 生命周期方法的对应关系以及为什么说“不完全对应”网上很多资料喜欢画这样一张表useEffect 的 [] 对应 componentDidMount有依赖的 useEffect 对应 componentDidUpdatecleanup 对应 componentWillUnmount。这套类比在入门阶段可以帮助理解但到了实际项目里它会严重误导你。类组件的生命周期方法是“单实例、最新值”的。componentDidUpdate 里读到的 this.state 永远是当前最新值因为所有生命周期方法都挂在同一个实例上。而函数组件的每次渲染都有自己的 props 和 state 快照useEffect 的 callback 以及它内部的闭包捕获的是触发那次 effect 的渲染数据。举个例子就能看出巨大差异。用类组件写一个定时器// class 版本 componentDidMount() { setInterval(() { this.setState({ count: this.state.count 1 }) }, 1000) }这里的 this.state 永远指向最新的实例所以即使定时器是在挂载时创建的它每次触发的 setState 也能基于最新值计算。但如果用函数组件直接在 effect 里 setInterval 并读取 state闭包捕获的是初始 state于是出现经典问题——定时器回调里永远读到旧值。这恰恰说明useEffect 并不是 lifecycle 的语法糖而是一套新的心智模型。正确理解方式是每一轮渲染都会产生一支独立的 effect “军队”它们拥有自己的 props/state 快照React 负责在需要的时候进行新旧替换。为了真正读懂闭包陷阱可以把下面这段代码跑一遍观察 1 秒后的输出function Counter() { const [count, setCount] useState(0) useEffect(() { const timer setTimeout(() { console.log(count) // 无论你怎么点这里输出的都是定时器创建那一刻的 count }, 3000) return () clearTimeout(timer) }, []) // 注意空数组effect 只建立一次 return button onClick{() setCount(c c 1)}1当前{count}/button }这个例子里count 被快速增加到 5但 3 秒后控制台输出的是 0而不是 5。原因就是 effect 的依赖为空数组React 只在首次渲染后创建了这个计时器那个闭包记住的 count 是 0。发请求、记录日志、防抖等场景遇到这个现象时解决方案往往不是“改 effect”而是把需要的值放进依赖数组。3. 依赖数组到底该怎么写五个级别的实战经验3.1 依赖数组的粒度与更新触发规则在实际开发中依赖数组的写法大致有五种层次每一种对应一种使用强度也对应不同的坑。完全省略依赖数组useEffect(() { // 每一轮渲染后执行 })这种写法代表“无论什么原因触发了重新渲染都要执行副作用”。适合场景极少通常是性能监控、调试日志这类需要“永远跟随最新渲染”的场景不适合发请求、写存储等重操作。空数组useEffect(() { // 挂载后执行一次 }, [])只执行一次适合初始化数据、建立全局监听。但要注意开发环境 React 18 StrictMode 下会执行两次这是 React 的“故意行为”用来暴露副作用未正确清理的问题后面会细讲。单值依赖useEffect(() { // 当 userId 变化时执行 }, [userId])这是最常见的模式本质是“数据变化后重新同步外部系统”。多值依赖useEffect(() { // 当 userId 或 filters 任一变化时执行 }, [userId, filters])无依赖的复杂对象const user { id: props.id, name: props.name } useEffect(() { // 想监听 props.id / props.name 的变化 }, [user])这是很经典的“自以为是”的写法也是 bug 高发地带。因为依赖数组的每个元素用的是 Object.is 比较而 user 每次渲染都是全新对象引用所以 Object.is 永远返回 false于是每次渲染都会触发 effect。你必须把依赖写成 props.id、props.name 这种原始值const id props.id const name props.name useEffect(() { // 这样才真正监听值的变化 }, [id, name])如果确实需要传递对象常见的做法是把它构建成纯函数返回新内存地址的方式并配合 useMemo 缓存或者直接打散成原始值依赖。3.2 为什么说“依赖数组不是响应式监听而是渲染同步机制”很多从 Vue 转 React 的开发者最容易踩的坑就是把 useEffect 里的依赖数组当成 Vue 的 watch。watch 是命令式监听某个响应式属性改变就触发对应回调。而 useEffect 是声明式的同步机制每一轮渲染React 对比依赖值如果不同就重新执行同步动作。这两者的区别会影响你怎么组织代码。Vue 里常见写法watch(() props.id, (newId, oldId) { fetch(/api/user/${newId}) })React 里 useEffect 则写为useEffect(() { fetch(/api/user/${id}) }, [id])看起来几乎一模一样但底层差异极大。watch 的触发时机是“数据改变后同步执行回调”effect 的触发时机是“渲染完成后如果依赖有变执行同步”。在严重并发的渲染场景下这两个机制的执行顺序和频率都不一样。更重要的是Vue 的 watch 回调里拿到的 newId 和 oldId 都是响应式的最新值而 React 的 effect 里拿到的 id 是触发这轮同步的渲染快照值。如果你在这个 effect 里面发起异步请求请求返回时组件的 props/state 可能又变了这时候就需要手动判断是否需要应用这个结果。这就是“请求竞态”问题后面我会专门展开。3.3 典型 bug 场景把对象/函数放进依赖数组导致死循环对象字面量依赖是开发者最常踩的坑之一。假设一个查询组件function SearchBox({ keyword }) { const [results, setResults] useState([]) const params { keyword, page: 1 } // 每次渲染都是新对象 useEffect(() { fetch(/api/search?keyword${params.keyword}page${params.page}) .then(res res.json()) .then(data setResults(data)) }, [params]) // 每次渲染 params 都是新引用effect 必然执行 return results.map(item div key{item.id}{item.name}/div) }这个组件会陷入死循环渲染 → params 是新的 → effect 执行 → setResults → 渲染 → params 又是新的……请求像洪水一样发出去。解决方式很朴素把依赖打散成 keyword、page 两个原始值useEffect(() { fetch(/api/search?keyword${keyword}page${page}) .then(res res.json()) .then(data setResults(data)) }, [keyword, page])这里的核心原则是把“判断是否同步”的职责交给原始值而不是把整个对象交给 React 去比较。如果被依赖的对象本身来自 props且父组件每次渲染都生成新对象那么你应该在父组件层面用 useCallback / useMemo 做缓存或者把值打平之后传入子组件。函数依赖同理虽然 React Hook 官方文档在 useEffect 中不需要包含函数因为函数本身就是捕获但在某些场景特别是函数是 props 传下来的且有状态更新时容易出现依赖数组里放函数导致 effect 每次渲染都执行的现象。我的建议是涉及函数依赖时优先检查“这个函数是否对当前渲染有影响”如果有用 useCallback 包一层再传入 effect而不是在 effect 里绕过函数体。4. 数据请求的完整实操从 loading 状态到请求取消4.1 一个商品列表页的完整实现数据请求是 useEffect 用得最多的场景也是最容易写不好的场景。以一个常见的“分类商品列表页”为例你需要处理进入页面或切换分类时请求数据、请求期间展示 loading、请求成功展示列表、请求失败展示错误、组件卸载时取消请求、连续切换分类时丢弃旧响应。完整的最小实现长这样import { useState, useEffect } from react function ProductList({ categoryId }) { const [products, setProducts] useState([]) const [loading, setLoading] useState(true) const [error, setError] useState(null) useEffect(() { let ignore false const controller new AbortController() setLoading(true) setError(null) async function loadProducts() { try { const res await fetch(/api/products?categoryId${categoryId}, { signal: controller.signal }) if (!res.ok) throw new Error(HTTP ${res.status}) const data await res.json() if (!ignore) { setProducts(data) } } catch (err) { // 如果是取消请求导致的错误不处理 if (err.name ! AbortError !ignore) { setError(err.message) } } finally { if (!ignore) { setLoading(false) } } } loadProducts() return () { ignore true controller.abort() } }, [categoryId]) if (loading) return div加载中.../div if (error) return div加载失败{error}/div return ( ul {products.map(p ( li key{p.id}{p.name}{p.price} 元/li ))} /ul ) }这个实现里有几个细节值得注意。ignore 标志位是为了防止“请求还没返回时组件就卸载了然后 setState 一个已经不存在的组件”这种 React 警告。因为清理函数会在组件卸载前执行ignore 被设为 true等请求返回时就不会去 setState 了。AbortController 则更进一步直接把还在途中的网络请求取消掉不仅避免 setState还节省网络资源。关于 cleanup 的时机很多教程会暗示它只在卸载时执行但事实上当你从分类 1 切换到分类 2 时React 会先执行上一次 effect 的 cleanup再执行最新渲染的 effect。也就是说切换分类时上一轮的网络请求会被 abort不会和下一轮的响应交叉污染。这是整个 useEffect 数据请求模式的定海神针。4.2 请求竞态问题的两种解法对比“竞态”这个词听起来高深其实很好理解你能点击多次按钮每次点击都会发请求。如果第一次请求很慢第二次请求很快那么第二次请求的结果先回来然后第一次的慢请求也回来了把界面数据覆盖成了旧结果。最终展示的数据与最后一次操作不匹配。竞态的标准解法有两类。一类是序列号方案useEffect(() { let active true fetch(/api/items?page${page}) .then(res res.json()) .then(data { if (active) setItems(data) }) return () { active false } }, [page])active 本质上就是 ignore 变量声明周期从 effect 开始到 cleanup 结束如果页面已经翻到下一页旧请求的 then 就不会更新状态。另一类是 AbortController 方案上面商品列表就是。用 AbortController 的额外好处是能够真实取消网络请求而不是假装忽略结果。对用户体验来说后者更好因为底层连接会尽快释放。如果在更复杂的场景里用 axios它原生支持 AbortSignal写法类似useEffect(() { const source axios.CancelToken.source() axios.get(/api/data, { cancelToken: source.token }) .then(res setData(res.data)) return () { source.cancel(组件已卸载取消请求) } }, [])axios 还有一种基于 AbortController 的新写法更贴近标准。无论用哪种核心都是一致的在 cleanup 里打上“我不要再理会这个请求的结果”的标记。如果你在项目里使用了 React QueryTanStack Query、SWR 这类请求库这些请求竞态问题往往被库自己解决了但了解底层原理有助于在库满足不了需求时自己手写。4.3 依赖数组里放“自定义对象”导致重复请求的排查思路实战中搜索二次封装组件时经常出现“每次输入都重复请求”的问题。比如const filters { keyword, sort: desc } useEffect(() { fetch(/api/search, { body: JSON.stringify(filters) }) }, [filters])filters 每次渲染都会重新创建于是每次输入都会请求。这里的问题不是 effect 写错了而是“对象引用不稳定”的锅。同一份数据每次渲染都生成新引用React 拿新引用和旧引用比较后发现“变了”于是重新执行 effect。解决思路按照实际场景有两条路。如果 filters 的字段不多直接拆开作为依赖useEffect(() { const currentFilters { keyword, sort: desc } fetch(/api/search, { body: JSON.stringify(currentFilters) }) }, [keyword, sort])如果 filters 很复杂拆不开就用 useMemo 包一层const filters useMemo(() ({ keyword, sort: desc }), [keyword, sort]) useEffect(() { fetch(/api/search, { body: JSON.stringify(filters) }) }, [filters])useMemo 在这里的作用是“只要 keyword 和 sort 没变就返回上一次缓存的对象引用”这样依赖比较才能正常工作。排查这类问题时最快的诊断方式是在 useEffect 里打印依赖值然后检查打印内容是否在不应该变化的时机变化了。5. 从 StrictMode 到 React 18useEffect 在开发和生产环境的双重面孔5.1 为什么 React 18 开发模式会执行两次 effectReact 18 之后如果项目使用了 React.StrictMode脚手架默认开启开发模式下组件的 useEffect 会被执行两次——更精确地说是挂载 effect → 清理 effect → 重新挂载 effect。首次看到这个现象的人都会吓一跳“我的代码是不是写错了数据请求怎么发了两遍”这是 React 故意为之的设计。它的目的是帮助开发者暴露那些“没有正确清理”的副作用。如果你在 effect 里建立了一次全局事件监听但忘了在 cleanup 里移除那么 StrictMode 会立刻让这个 bug 现形——你会发现监听函数被添加了两次甚至多次。如果你正确写了 cleanup那么这两次执行是干净且无副作用的。具体执行顺序是组件挂载 → 第一个 effect 执行 → 立刻执行其 cleanup → 再次执行 effect。这相当于 React 在开发环境里做了一次“预演”验证你的副作用具备可重复建立、可安全撤销的能力。我强烈建议不要为了消除控制台的双倍日志而关闭 StrictMode。生产环境不会执行两次关闭 StrictMode 只是在掩耳盗铃。正确做法是审视自己的 effect 是否缺少清理逻辑而不是和框架的检查机制对着干。5.2 类组件生命周期没有的“卸载路径”useEffect 里全部要手动维护类组件时代清理逻辑有专属生命周期方法而且只在卸载时执行一次。useEffect 的清理逻辑则可能在同一渲染周期和切换依赖时多次执行这成为 React Hooks 推广后很多人不适应的地方。有一种比较生动的类比你把 effect 想象成“入住酒店”。每次渲染你都开了一间新房间当依赖变化或组件卸载时你必须先办理退房cleanup再入住下一间。如果每次都忘了退房房间里就会堆满上一个用户留下的垃圾——事件监听、定时器、未完成的请求最终系统卡顿、行为错乱。从实际排查经验看前端性能排查中有相当一部分“低级卡顿”问题就出在忘记清理 effect 上。频繁切换页面后内存持续上涨、页面越用越卡基本都是滚动监听、定时器这类副作用在背后不断叠加导致的。5.3 你一定会遇到的白屏、闪烁问题排查React Native 或 Web 应用里启动阶段白屏或首帧闪烁往往是 useEffect 时序问题造成的。比如在 useEffect 里读取本地缓存来初始化状态那么在 effect 执行之前组件已经用默认状态渲染了第一帧用户会先看到默认的空白或错误 UI然后 cache 数据回来才重新渲染。这个“先空白后内容”的体验就是闪烁。常见的优化方向是把初始化逻辑提前到 useState 的惰性初始化或者 useLayoutEffect。useState 惰性初始化适合同步计算const [token] useState(() localStorage.getItem(token))useLayoutEffect 则在页面绘制之前同步执行适合需要“先改状态再让用户看到结果”的场景。但它会阻塞浏览器绘制所以不能滥用。只有 useEffect 表现出的闪烁严重到不可接受时才考虑。关于 React Native 启动白屏那更多是原生层面和新架构相关的问题但如果你在 RN 里用 useEffect 做初始化请求同样要思考这一帧问题——合理的做法通常是把初始化数据放到启动前处理或者为第一帧设计一个明确的 loading 占位。6. 面试和实际编码中关于 useEffect 的 12 个避坑问答6.1 高频面试题为什么 useEffect 里不能直接写 async这几乎是 React 面试必问题。原因很简单useEffect 的第一个参数需要返回一个“清理函数”或 undefined。而 async 函数永远返回 PromisePromise 不是函数也不是 undefined这种类型匹配直接失败。React 之所以不自动处理 Promise是因为如果支持那么每次渲染后都需要处理 promise 的异步时序会让整个清理流程变得不可预期。正确的写法是在 effect 里定义一个 async 函数然后调用它而不是把 async 函数本身作为 callbackuseEffect(() { async function fetchData() { const res await fetch(/api/data) const data await res.json() console.log(data) } fetchData() }, [])如果你需要清理 Promise 链可以在外层设计 ignore 变量或 AbortController前面已经覆盖。6.2 面试高频题依赖数组为 [] 时为什么还会拿到旧值闭包。每次渲染都会创建新的函数作用域和变量绑定。effect 一旦在首轮渲染时创建它的闭包引用的就是首轮渲染的那些变量。即使组件重新渲染了多次这个 effect 内部的 count、props 等变量还是老样子。解决方案通常有两种。如果依赖值本身应该变化就把它放进依赖数组effect 会随值变化重新执行闭包也更新到当前渲染。如果依赖确实不该变但你要读取最新值可以用 ref 保存一份最新引用const countRef useRef(count) useEffect(() { countRef.current count }, [count]) useEffect(() { const timer setInterval(() { console.log(countRef.current) // 永远是最新值 }, 1000) return () clearInterval(timer) }, [])这种“ref 桥接”的方式在定时器、防抖、长连接等需要稳定回调函数、但回调里又要读取最新状态的场景特别适用。6.3 面试高频题useEffect 与 useLayoutEffect 的区别两者作用域相似执行时机不同。useEffect 在浏览器完成绘制之后异步执行不会阻塞用户看到画面useLayoutEffect 在浏览器执行绘制之前同步执行会阻塞视觉更新。如果你的副作用需要操作 DOM并且操作后的结果用户应该第一时间看到比如测量元素尺寸、修改节点位置就用 useLayoutEffect否则容易出现画面抖动。如果副作用只是发请求、订阅数据这类不直接改变 DOM 同步画面形态的操作useEffect 就好。一个实用判断标准如果在 useEffect 里你读取 layout 后立刻 setState而这会导致闪烁那就换成 useLayoutEffect。由于 useLayoutEffect 在服务端渲染环境会收到警告实际业务里需要谨慎使用。6.4 面试高频题useEffect 里 setState 会不会死循环不会关键看依赖数组。useEffect 里调用 setState 会触发新一轮渲染如果新的渲染结果导致依赖数组中的值发生变化effect 会再次执行从而再次 setState这就是死循环。但如果 setState 设置的值和上次相同React 会进行浅比较后直接跳过渲染如果依赖数组没有值变化就只在挂载后执行一次。最常见的死循环模式有两种。一是每次 setState 都产生新数组/新对象依赖数组又包含这个新对象useEffect(() { setList(prev [...prev, 1]) // 每次渲染都是新数组 }, [list]) // list 是新引用永远不相等死循环二是 effect 依赖函数但函数每次渲染都被重新创建且没有被 useCallback 包裹。解决方式是与 useState 的函数式更新结合让状态更新不依赖旧值快照或者将依赖拆到更细粒度配合 useCallback 稳定函数引用。6.5 初学者最容易踩的坑fetch 请求在 StrictMode 下的双请求很多人开着 StrictMode 开发然后在 useEffect 里 fetch 一次数据结果发现请求发了两遍。这不是框架坏了也不是后端接口写错了是 React 开发模式故意执行的“挂载 → 卸载 → 重新挂载”流程。这里的核心洞察是你的组件必须能承受“重复执行”的考验。如果你用 AbortController 清理了第一个请求那么第二个请求时第一个已经被 cancel 了最后只保留了一个真实完成的数据流。如果没有任何清理逻辑两个请求都会发出去但最终只有最后一个 setState 的结果会生效另外一个请求白白浪费。通常后端不需要做任何改动这只是开发环境现象。但要是在生产环境也看到了双请求重点排查是不是组件被意外重复挂载、路由 key 变化导致组件重挂载。6.6 通过“divRef.current”操作 DOM 的注意事项直接在 useEffect 里通过 ref 操作 DOM 是合法的因为 effect 执行时 DOM 已经挂载。但要注意当依赖数组包含 reactive 值变化导致 effect 重新执行时你操作的是最新 DOM这是目标。如果 ref 绑定的是条件渲染的元素要确保存在性判断否则会抛 null 异常useEffect(() { const node divRef.current if (!node) return node.scrollIntoView({ behavior: smooth }) }, [activeId])更细节的一点是useRef 在重新渲染时始终返回同一个对象所以 effect 里读取 ref.current 不会存在闭包陷阱这使它成为跨渲染周期保存可变数据的可靠容器。6.7 依赖数组里放“函数”和“数组方法引用”的陷阱数组方法引用也是常见隐患。例如const pushFn array.push.bind(array) useEffect(() { /* 想监听 array 的变化 */ }, [array.push]) // 注意array.push 其实是同一个函数引用不会变化array.push 作为原型的引用并不会因为数组内容变化而改变把它放进依赖数组等于“永久不更新”。同理Math.max、Array.prototype.map 这类静态函数引用放进依赖数组基本等于白放。处理数组变化的正确方式是把数组本身或它的长度、内容快照放进依赖数组useEffect(() { // 数组有变化时执行 }, [myArray])如果数组是每次新建的则又回到了“用 useMemo 稳定引用”或“去掉对象依赖”的老话题。6.8 使用“”展示 0 和空数组时的渲染坑这虽然不完全属于 useEffect 话题但做数据请求后渲染列表时特别容易碰到。假如你从接口拿到了 products 数组初始值是 []。如果你写{products.length List items{products} /}当 products 是空数组时products.length 是 0渲染结果就是数字 0页面出现一个丑陋的 0。这属于 JavaScript 逻辑运算符的经典行为。正确写法是{products.length 0 List items{products} /}或者用三元表达式。这个坑和 useEffect 相关的场景是异步数据未返回时 state 是初始值初始值设计不合理导致首帧出现异常 UI。数据请求前把数组初始化为 []初始渲染时它为空数组但要展示“暂无数据”或 loading应该显式判断而不是依赖逻辑运算符的隐式转换。6.9 在 useEffect 中使用“setInterval”的推荐写法定时器是使用 useEffect 的高频场景也是最容易遇到闭包旧值问题的地方。推荐写法是让 effect 依赖定时器需要同步的数据useEffect(() { const timer setInterval(() { setCount(c c 1) // 利用函数式更新读取最新值 }, 1000) return () clearInterval(timer) }, [])这里用 setCount(c c 1) 而不是 setCount(count 1)是因为函数式更新不依赖外部闭包变量所以定时器闭包里也不用关心 count 是否最新React 会传入最新状态作为参数。这是一个非常优雅的解法能绕开闭包陷阱。如果定时器里还要用到外部复杂对象常见做法是结合 ref 维护最新值或者让 effect 依赖该对象值每次变化都重新启动定时器。6.10 为什么 useEffect 的 cleanup 不是在每次渲染后都执行有些人的理解是“render 之后执行 cleanup然后再执行 effect”其实并不完全准确。cleanup 只在以下时机执行组件即将卸载时effect 由于依赖变化即将被下一次 effect 替换时。对于依赖数组为空的 effectcleanup 只在卸载时执行一次。对于不传依赖数组的 effect每次渲染后都会执行 clean up因为每次依赖比较都“变化”所以每个 effect 的生命周期都很短。理解这个时机差异可以避免在 cleanup 里做依赖最新状态的复杂逻辑时感到困惑。6.11 排查 useEffect 内部同步逻辑出错时的“三板斧”出现“数据不对、状态没更新、请求异常多”这类问题我建议按三步来排查。第一打印依赖值和渲染顺序确认 effect 触发时机是否与预期一致。第二步检查是否缺少 cleanup尤其是有事件监听、定时器、请求这类副作用的 effect。第三步检查是否有对象/函数被放在依赖数组中导致引用不稳定。如果还是找不出问题建议把 effect 内部代码临时简化为 console.log逐步扩大范围确定是执行时机的问题还是内部逻辑的问题。调试完再还原。6.12 从 useEffect 延伸到整个 Hooks 体系的思维升级useEffect 只是 React Hooks 体系的一个环节。当你把数据请求抽象成自定义 Hook 时会同时用到 useState、useCallback、useRef、useMemo。一个典型例子是封装 useFetchimport { useState, useEffect } from react function useFetch(url) { const [data, setData] useState(null) const [loading, setLoading] useState(true) const [error, setError] useState(null) useEffect(() { let ignore false const controller new AbortController() setLoading(true) setError(null) fetch(url, { signal: controller.signal }) .then(res res.json()) .then(json { if (!ignore) { setData(json) setLoading(false) } }) .catch(err { if (!ignore err.name ! AbortError) { setError(err.message) setLoading(false) } }) return () { ignore true controller.abort() } }, [url]) return { data, loading, error } }自定义 Hook 是 useEffect 真正发挥价值的地方。项目里把重复的请求、事件订阅、存储同步逻辑抽象成 Hook 后组件的代码密度大幅降低维护成本也下降一个量级。这种思维转换是 React 函数组件进阶的核心。7. React 生态中的常见搭配zustand、路由、图表场景下的 useEffect7.1 zustand 等状态管理库出现后useEffect 的职责边界在哪前端状态管理工具zustand、Redux Toolkit、Jotai 等解决的是“跨组件共享状态”的问题useEffect 解决的是“渲染完成后同步外部系统”的问题。两件事有明显边界。在只属于单个组件的 UI 状态表单输入、折叠开关上用 useState 配合 useEffect 可能就够在全局共享、跨页面一致的状态上比如用户信息、购物车数据、主题配置用 zustand 等状态库会让状态流更清晰也减少了 useEffect 里手动同步的负担。有一点值得注意状态管理库本身也会受 useEffect 二次执行的影响。比如你在 useEffect 里调用了 zustand 的 setState 方法StrictMode 下会执行两次action 也会被调用两次。如果 action 里有发请求请求会发两遍。这里不是 zustand 的问题而是 effect 的清理模式决定的action 没有“可撤销”的语义所以重复执行的 effect 对它的副作用是无法自动抵消的。解决方式还是要么在 effect 内部做幂等处理要么把 effect 的重复执行检查逻辑前置。7.2 大屏可视化项目中的 resize、数据轮询、初始化参数计算在大屏项目中useEffect 最常见的三个用途是监听窗口 resize 并计算缩放比例、建立定时器轮询数据接口、根据路由参数初始化图表。最后一个尤其容易踩坑图表组件在拿到数据前可能要先用默认数据渲染一帧然后 useEffect 请求数据setState再渲染。如果数据处理逻辑复杂可能会有一段明显的空白或者旧数据闪烁。常规的优化思路是把大屏的初始尺寸计算放在 useLayoutEffect 里因为页面首帧渲染前就需要确定视口尺寸。响应式缩放公式比如const scale Math.min(clientWidth / designWidth, clientHeight / designHeight)只有在拿到真实的 DOM 尺寸后才能计算 scale这个测量必须在绘制之前或完成之后进行。如果在 useEffect 里做首帧会按错误比例渲染然后调整视觉上表现为“抖一下”。换到 useLayoutEffect计算会在浏览器绘制前完成用户就不会看到错误比例了。7.3 服务端渲染Next.js环境下 useEffect 不能做什么在 Next.js 这类 SSR 框架中useEffect 只在客户端执行服务端渲染阶段不会执行。这意味着第一次 HTML 输出不会有任何 effect 副作用的数据。如果你用 useEffect 发请求填充数据首屏 HTML 里展示的是 loading 状态然后在客户端执行请求再 hydrate。这就会带来两个问题一是 SEO 对内容不友好二是用户看到首帧 loading。正确做法是尽量把数据请求放到服务端getServerSideProps 或 RSC 的 async 组件让首屏 HTML 直接包含完整内容。useEffect 只负责那些真正发生在浏览器上的副作用——监听交互、连接 WebSocket、处理滚动位置等。这意味着你在 Next.js 项目里不能依赖 useEffect 来展示最重要的业务数据。它应该是“锦上添花”的浏览器能力增强而不是数据核心链路。这一点和纯 Vite React 的客户端渲染项目有很大不同。7.4 useEffect 主要实战场景速查简单统计一下我在实际项目里用到 useEffect 的场景数据请求、轮询、基于请求的派生数据更新事件监听resize、scroll、keydown、mousemove定时器 / 防抖 / 节流订阅外部数据源WebSocket、浏览器 API、第三方 SDK操作 DOM设置焦点、测量尺寸、动画同步 localStorage / sessionStorage与第三方图表库的生命周期联动在 React Native 中初始化原生模块、监听 AppState 变化这些场景都有一个共性组件必须在渲染完成后与外部世界建立连接然后在失效时断开连接。useEffect 就是 React 给出的统一窗口。8. 我的实操体会把 useEffect 当成“渲染的延伸”而不是“事件的回调”我最后想分享一点个人的实操体会。刚开始写 Hook 时我也把 useEffect 当成 class 时代生命周期和 Vue watch 的混合体结果就是依赖数组越写越乱各种闭包坑踩到怀疑人生。后来我转变了心智模型把 useEffect 当成“渲染的延伸”——它是渲染过程的一部分只是发生在浏览器完成绘制之后。有了这个认知很多问题就变得自然了。为什么依赖数组里不该放对象因为渲染的产出UI 描述每次都是全新的但依赖的“变与不变”必须能稳定判断所以应该放原始值。为什么清理函数这么重要因为每一轮渲染本身就是一次“连接”和“断开”的过程上一次的连接不清理下一次的连接就会叠加。为什么请求竞态必须处理因为渲染延伸出去的异步操作一定要在延伸结束unmount 或依赖更新时被“回收”。在实际编码时我还有一个习惯每写一个 useEffect先问自己两个问题。第一个如果这个 effect 在 StrictMode 下执行两次会不会出问题第二个如果组件在 effect 执行完之前就卸载会发生什么这两个问题能过滤掉大部分副作用相关的 bug。第二个问题的答案如果是“可能报错”那就说明缺了清理逻辑。另外还想提一个写代码时的小建议effect 内部的逻辑尽量保持“小而专一”。如果一个 effect 里既更新标题、又发请求、还做了表单校验一旦出问题很难定位是谁导致的。拆成多个 effect每个只负责一件独立的事排查成本会低很多。React 本身也允许你在一个组件里写多个 useEffect它们会按声明顺序执行所以不必担心拆开后顺序错乱。这个内容后续还可以继续往自定义 Hook 的方向扩展——把 useEffect、useCallback、useMemo 组合起来封装成项目里的通用数据获取、防抖、订阅等逻辑。掌握单个 Hook 只是第一步真正能提升开发效率的是学会把它们像积木一样搭起来。这也是函数组件从“能写”到“写得好”的分水岭。