从 0 到 1 实现一个 useState

大家好,我是印刻君。如果你是前端程序员,相信对 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>

其中:

  1. 引入 Babel 是为了让浏览器识别 JSX 语法;
  2. 引入 ReactDOM 是为了借助它的渲染能力,把组件显示到浏览器中,方便我们验证自己写的 useState 是否生效;
  3. 引入 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 的前端程序员。关注我,让前端知识有温度,技术落地有深度。

相关推荐
风骏时光牛马1 小时前
稳定性治理及疑难问题深度剖析与实战复盘
前端
JeffongTan9 小时前
在LWC中镶嵌VF Page获取用户IP
前端·javascript·salesforce
不cong明的亚子11 小时前
web live多屏互动平台
javascript·经验分享·音视频
陆枫Larry11 小时前
Astro 是什么
前端
是立不是利13 小时前
写给设计师的 CSS 博客美学指南
前端·css
dsyyyyy110113 小时前
JS复习,难点,易错点总结
开发语言·javascript·ecmascript
默_笙14 小时前
🏝 Docker 就是"房地产开发":从施工图纸到小区物业的容器化指南
前端·javascript
计算机魔术师14 小时前
Rohan Paul 谈用时间跨度衡量智能体能力,并引用 OpenAI 自动化研究实习生里程碑
前端
kyriewen14 小时前
我手写了个极简版 React Router——才搞懂 v6 为什么砍了这么多 API
前端·javascript·程序员
a11177614 小时前
Shirone 二次元博客主题 开源
前端·开源