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

查看飞书原文 ↗3,678 字符
useEffect 的依赖项:为何如此重要?​
​
在日常开发中,我们常常会遇到需要在组件渲染完成后执行某些“副作用”操作的场景,比如:​
​
•
数据获取:从服务器请求数据。​
•
DOM 操作:手动修改 DOM 元素。​
•
事件监听:添加或移除事件监听器。​
​
useEffect 就是 React 为我们提供的处理这些副作用的钩子。然而,一个关键的问题是:**这些副作用应该在什么时候执行?** 是每次组件更新都执行,还是仅在特定时机执行?​
​
useEffect 的第二个参数——**依赖项数组 (Dependency Array)**,正是用来回答这个问题的。它赋予了我们精确控制副作用执行时机的能力,从而避免不必要的重复执行,提升应用性能。​
​
useEffect 的基本结构如下:​
​
useEffect(() => {
  // 副作用操作
  return () => {
    // 清理操作(可选)
  };
}, [dep1, dep2, ...]); // 依赖项数组
​
我们可以根据依赖项数组的不同,将 useEffect 的行为分为三种情况:​
​
1. 不提供依赖项:每次渲染后都执行​
​
如果完全不提供第二个参数,useEffect 会在组件每次渲染(包括初始渲染和后续更新)完成后都执行。​
​
useEffect(() => {
  console.log('组件已更新');
  // 这个 effect 会在每次渲染后都运行
});
​
这种情况通常不是我们期望的,因为它可能导致不必要的性能开销。例如,如果我们在 effect 中进行网络请求,每次组件的微小更新都会触发一次新的请求,这显然是需要避免的。​
​
2. 空依赖项数组 []:仅在初始渲染后执行一次​
​
当我们提供一个空的依赖项数组 [] 时,useEffect 只会在组件**初始渲染**后执行一次。后续的任何更新都不会再次触发它。​
​
useEffect(() => {
  // 从服务器获取初始数据
  fetchData();
}, []); // 空数组表示没有依赖项
​
这种模式非常适合执行那些只需要运行一次的操作,比如:​
​
•
获取初始数据。​
•
设置只需添加一次的全局事件监听器。​
•
初始化第三方库。​
​
同时,useEffect 返回的清理函数也只会在组件**卸载**时执行一次,非常适合用来清理在 effect 中设置的订阅或定时器。​
​
3. 包含依赖项的数组 [dep1, dep2, ...]:依赖项变化时执行​
​
这是最常见也最灵活的用法。useEffect 会在初始渲染后执行,并且在后续的每次渲染中,React 会检查依赖项数组中的值是否发生了变化。如果**至少有一个依赖项**与上一次渲染时的值不同,effect 就会被重新执行。​
​
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 进行的是浅比较,因此对于引用类型(如对象、数组和函数)需要格外小心。​
​
问题场景:​
​
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,可以将其定义在组件外部。​
const options = { step: 1 }; // 在组件外部定义,全局唯一

function Counter() {
  // ...
  useEffect(() => {
    // ...
  }, [options]);
}
​
​
2.
使用 useMemo 或 useCallback**:如果依赖项确实需要根据 props 或 state 计算得出,可以使用 useMemo 来记忆(memoize)对象或数组,或使用 useCallback 来记忆函数。​
​
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 执行,我们需要确保依赖项(尤其是对象、数组和函数等引用类型)在渲染之间保持稳定,必要时可以借助 useMemo 和 useCallback。​
​
正确理解和使用 useEffect 的依赖项,是编写高效、可预测的 React 组件的关键。希望这次的梳理能帮助你更好地掌握这个强大的 Hook。​
​
本文暂未被其它文档引用