Skip to content

解释一下 useEffect 的依赖项?它背后的原理是什么?

useEffect 的依赖项:为何如此重要?

在日常开发中,我们常常会遇到需要在组件渲染完成后执行某些“副作用”操作的场景,比如:

  • 数据获取:从服务器请求数据。

  • DOM 操作:手动修改 DOM 元素。

  • 事件监听:添加或移除事件监听器。

useEffect 就是 React 为我们提供的处理这些副作用的钩子。然而,一个关键的问题是:这些副作用应该在什么时候执行? 是每次组件更新都执行,还是仅在特定时机执行?

useEffect 的第二个参数——依赖项数组 (Dependency Array),正是用来回答这个问题的。它赋予了我们精确控制副作用执行时机的能力,从而避免不必要的重复执行,提升应用性能。

useEffect 的基本结构如下:

javascript
useEffect(() => {
  // 副作用操作
  return () => {
    // 清理操作(可选)
  };
}, [dep1, dep2, ...]); // 依赖项数组

我们可以根据依赖项数组的不同,将 useEffect 的行为分为三种情况:

不提供依赖项:每次渲染后都执行

如果完全不提供第二个参数,useEffect 会在组件每次渲染(包括初始渲染和后续更新)完成后都执行。

javascript
useEffect(() => {
  console.log('组件已更新');
  // 这个 effect 会在每次渲染后都运行
});

这种情况通常不是我们期望的,因为它可能导致不必要的性能开销。例如,如果我们在 effect 中进行网络请求,每次组件的微小更新都会触发一次新的请求,这显然是需要避免的。

2. 空依赖项数组 []:仅在初始渲染后执行一次

当我们提供一个空的依赖项数组 [] 时,useEffect 只会在组件初始渲染后执行一次。后续的任何更新都不会再次触发它。

javascript
useEffect(() => {
  // 从服务器获取初始数据
  fetchData();
}, []); // 空数组表示没有依赖项

这种模式非常适合执行那些只需要运行一次的操作,比如:

  • 获取初始数据。

  • 设置只需添加一次的全局事件监听器。

  • 初始化第三方库。

包含依赖项的数组 [dep1, dep2, ...]:依赖项变化时执行

这是最常见也最灵活的用法。useEffect 会在初始渲染后执行,并且在后续的每次渲染中,React 会检查依赖项数组中的值是否发生了变化。如果至少有一个依赖项与上一次渲染时的值不同,effect 就会被重新执行。

javascript
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    // 当 userId 变化时,重新获取用户数据
    fetchUserData(userId).then(data => setUser(data));
  }, [userId]); // 依赖于 userId

  // ...
}

在这个例子中,useEffect 的副作用是获取用户数据。它依赖于 userId 这个 prop。当 userId 发生变化时(例如,用户切换了查看的个人资料页面),effect 会重新执行,从而获取新用户的数据。如果组件因为其他状态变化而重新渲染,但 userId 保持不变,那么这个 effect 就不会执行。

工作原理:比较与执行

那么,React 是如何实现对依赖项变化的精确监听的呢?这背后的机制其实很直观。

在每次组件渲染时,React 会做以下几件事:

  1. 保存上一次的依赖项数组:在 useEffect 执行后,React 会在内部保存当前的依赖项数组。

  2. 比较新旧依赖项:在下一次渲染时,React 会将新的依赖项数组与上一次保存的数组进行逐项比较。

  3. 浅比较机制:这个比较过程是浅比较 (Shallow Comparison)。对于数组中的每一个依赖项,React 会使用 Object.is() 算法来判断新旧值是否相等。

Object.is() 的行为与严格相等 === 非常相似,但有两个细微差别:

  • 它认为 NaN 等于 NaN

  • 它认为 +0-0 是不相等的。

如果比较发现任何一个依赖项发生了变化,React 就会认为该 effect 是“过时”的,需要重新执行。在重新执行新的 effect 之前,React 会先运行上一个 effect 返回的清理函数(如果存在),以确保清理掉旧的副作用。

一个关于对象和函数的常见陷阱

由于 React 进行的是浅比较,因此对于引用类型(如对象、数组和函数)需要格外小心。

问题场景:

javascript
function Counter() {
  const [count, setCount] = useState(0);

  const options = { step: 1 }; // 在每次渲染时都会创建一个新对象

  useEffect(() => {
    console.log('Effect ran');
  }, [options]); // 依赖项是对象

  return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}

在这个例子中,即使 options 对象的内容 { step: 1 } 看起来没有变化,但由于它是在 Counter 组件内部定义的,每次组件渲染时都会创建一个新的对象实例。因此,useEffect 的依赖项比较 Object.is(oldOptions, newOptions) 结果总是 false,导致 effect 在每次渲染后都会执行。

解决方案:

为了解决这个问题,我们需要确保依赖项在渲染之间保持稳定。

  1. 移到组件外部:如果依赖项不依赖于组件的 props 或 state,可以将其定义在组件外部。
javascript
const options = { step: 1 }; // 在组件外部定义,全局唯一

function Counter() {
  // ...
  useEffect(() => {
    // ...
  }, [options]);
}
  1. 使用 useMemouseCallback**:如果依赖项确实需要根据 props 或 state 计算得出,可以使用 useMemo 来记忆(memoize)对象或数组,或使用 useCallback 来记忆函数。
javascript
function Counter() {
  const [step, setStep] = useState(1);

  // 使用 useMemo 记忆 options 对象
  const options = useMemo(() => ({ step }), [step]);

  useEffect(() => {
    console.log('Effect ran');
  }, [options]); // 只有当 step 变化时,options 才会变
  // ...
}

useMemo 会确保只有当它的依赖项 ([step]) 发生变化时,才会重新计算并返回一个新的 options 对象。否则,它会返回上一次缓存的对象实例。useCallback 对函数的作用与此类似。

总结

综上,我们可以得出一个清晰的结论:

  • useEffect 的依赖项数组是控制副作用执行时机的核心工具。

  • 其工作原理基于 React 在两次渲染之间对依赖项数组进行的浅比较

  • 为了避免不必要的 effect 执行,我们需要确保依赖项(尤其是对象、数组和函数等引用类型)在渲染之间保持稳定,必要时可以借助 useMemouseCallback

正确理解和使用 useEffect 的依赖项,是编写高效、可预测的 React 组件的关键。希望这次的梳理能帮助你更好地掌握这个强大的 Hook。

不知道说啥了很无语了