Skip to content

解释一下 React Hooks 的执行顺序和依赖规则

深入理解 React Hooks:执行顺序与依赖规则

从类组件转向函数组件的开发模式时,我们首先会遇到的核心概念之一就是 Hooks。它彻底改变了 React 中状态管理与副作用处理的方式。然而,Hooks 并非没有约束,其背后有两条至关重要的设计原则,直接关系到代码的稳定性和可预测性。

这两条原则分别是:

  1. 稳定的调用顺序:确保每次渲染时,Hooks 的调用顺序都完全一致。

  2. 明确的依赖关系:通过依赖数组,精确控制副作用(useEffect)、记忆化(useMemouseCallback)的触发时机。

理解这两点,是精通 React Hooks 的关键。接下来,我们将逐一拆解。

稳定的调用顺序:Hooks 如何“记住”状态?

React 要求我们只能在函数组件的顶层调用 Hooks,不能在循环、条件判断或嵌套函数中调用。

这条规则初看起来可能有些刻板,但其背后是 Hooks 实现机制的基石。我们可以将组件内部的 Hooks 想象成一个“链表”或“数组”。当组件第一次渲染时,useStateuseEffect 等 Hooks 会按照它们的调用顺序,被依次放入这个内部列表中。

例如,对于这样一个组件:

javascript
function UserProfile({ id }) {
  const [name, setName] = useState('');      // Hook 1
  const [age, setAge] = useState(0);         // Hook 2
  useEffect(() => { /* ... */ }, [id]);      // Hook 3

  // ...
}

React 内部会建立一个类似这样的结构来追踪状态:

  1. useState('') -> name 的状态

  2. useState(0) -> age 的状态

  3. useEffect(...) -> 副作用及其依赖

当组件因为状态变更或 props 变化而重新渲染时,React 会再次按照顺序调用这些 Hooks。它会依据这个稳定不变的顺序,准确地从内部列表中取出上一次的状态数据,从而保证了状态的连续性。

为什么不能在条件语句中调用 Hooks?

让我们看一个错误的例子:

javascript
// 错误示范 🚫
function UserProfile({ showAge }) {
    const [name, setName] = useState('Alice'); // Hook 1

    if (showAge) {
        const [age, setAge] = useState(28);      // Hook 2 (条件性调用)
    }

    useEffect(() => { /* ... */ });           // Hook 3
}
  • 首次渲染 (showAgetrue)**:

    1. useState('Alice') -> name 状态
    2. useState(28) -> age 状态
    3. useEffect(...) -> 副作用 React 的内部列表是 [nameState, ageState, effect]
  • 第二次渲染 (showAge 变为 false)**:

    1. useState('Alice') -> React 期望这是 name,匹配成功。
    2. if (showAge) 条件为假,useState(28) 被跳过
    3. useEffect(...) 被调用。此时 React 期望调用的是列表中的第二个 Hook(即 age 的状态),但实际上却调用了 useEffect

这种错位会导致 React 无法正确获取状态,从而引发不可预测的 Bug。这正是 React 强制要求在顶层调用 Hooks 的根本原因:保证每一次渲染,Hooks 的调用顺序都和上一次完全相同。

正确做法:将条件逻辑移入 Hook 内部。

javascript
// 正确示范 ✅
function UserProfile({ showAge }) {
  const [name, setName] = useState('Alice');
  const [age, setAge] = useState(28);

  useEffect(() => {
    // ...
  });

  return (
    <div>
      <p>Name: {name}</p>
      {showAge && <p>Age: {age}</p>}
    </div>
  );
}

明确的依赖关系:告诉 React 何时“更新”

useEffectuseCallbackuseMemo 这三个 Hooks 共享一个相同的机制:依赖数组。这个数组是我们与 React 沟通的桥梁,用来告知它:“当且仅当这个数组里的值发生变化时,你才需要重新执行这个副作用(或重新计算这个值)。”

我们通过 useEffect 来分析依赖数组的三种典型场景。

无依赖数组:每次渲染都执行

javascript
useEffect(() => {
  console.log('Component re-rendered');
});

如果不提供第二个参数(依赖数组),useEffect 内的函数会在每一次组件渲染(包括首次渲染)之后执行。这等同于类组件中 componentDidMountcomponentDidUpdate 的结合体。

这种模式在某些简单场景下可用,但通常需要避免,因为它很容易导致性能问题,或在数据请求等场景下造成无限循环。

空依赖数组 []:仅执行一次

javascript
useEffect(() => {
  // 比如:从服务器获取初始数据
  fetchData();
}, []); // 空数组

当依赖数组为空 [] 时,useEffect 内的函数只会在组件首次渲染后执行一次。这与类组件中的 componentDidMount 行为完全一致。

它最常见的用途包括:

  • 初始数据的获取。
  • 设置事件监听器(如 window.addEventListener)。
  • 启动定时器(如 setInterval)。

重要提示:当你在 useEffect 中使用了组件作用域内的任何变量或函数(包括 props 和 state),都应该诚实地将它们列入依赖数组,否则可能会遇到“陈旧闭包”(Stale Closure)的问题。

包含依赖项的数组 [dep1, dep2, ...]

这是最常见也是最强大的模式。useEffect 会在首次渲染后执行,并且在后续的每一次渲染中,它会检查依赖数组中的每一项是否发生了变化。如果任何一项发生了变化,副作用函数就会被重新执行。

React 使用 Object.is 算法来比较依赖项。对于原始类型(string, number, boolean 等),它会比较值是否相等;对于引用类型(object, array, function),它会比较引用地址是否相同。

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

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

在这个例子中,只有当 userId 这个 prop 发生变化时,fetchUser 才会被再次调用。如果组件因为其他状态(比如父组件的一个无关状态)更新而重新渲染,只要 userId 保持不变,这个 useEffect 就不会触发。

延伸探讨:陈旧闭包与函数式更新

“诚实地填写依赖”是使用 Hooks 的黄金法则。违反它最常导致的便是“陈旧闭包”问题。

考虑这个计数器:

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      console.log(`Count is: ${count}`); // `count` 被闭包捕获
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // 故意写了空数组

  return <h1>{count}</h1>;
}

由于我们使用了 []useEffect 只执行一次。这意味着 setInterval 的回调函数是在首次渲染时创建的,它“记住”了当时 count 的值,也就是 0。无论后续 setCount 如何改变 count 的值,console.log 将永远输出 Count is: 0

常规修复:将 count 加入依赖数组。

javascript
useEffect(() => {
  // ...
}, [count]);

这样可以解决问题,但引入了新的行为:每次 count 改变,useEffect 都会重新执行,即清除旧的定时器并创建一个新的定时器。对于这个场景,这是可以接受的,但在某些复杂逻辑下可能并非我们所愿。

更优的解决方案:使用函数式更新。

setState 函数提供了一个函数式更新的形态,允许我们基于前一个状态来计算新状态。

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

  useEffect(() => {
    const intervalId = setInterval(() => {
      // 无需依赖外部的 count
      setCount(prevCount => prevCount + 1);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []); // 现在可以安全地使用空数组

  return <h1>{count}</h1>;
}

通过 setCount(prevCount => ...),我们可以在不直接访问外部 count 变量的情况下更新状态。这样一来,useEffect 就不再依赖于 count,我们也可以安全地使用 [] 来确保定时器只被创建一次。

总结与建议

回顾全文,我们探讨了 React Hooks 的两大核心原则:

  1. 调用顺序必须稳定:这是 React 内部机制的要求,通过始终在组件顶层调用 Hooks 来保证。
  2. 依赖关系必须明确:通过诚实地填写依赖数组,我们可以精确控制副作用的执行时机,避免 bug 和性能问题。

为了帮助我们遵循这些规则,React 官方提供了一个 ESLint 插件 eslint-plugin-react-hooks。它包含两个极其有用的规则:

  • rules-of-hooks:强制执行第一条规则,确保 Hooks 在顶层被调用。

  • exhaustive-deps:检查 useEffect 等的依赖数组,并警告我们是否遗漏了依赖项。

在日常开发中,我们应当高度信任 exhaustive-deps 插件的提示。它不是在制造麻烦,而是在帮助我们提前发现潜在的“陈旧闭包”等问题。

将这两大原则内化于心,我们就能写出更健壮、更可预测、更易于维护的 React 组件。

不知道说啥了很无语了