拿useEffect当“刷新键”来用之前,先想清楚这其实是一张通向React外部世界的门票。很多同学第一次接触这个Hook的时候,都会觉得它像周公解梦一样神奇:“我明明没写任何调用逻辑,怎么页面一变,这个函数就自己跑了?”这种“自动刷新世界”的体验确实让人上头,但等你在生产环境被它坑过几次之后就会发现,useEffect真正的名字应该叫“副作用管理”,它把所有和React渲染无关的活都接了下来:发请求、订阅事件、操作DOM、埋点上报、启动定时器。这篇文章我会用多年踩坑换来的经验,把这个Hook从原理到实战彻底讲透,适合刚学完React基础但每次写useEffect都没底的同学,也适合已经写了两三年React却总是被依赖数组折磨的资深开发。
1. 内容整体设计与思路拆解
1.1 useEffect到底解决的是什么问题
React组件的核心职责是“根据状态渲染UI”,但真实的页面永远不只有UI。你要在组件挂载后去后端拉一次列表数据,你要在某个状态变化时给第三方统计平台发一条埋点,你要在弹窗打开时给document注册一个键盘监听——这些操作有一个共同点:它们都不能直接写在渲染过程里,因为React的渲染函数必须是纯函数,一旦在里面偷偷改了状态或者碰了浏览器API,轻则触发额外的重复渲染,重则直接把整个应用跑崩。
useEffect就是React官方给出来的、专门承载这些“非渲染工作”的通道。它接受两个参数:第一个是effect回调函数,里面写你要执行的副作用逻辑;第二个是依赖数组deps,告诉React“什么时候该重新执行这个回调”。这两个参数组合起来,就让React有了一个可以精确控制的“刷新”机制:每当依赖数组里的某个值发生变化,React就会在DOM更新完成之后,重新执行一遍回调函数。
这里的关键认知是:useEffect不是用来“响应状态变化”的函数,而是用来“同步外部系统”的通道。如果你的逻辑只是“根据A状态计算B状态”,那你应该用useMemo或者直接在渲染期计算,而不是塞进useEffect——我曾经见过一个同事把所有状态联动都写成useEffect,结果一个页面挂了十多个effect,每次数据变化都经过好几轮“更新-触发effect-再更新”的循环,最后性能惨不忍睹,代码也没法维护。
1.2 为什么说依赖数组才是真正的“刷新开关”
很多人把useEffect当成“每次渲染都跑一遍的东西”,这其实只对了一半。默认情况下,也就是第二个参数完全不传的时候,useEffect确实会在组件每次渲染完成后都执行。但实战中绝大多数场景都不需要这么高的频率,所以你需要在依赖数组里告诉React:只在这些东西变了才跑。
打个比方:你家里养了一条特别警觉的看门狗,如果它听到任何动静都叫(每次渲染都执行),你一整晚都别想睡。依赖数组就像是给你这条狗设定“只在敲门声响起时叫”的训练规则。组件挂载后首次渲染会执行一次(相当于狗第一天上班,先熟悉一下环境),之后只有当依赖数组里的项发生变化时,effect才会再次触发。传一个空数组[]则表示“只在挂载时跑一次,之后什么都不管了”。
但这里有个新手最容易踩的坑:依赖数组的比较是浅比较,用的是Object.is。如果你把一个对象字面量写进依赖数组,比如{ id: 1 },那React每次渲染拿到的都是一个新的引用,Object.is判断两个对象不相等,effect就会无限循环。这个问题我后面会用一整节来讲,因为它真的是生产环境最常见的线上事故来源。
1.3 从组件生命周期模型到状态同步模型
React 16.8之前,老开发者习惯了三个生命周期方法:componentDidMount、componentDidUpdate、componentWillUnmount。useEffect把这三个方法的能力统一到了一个API里:通过依赖数组控制执行时机,通过在effect里返回清理函数对应componentWillUnmount。
我刚开始带团队的时候,总有人问我:“那useEffect到底对应挂载、更新还是卸载?”我的回答是:它一个都不对应,它对标的是“与外部世界的同步关系”。你想想,一次effect完整执行包含两个阶段:跑副作用逻辑 + 执行上一次遗留的清理函数。这就意味着,React在渲染结束后不是简单地说“该刷新了”,而是先“打扫战场”再“布置新战场”。理解了这个模型,你才能写出正确的取消请求、移除监听、清除定时器的代码。
2. 核心细节解析与实操要点
2.1 依赖数组的五种写法,每一种都对应一种业务
依赖数组的写法,表面上只有“传/不传/传空”三种情况,但实际组合起来至少有五种常见用法,我把它们整理成一张表:
| 写法 | 执行时机 | 典型场景 |
|---|---|---|
| 不传第二个参数 | 每次渲染后都执行 | 几乎不使用,了解即可 |
[] | 挂载完成后执行一次 | 初始化拉取列表、注册全局监听 |
[a, b] | a或b变化时执行 | 输入联动、筛选条件变化后重新请求 |
| 依赖项是props里的函数 | props函数引用变化时执行 | 父组件传递给子组件的回调逻辑 |
| 依赖项是ref.current | 通常无效 | 需要读取最新DOM时用回调ref替代 |
第一眼看上去,传一个空数组好像最省事,副作用只跑一次,但你要小心:如果在副作用里引用了props或者state,那它们只会被初始值闭包捕获,以后永远拿不到新值。这就是React社区著名的“闭包陷阱”。
举个实战例子:你有一个详情页,挂载时发请求,请求体里要用到props.userId。如果依赖数组写成[],那所有逻辑里用到的userId都是第一次渲染时的旧值。后来用户切换了账号,页面组件没有重新挂载,effect也不会再执行,界面上的数据永远是上一个用户的。这种情况正确写法是把props.userId放进依赖数组。如果你确实只想在挂载时执行一次,又需要用到最新值,那就得借助useRef来绕过闭包问题——这个方案后面会细讲。
2.2 清理函数的三类用途:监听、定时器、异步请求
React官网有一个关键词叫cleanup,很多人直接忽略它,觉得useEffect能跑就行。但在真实项目中,不写清理函数的后果通常是:内存泄漏、事件重复触发、请求结果竞态。
第一类是事件监听。弹窗组件挂载时给document绑定了keydown,如果关闭时不解除监听,每开一次弹窗就多绑一个监听器,键盘事件就会触发N次逻辑,而且用户关掉弹窗后按方向键,页面还能莫名其妙地滚动。清理函数里必须写document.removeEventListener,而且要确保传入的处理器函数是同一个引用——这就是为什么我建议你在effect内部定义函数,而不要在外面定义再引用,容易因为闭包不同导致解绑失败。
第二类是定时器。搜索框防抖是最经典的例子:用户连续输入时,每次按键都会清掉上一个定时器并重新计时。如果不清理,上一次定时器到期后照样会发出请求,你就看到一连串没用的网络请求发出去。在清理函数里clearTimeout,才能保证只有最后一次停留满300毫秒的输入才会真正触发请求逻辑。
第三类是异步请求的竞态。这是最阴间的坑之一:你发了一个请求,数据还没回来,用户又切换了筛选条件,第二次请求发出去了,结果第一次请求先返回,把第二次的结果覆盖掉了。解决方法是每次effect执行时,用一个let cancelled = false标记,在清理函数里把它设为true,然后异步回调拿到数据后先判断这个标记,如果已经取消就直接丢弃结果。
useEffect(() => { let cancelled = false; fetch(`/api/list?page=${page}`) .then(res => res.json()) .then(data => { if (!cancelled) { setList(data); } }); return () => { cancelled = true; }; }, [page]);这段代码看起来平平无奇,但它真正实现了“只接受最新一次请求的结果”。我把它称为“卑微的取消标记”,因为JavaScript里的fetch根本没有真正的取消机制,AbortController虽然可以中断请求,但很多接口和中间层处理得并不好,反而是这种标记法在各种场景下都非常稳。
2.3 为什么在effect里修改外部变量常常无效
新手经常写这样的代码:在useEffect里给一个全局变量赋值,或者修改一个let声明的局部变量,然后发现UI根本没有变。原因很简单:useEffect里做的操作,React并不可知。你改了局部的let temp = 0,React不知道;你改了全局数组window.list.push(item),React也不知道。只有通过setState触发的新渲染,React才能看到变化。
这不是useEffect的问题,而是React数据流方向的问题。React希望你始终用“状态驱动”的思维:先把值放到state里,然后React负责重新渲染,渲染结果里所有东西都从state派生。如果直接把值塞进useEffect,你就绕过了React的调度系统,副作用成功执行了,UI却不会响应。
我自己的经验法则是:能在渲染期算出来的,就不放useEffect;非要在effect里做的,一定要以setState收尾。比如根据一个搜索参数去请求数据,请求结果必须放进state;但如果你只是想根据参数拼接一个跳转链接,直接在渲染里算就行,完全没有必要引入effect。
3. 实操过程与核心环节实现
3.1 第一个能跑的完整流程:一个带防抖的搜索页面
说再多理论,不如直接写一个项目里最常见的例子:搜索框实时请求。我把步骤拆给你看,每一步我会标注“为什么”。
第一步,先定义搜索关键字state和一个保存结果的列表state:
function SearchBox() { const [keyword, setKeyword] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false);第二步,写useEffect,依赖数组放keyword。这里如果直接放keyword,用户每敲一个字母就会发一次请求,很浪费。所以我先给关键字做一个300毫秒的防抖处理。这一步我通常用useRef配合useState,而不是简单地在effect里setTimeout,因为单独写定时器的话,每次渲染都会创建新的闭包环境,特别容易乱:
const [debouncedKeyword, setDebouncedKeyword] = useState(keyword); useEffect(() => { const timer = setTimeout(() => { setDebouncedKeyword(keyword); }, 300); return () => clearTimeout(timer); }, [keyword]);第三步是真正的请求effect,依赖数组放debouncedKeyword。这个设计保证了只有用户停止输入300毫秒之后才会发出请求,而且effect内部通过清理函数解决了竞态问题:
useEffect(() => { if (!debouncedKeyword.trim()) { setResults([]); return; } let cancelled = false; setLoading(true); fetch(`/api/search?q=${encodeURIComponent(debouncedKeyword)}`) .then(res => res.json()) .then(data => { if (!cancelled) { setResults(data.list); } }) .finally(() => { if (!cancelled) { setLoading(false); } }); return () => { cancelled = true; }; }, [debouncedKeyword]);看到没有,两个useEffect各司其职,一个负责搬砖,一个负责砌墙。第一个effect把原始的keyword降频成debouncedKeyword,第二个effect只在降频后的值变化时才真正发请求。这种写法在团队代码评审的时候很容易过,因为逻辑边界非常清晰。
3.2 effect执行顺序与生命周期节点的对应关系
使用React 18以后,严格模式默认会在开发环境里让effect执行两次(挂载→清理→再挂载)。第一次见到这个现象的同学们不要慌,这不是bug,这是React故意暴露潜在问题的机制。它逼迫你把清理函数写得足够健壮:如果你的effect秒挂秒清之后还能正常运行,那说明代码没有泄漏。很多没经验的同学在这里被吓到,以为是自己setState死循环,结果排查半天发现问题出在“开发环境双调用”上。
生产环境里effect的执行顺序是固定的:组件渲染完成后,React会按照useEffect在代码中出现的顺序依次执行;每个effect的清理函数则会在下一次effect执行之前统一清理一遍。换句话说,React不是“边执行边清理”,而是“先把上一轮的旧账全部结清,再开新一轮”。这个概念很重要,因为如果你在effect A里修改了effect B依赖的值,那B会在A跑完后的这一轮正常触发,但如果你在effect A的清理函数里修改了B依赖的值,就可能引发一些难以察觉的连锁反应。
3.3 依赖数组里放函数时的常见处理方案
在React里写函数组件久了,你会发现一个尴尬的问题:父组件传给子组件的函数,如果每次渲染都重新定义,那么子组件里useEffect只要依赖了这个函数,就会在父组件每次渲染后都重新执行。解决办法有几种,我按推荐程度排序:
第一,如果函数只是父组件内部用的,就不应该传进useEffect的依赖数组,你可以在effect内部直接调用,不需要放到依赖里,只要确保effect内部用到的props和state都列全就行。不过我一般会避免这个做法,因为ESLint插件会强烈报警告。
第二,使用useCallback包裹函数,让它在依赖不变的条件下保持引用稳定。比如父组件有个handleSearch函数,里面只依赖pageSize这个state,那你用useCallback(() => { ... }, [pageSize])包一圈,子组件useEffect把handleSearch放进依赖数组,就可以做到只有pageSize变化时才重新触发。
第三,如果函数真的足够“纯”,也就是不依赖任何可变值,那可以直接在组件外部定义,全局只有一份引用,放进依赖数组永远不会造成额外触发。
真实项目中,我最常用的是useCallback方案。虽然多写几行字,但它在避免无效effect触发的同时,也让子组件可以放心地用React.memo做渲染优化。如果你的代码里到处都是“每次渲染都重新创建的函数”,那别说useEffect了,整个应用的性能都要打个问号。
4. 常见问题与排查技巧实录
4.1 无限循环:依赖数组里的对象引用陷阱
这是我在各个技术群里回答次数最多的问题:“为什么我的useEffect疯狂请求接口?”十次有八次,是依赖数组里放了对象或数组。
举个例子:
useEffect(() => { fetchData({ filters: filters }); }, [filters]);如果filters来自props或者useState,它本身可能是个稳定引用,那没问题。但如果你写的是{ name: keyword }这种字面量,React每次渲染都创建一个全新的对象,Object.is比较永远不相等,effect就会无限循环。很多人一开始想不通:值明明一样啊,为什么React说它变了?
原因就是引用相等性。两个对象哪怕内部的每一个属性都相同,只要不是同一个引用,Object.is就返回false。我在排查这个问题的时候,会先让同事把依赖数组里的对象拆开,改成原始类型的属性,比如[filters.name, filters.date],这样引用比较就变成值比较,循环立刻停止。如果对象本身确实需要整体传递,就考虑用useMemo把它缓存起来,保证依赖不变时返回同一个引用。
4.2 闭包陷阱:为什么effect里拿到的state总是旧值
闭包陷阱是另一个高频问题。核心场景是:在useEffect里实现一个定时器,每秒打印一次某个state的值,结果发现打印出来的永远是初始值。看起来像是useEffect没有被刷新,实际上是你创建定时器时闭包捕获了那一轮的state,而定时器执行的时候用的还是旧闭包。
解决方法有三种,我按推荐顺序列出:
用useRef保存最新值。把state放到ref里,每次渲染时用effect把它同步一下,然后定时器逻辑里读ref.current,永远拿到最新值。
const countRef = useRef(0); countRef.current = count; // 这一行必须在组件渲染期执行 useEffect(() => { const timer = setInterval(() => { console.log(countRef.current); }, 1000); return () => clearInterval(timer); }, []);把state放进依赖数组并重新创建定时器。这样每次count变化都会重建定时器,虽然逻辑简单,但如果定时器中断/重启成本高,就不太合适。
用useReducer把逻辑封装起来,让 reducer根据action计算新状态,避免在effect内部读state。
我个人最常用第一种。注意countRef.current = count要在渲染期执行,而不是在effect里执行,因为effect执行时机比较晚,可能在定时器已经启动之后才会更新ref,导致第一轮打印的还是旧值。
4.3 如何在effect里只取最新值但不想每次重新执行
这个问题很多人绕了很久。需求往往是:因为某个副作用太昂贵,我不想让它在某个state变化时重新执行,但我又必须在定时器或者事件回调里拿到这个state的最新值。
答案就是useRef方案:渲染期把state同步给ref,effect的依赖数组保持为空,回调里只读ref。用这种方式,你既享受了“只跑一次”的轻量,又不会遇到闭包陷阱。
但这里有个重要前提:ref的同步必须在渲染期完成。如果你在useEffect里赋值countRef.current = count,那么这个赋值发生在DOM提交之后,而你的定时器如果已经在useEffect里启动了,那一轮定时器读取ref时,ref还没来得及更新。所以我总是把ref同步放在组件函数体的第一层,也就是和state声明并列的位置,而不是塞在某个effect里。
4.4 ESLint的exhaustive-deps规则要不要关掉
这个话题我在团队里没有少吵过。React官方提供的react-hooks/exhaustive-deps插件,强制要求effect里用到的变量全部出现在依赖数组里。很多开发者嫌它烦,直接关掉或者把数组写成[]硬扛。
我的建议非常简单:不要关,但也不要无脑满足它。这条规则本质是让你正视effect和外部变量之间的关系。如果你把某个变量写进effect却故意不放依赖数组,ESLint会提醒你,这时候你该反思的是“我为什么要在effect里读一个我不打算响应它的值”。如果确实需要这么干,方案是改成ref,或者用官方的useEvent(React 19里有相关调整)思路来解决。如果你只是靠注释// eslint-disable-line压掉警告,那其实就是埋了一颗地雷,等到哪天需求变更、代码路径变了,闭包陷阱就爆了。
5. 场景化应用与扩展实战
5.1 路由参数变化时重新拉详情数据
页面详情是useEffect最典型的应用场景之一。你有一个/user/:id路由,用户从列表页点进来,组件挂载后按id请求详情。用户如果继续点击“下一个用户”,组件不会重新挂载,但路由参数变了,这时候你的useEffect必须订阅这个变化。
关键点是:依赖数组里不能只写props.match.params.id就完事了,你要想清楚请求的完整链路。如果详情数据里还依赖一个locale,那它也得进依赖数组。否则用户在切换语言时不会重新请求,展示的还是旧语言的数据。
useEffect(() => { setLoading(true); fetchUser(id).then(user => { setUser(user); }).finally(() => setLoading(false)); }, [id, locale]);这里有一个我踩过很多次的细节:不要在useEffect里把setLoading(false)写在then外面,否则请求失败时loading状态可能卡在true。要放在finally里,并且用清理标记保护setState调用。
5.2 自定义Hook封装:把逻辑喂给任意组件
useEffect最强大的用法是作为自定义Hook的底层零件。比如我把“请求列表”这个通用逻辑抽成useFetchList:
function useFetchList(url, params) { const [data, setData] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); fetch(`${url}?${new URLSearchParams(params)}`) .then(res => res.json()) .then(result => { if (!cancelled) { setData(result); setError(null); } }) .catch(err => { if (!cancelled) { setError(err); } }) .finally(() => { if (!cancelled) { setLoading(false); } }); return () => { cancelled = true; }; }, [url, JSON.stringify(params)]); // 注意:url和params的引用需要稳定,一般由调用方用useMemo/useCallback保证 return { data, loading, error }; }这里面的细节值得多说一句:依赖数组里我故意用了JSON.stringify(params),这样对象属性值没变时,effect不会重新触发。但这个方法有明显缺点:JSON.stringify本身有性能开销,且对象属性顺序变化也会造成误判。生产环境我更推荐用useMemo对外部传入的params做缓存,不到万不得已不考虑字符串化方案。
5.3 和useLayoutEffect的边界划分
很多开发者知道有useLayoutEffect,但从来分不清什么时候该用哪个。一句话说明白:useEffect在DOM变更后异步执行,用户在浏览器绘制内容之前看不到它;useLayoutEffect则是在DOM变更后同步执行,可以拿到真实的布局信息后再动手,但会阻塞浏览器绘制。
实战选择标准:如果你需要读取DOM几何属性,比如元素宽高,或者需要同步调整DOM位置避免闪烁,用useLayoutEffect。其他情况,包括发请求、设置定时器、订阅事件,一律用useEffect。如果你误把大量计算放到useLayoutEffect里,浏览器会被卡住,用户感受到的就是页面操作极不流畅。
我自己的原则是:默认useEffect,只有明确出现“闪烁”或者“布局抖动”问题时才升级成useLayoutEffect,而且升级前必须先确认问题确实来自effect执行时机,不要凭感觉乱换。
5.4 并发特性里的useEffect:startTransition与useTransition
React 18支持的并发特性对useEffect也有影响。尤其是使用startTransition的时候,非紧急更新会被标记为低优先级,React可以在更新时间片里暂停渲染,此时useEffect的执行时机也会跟着进行调整。在真实业务中,你通常不需要关心这种底层调度差异,但如果你在做列表筛选、搜索联想这种高频更新,把setState包进startTransition可以明显减少卡顿。
一个实操建议:搜索框打字时候,输入框自身的value更新用紧急更新(正常setState),搜索结果的列表更新用startTransition包裹,这样即使结果列表很大,也不会阻塞用户继续打字。此时useEffect依然照常工作,只是React会在有空的时候才执行effect,你不需要在useEffect层做任何改动。
6. 写在最后的几个小习惯
写useEffect的这四年里,我自己沉淀下来几条习惯,算不上什么高深理论,但对避免线上事故特别有用。
第一,每个useEffect只干一件事。如果一段逻辑里又要发请求又要绑定事件又要搞埋点,拆成三个hook,可读性直接上一个台阶。团队代码评审的时候,单元效应比大杂烩好聊得多。
第二,所有异步逻辑里的setState都要加取消保护。哪怕你觉得“这个页面很简单不可能竞态”,也别赌。人脑对异步顺序的判断能力极其有限,加一个cancelled标记的成本几毛钱,不加的代价可能是用户看到旧数据覆盖新数据,然后截图来问你是怎么回事。
第三,依赖数组不是摆设,也不是负担。每次你写useEffect,我都建议默念一遍:“这里依赖了哪些外部值?如果这些值变了,外部系统需要跟着变吗?”想清楚再填数组,比写完代码让ESLint告诉你缺依赖要靠谱得多。
最后再分享一个我刚入行时踩过的坑。有次我做一个管理系统,用户访问页面后要按角色权限渲染菜单,我顺手把权限逻辑写进了useEffect,然后里面又调用了setPermissions。那一次是典型的“在effect里派生state”:依赖数组里放了userId和role,effect里setPermissions,而SetPermissions每次返回新对象,导致又一个effect依赖了这个对象,屏幕疯狂刷新。最后我把权限派生逻辑移到了渲染期的useMemo里,整个链路瞬间清爽。这件事教给我的道理是:useEffect虽然强大,但它不是React世界的“万能刷新键”,周公解梦似的让世界自动更新,只会让代码进入无法预测的漩涡。真正稳妥的方式,是认清哪些内容属于渲染,哪些内容属于副作用,然后把每一段逻辑放进它该待的位置。