从 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 的前端程序员。关注我,让前端知识有温度,技术落地有深度。

相关推荐
晓得迷路了2 小时前
栗子前端技术周刊第 118 期 - Oxfmt Beta、Angular GitHub stars、React 基金会...
前端·javascript·react.js
亿元程序员2 小时前
小伙伴说我的拼图游戏用Mask不能合批...
前端
恋猫de小郭2 小时前
AI 正在造就你的「认知卸载」,但是时代如此
前端·人工智能·ai编程
摸鱼的春哥2 小时前
Agent教程14:记忆才是Agent开发的核心
前端·javascript·后端
明月_清风2 小时前
Clipboard API 深度实战:如何同时存入“纯文本”和“富文本”两种格式?
前端·javascript
明月_清风3 小时前
权限陷阱:为什么你的“点击复制”在某些浏览器或 iframe 里会失效?
前端·javascript
掘金安东尼12 小时前
让 JavaScript 更容易「善后」的新能力
前端·javascript·面试
掘金安东尼12 小时前
用 HTMX 为 React Data Grid 加速实时更新
前端·javascript·面试
灵感__idea14 小时前
Hello 算法:众里寻她千“百度”
前端·javascript·算法