陌上人如玉
公子世无双

11.1 手写 useState / useReducer

11.1 手写 useState / useReducer

一句话:手写 useState 的关键是「用模块级数组 + 游标,按调用顺序存取每个 Hook 的状态」,并在 setState 后「触发组件重新渲染」——这正好解释了「为什么 Hooks 不能写在条件里」。

一、目标与难点

要实现的三个能力:
① 【跨渲染保存状态】——组件重新执行时,读到的还是「上次那个值」
② 【按调用顺序对应】——多个 useState 各自对应自己的状态
③ 【更新后重新渲染】——setState 要触发组件函数重新执行

难点:
· 函数组件的状态不能放在「组件函数内部的变量」里
  (每次渲染函数都会重新执行 → 局部变量被重置)
· 不能用「变量名」来对应状态(运行时拿不到变量名)
  → 只能用【调用顺序】对应(这正是 React 的做法)

二、最简实现(教学版)

// ===== 模拟 React 的运行时 =====

/** 所有 Hook 的状态(真实 React 中是「挂在 fiber 上的链表」) */
let hookStates = [];
/** 当前处理到第几个 Hook(游标) */
let hookIndex = 0;
/** 当前正在渲染的组件 */
let currentComponent = null;

/** 渲染器:执行组件函数并挂载/更新 DOM */
function render(Component, container) {
  currentComponent = Component;
  hookIndex = 0;                                      // ⭐ 每次渲染从头开始
  const element = Component();
  container.innerHTML = '';                           // 简化:清空重建
  mountElement(element, container);
}

function mountElement(element, container) {
  if (typeof element === 'string' || typeof element === 'number') {
    container.appendChild(document.createTextNode(String(element)));
    return;
  }
  const { type, props } = element;
  const dom = document.createElement(type);

  Object.entries(props ?? {}).forEach(([key, value]) => {
    if (key === 'children') return;
    if (key.startsWith('on') && typeof value === 'function') {
      dom.addEventListener(key.slice(2).toLowerCase(), value);   // onClick → click
    } else {
      dom.setAttribute(key, String(value));
    }
  });

  const children = props?.children;
  (Array.isArray(children) ? children : children == null ? [] : [children]).forEach((c) => {
    mountElement(c, dom);
  });

  container.appendChild(dom);
}

// ===== useState 的实现 =====
function useState(initialValue) {
  const index = hookIndex++;                          // ⭐ 当前 Hook 的「槽位号」

  // ① 首次渲染:初始化
  if (hookStates[index] === undefined) {
    hookStates[index] = typeof initialValue === 'function' ? initialValue() : initialValue;
  }

  const setState = (newValue) => {
    const prev = hookStates[index];
    const next = typeof newValue === 'function' ? newValue(prev) : newValue;

    if (Object.is(prev, next)) return;                // ⭐ bailout(相同值跳过)

    hookStates[index] = next;
    rerender();                                        // ⭐ 触发重新渲染
  };

  return [hookStates[index], setState];
}

function rerender() {
  const container = document.getElementById('root');
  render(currentComponent, container);                  // 简化:整棵重渲染
}

// ===== 使用 =====
function Counter() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('张三');

  return {
    type: 'div',
    props: {
      children: [
        { type: 'span', props: { children: `${name}: ${count}` } },
        {
          type: 'button',
          props: { onClick: () => setCount((c) => c + 1), children: '+' },
        },
      ],
    },
  };
}

render(Counter, document.getElementById('root'));
⭐ 最简版本的三个关键点:
① `hookStates` 是【模块级】的(跨渲染存活)
② `hookIndex` 在每次渲染开始时【重置为 0】,每调用一个 Hook 就 +1
③ `setState` 把新值写进 `hookStates[index]`,然后「触发重渲染」

三、为什么「顺序」如此重要

假设第一次渲染:
  hookStates = []
  const [a] = useState(0);        → 槽位 0,hookStates[0] = 0
  const [b] = useState('x');      → 槽位 1,hookStates[1] = 'x'
  → hookStates = [0, 'x']

第二次渲染(某个条件让它跳过了第一个 Hook):
  if (cond) { const [a] = useState(0); }   ← 被跳过
  const [b] = useState('x');               → 槽位 0 ← ⚠️ 读到了 a 的状态!

结果:b 拿到了 a 的值 → 状态错乱 / 报错
// ✅ 所以规则是:每次渲染必须「以相同顺序、相同次数」调用所有 Hook
const [a] = useState(0);          // 槽位 0(永远)
const [b] = useState('x');        // 槽位 1(永远)

更深入的分析见 2.1 Hooks 规则与实现原理。

四、改进版:支持批处理与「更新队列」

// 更接近真实 React 的实现(简化)
type Hook = {
  state: unknown;
  queue: Array<unknown | ((prev: unknown) => unknown)>;
};

let hooks: Hook[] = [];
let cursor = 0;
let rendering = false;
let pending = false;
let scheduledComponent: (() => void) | null = null;

function useState<S>(initial: S | (() => S)) {
  const index = cursor++;

  if (!hooks[index]) {
    hooks[index] = {
      state: typeof initial === 'function' ? (initial as () => S)() : initial,
      queue: [],
    };
  }

  const hook = hooks[index];

  // ⭐ 消费「更新队列」(在渲染过程中应用)
  if (hook.queue.length) {
    hook.state = hook.queue.reduce<unknown>((acc, action) => {
      return typeof action === 'function' ? (action as (p: unknown) => unknown)(acc) : action;
    }, hook.state);
    hook.queue = [];
  }

  const setState = (action: S | ((prev: S) => S)) => {
    hook.queue.push(action);                       // ⭐ 入队(不立即改 state)
    scheduleRerender();
  };

  return [hook.state as S, setState] as const;
}

function scheduleRerender() {
  if (rendering) {
    pending = true;                                 // ⭐ 渲染中触发 → 等本次结束再重渲染
    return;
  }
  queueMicrotask(() => {                            // 简化:用微任务模拟批处理
    if (scheduledComponent) {
      scheduledComponent();
      if (pending) { pending = false; scheduleRerender(); }
    }
  });
}
⭐ 这个版本演示了三个真实机制:
① 【更新队列】——setState 不是「直接改 state」,而是「入队」;
   渲染时再按顺序应用队列(这样多次更新能正确累加)
② 【批处理】——多次 setState 只触发一次重新渲染(用微任务/调度器合并)
③ 【渲染中的更新】——渲染期间触发的更新要「等本次渲染结束」再处理
// 为什么「入队」而不是「直接改」?——这个例子能说明
function Counter() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount((c) => c + 1);     // 入队:fn1
    setCount((c) => c + 1);     // 入队:fn2
    setCount((c) => c + 1);     // 入队:fn3
    // 渲染时依次应用:0 → 1 → 2 → 3 ✅
  };

  return { /* ... */ };
}

五、useReducer 的实现

type Reducer<S, A> = (state: S, action: A) => S;

export function useReducer<S, A>(
  reducer: Reducer<S, A>,
  initialState: S,
  init?: (arg: S) => S
): [S, (action: A) => void] {
  const index = cursor++;

  if (!hooks[index]) {
    hooks[index] = {
      state: init ? init(initialState) : initialState,     // ⭐ 惰性初始化
      queue: [],
      isReducer: true,
      reducer,
    };
  }

  const hook = hooks[index] as Hook & { isReducer: true; reducer: Reducer<S, A> };

  // 用 reducer 消费队列(每个 action 依次作用)
  if (hook.queue.length) {
    hook.state = hook.queue.reduce<unknown>((acc, action) => hook.reducer(acc as S, action as A), hook.state);
    hook.queue = [];
  }

  // ⭐ dispatch 的引用【稳定】(用闭包捕获 index,不依赖渲染)
  const dispatch = useCallbackOnce(
    (action: A) => {
      const h = hooks[index];
      h.queue.push(action);
      scheduleRerender();
    },
    index
  );

  return [hook.state as S, dispatch];
}

/** 保证「同一个槽位的 dispatch」是同一个函数引用 */
const stableCallbacks = new Map<number, unknown>();
function useCallbackOnce<T>(fn: T, index: number): T {
  if (!stableCallbacks.has(index)) stableCallbacks.set(index, fn);
  return stableCallbacks.get(index) as T;
}
⭐ 两个关键点:
① dispatch 的引用【稳定】
   真实 React 用「fiber + queue」保证 dispatch 不变;
   这里用「按槽位缓存」来模拟(Map<index, fn>)
② reducer 在【渲染时】被调用(消费队列时)
   → 所以它必须是纯函数(见 2.8)
// ✅ 用 useReducer 实现计数器
function counterReducer(state: { count: number }, action: { type: 'inc' | 'dec' }) {
  switch (action.type) {
    case 'inc': return { count: state.count + 1 };
    case 'dec': return { count: state.count - 1 };
    default: return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(counterReducer, { count: 0 });
  return {
    type: 'button',
    props: {
      onClick: () => dispatch({ type: 'inc' }),
      children: String(state.count),
    },
  };
}

六、面试追问与要点

Q: 为什么用「数组 + 游标」而不是「Map + 变量名」?
A: 因为运行时拿不到「调用方的变量名」(那是编译期信息)。
   React 只能靠「调用顺序」来对应 Hook 与其状态 —— 这正是数组 + 游标的模型。

Q: 为什么 hookIndex 每次渲染要重置为 0?
A: 因为「同一个组件每次渲染都会重新调用所有 Hook」,
   必须从头开始按顺序读取历史状态。

Q: 多个组件共享 hooks 数组会冲突吗?
A: 真实 React 里每个 fiber 有自己的 memoizedState 链表(互不干扰)。
   教学版只有一个全局数组 → 多组件会串(真实实现用「当前渲染的 fiber」隔离)。

Q: setState 是同步还是异步?
A: 本实现里 scheduleRerender 用微任务 → 表现为「异步 + 批处理」,
   这与 React 18 的自动批处理一致(见 4.7)。

Q: 为什么 setState 要支持函数式更新?
A: 因为「入队」时并不知道最终会应用几次(批处理会合并),
   所以要用「函数」把「计算」延迟到「应用时」执行 —— 这样能正确累加。

Q: 为什么 bailout(相同值跳过)要用 Object.is 而不是 ===?
A: Object.is 能正确处理 NaN(Object.is(NaN, NaN) === true,而 NaN === NaN 是 false)
   与 -0/+0 的差异(Object.is(-0, +0) === false)。

七、完整可运行版本(单文件)

<!doctype html>
<html>
  <body>
    <div id="root"></div>
    <script>
      // ---------- React 运行时(简化) ----------
      let hookStates = [];
      let hookIndex = 0;
      let currentRoot = null;
      let currentComponent = null;

      function createElement(type, props, ...children) {
        return { type, props: { ...props, children: children.length === 1 ? children[0] : children } };
      }

      function useState(initial) {
        const i = hookIndex++;
        if (hookStates[i] === undefined) {
          hookStates[i] = typeof initial === 'function' ? initial() : initial;
        }
        const setState = (v) => {
          const prev = hookStates[i];
          const next = typeof v === 'function' ? v(prev) : v;
          if (Object.is(prev, next)) return;                 // bailout
          hookStates[i] = next;
          scheduleRender();
        };
        return [hookStates[i], setState];
      }

      let scheduled = false;
      function scheduleRender() {
        if (scheduled) return;                               // 批处理
        scheduled = true;
        queueMicrotask(() => {
          scheduled = false;
          hookIndex = 0;                                     // 游标归零
          render(currentRoot, currentComponent);
        });
      }

      // ---------- 渲染器 ----------
      function render(root, Component) {
        currentRoot = root;
        currentComponent = Component;
        root.innerHTML = '';
        root.appendChild(mount(Component()));
      }

      function mount(vnode) {
        if (vnode == null || vnode === false || vnode === true) return document.createTextNode('');
        if (typeof vnode === 'string' || typeof vnode === 'number') return document.createTextNode(String(vnode));

        const el = document.createElement(vnode.type);
        const { children, ...props } = vnode.props ?? {};

        for (const [k, v] of Object.entries(props)) {
          if (k.startsWith('on') && typeof v === 'function') {
            el.addEventListener(k.slice(2).toLowerCase(), v);
          } else {
            el.setAttribute(k, String(v));
          }
        }

        (Array.isArray(children) ? children : [children]).forEach((c) => el.appendChild(mount(c)));
        return el;
      }

      // ---------- 用户代码 ----------
      const h = createElement;

      function Counter() {
        const [count, setCount] = useState(0);
        const [step, setStep] = useState(1);

        return h('div', null,
          h('p', null, `count = ${count}, step = ${step}`),
          h('button', { onClick: () => setCount((c) => c + step) }, `+${step}`),
          h('button', { onClick: () => setStep((s) => s + 1) }, 'step +1'),
          h('button', { onClick: () => { setCount((c) => c + 1); setCount((c) => c + 1); } }, '+2(批处理)')
        );
      }

      render(document.getElementById('root'), Counter);
    </script>
  </body>
</html>
⭐ 建议实际跑一遍这个文件(或用 Node + jsdom),观察:
① 点「+2(批处理)」时:count 加了 2,但只渲染了一次(两次更新被合并)
② 点「step +1」后:再点「+step」是按「新的 step」计算(闭包与队列都正确)

八、常见坑

① 用「组件函数内的局部变量」存状态
   → 每次渲染都被重置(这是「为什么要用模块级/spa 级存储」的原因)

② 忘记「渲染开始重置 hookIndex」
   → 第二次渲染会从上次的位置继续 → 读错槽位

③ 把 hooks 数组「放在组件函数外部但每次重建」
   → 等价于「每次渲染都初始化」

④ 用「变量名」而不是「顺序」对应
   → 运行时无法实现(JS 拿不到变量名)

⑤ setState 直接改 state 而不入队
   → 连续多次更新会被覆盖(只有最后一次生效)

⑥ 忘记 bailout(相同值也触发渲染)
   → 性能问题(真实 React 用 Object.is)

⑦ 用 `===` 而不是 `Object.is`
   → NaN 与 -0/+0 的判断不正确

⑧ 单例 hooks 数组「不区分组件」
   → 多个组件会互相污染(真实 React 用 fiber 隔离)

⑨ 在渲染中「同步」触发重渲染 → 无限循环
   → 必须用「调度器」(微任务/宏任务)延后

⑩ 认为「手写实现与真实 React 等价」
   → 真实实现还有:fiber 链表、优先级车道、可中断渲染、
     更新队列的「基础状态 + 待处理队列」、Suspense 等

九、面试延伸

  1. 「手写一个 useState」
    核心是「模块级数组 + 游标」:① 用一个跨渲染存活的数组 hookStates 保存各 Hook 的状态;② 用 hookIndex 游标记录「当前处理到第几个 Hook」,每次渲染开始时重置为 0、每调用一个 Hook 就 +1;③ useState 首次渲染时初始化槽位,之后按顺序读取历史值;④ setState 把新值写入槽位(或入队)并触发重新渲染。这个结构正好解释了「为什么 Hooks 不能在条件/循环里调用」——因为「身份靠顺序」,顺序一变就错位。

  2. 「为什么不能把状态存在组件函数的局部变量里?」
    因为「每次渲染都是一次新的函数调用」——函数内部的局部变量在每次执行时都会被重新创建并初始化,无法跨渲染保留。所以状态必须存在「组件之外、但与该组件实例关联」的地方。真实 React 把它挂在 fiber 节点的 memoizedState(Hook 链表) 上;教学版为了简单用「模块级数组」代替(代价是「多个组件会互相污染」,真实实现用 fiber 隔离)。

  3. 「为什么要用『调用顺序』而不是『变量名』对应状态?」
    因为运行时拿不到调用方的变量名——变量名是编译期的信息(const [count, setCount] = useState(0) 编译后只是「按顺序的一次函数调用」,count 这个名字在运行时不存在)。所以 React 只能采用「按调用顺序对应」的方案:第 1 个 Hook 对应链表第 1 个节点,第 2 个对应第 2 个……这也直接推导出「每次渲染必须以相同顺序、相同次数调用所有 Hook」这条规则。

  4. 「setState 为什么要支持『函数式更新』?」
    因为它采用「入队 + 批处理」的模型:setState 调用时并不立即计算新状态(只是把「更新」放进队列),真正的计算发生在「渲染时依次应用队列」。如果传的是「值」(setCount(count + 1)),那三次调用入队的都是「同一个基于旧快照算出的值」→ 最终只加了 1;如果传的是「函数」(setCount(c => c + 1)),计算被延迟到应用时执行 → 依次得到 1、2、3。所以「函数式更新」是这个模型下的必然要求。

  5. 「手写实现和真实 React 有什么区别?」
    教学版缺失的部分:① fiber 对应——真实实现把 Hook 链表挂在「每个 fiber 节点」上(而不是一个全局数组),因此多组件/多实例互不干扰;② 优先级调度——真实实现用 Lane 模型 + 可中断渲染,本实现是「微任务里整棵重渲染」;③ 更新队列的细节——真实实现有「基础状态(baseState)+ 待处理队列(baseQueue)」来处理「高优先级插入」的场景;④ 调度与 effect——真实的 useEffect 在「绘制后异步执行」,还有 useLayoutEffect、useInsertionEffect 等不同时机;⑤ Suspense / 并发特性。所以「手写」的目的是理解机制,不是「复刻实现」。

  6. 「为什么 dispatch/setState 的引用是稳定的?」
    因为它们不依赖渲染产生的任何值——setState 只需要「知道自己是哪个 Hook」(槽位号 / fiber 与 queue 的位置),就可以把更新写进去。所以 React 可以在整个组件生命周期里返回同一个函数引用(这也是为什么它可以安全地放进 useEffect 的依赖数组、或者作为 props 传给 memo 子组件而不破坏优化)。教学版里可以用「按槽位缓存函数」来模拟这一点。

  7. 「bailout 为什么用 Object.is 而不是 ===?」
    因为 Object.is 能正确处理两个 === 处理不好的边界:① NaN——Object.is(NaN, NaN) === true,而 NaN === NaN 是 false(如果用 ===,setState(NaN) 会被误判为「值变了」);② -0 与 +0——Object.is(-0, +0) === false,而 -0 === +0 是 true(如果用 ===,从 -0 改到 +0 会被误判为「没变」)。React 在「比较依赖数组」「比较 state」等很多地方都用 Object.is。

  8. 「useReducer 的实现与 useState 有什么关系?」
    关系是「useReducer 更基础」——在真实 React 里,useState 内部就是「用 useReducer 实现」的(useState 的 reducer 是「如果是函数就调用,否则直接替换」)。所以手写时也可以「先写 useReducer,再基于它实现 useState」。两者共享同一套「Hook 槽位 + 更新队列」机制,区别只是「如何计算新状态」:useReducer 用你提供的纯函数,useState 用「替换或函数式更新」。

一句话速记

手写 useState 的核心是「模块级数组 hookStates + 游标 hookIndex」:每次渲染把游标归零、每调用一个 Hook 就 +1、按顺序读写槽位(这解释了「为什么身份靠顺序、不能在条件里调用」);setState 应「把更新入队」(而不是直接改),在渲染时依次应用队列(这才是「函数式更新能正确累加」的原因),并用 Object.is 做 bailout;用微任务/调度器触发重渲染以模拟「异步 + 批处理」,不要在渲染中同步触发(会死循环);dispatch/setState 的引用稳定(不依赖渲染值);useReducer 更基础(真实 React 的 useState 用它实现);手写版的目的是「理解机制」(真实实现还有 fiber 隔离、Lane 优先级、可中断渲染、baseState/baseQueue 等)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 11.1 手写 useState / useReducer

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫