
一句话:手写
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 等
九、面试延伸
- 「手写一个
useState」
核心是「模块级数组 + 游标」:① 用一个跨渲染存活的数组hookStates保存各 Hook 的状态;② 用hookIndex游标记录「当前处理到第几个 Hook」,每次渲染开始时重置为 0、每调用一个 Hook 就 +1;③useState首次渲染时初始化槽位,之后按顺序读取历史值;④setState把新值写入槽位(或入队)并触发重新渲染。这个结构正好解释了「为什么 Hooks 不能在条件/循环里调用」——因为「身份靠顺序」,顺序一变就错位。 -
「为什么不能把状态存在组件函数的局部变量里?」
因为「每次渲染都是一次新的函数调用」——函数内部的局部变量在每次执行时都会被重新创建并初始化,无法跨渲染保留。所以状态必须存在「组件之外、但与该组件实例关联」的地方。真实 React 把它挂在 fiber 节点的memoizedState(Hook 链表) 上;教学版为了简单用「模块级数组」代替(代价是「多个组件会互相污染」,真实实现用 fiber 隔离)。 -
「为什么要用『调用顺序』而不是『变量名』对应状态?」
因为运行时拿不到调用方的变量名——变量名是编译期的信息(const [count, setCount] = useState(0)编译后只是「按顺序的一次函数调用」,count这个名字在运行时不存在)。所以 React 只能采用「按调用顺序对应」的方案:第 1 个 Hook 对应链表第 1 个节点,第 2 个对应第 2 个……这也直接推导出「每次渲染必须以相同顺序、相同次数调用所有 Hook」这条规则。 -
「
setState为什么要支持『函数式更新』?」
因为它采用「入队 + 批处理」的模型:setState调用时并不立即计算新状态(只是把「更新」放进队列),真正的计算发生在「渲染时依次应用队列」。如果传的是「值」(setCount(count + 1)),那三次调用入队的都是「同一个基于旧快照算出的值」→ 最终只加了 1;如果传的是「函数」(setCount(c => c + 1)),计算被延迟到应用时执行 → 依次得到 1、2、3。所以「函数式更新」是这个模型下的必然要求。 -
「手写实现和真实 React 有什么区别?」
教学版缺失的部分:① fiber 对应——真实实现把 Hook 链表挂在「每个 fiber 节点」上(而不是一个全局数组),因此多组件/多实例互不干扰;② 优先级调度——真实实现用 Lane 模型 + 可中断渲染,本实现是「微任务里整棵重渲染」;③ 更新队列的细节——真实实现有「基础状态(baseState)+ 待处理队列(baseQueue)」来处理「高优先级插入」的场景;④ 调度与 effect——真实的useEffect在「绘制后异步执行」,还有useLayoutEffect、useInsertionEffect等不同时机;⑤ Suspense / 并发特性。所以「手写」的目的是理解机制,不是「复刻实现」。 -
「为什么
dispatch/setState的引用是稳定的?」
因为它们不依赖渲染产生的任何值——setState只需要「知道自己是哪个 Hook」(槽位号 / fiber 与 queue 的位置),就可以把更新写进去。所以 React 可以在整个组件生命周期里返回同一个函数引用(这也是为什么它可以安全地放进useEffect的依赖数组、或者作为 props 传给memo子组件而不破坏优化)。教学版里可以用「按槽位缓存函数」来模拟这一点。 -
「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。 -
「
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等)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!