大家好,我是印刻君。如果你是前端程序员,相信对 React 并不陌生。但你有没有想过,如果 React 没有给你提供 useState,你能否自己实现一个呢?
今天我就带你从零实现一个 useState。我会先梳理它必备的核心能力,再用极简代码实现一个功能完整的基础版,在基础版之上,我最终会实现一个更严谨的进阶版。
一、useState 的核心能力拆解
动手前,我们先明确一个合格的 useState 必须具备的核心能力。具体有以下几点:
1.1 数据持久化与触发重新渲染
我们通过一个简单示例理解这两个能力:
javascript
function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
}
return (
<div>
<p>{count}</p>
<button onClick={handleClick}>计数</button>
</div>
)
}
1.1.1 数据持久化
组件首次挂载时,count 的值是 0;点击"计数"按钮之后,组件会重新执行,但 count 的值并没有变回初始值 0,而是"记住"了更新之后的 1。
这种在组件多次渲染中记忆数据,不被重置的性质就是 state 的数据持久化能力。
1.1.2 触发重新渲染
点击"计数"按钮之后,setCount 会更新 count 的值,React 检测到 state 发生变化,会再执行一遍组件函数。
这种检测 state 变化并更新组件的性质,就是触发重新渲染。
1.2 setState 支持函数式更新
如下面代码示例,setState 既可以传入一个值,也可以传入一个回调函数。
传入回调函数的方式,就是函数式更新。
javascript
const handleClick = () => {
setCount(prev => prev + 1)
}
1.3 setState 支持批量更新
当你连续多次调用 setState(比如示例中的 setCount 时),并不会触发多次渲染。React 会把这些更新操作整合起来,只触发一次渲染。
这就是 setState 的批量更新特性。
javascript
const handleClick = () => {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
}
1.4 useState 可多次调用,创建多个相互独立的状态
在一个组件中,你可以多次调用 useState,每次调用会创建一个独立的状态单元(包括状态值和更新函数)。
比如示例中,更新 count 并不会影响 str 和 time,修改 str 也不会影响 count 和 time。
javascript
function App() {
const [count, setCount] = useState(0)
const [str, setStr] = useState('ink')
const [time, setTime] = useState(Date.now())
// ...
}
二、基础版的 useState
2.0 环境准备
要自己实现 useState 并验证是否符合预期,我们需要先搭建一个简单的浏览器运行环境,也就是下方的 HTML 模板:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>从零实现 useState</title>
<script
src="https://unpkg.com/@babel/standalone/babel.min.js"
></script>
<script
src="https://unpkg.com/react@18/umd/react.development.js"
></script>
<script
src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('root'))
function App() {
return (
<p>从零开始实现 useState</p>
)
}
function render() {
root.render(<App />)
}
render()
</script>
</body>
</html>
其中:
- 引入 Babel 是为了让浏览器识别 JSX 语法;
- 引入 ReactDOM 是为了借助它的渲染能力,把组件显示到浏览器中,方便我们验证自己写的 useState 是否生效;
- 引入 React 是因为 ReactDOM 内部代码依赖它,但我们全程不会使用 React 自带的 useState,而是自己实现。
整个模板的功能,就是把 App 组件渲染到页面的 root 节点上,方便我们后续测试自定义 useState。
2.1 实现数据持久化、触发重新渲染
2.1.1 实现 state 数据持久化
在之前准备的 HTML 模板基础上,我们先实现最基础的 state 数据持久化功能。要做到这一点,需要满足两个要求:
1. state 不随函数执行重置。
state 需要在函数内执行,但更新组件时不能被重置,这意味着 state 不能存储在函数内部(局部变量会在函数执行后销毁),而必须存储函数外部作为全局变量。
2. state 只在首次渲染时初始化。
state 只在第一次执行时用初始值初始化,后续不能重复初始化。这要求我们区分"首次渲染"和"更新渲染"两个时机,我们可以用 state 是否为 undefined 来区分。
满足要求的代码如下:
javascript
let state
function render() {
root.render(<App />)
}
function useState(initialVal) {
if (state === undefined) {
state = initialVal
}
function setState(newVal) {
state = newVal
}
return [state, setState]
}
2.1.2 实现 setState 触发组件重新渲染
接下来我们给自定义的 useState 补上"调用 setState"触发渲染的能力。
在 React 源码中,调度更新的模块叫做 Scheduler,负责决定"什么时候更新"。
我们这里简化处理,不实现优先级调度,直接在 setState 后立即触发渲染,把这个函数叫做 schedule 以示致敬。
javascript
function schedule() {
render()
}
function useState(initialVal) {
if (state === undefined) {
state = initialVal
}
function setState(newVal) {
state = newVal
schedule()
}
return [state, setState]
}
2.1.3 完整代码
结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/a...
2.2 实现函数式更新
2.2.1 实现逻辑
接下来我们给 setState 增加函数式更新 的能力。核心逻辑是:
如果 setState 的参数类型是函数,就把"上一次的 state"传给这个函数,并把函数的返回值作为新的 state;
如果 setState 的参数类型是普通值,直接用这个值更新 state。
javascript
function useState(initialVal) {
// ...
const prevState = state
function setState(action) {
state = typeof action === 'function'
? action(prevState)
: action
schedule()
}
return [state, setState]
}
2.2.2 完整代码
结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/w...
2.3 实现批量更新
先说明,我们接下来实现的是手动批量更新(需要主动调用方法包裹 setState),语法如下:
javascript
const handleClick = () => {
batchUpdate(() => {
setCount(prev => prev + 1)
setCount(prev => prev + 1)
setCount(prev => prev + 1)
})
}
而 React 源码中的是自动批量更新(不需要主动调用方法包裹 setState)。
我们选择手动批量更新是为了简化理解,核心逻辑和 React 源码的批量更新是一致的。
2.3.1 核心思路
批量更新的本质是"先收集所有更新任务,最后一次性执行",实现这个逻辑需要两个全局变量、两个核心函数。
1. 两个全局变量
- queue,更新队列,专门用来存放 setState 更新任务;
- isBatchingUpdates,更新标记,用来判断当前是否处于批量更新阶段。
javascript
let queue = []
let isBatchingUpdates = false
// ...
function useState(initialVal) {
if (state === undefined) {
state = initialVal;
}
function setState(action) {
if (isBatchingUpdates) {
// 批量阶段:把更新任务加入队列
queue.push(action);
} else {
// 非批量阶段:直接更新 state, 立即渲染
const prevState = state;
state = typeof action === "function" ? action(prevState) : action;
schedule();
}
}
return [state, setState];
}
2. 两个核心函数
- flushUpdates,执行队列内所有的更新任务,返回最后的 state;
javascript
function flushUpdates() {
let currentState = state;
// 遍历队列,依次执行每个更新任务
while (queue.length > 0) {
const update = queue.shift();
currentState = typeof update === "function" ? update(currentState) : update;
}
return currentState;
}
- batchUpdate,手动开启批量更新
javascript
function batchUpdate(callback) {
isBatchingUpdates = true;
try {
// 执行用户传入的回调(里面会调用多次 setState)
callback();
} finally {
isBatchingUpdates = false;
// 批量阶段结束后,执行所有更新任务并更新 state
state = flushUpdates();
schedule();
}
}
2.3.2 完整代码
结合 HTML 模板的完整代码:codesandbox.io/p/sandbox/3...
2.3.3 React 原生批量更新的特性
不同 React 版本中,setState 的"同步/异步"表现不同,核心原因是批量更新标记的开启规则不一样:
React18 之前
批量更新仅在 React 管控的场景自动开启(比如点击/输入等合成事件,useEffect/生命周期钩子),此时 setState 会先收集任务、延迟渲染,表现为"异步";
而在原生事件(比如 document.onclick)、定时器(setTimeout)和 Promise 回调中,批量更新标记未开启,useState 会立即更新并渲染,表现为"同步"。
React18 之后
批量更新标记默认全局开启,几乎所有场景下 setState 都是"异步"的。只有用 flushSync 包裹时,才会强制同步更新。
2.4 支持多个 useState
2.4.1 核心思路
我们之前只在全局定义了一个 state,如果多次调用 useState(比如同时定义 count 和 age 两个状态),会导致状态值混乱,要解决这个问题,核心思路如下:
1. 用数组存储多个 state
把全局的单个 state 改成 state 数组,每个 useState 都对应 state 数组中的一个元素。
javascript
let stateArr = []
2. 用下标(调用顺序)来匹配 state 用数组存储多个 state 后,我们需要在调用 setState 时准确知道更新数组中的哪一个 state。
因此我们需要靠下标(调用顺序)来匹配 state。
在全局维护一个 hookIndex 变量,每调用一次 useState,hookIndex 变量就自增 1(保存在 useState 的闭包中),这样每个 useState 都对应数组中的一个固定下标,就能精准匹配自己的 state。
javascript
let stateArr = [];
let hookIndex = 0;
// ...
function useState(initialVal) {
const currentIndex = hookIndex;
if (!stateArr[currentIndex]) {
stateArr[currentIndex] = initialVal;
// ...
}
function setState(action) {
if (isBatchingUpdates) {
// ...
} else {
// 非批量阶段:直接更新对应下标的 state,立即渲染
const prevState = stateArr[currentIndex];
stateArr[currentIndex] = typeof action === "function" ? action(prevState) : action;
schedule();
}
}
// 索引自增,匹配下一次 useState 调用
hookIndex++;
return [stateArr[currentIndex], setState];
}
3. 批量更新队列也要适配多状态
之前为单个 useState 维护了一个批量更新队列,现在需要支持多个 useState,单个队列也需要改造为队列数组。
javascript
let queueArr = [];
// ...
function flushUpdates() {
// 遍历每个 state 的更新队列,执行更新
for (let i = 0; i < queueArr.length; i++) {
const queue = queueArr[i] || [];
let currentState = stateArr[i];
while (queue.length > 0) {
const update = queue.shift();
currentState = typeof update === "function" ? update(currentState) : update;
}
stateArr[i] = currentState; // 更新对应下标的state
queueArr[i] = []; // 清空当前队列
}
}
// ...
function useState(initialVal) {
const currentIndex = hookIndex;
if (!stateArr[currentIndex]) {
// ...
queueArr[currentIndex] = []; // 初始化对应下标的更新队列
}
function setState(action) {
if (isBatchingUpdates) {
// 批量阶段:把更新任务加入当前下标对应的队列
queueArr[currentIndex].push(action);
} else {
// ...
}
}
// ...
}
2.4.2 完整代码
结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/j...
2.4.3 为什么 useState 不能放在 if 和 for 循环中
我们用数组实现的 useState 有个关键限制:必须在组件顶层调用,不能放在 if、for 循环里,否则会导致状态错乱。
比如下面例子,就是条件语句导致状态混乱。因为 if 的存在,flag 对应的 hookIndex 不固定,状态就会匹配错误。
javascript
function App() {
const [count, setCount] = useState(0) // hookIndex = 0
if (count > 0) {
const [extra, setExtra] = useState(0) // ❌ 错误!条件调用
}
const [flag, setFlag] = useState(false) // hookIndex 可能是 1 或 2,无法确定!
}
React 源码没有用数组存储 Hooks,而是用链表(每个 Hook 是一个节点,调用时从当前节点移到下一个)。但核心逻辑和数组实现一致,都依赖固定的调用顺序匹配状态,因此依然要遵守规则:useState 不能放在 if、for 循环中。
三、进阶版:基于链表的 useState
3.1 数组方案的致命缺陷:无法适配 React 18 并发模式
我们之前用数组实现的 useState,在同步渲染下能正常工作,但在 React18 的并发模式(Concurrent Mode)下会彻底失效。因为并发模式下可能中断渲染,一旦中断渲染,数组的索引就会全部错乱。
3.1.1 什么是并发模式?
我们知道,浏览器的刷新帧率约为 60fps,也就是大概每 16.6ms 刷新一次。这意味着如果一段 JavaScript 代码执行时间超过 16.6ms,就会阻塞页面刷新,导致卡顿。
React 的并发模式,就是允许中断渲染过程(比如优先处理用户点击、输入等高频交互),等浏览器空闲时再恢复渲染,避免页面卡顿。
3.1.2 数组在并发模式下的问题
数组实现的 useState 依靠全局唯一的 hookIndex 来匹配状态:
如果组件渲染过程被中断(比如渲染到一半,hookIndex 刚走到 2),等恢复渲染时,全局 hookIndex 并不会自动回到中断前的位置,而是会继续往后自增,这就会导致后续的 useState 与状态数组的下标 错位,最终状态匹配错误。
而链表实现则可以很好地解决这个问题:
链表不依赖全局索引,而是为每个组件独立维护一条 Hook 链表,并只用一个当前节点指针来记录遍历位置。渲染中断时,只需要保存当前指针指向的 Hook 节点;恢复渲染时,直接从这个节点继续往下遍历即可,不会出现索引错乱、状态错位的问题。
3.2 利用链表替代数组
现在我们基于之前的数组版本,把 useState 改造成链表实现,这样可以适配并发模式,更贴合 React 源码。
改造可以分为 4 个关键步骤:
3.2.1 定义 Hook 链表节点(替换数组存储)
首先会删除全局的 stateArr(状态数组)和 hookIndex(状态索引),改用链表节点存储每个 useState 的状态。
- 每个 Hook 节点包含状态值、更新队列,以及指向下一个节点的指针;
- 用 rootHook 记录链表的头节点,利用 currentHook 记录链表的当前节点。
javascript
// 定义单个 Hook 节点结构(链表核心)
function createHookNode(initialVal) {
return {
state: initialVal, // 当前 Hook 的状态值
queue: [], // 当前 Hook 的更新队列
next: null // 指向下一个 Hook 节点的指针
};
}
// 链表核心指针:rootHook(链表头)、currentHook(当前遍历节点)
let rootHook = null;
let currentHook = null;
3.2.2 渲染函数适配(重置链表指针)
每次组件渲染前,需要把 currentHook 重置到链表头(rootHook),替代原本 hookIndex = 0 的逻辑,这样可以保证每次渲染时都从第一个 Hook 节点开始遍历。
javascript
function render() {
currentHook = rootHook;
root.render(<App />);
}
3.2.3 适配 useState 函数(遍历链表匹配状态)
之前靠自增 hookIndex 找到对应的 state,现在改为遍历链表(移动 currentHook 指针)匹配状态:
- 首次渲染时,创建新的 Hook 节点并挂载到链表末尾;
- 调用 setState 时,操作当前节点的状态 / 队列,而非数组下标;
- 每次调用完 useState,把指针移到下一个节点(替代原来的
hookIndex++)。
javascript
function useState(initialVal) {
// 首次渲染:创建新节点,初始化链表
if (!currentHook) {
const newHook = createHookNode(initialVal);
// 链表为空时,rootHook指向第一个节点
if (!rootHook) {
rootHook = newHook;
} else {
// 链表已有节点,挂载到当前节点的next
let lastHook = rootHook;
while (lastHook.next) {
lastHook = lastHook.next;
}
lastHook.next = newHook;
}
currentHook = newHook;
}
// 保存当前节点(避免后续指针移动影响)
const hookNode = currentHook;
function setState(action) {
if (isBatchingUpdates) {
// 批量阶段:加入当前节点的更新队列
hookNode.queue.push(action);
} else {
// 非批量阶段:直接更新状态并渲染
const prevState = hookNode.state;
hookNode.state = typeof action === "function" ? action(prevState) : action;
schedule();
}
}
// 移动指针到下一个节点(替代原hookIndex++)
currentHook = currentHook.next;
return [hookNode.state, setState];
}
3.2.4 批量更新函数适配
之前批量更新是遍历 "队列数组",现在更新队列存在每个链表节点里,因此改为遍历整个链表,逐个执行节点的更新任务。
javascript
function flushUpdates() {
// 遍历整个Hook链表,执行每个节点的更新队列
let hook = rootHook;
while (hook) {
const queue = hook.queue;
let currentState = hook.state;
// 执行当前节点的所有更新任务
while (queue.length > 0) {
const update = queue.shift();
currentState = typeof update === "function" ? update(currentState) : update;
}
hook.state = currentState; // 更新节点状态
hook.queue = []; // 清空队列
hook = hook.next; // 移动到下一个节点
}
}
3.2.5 完整代码
结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/l...
3.3 利用环状链表替换队列
React 源码中,Hook 的更新队列并非普通数组,而是环状链表(循环链表)。相比普通数组,环状链表在 "频繁新增 / 删除更新任务" 时性能更高,且能更高效地处理并发模式下的更新中断 / 恢复。
为了更贴合 React 源码,我们把每个 Hook 节点中的 queue 替换为环状链表队列,并适配对应的 "入队、遍历执行" 逻辑。 大概可以分为 4 个步骤:
3.3.1 定义环状链表的节点
我们先创建环状链表的基础单元(单个更新任务),每个节点包含:
- action:更新动作(比如 prev => prev + 1);
- next:指向下一个更新任务节点的指针(最后一个节点的 next 指向头节点)。
javascript
// 新增:定义环状链表的更新任务节点
function createUpdateNode(action) {
return {
action: action, // 存储更新动作(值/函数)
next: null // 指向下一个更新任务节点
};
}
3.3.2 修改 Hook 节点结构(替换普通数组队列)
我们把 Hook 节点中的 queue 替换为环状链表的核心指针:
- queueHead:更新队列的头节点(默认 null);
- queueTail:更新队列的尾节点(默认 null),环状链表的 queueTail.next = queueHead。
javascript
// 改造:Hook节点不再用数组队列,改用环状链表指针
function createHookNode(initialVal) {
return {
state: initialVal, // 当前Hook的状态值
queueHead: null, // 更新队列头节点(环状链表)
queueTail: null, // 更新队列尾节点(环状链表)
next: null // 指向下一个Hook节点的指针
};
}
3.3.3 适配 setState 入队逻辑(新增任务到环状链表)
原来的 hookNode.queue.push(action) 替换为 "环状链表入队":
- 若队列为空:头/尾节点都指向新任务;
- 若队列非空:尾节点的 next 指向新任务,更新尾节点,且尾节点 next 指向头节点(形成环)。
javascript
// 新增:更新任务入队(环状链表)
function enqueueUpdate(hookNode, action) {
const newNode = createUpdateNode(action);
// 队列为空:头/尾节点都指向新节点
if (!hookNode.queueHead) {
hookNode.queueHead = newNode;
hookNode.queueTail = newNode;
newNode.next = newNode; // 环状:自己指向自己
} else {
// 队列非空:尾节点next指向新节点,更新尾节点,形成环
hookNode.queueTail.next = newNode;
hookNode.queueTail = newNode;
newNode.next = hookNode.queueHead;
}
}
// 改造useState中的setState:
function setState(action) {
if (isBatchingUpdates) {
// 替换:数组push → 环状链表入队
enqueueUpdate(hookNode, action);
} else {
// 非批量逻辑不变(仅演示批量场景,非批量可复用入队+执行逻辑)
const prevState = hookNode.state;
hookNode.state = typeof action === "function" ? action(prevState) : action;
schedule();
}
}
3.3.4 适配 flushUpdates(遍历环状链表执行更新)
原来的 "遍历数组 queue.shift ()" 替换为 "遍历环状链表":
- 从队列头开始遍历,直到回到头节点(环状终止条件);
- 执行所有更新任务后,清空环状链表(重置 head/tail 为 null)。
javascript
// 改造:批量更新核心(遍历环状链表执行更新)
function flushUpdates() {
let hook = rootHook;
while (hook) {
const head = hook.queueHead;
let currentState = hook.state;
// 若有更新任务,遍历环状链表
if (head) {
let currentNode = head;
// 环状链表遍历:直到回到头节点(终止)
do {
const action = currentNode.action;
// 执行更新动作(和原逻辑一致)
currentState = typeof action === "function" ? action(currentState) : action;
currentNode = currentNode.next;
} while (currentNode !== head); // 环状终止条件
// 执行完所有任务,清空环状链表
hook.queueHead = null;
hook.queueTail = null;
// 更新Hook节点的最终状态
hook.state = currentState;
}
hook = hook.next; // 移动到下一个Hook节点
}
}
3.3.5 完整代码
结合 HTML 模板的完整代码我放到 codesandbox 上了,链接为:codesandbox.io/p/sandbox/3...
四、总结
本篇文章,我拆解了 useState 的核心能力,并完成了基础版到进阶版的手写。通过从 0 到 1 实现一个 useState,你知道了 useState 的核心能力、设计思路和局限。
相信了解这些,能帮助吃透底层原理,从而更轻松应对面试,也更快地在日常开发中定位问题。
我是印刻君,一位探索 AI 的前端程序员。关注我,让前端知识有温度,技术落地有深度。