知

模块二 · Hooks 与数据

第 6 / 18 节17 分钟阅读型

Hooks 为什么必须按顺序调用

Hooks 不是魔法,而是挂在 Fiber 上的链表。这一节讲清调用规则、依赖比较、闭包陷阱,以及自定义 Hook 的边界。

读完这一节,你能做到

  1. 说清 Hooks 存储在哪,以及为什么依赖调用顺序。
  2. 解释"条件调用 Hooks"为什么会错位。
  3. 判断依赖数组该写什么,避免无限循环与漏更新。
  4. 用 ref 或函数式更新解决闭包读到旧值的问题。

一、Hooks 存在哪

每个组件实例(Fiber)上有一条 Hooks 链表:

fiber.memoizedState → hook1 → hook2 → hook3 → ...

每次渲染时,React 按顺序遍历这条链表,把第 n 次 useState 对应到第 n 个节点。顺序就是身份,没有名字。

所以 Hooks 的规则不是风格要求,而是数据结构决定的:顺序变了,状态就串了。

二、为什么不能条件调用

if (visible) {
  const [name, setName] = useState(""); // 错误
}

第一次渲染 visible=true,链表是 [useState, useEffect];第二次 visible=false,链表变成 [useEffect]。React 会把 useEffect 当成第一个 Hook,读到的是 useState 的状态,直接错位。

正确做法:Hooks 永远在顶层调用,条件放到 Hook 内部:

const [name, setName] = useState("");
if (!visible) return null;

三、依赖数组怎么比较

React 用 Object.is 逐项比较依赖:

写法结果说明
[]只执行一次没有依赖
[a, b]a 或 b 变化时执行基本类型安全
[obj]每次渲染都执行对象字面量每次都是新引用
[obj.id]id 变化时执行用稳定值做依赖

三条实践:

  1. 依赖里只放"用到的、会变的"值,不要放整个对象。
  2. 函数依赖用 useCallback 包,或用函数式更新避免依赖。
  3. 不要为了"只执行一次"而故意漏依赖,用 ref 保存最新值更安全。

四、闭包陷阱

useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, []);

count 永远是最初的 0,因为 effect 只执行一次,闭包捕获了那次渲染的 count。两种修法:

  • 把 count 放进依赖,每次重建定时器。
  • 用函数式更新 setCount(c => c + 1),不依赖外部值。

五、自定义 Hook 的边界

自定义 Hook 本质是"把一组 Hooks 抽成函数",它必须:

  • 以 use 开头,让 lint 能检查规则。
  • 只在顶层调用其他 Hooks。
  • 返回稳定的接口,不暴露内部实现。
function useDebounced(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);
  }, [value, delay]);
  return debounced;
}

注意:自定义 Hook 之间不共享状态,每次调用都是独立的一份。

六、常见坑

  1. 在条件、循环、回调里调用 Hooks。
  2. 依赖数组放了对象/函数字面量,导致每次渲染都执行。
  3. 漏依赖导致读到旧值,还以为是 React 的 bug。
  4. 用 useEffect 做派生计算,其实应该用 useMemo。
  5. 自定义 Hook 里返回了新对象,导致调用方每次重渲染。

七、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 故意在 if 里调用 useState,观察报错信息。
  2. 写一个 useDebounced,并用它做搜索输入。
  3. 把一个漏依赖的 effect 改成函数式更新,验证不再读旧值。

② 让 AI 写同一题

把第 2 题(useDebounced)交给 AI,补一句 React 19 + TS,返回的函数必须引用稳定。

③ 对比要盯的点

  • 定时器它在 return 里 clearTimeout 了吗?依赖一变,旧定时器不清就会连发多次。
  • 依赖数组里放了每次渲染都新建的回调吗?那防抖永远不会生效。
  • 返回值它用 useCallback 包了吗?没包的话,调用方一放进依赖就会连环重渲染。

④ 一句话取舍

它的防抖在“父组件每秒重渲染一次”时还生效吗?说清为什么,而不是“应该可以”。

八、自测

  1. Hooks 存储在哪?
  2. 为什么条件调用会错位?
  3. Object.is 比较对依赖数组意味着什么?
  4. 闭包读到旧值的两种修法是什么?
  5. 自定义 Hook 之间共享状态吗?

参考答案要点:Fiber 链表;顺序即身份;对象引用比较;依赖补全或函数式更新;不共享。

附:Hooks 链表长什么样

fiber.memoizedState
  → { memoizedState: 0, next: hook2 }   // useState
  → { memoizedState: fn,  next: hook3 } // useEffect
  → { memoizedState: null, next: null } // useRef

每次渲染按 next 依次读取,调用顺序变了,读到的节点就变了。这就是规则的来源。

附:依赖数组的四个反例

写法问题修法
[user]对象引用每次新[user.id]
[] 但用了 props永远旧值补依赖或 ref
[fn]函数每次新useCallback / 函数式更新
[a.b.c]中间层可能为空可选链 + 默认值

附:从类组件到 Hooks 的迁移

类组件的 componentDidMount 对应 useEffect(..., []),componentDidUpdate 对应带依赖的 effect,componentWillUnmount 对应 cleanup。区别是:Hooks 把“按生命周期组织”改成“按逻辑组织”,同一个功能的代码放在一起。

迁移时不要机械替换:先按职责拆逻辑,再决定哪些状态需要保留。类组件里常见的 this.setState 合并更新,在 Hooks 里要用展开运算符显式合并。

附:useState 的最小实现

let hookIndex = 0;
const hooks = [];

function useState(initial) {
  const i = hookIndex++;
  hooks[i] = hooks[i] ?? initial;
  const setState = (next) => {
    hooks[i] = typeof next === "function" ? next(hooks[i]) : next;
    render();
  };
  return [hooks[i], setState];
}

真实实现挂在 Fiber 上,并且有调度与批处理;但“按调用顺序取槽位”这一点,就是上面这几行。

附:Hooks 的常见报错

报错原因修法
Rendered more hooks条件分支多调了 Hook提到顶层
Rendered fewer hooks提前 return把 return 放到 Hooks 之后
Cannot update during renderrender 里 setState移到 effect/事件
Missing dependency依赖漏写补依赖或改函数式

看到这几类报错,先查调用顺序与依赖,不要先怀疑 React。

附:useRef 与闭包

useRef 返回的对象在多次渲染间保持同一引用,.current 可读写且不会触发渲染。两个典型用途:

  • 保存定时器 id、上一次的值、是否已挂载。
  • 在回调里读取最新 state,绕过闭包快照。

但它不是“响应式”的:改 .current 不会让组件更新,不要拿它存需要展示的数据。

附:Hooks 与类组件的差异

维度类组件Hooks
逻辑复用HOC/render props自定义 Hook
状态组织按生命周期按逻辑
this需要绑定不需要
闭包实例属性每次渲染独立

Hooks 的代价是“顺序规则”和“闭包快照”,换来的是更简单的逻辑复用与更少的嵌套。规则不是限制,而是数据结构的外显;理解链表,就不会再问为什么。每次渲染都是一次独立的函数调用,Hooks 只是把状态挂在了 Fiber 上。顺序对了,状态才对;依赖写全,副作用才可预测。顺序即契约,规则即设计;理解即自由,先记规则再记原因。

附录:高频追问

问:Hooks 为什么不能放在循环里? 循环次数变化会改变调用顺序,状态错位。

问:依赖数组为空等于 componentDidMount 吗? 不完全等价;StrictMode 下会执行两次,且拿不到最新 props。

问:useMemo 和 useCallback 的关系? 前者缓存值,后者缓存函数;都是性能提示,不是语义保证。

问:为什么 effect 里读到旧 state? 闭包捕获了那次渲染的快照。

问:useRef 能替代 state 吗? 不能,ref 变化不触发渲染。

问:自定义 Hook 会共享状态吗? 不会,每次调用都是独立的一份。

问:Hooks 能在普通函数里调用吗? 不能,必须在组件或其他 Hook 的顶层。

问:依赖数组里放函数会怎样? 函数每次新建,effect 每次都执行;用 useCallback 或函数式更新。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解