搞定useEffect依赖优化:告别频繁执行bug,掌握性能优化正确姿势

搞定useEffect依赖优化:告别频繁执行bug,掌握性能优化正确姿势 一

文章目录CloseOpen

依赖数组的底层逻辑:为什么你总是踩坑?

很多人觉得依赖数组就是“放变量消除警告”的工具,这其实是把因果关系搞反了。React设计依赖数组的核心目的,是让你告诉它:“这些变量变了,我的副作用逻辑才需要重新执行”。要是没搞懂它的底层逻辑,踩坑就是必然的。

依赖数组到底在比较什么?

你可能不知道,React检查依赖是否变化时,用的是“浅比较”(===)——基本类型比“值”,引用类型比“地址”。举个例子:如果依赖是数字count,只要count的值没变(比如从2变成2),React就认为依赖没变;但如果依赖是对象{name: '张三'},哪怕两次对象的内容一模一样,只要引用地址变了(比如每次渲染都创建新对象),React就会判定“依赖变了”,触发effect重新执行。

我之前带过一个实习生,他写用户信息组件时,effect里要根据用户对象user更新头像。他把user直接放进依赖数组,结果用户输入框每输一个字,user对象就重新创建(引用变了),effect跟着执行,头像组件疯狂重渲染,输入都卡顿。后来我让他改用user.id作为依赖(基本类型,值不变引用就不变),问题立马解决。所以你看,搞懂“比较规则”,很多问题根本不会发生。

最容易踩的三个依赖陷阱

我梳理了自己和团队遇到的高频问题,发现大家踩坑基本逃不出这三种情况:

第一个陷阱:“漏写依赖”——逻辑“装死”不执行

你在effect里用了setData更新数据,但依赖数组只放了id,没放setData。表面看setData是稳定的(useState返回的函数引用不变),好像没问题。但如果setData是通过props从父组件传下来的呢?万一父组件重渲染时传了新的setData函数,你的effect却因为没依赖它,导致新的setData永远不会被执行。我之前做一个数据看板时就踩过这个坑,父组件传的onDataChange变了,子组件effect没更新,数据同步完全失效,排查时还以为是接口问题,绕了好大一圈。

第二个陷阱:“依赖过多”——组件“多动症”疯狂执行

这是新手最容易犯的错:看到警告就把所有变量都塞进依赖数组。比如effect里只需要用userId发请求,但你把user对象、handleClick函数全加进去了。结果user对象只要有一个字段变(比如用户改了昵称),哪怕userId没变,effect也会重新发请求,不仅浪费性能,还可能导致数据错乱。我见过最夸张的案例,一个组件因为依赖数组里放了整个props对象,父组件每次重渲染,它就跟着执行effect,一天下来接口请求量翻了10倍。

第三个陷阱:“引用类型依赖”——明明内容没变,却总被“误判”

对象、数组、函数这些引用类型,是依赖数组的“隐形杀手”。比如你在组件里定义了一个函数formatData,然后放进effect依赖:

const formatData = (list) => list.map(item => ({...item, time: new Date(item.time)}));

useEffect(() => {

const result = formatData(data);

setFormattedData(result);

}, [data, formatData]); // 每次渲染formatData都是新函数,effect必执行

这里formatData每次组件渲染都会重新创建(引用变了),哪怕data没变,effect也会执行。这种“无效执行”多了,组件性能肯定垮掉。

从“消除警告”到“性能最优”:三步优化法

知道了问题在哪,优化就有方向了。我 了一套“三步优化法”,从“能跑”到“跑好”,亲测在项目里有效,你可以直接套用。

第一步:精准设置依赖——先做“加法”再做“减法”

很多人写依赖数组时喜欢“凭感觉加”,其实最稳妥的办法是“先全加进去,再逐步精简”。

先做“加法”:列出所有“动态变量”

你可以把effect函数里用到的所有变量列出来,不管是state、props,还是组件内定义的变量,只要它“可能变化”,就先放进依赖数组。React官方文档里明确说过:“依赖数组应该包含effect中使用的、并且可能随时间变化的所有变量”(React官方文档)。刚开始可能会觉得依赖很多,但这是保证逻辑正确的基础——我自己写effect时,都会先这么做,哪怕依赖数组很长,先确保“该执行的时候一定执行”。

再做“减法”:剔除“稳定不变”的依赖

加完依赖后,你可以逐个检查:这个变量真的会变吗?比如:

  • useState返回的set函数(如setCount):引用永远不变,可以从依赖中移除;
  • 组件外定义的常量或函数:比如const API_URL = 'xxx',不会随渲染变化,也可以移除;
  • 用useRef保存的值:ref.current变化不会触发重渲染,effect依赖里不用加ref本身,要用ref.current的话需要其他处理(比如配合useEffect监听ref变化)。
  • 举个例子,之前我写一个列表加载effect:

    // 优化前:依赖漏了page,导致翻页后不加载数据
    

    useEffect(() => {

    fetchData({ type, page }).then(setList);

    }, [type]); // 漏了page依赖

    // 优化后:先加全依赖,再剔除稳定变量

    const fetchData = useCallback((params) => api.get('/list', params), []); // 用useCallback缓存函数

    useEffect(() => {

    fetchData({ type, page }).then(setList);

    }, [type, page, fetchData]); // 先加全依赖,fetchData是稳定的(useCallback缓存),实际依赖是type和page

    先加全依赖保证逻辑正确,再剔除稳定变量减少执行次数,这是最安全的优化路径。

    第二步:处理复杂依赖——让对象和函数“稳定”下来

    遇到对象、数组、函数这些引用类型依赖时,直接放进数组肯定出问题。这时候你需要用“稳定化”技巧,让它们的引用保持不变。

    对象/数组依赖:用useMemo“缓存”引用

    如果effect里依赖的是对象或数组,你可以用useMemo缓存它们,只有内容变了才更新引用。比如之前那个用户信息组件的例子:

    // 优化前:依赖user对象,引用变化导致effect频繁执行
    

    useEffect(() => {

    updateAvatar(user.avatar);

    }, [user]); // user引用变,effect就执行

    // 优化后:用useMemo缓存关键字段,只依赖必要值

    const userId = user.id;

    const userAvatar = useMemo(() => user.avatar, [user.id, user.avatar]); // 只有id或avatar变,才更新引用

    useEffect(() => {

    updateAvatar(userAvatar);

    }, [userAvatar]); // 依赖稳定的userAvatar,只在头像变时执行

    这里用useMemo缓存user.avatar,依赖user.iduser.avatar——只有用户ID变了(用户切换)或头像变了,userAvatar的引用才会更新,effect才执行,比直接依赖整个user对象精准多了。

    函数依赖:两种方案选其一

    如果effect里依赖函数,有两种处理方式:

  • 方案1:用useCallback缓存函数:如果函数需要在组件外使用(比如传给子组件),用useCallback缓存它,依赖数组填函数内部用到的变量。
  • 方案2:把函数定义在effect内部:如果函数只在effect里用,直接定义在effect里,就不用加进依赖了。
  • 我在项目里更推荐方案2,代码更简洁。比如之前的formatData函数:

    // 优化前:函数定义在组件内,依赖导致effect频繁执行
    

    const formatData = (list) => list.map(...);

    useEffect(() => {

    const result = formatData(data);

    setFormattedData(result);

    }, [data, formatData]);

    // 优化后:函数定义在effect内部,无需依赖

    useEffect(() => {

    // 函数只在effect内部用,直接定义在这里

    const formatData = (list) => list.map(...);

    const result = formatData(data);

    setFormattedData(result);

    }, [data]); // 只依赖data,逻辑更清晰

    第三步:用useCallback/useMemo“助攻”——但别过度优化

    提到依赖优化,很多人会说“用useCallback和useMemo啊”,但这两个Hooks不是银弹,用多了反而增加代码复杂度。我 了一个“使用原则”:只有当变量作为effect依赖,或作为props传给子组件时,才需要缓存

    比如子组件是纯组件(React.memo包装),你传一个函数作为props,这时候就需要用useCallback缓存函数,否则子组件会因为函数引用变化频繁重渲染。但如果函数只在当前组件内部用,而且不作为effect依赖,就完全没必要用useCallback。

    我之前见过有同事把所有函数都用useCallback包起来,说“这样性能好”,结果代码里全是useCallback,可读性差了一大截,其实很多都是无效优化。记住:优化的目的是让代码跑得更好,不是为了用更多API

    为了让你更直观地理解不同场景的优化方法,我整理了一个对比表,都是项目里常见的情况,你可以参考着改:

    场景 优化前代码 优化后代码 优化效果
    对象依赖 useEffect(() => {
    update(user);
    }, [user]);
    const {id, name} = user;
    useEffect(() => {
    update({id, name});
    }, [id, name]);
    只在id/name变化时执行,避免对象引用变化导致的无效执行
    函数依赖(内部使用) const handle = () => { … };
    useEffect(() => {
    handle();
    }, [handle]);
    useEffect(() => {
    const handle = () => { … };
    handle();
    }, []);
    函数定义在effect内,无需依赖,代码更简洁
    子组件props依赖 const onClick = () => { … };
    return ;
    const onClick = useCallback(() => { … }, [deps]);
    return ;
    子组件(React.memo包装)不会因函数引用变化重渲染

    最后想跟你说,依赖优化不是一蹴而就的,我自己也是踩了无数坑才 出这些方法。刚开始你可能会觉得麻烦,但写多了就会形成肌肉记忆——看到effect就下意识检查依赖,遇到引用类型就想怎么稳定化。如果你按这些方法优化了useEffect,遇到了新问题或者有更好的技巧,欢迎在评论区告诉我,我们一起讨论进步!


    你有没有遇到过这种情况:写了个用户信息组件,effect里要根据用户对象更新头像,你把整个user对象放进依赖数组,结果用户在输入框里每敲一个字,头像组件就跟着闪一下?我之前带的实习生就踩过这个坑,当时他百思不得其解:“用户对象里的头像字段明明没动啊,怎么effect老执行?”其实问题就出在React对依赖的“比较方式”上——它看的不是对象里的数据长得一不一样,而是这个对象在内存里的“门牌号”变没变。

    你想啊,基本类型比如数字、字符串,比较的时候就是比“值”,比如count从5变成5,React就觉得“没变化”;但对象、数组这种引用类型不一样,每次渲染时就算内容完全相同,只要你重新写了{name: '张三'},它在内存里就是个新对象,“门牌号”变了,React就会判定“依赖变了”,effect自然就得重新跑。就像你每次搬家都换个新地址,哪怕家里东西一模一样,快递员也得按新地址重新送一趟。那个实习生后来把依赖改成user.id就好了——user.id是数字,值不变“门牌号”就不变,effect自然安分了。

    那遇到对象依赖该怎么办呢?我 了两个最实用的办法。第一个是“拆”,把对象里真正会影响逻辑的基本类型属性抽出来当依赖,比如刚才说的user.id,或者user.avatarUrl,这样就算对象整体变了,只要关键属性没变,effect就不会瞎执行。第二个是“存”,用useMemo把对象“存”起来,告诉React“只有这些字段变了,才算这个对象真的变了”。比如你可以写const userInfo = useMemo(() => ({id: user.id, avatar: user.avatar}), [user.id, user.avatar]),这样只有idavatar变了,userInfo的“门牌号”才会变,effect依赖它就稳当了。我自己在项目里更常用第一种,毕竟少写几行代码总是好的,你也可以试试哪种更适合你的场景。


    依赖数组为空([])时,effect会执行几次?

    当依赖数组为空时,effect只会在组件首次挂载后执行一次,且后续组件重渲染时不会再次执行,类似class组件的componentDidMount。这是因为空依赖表示“没有任何变量需要监听变化”,所以React认为副作用逻辑只需执行一次初始化操作。

    为什么依赖数组里放了对象,effect会频繁执行?

    React对依赖数组的比较是“浅比较”(===),对象属于引用类型,比较的是内存地址而非内容。如果每次渲染时对象都是新创建的(即使内容相同,地址不同),React会判定“依赖变化”并触发effect。解决方法是:提取对象中的基本类型属性(如user.id)作为依赖,或用useMemo缓存对象引用(仅在内容变化时更新地址)。

    函数必须放进useEffect的依赖数组吗?

    不一定。如果函数定义在effect内部(仅在effect中使用),无需放进依赖数组;如果函数定义在组件顶层且被effect使用,通常需要放进依赖数组。若函数引用不稳定(如每次渲染创建新函数),可通过useCallback缓存函数引用,避免effect无效执行。

    可以直接用// eslint-disable-next-line忽略依赖警告吗?

    不 依赖警告本质是React帮你检查“可能遗漏的依赖”,忽略警告可能导致逻辑错误(如依赖变化时effect不执行)。 若effect中用了state却没放进依赖,当state变化时effect不会重新执行,可能导致数据不一致。正确做法是排查依赖是否完整,或通过提取基本类型、缓存引用等方式解决警告。

    0
    显示验证码
    没有账号?注册  忘记密码?