10.解释一下 React Hooks 的执行顺序和依赖规则 来源:https://w0hog67yl81.feishu.cn/wiki/BjVWwJnrsixHyWkpFXYcW6v3n7e 采集状态:已完成正文采集;正文滚动至底部,按块及代码行号采集 深入理解 React Hooks:执行顺序与依赖规则​ ​ 从类组件转向函数组件的开发模式时,我们首先会遇到的核心概念之一就是 Hooks。它彻底改变了 React 中状态管理与副作用处理的方式。然而,Hooks 并非没有约束,其背后有两条至关重要的设计原则,直接关系到代码的稳定性和可预测性。​ ​ 这两条原则分别是:​ ​ 1. 稳定的调用顺序:确保每次渲染时,Hooks 的调用顺序都完全一致。​ 2. 明确的依赖关系:通过依赖数组,精确控制副作用(useEffect)、记忆化(useMemo、useCallback)的触发时机。 理解这两点,是精通 React Hooks 的关键。接下来,我们将逐一拆解。​ ​ 一、稳定的调用顺序:Hooks 如何“记住”状态?​ ​ React 要求我们**只能在函数组件的顶层调用 Hooks**,不能在循环、条件判断或嵌套函数中调用。​ ​ 这条规则初看起来可能有些刻板,但其背后是 Hooks 实现机制的基石。我们可以将组件内部的 Hooks 想象成一个“链表”或“数组”。当组件第一次渲染时,useState、useEffect 等 Hooks 会按照它们的调用顺序,被依次放入这个内部列表中。​ ​ 例如,对于这样一个组件:​ ​ 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?​ ​ 让我们看一个错误的例子:​ ​ // 错误示范 🚫 function UserProfile({ showAge }) { const [name, setName] = useState('Alice'); // Hook 1 if (showAge) { const [age, setAge] = useState(28); // Hook 2 (条件性调用) } useEffect(() => { /* ... */ }); // Hook 3 } ​ • 首次渲染 (showAge 为 true)**: a. useState('Alice') -> name 状态 b. useState(28) -> age 状态 c. useEffect(...) -> 副作用 React 的内部列表是 [nameState, ageState, effect]。 • 第二次渲染 (showAge 变为 false)**: a. useState('Alice') -> React 期望这是 name,匹配成功。 b. if (showAge) 条件为假,useState(28) **被跳过**。 c. useEffect(...) 被调用。此时 React 期望调用的是列表中的第二个 Hook(即 age 的状态),但实际上却调用了 useEffect。 这种错位会导致 React 无法正确获取状态,从而引发不可预测的 Bug。这正是 React 强制要求在顶层调用 Hooks 的根本原因:保证每一次渲染,Hooks 的调用顺序都和上一次完全相同。​ ​ 正确做法:将条件逻辑移入 Hook 内部。​ ​ // 正确示范 ✅ function UserProfile({ showAge }) { const [name, setName] = useState('Alice'); const [age, setAge] = useState(28); useEffect(() => { // ... }); return (

Name: {name}

{showAge &&

Age: {age}

}
); } ​ 二、明确的依赖关系:告诉 React 何时“更新”​ ​ useEffect、useCallback 和 useMemo 这三个 Hooks 共享一个相同的机制:**依赖数组**。这个数组是我们与 React 沟通的桥梁,用来告知它:“当且仅当这个数组里的值发生变化时,你才需要重新执行这个副作用(或重新计算这个值)。”​ ​ 我们通过 useEffect 来分析依赖数组的三种典型场景。​ ​ 1. 无依赖数组:每次渲染都执行​ ​ useEffect(() => { console.log('Component re-rendered'); }); ​ 如果不提供第二个参数(依赖数组),useEffect 内的函数会在**每一次组件渲染**(包括首次渲染)之后执行。这等同于类组件中 componentDidMount 和 componentDidUpdate 的结合体。​ ​ 这种模式在某些简单场景下可用,但通常需要避免,因为它很容易导致性能问题,或在数据请求等场景下造成无限循环。​ ​ 2. 空依赖数组 []:仅执行一次​ ​ useEffect(() => { // 比如:从服务器获取初始数据 fetchData(); }, []); // 空数组 ​ 当依赖数组为空 [] 时,useEffect 内的函数**只会在组件首次渲染后执行一次**。这与类组件中的 componentDidMount 行为完全一致。​ ​ 它最常见的用途包括:​ • 初始数据的获取。​ • 设置事件监听器(如 window.addEventListener)。​ • 启动定时器(如 setInterval)。 重要提示:当你在 useEffect 中使用了组件作用域内的任何变量或函数(包括 props 和 state),都应该诚实地将它们列入依赖数组,否则可能会遇到“陈旧闭包”(Stale Closure)的问题。​ ​ 3. 包含依赖项的数组 [dep1, dep2, ...]​ ​ 这是最常见也是最强大的模式。useEffect 会在首次渲染后执行,并且在后续的每一次渲染中,它会**检查依赖数组中的每一项是否发生了变化**。如果任何一项发生了变化,副作用函数就会被重新执行。​ ​ React 使用 Object.is 算法来比较依赖项。对于原始类型(string, number, boolean 等),它会比较值是否相等;对于引用类型(object, array, function),它会比较**引用地址**是否相同。​ ​ function UserProfile({ userId }) { const [user, setUser] = useState(null); useEffect(() => { // 当 userId 变化时,重新获取用户数据 fetchUser(userId).then(data => setUser(data)); }, [userId]); // 依赖于 userId } ​ 在这个例子中,只有当 userId 这个 prop 发生变化时,fetchUser 才会被再次调用。如果组件因为其他状态(比如父组件的一个无关状态)更新而重新渲染,只要 userId 保持不变,这个 useEffect 就不会触发。​ ​ 延伸探讨:陈旧闭包与函数式更新​ ​ “诚实地填写依赖”是使用 Hooks 的黄金法则。违反它最常导致的便是“陈旧闭包”问题。​ ​ 考虑这个计数器:​ ​ function Timer() { const [count, setCount] = useState(0); useEffect(() => { const intervalId = setInterval(() => { console.log(`Count is: ${count}`); // `count` 被闭包捕获 }, 1000); return () => clearInterval(intervalId); }, []); // 故意写了空数组 return

{count}

; } ​ 由于我们使用了 [],useEffect 只执行一次。这意味着 setInterval 的回调函数是在首次渲染时创建的,它“记住”了当时 count 的值,也就是 0。无论后续 setCount 如何改变 count 的值,console.log 将永远输出 Count is: 0。​ ​ 常规修复:将 count 加入依赖数组。​ ​ useEffect(() => { // ... }, [count]); ​ 这样可以解决问题,但引入了新的行为:每次 count 改变,useEffect 都会重新执行,即**清除旧的定时器并创建一个新的定时器**。对于这个场景,这是可以接受的,但在某些复杂逻辑下可能并非我们所愿。​ ​ 更优的解决方案:使用函数式更新。​ ​ setState 函数提供了一个函数式更新的形态,允许我们基于**前一个状态**来计算新状态。​ ​ function Timer() { const [count, setCount] = useState(0); useEffect(() => { const intervalId = setInterval(() => { // 无需依赖外部的 count setCount(prevCount => prevCount + 1); }, 1000); return () => clearInterval(intervalId); }, []); // 现在可以安全地使用空数组 return

{count}

; } ​ 通过 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 组件。​ ​