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

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

1. 技术难点:一个每次重跑的函数,怎么拥有跨渲染的记忆

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

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

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

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

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

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

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

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

scss 复制代码
FiberNode
└── memoizedState ──► Hook1 ──► Hook2 ──► Hook3 ──► null
                      (useState) (useEffect) (useRef)

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

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

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

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

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

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

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

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

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

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

// 重放:遍历 queue,逐个执行
function basicStateReducer(state, action) {
  return typeof action === 'function' ? action(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。
js 复制代码
// deps 比较的核心逻辑(源码 areHookInputsEqual 的简化)
function depsChanged(prevDeps, nextDeps) {
  if (prevDeps === null) return true;                    // 首次挂载,必执行
  if (prevDeps.length !== nextDeps.length) return true;
  for (let i = 0; i < nextDeps.length; i++) {
    if (!Object.is(prevDeps[i], nextDeps[i])) return true;
  }
  return false;
}

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

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

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

jsx 复制代码
function Bad({ show }) {
  if (show) {
    const [a] = useState(1);   // 第 1 次调用
  }
  const [b] = useState(2);     // show 为 false 时它是第 1 次,true 时是第 2 次
}

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

2.6 可运行的最小实现

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

js 复制代码
// mini-hooks.mjs ------ 最小可运行 Hooks 内核(Node 直接跑)
// 运行: node mini-hooks.mjs

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

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

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

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

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

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

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

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

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

3. 应用场景

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

  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,别指望闭包。

4. 总结

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

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

相关推荐
计算机魔术师1 小时前
用AI拒批老人看病?美国这个试点项目的激励机制出了大问题
前端
风骏时光牛马1 小时前
AI服务线上响应异常故障
前端
IT_陈寒1 小时前
JavaScript的this指向问题又让我加了个班
前端·人工智能·后端
PC2005_cloud1 小时前
Nginx 学习笔记:Server 块配置详解,域名路由与多站点部署实战
前端·后端
YIAN1 小时前
LangChain.js 对话记忆体系(一):内存存储与文件持久化,让 AI 拥有对话记忆
前端·后端·langchain
__sjfzllv___1 小时前
在职前端Leader学习/转行 AI Agent -DAY64
前端
IT_陈寒1 小时前
React状态管理这个坑,我是怎么翻车的
前端·人工智能·后端
flash俊杰1 小时前
工程文件版本迁移与崩溃恢复:schemaVersion、Migration 链与原子持久化
前端