useEffect依赖引用类型

2023-12-04
19417 分钟
...

如果你是一个入行不久的前端开发,面试中多半会遇到一个问题:

你认为使用React要注意些什么?

这个问题意在考察你对React的使用深度,因为沉浸式地写过一个项目就会发现,不同于一些替你做决定的框架,“潜规则”丰富的React远比看上去要难相处。
React中主要有两类坑点,一种是让你措手不及,结果对不上预期,严重影响开发进度,另一种更为头痛,表面风平浪静,水下暗流涌动。
官方文档的触角只伸到Demo级别,并不涉及花样百出的最差实践,所以下一批开发者又会掉入相同的陷阱。隐藏的坑点需要开发者亲自下地扫雷,经验主义发挥了重要作用,尤其是在Hooks使用中。
为了避免更多的心智负担,这个系列的文章会介绍一些React使用的常见陷阱,带你追溯原因和探索解决方案,帮助新手迅速跳过坑点。

const Issue = function () {
  const [count, setCount] = useState(0);
  const [person, setPerson] = useState({ name: 'Alice', age: 15 });
  const [array, setArray] = useState([1, 2, 3]);

  useEffect(() => {
    console.log('Component re-rendered by count');
  }, [count]);

  useEffect(() => {
    console.log('Component re-rendered by person');
  }, [person]);

  useEffect(() => {
    console.log('Component re-rendered by array');
  }, [array]);

  return (
    <div>
      <p>You clicked {count} times</p>
      <button onClick={() => setCount(1)}>Update Count</button>
      <button onClick={() => setPerson({ name: 'Bob', age: 30 })}>Update Person</button>
      <button onClick={() => setArray([1, 2, 3, 4])}>Update Array</button>
    </div>
  );
};

在这个案例中,初始化了三个状态,和对应的三个副作用函数useEffect,理想状态是状态的值更新时才触发useEffect。
多次点击Update Count更新State,因为更新后的值还是1,所以第一个useEffect执行第一次后不会重复执行,这符合预期。但是重复点击Update Person和Update Array时,却不是这样,尽管值相同,但useEffect每一次都会触发。当useEffect中的副作用计算量较大时,必然会引起性能问题。

为了追溯这个原因,可以首先熟悉一下useEffect的源码:

function useEffect(create, deps) {
  const fiber = get();
  const { alternate } = fiber;

  if (alternate !== null) {
    const oldProps = alternate.memoizedProps;
    const [oldDeps, hasSameDeps] = areHookInputsEqual(deps, alternate.memoizedDeps);

    if (hasSameDeps) {
      pushEffect(fiber, oldProps, deps);
      return;
    }
  }

  const newEffect = create();

  pushEffect(fiber, newEffect, deps);
}

function areHookInputsEqual(nextDeps, prevDeps) {
  if (prevDeps === null) {
    return false;
  }

  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    if (Object.is(nextDeps[i], prevDeps[i])) {
      continue;
    }

    return false;
  }

  return true;
}

在上面的代码中,我们着重关注areHookInputsEqual的实现,这个函数对比了前后两次传入的依赖项,决定了后续副作用函数create()是否会执行。可以明显看到,useEffect对于依赖项执行的是浅比较,即Object.is (arg1, arg2),这可能是出于性能考虑。对于原始类型这没有问题,但对于引用类型(数组、对象、函数等),这意味着即使内部的值保持不变,引用本身也会发生变化,导致 useEffect执行副作用。

1.饮鸩止渴

缝缝补补只是为了等一个人替你推倒重盖

最直接的思路是把useEffect的依赖项从引用类型换成基本类型:

  useEffect(() => {
    console.log('Component re-rendered by person');
  }, [JSON.stringify(person)]);

  useEffect(() => {
    console.log('Component re-rendered by array');
  }, [JSON.stringify(array)]);

表面上可行,实际后患无穷(具体参考JSON.stringify为什么不能用来深拷贝),为了避坑而挖另外的坑,显然不是我们期待的解决方案。
对比之下,这样的写法可以容忍,但是person对象如果增加了其他属性,你要确保自己还记得更新依赖,否则依然是掩盖问题。

useEffect(() => {
  console.log('Component re-rendered by person');
}, [person.name, person.age]);

2.前置拦截

第二种思路:

在你决定要出手之前,我已经帮你决定了 —— 格林公式引申公理

我们可以把问题尽可能前置,手动加一层深对比,如何发现引用值没有变化,就不执行状态更新的逻辑,也就不会触发useEffect重复执行。

<button onClick={() => {
    const newPerson = { name: 'Bob', age: 18 };
    if (!isEqual(newPerson, person)) {
      setPerson(newPerson)}
    }
  }
>Update person</button>

但这样显然不太优雅,且每一次写setState时心智负担太重,对比逻辑可不可以封装起来。

3.他山之石

实际上自定义的Hooks就是为了解决方法级别的逻辑复用,这里我们利用useRef绑定的值可以跨渲染周期的特点,实现一个自定义的useCompare。

const useCompare = (value, compare) => {
  const ref = useRef(null);
  if (!compare(value, ref.current)) {
    ref.current = value;
  }
  return ref.current;
}

经过ref记录的上一次结果,我们同时拥有了前后两次更新的状态,如果发现值不同,再让ref绑定新的引用类型地址。

import { isEqual } from 'lodash';

const comparePerson = useCompare(person, isEqual);

useEffect(() => {
    console.log('Component re-rendered by comparePerson');
}, [comparePerson]);


useEffect(() => {
  console.log('Component re-rendered by person');
}, [person]);

需要注意的是,这里使用了lodash的isEqual函数实现深对比,看似省心实际是一个成本极其不稳定的选择,如果对象过于庞大,可能得不偿失,可以传入简化的compare函数,有取舍的比较常变的key值。
而且每次又到单独调用useCompare生成新的对象,这里的逻辑也值得被封装。

4.回归本质

停止曲线救国,直面问题本身。

说了这么多,实际还是useEffect中对比逻辑问题,本着支持拓展但不支持修改的原则,我们需要支持一个新的useEffect支持深度对比。我们将useRef实现的记忆引用传入useEffect的对比逻辑中:

import { useEffect, useRef } from 'react';
import isEqual from 'lodash.isequal';

const useDeepCompareEffect = (callback, dependencies, compare) => {
  
  if (!compare) compare = isEqual;
  const memoizedDependencies = useRef([]);
  if (!compare (memoizedDependencies.current, dependencies)) {
    memoizedDependencies.current = dependencies;
  }
  useEffect(callback, memoizedDependencies.current);
};

export default useDeepCompareEffect;


function App({ data }) {
  useDeepCompareEffect(() => {
    
    console.log('data 发生了改变', data);
  }, [data]);

  return <div>Hello World</div>;
}

考虑到前文提到的复杂对象的深对比隐患,我依然结和个人意志,在useDeepCompareEffect中加了一个可选参数compare函数,把isEqual作为一种默认模式。于是,我们终于有了一劳永逸的方法。

实际上,react-use和a-hooks等第三方库都已经实现了useDeepCompareEffect,也可以发现自定义hooks解决问题将会是目前体系下一种复用性极高的实践。
useDeepCompareEffect - ahooks 3.0
react-use/useDeepCompareEffect.md at master · streamich/react-use

以上是这个系列的第一篇,通过推导结论的过程,能看出我们解决问题的一般思路:

由异常现象出发(依赖项的值不变但重新渲染),到文档和源码追根溯源(useEffect),在语言底层定位原因(基本类型和引用类型的储存方式不同),围绕原因提出治标(方案1)或治本(方案2,3)的方案,优化方案形成更好的工程实践(方案4),总结形成可推广服用的逻辑(ahooks / react-use)。

新手坚持这套万能方法论,处理实际困难和屏障,必然能快速成长。

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
react2025-12-02
useEffect与useLayoutEffect对比
在 React Hooks 中, 和 都用于处理副作用逻辑,例如订阅、事件监听、DOM 操作等。但它们有着本质性的执行时机差异,这影响到页面渲染、布局抖动、性能表现等关键点。 1\. 执行时机的核心区别 在 React 的渲染流程中: 1. 渲染(Render phase) React 根据 state/props 计算 UI,生成虚拟 DOM,不访问真实 DOM。 2. 提交(Commit phase) React 将虚拟 DOM 变...
学习
react2025-08-03
在 React 中实现倒计时功能会有什么坑
倒计时 倒计时是一个非常常见的业务场景,但是在 React 中实现起来,却不算简单。 首先我们来看这段倒计时代码,它能否正常执行? 来看看实际表现效果 可以看到计时器在不断执行,但是 的值却没有变。 这是一个很经典的问题:React 闭包陷阱 。 我们来详细分析下: 的依赖数组 是空的,这意味着 effect 只会在组件挂载时执行一次,而不会在 状态更新时重新执行。所以 回调函数中捕获的 值始终是初始值 。 这个问题解决起来也很简单,有...
学习面试
react2025-07-30
React性能优化三剑客:memo、useMemo和useCallback详解
前言 在React开发中,性能优化是一个永恒的话题。今天我们就来深入探讨React提供的三个重要性能优化工具: 、 和 ,它们如何帮助我们构建更高效的React应用。 1\. 为什么需要性能优化? 🤔 React的核心机制是当组件的state或props发生变化时,组件会重新渲染。但有时这种重新渲染是不必要的: 父组件更新导致所有子组件重新渲染 ,即使子组件的props没有变化 复杂计算在每次渲染时重复执行 ,消耗大量资源 函数引用在...
学习面试
react2025-06-05
React 中 useDeferredValue 和 startTransition 的核心区别与使用场景
React 中 和 ,两者都用于优化性能,但适用场景和实现方式不同。 核心概念解析 1\. useDeferredValue:延迟值更新 作用 :告诉 React 延迟更新某个值 ,直到所有高优先级渲染任务完成。 适用场景 :当某个值的更新会触发计算密集型渲染(如复杂列表、图表),但需要优先保证其他高优先级 UI(如输入框)的响应。 机制 : 保持旧值显示,后台计算新值。 计算完成后,用新值更新 UI,期间允许用户继续交互。 示例 :...
学习
react2024-12-18
Antd 样式覆盖
&nbsp; 目前作者所在的业务正在升级 Antd5.0,不得不说 Antd5.0 真的太香了,但是由于团队的 UE 规范进行了大改版,Antd5.0 所有的 必须对齐最新的规范,因此就需要对 Antd5.0 组件做主题定制。 现状 目前针对项目中使用的 antd 高频组件进行了梳理,并且根据规范基于 design token 对这些高频组件进行样式定制,对于主题色、圆角、边框、字体等用户 高的主题都已经满足团队规范。 虽然 Desig...
学习
react2024-12-06
React 19 终于发布,一大波新功能正式升级
这篇文章主要介绍了 React 19 的新功能,包括 Actions 自动处理数据突变相关状态,新增的 useActionState、useFormStatus、useOptimistic 等 hook,新的 use API 读取渲染资源,新的 React DOM 静态 API 生成静态站点,以及 React 服务器组件中的 RSC 和 RSA 等内容。同时提到后续会分享针对旧版功能的优化和改良。 关联问题: React 19 性能如何...
学习面试

评论

请登录后发表评论

去登录
加载评论中...

目录