Hooks 原理:把"每次重跑的函数"变成"有记忆的组件"

文章来源声明: 原文作者:前端阿凡; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689299210249240627; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

从链表与顺序视角讲透Hooks,适合想深入React运行时、理解条件Hooks报错本质及性能优化原理的开发者,助你从背规则转向懂设计。

Day15 把 Fiber 架构拆开了:渲染变成了可中断的工作队列,状态藏在双缓存树里。今天在这个地基上回答一个更贴近日常的问题:函数组件每次渲染都会**整个重新执行一遍**,那 `useState` 保存的状态到底存在哪里?凭什么函数执行完就"消失"了,下一次渲染却还能把它找回来?
  1. 技术难点:一个每次重跑的函数,怎么拥有跨渲染的记忆

先看清问题的形状。类组件的状态在 this 上,this 是组件实例,实例活多久,状态就活多久,生命周期清清楚楚。函数组件没有实例,它只是一个普通的函数:

<span>function</span> <span>Counter</span>(<span></span>) {
  <span>let</span> count = <span>0</span>;            <span>// 每次调用都是全新的局部变量</span>
  <span>const</span> <span>inc</span> = (<span></span>) => count++;
  <span>return</span> <span><span><<span>button</span> <span>onClick</span>=<span>{inc}</span>></span>{count}<span></<span>button</span>></span></span>;
}

这个组件每渲染一次,count 就被重置一次,点按钮永远不会让数字涨上去。问题分成两层:

  1. 状态必须活在函数之外。既然函数体执行完就散架,能跨渲染存活的唯一去处是函数调用方(React 运行时)持有的数据结构。在 Fiber 架构里,这个结构就是那个组件对应的 Fiber 节点。
  2. 函数不能自己声明"我要第几个状态"。一个组件里可能调用十几次 useState,每次调用的先后位置不同、含义不同。React 必须有一种机制,在组件重跑时,把"这一次调用"和"上一次调用"一一对上号。

难点 2 是整个 Hooks 设计最反直觉的地方。类组件里每个状态有名字(this.count、this.name),Hooks 里状态没有名字,只有顺序。这个"顺序即身份"的约定,是后面一切规则(为什么不能写在 if 里、为什么不能包在循环里)的总根源。

  1. 完整解法:Fiber 上的 Hook 链表 + 双 Dispatcher

2.1 状态就挂在 Fiber 上:memoizedState 链表

回忆 Day15:每个组件对应一个 FiberNode。这个节点上有一个字段叫 memoizedState,在函数组件里,它指向一条 Hook 链表的头:

FiberNode
└── memoizedState ──► Hook1 ──► Hook2 ──► Hook3 ──► null
                      (useState) (useEffect) (useRef)

每个 Hook 节点大概长这样(源码里叫 Hook):

<span>interface</span> <span>Hook</span> {
  <span>memoizedState</span>: <span>any</span>;   <span>// 这个 hook 保存的值:useState 存 state,</span>
                        <span>// useEffect 存 effect 对象,useRef 存 { current }</span>
  <span>baseState</span>: <span>any</span>;       <span>// 本次更新前的基准状态</span>
  <span>queue</span>: <span>UpdateQueue</span>;   <span>// setState 的更新队列(环形链表)</span>
  <span>next</span>: <span>Hook</span> | <span>null</span>;    <span>// 指向下一个 hook,串成链表</span>
}

组件第一次渲染时,React 按调用顺序从无到有构建这条链表;之后每次渲染,组件函数重跑,useState() 之类调用不会新建节点,而是沿着已有链表一个个往后取。取到第几个,取决于这次执行到了第几次调用。

2.2 双 Dispatcher:mount 和 update 是两套实现

React 内部同一个 hook 名字,背后有两套完全不同的实现。入口是一个全局 dispatcher,在渲染开始前被切换到对应版本:

<span>// 伪代码:HooksDispatcher 的两个形态</span>
<span>const</span> <span>HooksDispatcherOnMount</span> = {
  <span>useState</span>: mountState,
  <span>useEffect</span>: mountEffect,
  <span>// ...</span>
};
<span>const</span> <span>HooksDispatcherOnUpdate</span> = {
  <span>useState</span>: updateState,
  <span>useEffect</span>: updateEffect,
  <span>// ...</span>
};

首次渲染走 mount*:创建 Hook 节点、挂进链表、初始化值。更新渲染走 update*:沿链表取旧节点、计算新值。这就是为什么同一个 useState,首次调用和后续调用行为略有差异,也解释了为什么 React 内部 useState 其实是 useReducer 的语法糖,底层走同一套 updateReducer。

2.3 useState 的值更新:队列重放,不是直接赋值

很多人以为 setState(1) 就是"把 state 改成 1"。实际上它做的是:把一次更新 入队。每次渲染时,React 把队列里的更新按顺序重放,算出最终 state。这样设计才能支持批量更新和函数式更新:

<span>// setState 内部:入队而非赋值</span>
<span>const</span> <span>dispatch</span> = (<span>action</span>) => {
  <span>const</span> update = { action, <span>next</span>: <span>null</span> };
  <span>// 插到 queue 环形链表尾部</span>
  <span>// 之后调度一次渲染(scheduleUpdateOnFiber,按 lane 定优先级)</span>
};

<span>// 重放:遍历 queue,逐个执行</span>
<span>function</span> <span>basicStateReducer</span>(<span>state, action</span>) {
  <span>return</span> <span>typeof</span> action === <span>'function'</span> ? <span>action</span>(state) : action;
}

同一个事件里连调三次 setCount(c => c + 1),React 会合并成一次渲染,重放时依次 0→1→2→3,最终渲染 3。函数式更新的意义就在这:它不是读闭包里的旧值,而是拿到"最新计算到一半的值"继续算。

2.4 useEffect:不在渲染时执行,而推迟到 commit

useEffect(fn, deps) 里的 fn 绝不会在渲染过程中执行。渲染阶段应该纯净(Day15 里说过的可中断前提),副作用必须推迟到不可中断的 commit 阶段统一冲刷。它的实现分三步:

  1. 渲染时:把 fn 和 deps 包装成一个 effect 对象,push 进 fiber 的 updateQueue(也是环形链表)。
  2. commit 时:React 遍历 effect 链表。先做依赖 diff:拿 deps 和上次的 deps 用 Object.is 逐项比较,任何一个变了(或首次挂载),就标记"需要执行"。
  3. 执行时:先跑上一次留下的 cleanup(如果存在),再跑本次 fn,把返回值存成新的 cleanup。组件卸载时,执行最后一个 cleanup。
<span>// deps 比较的核心逻辑(源码 areHookInputsEqual 的简化)</span>
<span>function</span> <span>depsChanged</span>(<span>prevDeps, nextDeps</span>) {
  <span>if</span> (prevDeps === <span>null</span>) <span>return</span> <span>true</span>;                    <span>// 首次挂载,必执行</span>
  <span>if</span> (prevDeps.<span>length</span> !== nextDeps.<span>length</span>) <span>return</span> <span>true</span>;
  <span>for</span> (<span>let</span> i = <span>0</span>; i < nextDeps.<span>length</span>; i++) {
    <span>if</span> (!<span>Object</span>.<span>is</span>(prevDeps[i], nextDeps[i])) <span>return</span> <span>true</span>;
  }
  <span>return</span> <span>false</span>;
}

注意 diff 用的是 Object.is,也就是引用比较。这解释了两个高频坑:依赖数组里写对象字面量或内联函数,每次渲染都是新引用,diff 永远认为"变了",effect 每次都会执行;反过来,依赖数组传 [] 且依赖了外部状态,拿到的永远是第一次渲染的闭包(stale closure)。

2.5 为什么 Hooks 不能写在条件里:顺序即身份

把所有机制拼起来,规则就呼之欲出了。React 用"第几次调用"来匹配状态,不认名字、不认条件。假如这样写:

<span>function</span> <span>Bad</span>(<span>{ show }</span>) {
  <span>if</span> (show) {
    <span>const</span> [a] = <span>useState</span>(<span>1</span>);   <span>// 第 1 次调用</span>
  }
  <span>const</span> [b] = <span>useState</span>(<span>2</span>);     <span>// show 为 false 时它是第 1 次,true 时是第 2 次</span>
}

show 在两次渲染间变化时,b 对上的 Hook 节点身份变了,读到的是别人的状态,链表错位,数据全乱。React 的解法是不让它发生:官方规则 + eslint-plugin-react-hooks 的 rules-of-hooks 检查。它不是道德约束,是链表数据结构的内在要求。

2.6 可运行的最小实现

下面这段约 90 行的代码,把"链表 + 游标 + 顺序即身份 + deps diff"完整跑一遍,Node 直接可跑。它没有 DOM,只演示状态机本身。

<span>// mini-hooks.mjs —— 最小可运行 Hooks 内核(Node 直接跑)</span>
<span>// 运行: node mini-hooks.mjs</span>

<span>// ---------- 1. 核心:状态数组 + 游标(对应 fiber.memoizedState 链表) ----------</span>
<span>let</span> states = [];    <span>// 模拟 Hook 链表,下标即"第几次调用"</span>
<span>let</span> effects = [];   <span>// effect 的 deps 存档</span>
<span>let</span> cleanups = [];  <span>// 上次 effect 留下的清理函数</span>
<span>let</span> cursor = <span>0</span>;     <span>// 渲染游标:每渲染一轮从 0 开始走</span>

<span>function</span> <span>useState</span>(<span>initial</span>) {
  <span>const</span> i = cursor++;                       <span>// 顺序即身份:本次是第几个调用</span>
  <span>if</span> (states[i] === <span>undefined</span>) {            <span>// mount:首次调用,初始化</span>
    states[i] = <span>typeof</span> initial === <span>'function'</span> ? <span>initial</span>() : initial;
  }
  <span>const</span> <span>set</span> = (<span>action</span>) => {                 <span>// update:入队并重放(此处简化成直接算)</span>
    <span>const</span> next = <span>typeof</span> action === <span>'function'</span> ? <span>action</span>(states[i]) : action;
    <span>if</span> (!<span>Object</span>.<span>is</span>(states[i], next)) {
      states[i] = next;
      <span>rerender</span>();                           <span>// 触发一轮新渲染</span>
    }
  };
  <span>return</span> [states[i], set];
}

<span>function</span> <span>useEffect</span>(<span>fn, deps</span>) {
  <span>const</span> i = cursor++;
  <span>const</span> prevDeps = effects[i];
  <span>const</span> changed = !prevDeps || prevDeps.<span>length</span> !== deps.<span>length</span> ||
    deps.<span>some</span>(<span>(<span>d, j</span>) =></span> !<span>Object</span>.<span>is</span>(prevDeps[j], d));
  <span>if</span> (changed) {
    <span>if</span> (cleanups[i]) cleanups[i]();         <span>// 先清上一次</span>
    cleanups[i] = <span>fn</span>();                     <span>// 再执行,存新 cleanup</span>
    effects[i] = deps;
  }
}

<span>// ---------- 2. 假渲染器:每轮重置游标,重跑组件函数 ----------</span>
<span>let</span> renderFn = <span>null</span>;
<span>function</span> <span>rerender</span>(<span></span>) {
  cursor = <span>0</span>;                               <span>// 关键:新一轮从链表头重新走</span>
  <span>const</span> vnode = <span>renderFn</span>();
  <span>console</span>.<span>log</span>(<span>'[render]'</span>, <span>JSON</span>.<span>stringify</span>(vnode));
}
<span>function</span> <span>run</span>(<span>component</span>) {
  renderFn = component;
  <span>rerender</span>();
}

<span>// ---------- 3. 一个"组件":两个 useState + 一个 useEffect ----------</span>
<span>let</span> api = <span>null</span>; <span>// 暴露 setter,模拟事件回调</span>
<span>function</span> <span>Counter</span>(<span></span>) {
  <span>const</span> [count, setCount] = <span>useState</span>(<span>0</span>);
  <span>const</span> [step, setStep] = <span>useState</span>(<span>2</span>);
  <span>useEffect</span>(<span>() =></span> {
    <span>console</span>.<span>log</span>(<span>`  effect 执行:count=<span>${count}</span>`</span>);
    <span>return</span> <span>() =></span> <span>console</span>.<span>log</span>(<span>`  cleanup:count=<span>${count}</span> 被清理`</span>);
  }, [count]); <span>// 只依赖 count:step 变不触发</span>
  api = { <span>inc</span>: <span>() =></span> <span>setCount</span>(<span>(<span>c</span>) =></span> c + step), <span>stepUp</span>: <span>() =></span> <span>setStep</span>(<span>(<span>s</span>) =></span> s + <span>1</span>) };
  <span>return</span> { count, step };
}

<span>// ---------- 4. 跑起来:首次渲染 → 点两次 → 改 step → 再点 ----------</span>
<span>run</span>(<span>Counter</span>);                 <span>// 首次:链表 mount,effect 必执行</span>
api.<span>inc</span>();                    <span>// 0→2:step=2,count 变了,effect 重跑(先 cleanup)</span>
api.<span>inc</span>();                    <span>// 2→4:同上</span>
api.<span>stepUp</span>();                 <span>// step:2→3,effect 不依赖 step,不重跑</span>
api.<span>inc</span>();                    <span>// 4→7:函数式更新吃到最新 step=3</span>
<span>// 期望输出:</span>
<span>// [render] {"count":0,"step":2}</span>
<span>//   effect 执行:count=0</span>
<span>// [render] {"count":2,"step":2}</span>
<span>//   cleanup:count=0 被清理</span>
<span>//   effect 执行:count=2</span>
<span>// [render] {"count":4,"step":2}</span>
<span>//   cleanup:count=2 被清理</span>
<span>//   effect 执行:count=4</span>
<span>// [render] {"count":4,"step":3}</span>
<span>// [render] {"count":7,"step":3}</span>
<span>//   cleanup:count=4 被清理</span>
<span>//   effect 执行:count=7</span>

再把"条件 Hooks 的灾难"也演出来,对比就更直观。把上面组件的第二个 useState 换成条件调用,跑几次就能看到状态错位:

<span>// mini-hooks 配套演示:条件调用导致的链表错位</span>
<span>// 注意:换组件 = 换 fiber = 重置内核状态(真实 React 里每个 fiber 各有一条链表)</span>
states = []; effects = []; cleanups = [];
<span>let</span> flag = <span>true</span>;
<span>function</span> <span>Tricky</span>(<span></span>) {
  <span>const</span> [a, setA] = <span>useState</span>(<span>'A'</span>);
  <span>if</span> (flag) {                        <span>// 渲染 1 走这里,渲染 2 不走</span>
    <span>const</span> [b, setB] = <span>useState</span>(<span>'B'</span>); <span>// 条件里调用 hook —— 禁止!</span>
  }
  <span>const</span> [c, setC] = <span>useState</span>(<span>'C'</span>);
  <span>return</span> { a, c };
}
<span>run</span>(<span>Tricky</span>);   <span>// flag=true :c 认领"第 3 个"节点 → 'C'</span>
flag = <span>false</span>;
<span>run</span>(<span>Tricky</span>);   <span>// flag=false:c 认领"第 2 个"节点 → 错位成 'B'</span>
               <span>// 同一行代码,两次渲染读到不同身份的状态</span>
<span>// 期望输出:</span>
<span>// [render] {"a":"A","c":"C"}</span>
<span>// [render] {"a":"A","c":"B"}   ← 错位!c 读到了 b 的状态</span>

第 2 段代码里,flag 翻转一次后,c 对上的状态从"第三个调用"变成了"第二个调用",读到的值直接错位。这正是 eslint 那条 react-hooks/rules-of-hooks 规则在运行时层面保护的东西。

  1. 应用场景

理解了内核,很多日常决策会变得清晰:

  1. 自定义 Hook 是复用的唯一正确姿势。逻辑复用的演进史:mixin(命名冲突、隐式依赖)→ HOC(props 来源成谜、组件层级地狱)→ render props(回调嵌套)→ Hooks(普通函数组合,无包裹、无 this)。把 useState/useEffect 组合成 useLocalStorage、useDebounce、useRequest 这类自定义 Hook,本质就是复用一段 hook 链表模板。

  2. 性能优化的判断标准。useMemo/useCallback 的意义不是"缓存计算",而是稳定引用,让子组件的 memo 比较和 useEffect 的 deps diff 能命中。值没变但每次渲染都产生新引用的场景(把对象/函数传进依赖数组、传给 memo 子组件)才值得用。

  3. 副作用与生命周期的对应。useEffect(异步、不阻塞绘制)适合订阅、埋点、请求;useLayoutEffect(commit 后同步执行、会阻塞绘制)适合测量 DOM、需要避免闪跳的场景;useInsertionEffect 只用于 CSS-in-JS 注入样式。

  4. 并发渲染下的心智模型。React 18+ 中,渲染可被中断,但 effect 一定在真实提交后执行,所以"渲染读状态、effect 做副作用"的分工在并发下依然安全。读最新状态请用 useRef 或 useSyncExternalStore,别指望闭包。

  5. 总结


Hooks 的全部魔法只有一句话:状态不在函数里,在 Fiber 上,函数只是按顺序去认领。memoizedState 链表是存储,游标是定位,双 Dispatcher 是 mount/update 两套行为的分流,deps diff 用引用比较决定副作用要不要重跑。它用"放弃名字、拥抱顺序"换来了函数组件的组合自由,代价就是那条不可违背的规则:调用顺序必须稳定。理解到这一层,写 Hooks 就不再是背规则,而是顺着数据结构走。

下一篇进入 Day17 并发模式,看 lane 优先级和可中断渲染如何把"按顺序认领状态"这件事,放进一个随时可能被打断的世界里。