写了这么久 useState,你真的知道它在干什么吗?

写了这么久 useState,你真的知道它在干什么吗?

导读:从一个计数器说起

我们来写一个计数器------点击按钮,数字加 3:

jsx 复制代码
function APP() {
  const [count, setCount] = useState(0);
  const addCount = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };
  return (
    <>
      <p>当前计数:{count}</p>
      <button onClick={addCount}>+3</button>
    </>
  );
}

你期望点击一次 count 变成 3。实际跑起来------结果是 1

为什么会这样?它不是把 count + 1 执行了三次吗?

这个问题会牵扯出 React 中最核心的几个概念:JS 传参机制、闭包快照、批量更新、惰性求值 。这篇文章会从底层 JS 行为出发,一步步拆解这些概念,帮你真正理解 useState 在做什么------而不只是知道怎么用。

本文所有代码均来自一个真实的 Vite + React 项目,完整源码附在各章节末尾。


一、useState:hooks 时代的"带头大哥"

1.1 它是什么

useState 是 React 函数组件里最基础的 Hook。在 class 组件时代,状态只能写在 this.state 里,setState 还得手动 merge,逻辑也四散在各生命周期中。Hooks 的出现把状态管理拉到了一个更"函数式"的范式上------UI 就是状态的函数

它也是所有 Hook 里第一个进入开发者视野的,所以 README 里称它为"函数式编程的带头大哥"。

1.2 参数和返回值

jsx 复制代码
const [state, setState] = useState(初始值 | 函数);
位置 内容 说明
参数 初始值函数 传值:简单场景。传函数:惰性初始化(第四章细讲)
返回值 [state, setState] 数组解构,当前状态 + 修改状态的触发器

你给 useState 一个初始值,它还你一个数组:第一个是当前状态,第二个是一个能触发重渲染的函数。

1.3 数据驱动的三个来源

在函数组件里,决定 UI 的数据有三种来源:

perl 复制代码
┌──────────────────────────────────────────────┐
│            组件中数据从哪来?                  │
│                                              │
│  ① state    → useState / useReducer          │
│  ② props    → 父组件传入                      │
│  ③ computed → 由 ①② 计算派生                 │
└──────────────────────────────────────────────┘

在这个例子里:

jsx 复制代码
const [users] = useState(() => heavyComputation());   // ① state
const [filterText, setFilterText] = useState('');      // ① state
// ↓ ③ computed ------ 不存,直接算
const filteredUsers = users.filter(user =>
  user.name.includes(filterText)
);

filteredUsers 没有自己的 useState,它完全由 usersfilterText 决定------这就是计算属性。如果你用另一个 state 来存它,反而会引入同步问题,因为 state 和 state 之间没有自动的依赖追踪。


二、Fragment:来无影去无踪的容器

2.1 React 组件的"单根"约束

先看 App2.jsx 的 JSX 结构:

jsx 复制代码
function APP() {
  // ...
  return (
    <>                           {/* ← 这是什么? */}
      <p>当前计数:{count}</p>
      <button onClick={addCount}>+3</button>
    </>
  );
}

React 组件的 return 只能返回一个根元素。如果你这样写:

jsx 复制代码
return (
  <p>当前计数:{count}</p>
  <button onClick={addCount}>+3</button>   // ❌ 并列根元素 → 报错
);

JSX 本质上是 React.createElement() 的语法糖,每个调用返回一个对象。return 后面放两个表达式?JS 做不到。

所以需要一个容器把多个元素包起来。选择有两个:

方案 DOM 产物 何时用
<div>...</div> 多余一层 div 需要样式/布局包裹时
<>...</>(Fragment) 零 DOM 痕迹 纯分组,不想污染 DOM

2.2 Fragment 的生命周期

xml 复制代码
挂载前:
  内存中 → <></> 包裹 <p> 和 <button>

挂载后:
  DOM 中 → <div id="root">
             <p>当前计数:0</p>       ← Fragment 消失了
             <button>+3</button>     ← 子元素直接并列
           </div>

这就是 README 里说的:"内部挂载子元素们(DOM 树的功能),一次性挂载到页面上 #root,Fragment 元素就会功成身退"。

Fragment 没有实体 DOM 节点,它只是一个逻辑分组的占位符。挂载完成后,它的使命就结束了。

2.3 原生的对照:DocumentFragment

React 的 Fragment 不是凭空捏造的------浏览器原生就有类似的 API:DocumentFragment

打开 text.html,里面有一个对比实验:

html 复制代码
<ul id="list"></ul>
<script>
  const data = ["任务1", "任务2", "任务3"];
  const olist = document.querySelector('#list');

  // 文档碎片 --- 相当于 <></> 标签,没有实体,在内存中先批量挂载
  const fragment = document.createDocumentFragment();

  for (const task of data) {
    // DOM API 创建元素
    const item = document.createElement('li'); // JS 运行,内存操作
    item.innerText = task;
    // olist.appendChild(item);               // ← 如果写在这里,每次 append 都触发重排
    fragment.appendChild(item);                // ← 先挂到 fragment 上,纯内存操作
  }

  // 一次性插入,页面只渲染一次
  olist.appendChild(fragment);
</script>

关键点在于两张操作的成本不一样:

复制代码
JS 操作(createElement, innerText)→ 纳秒级,在内存里跑
DOM 渲染(appendChild 到真实节点)→ 毫秒级,触发样式计算 + 布局 + 绘制

如果循环里每次都 olist.appendChild(item)

复制代码
循环 3 次 → 3 次重排 → 页面闪了 3 次

用 Fragment/DocumentFragment 后:

复制代码
循环 3 次 → 都在内存里拼 → 最后一次 appendChild → 1 次重排

映射关系:

xml 复制代码
React 层        →  <></>  / <React.Fragment>
浏览器原生层     →  DocumentFragment
核心思想         →  内存中批量组装,一次性渲染

React 的设计团队显然从浏览器 API 的设计哲学中汲取了灵感------虚拟 DOM 本身也可以理解为一个巨大的、跨平台的 DocumentFragment


三、异步更新:setCount 后发生了什么

3.1 谜题重现

回到开头的计数器。count 初始值是 0:

jsx 复制代码
const addCount = () => {
  setCount(count + 1);  // 修改状态 --- 异步
  console.log(count);   // 同步 --- 打印 0
  setCount(count + 1);
  setCount(count + 1);
};

点击按钮后,count 变成 1,而不是 3。控制台打印的是 0。

3.2 两个关键认知

认知一:setCount 不是赋值语句

很多初学者下意识地把 setCount(count + 1) 理解成 count = count + 1,但这不是赋值。setCount 只做一件事:

把"我希望 count 变成什么"这个意图放进 React 的更新队列。React 在合适的时机处理它。

scss 复制代码
你的代码                    React 内部
─────────                  ──────────
setCount(1) ──────┐
setCount(1) ──────┼────→  [更新队列] ──→ 合并处理 ──→ 重渲染
setCount(1) ──────┘
认知二:每次渲染的 state 是一个快照

函数组件的本质是:每当状态变化,React 就重新执行整个函数 。而同一轮执行中,count 是一个不会变的常量------它是当前"快照"里的值。

javascript 复制代码
App 执行(第 N 次渲染,count = 0)
  │
  ├─ const [count, setCount] = useState(0);  // count = 0(常量,本轮不变)
  │
  ├─ addCount 被调用
  │    ├─ setCount(0 + 1) → React 记录:"最终 count 应为 1"
  │    ├─ console.log(count) → 打印 0(快照仍是 0)
  │    ├─ setCount(0 + 1) → React:"还是 1,没变化"
  │    └─ setCount(0 + 1) → React:"还是 1"
  │
  ├─ return <p>{count}</p>   // → 还是 0
  │
  └─ 本轮结束 → React 发现最终 state = 1 → 触发下次渲染

App 执行(第 N+1 次渲染,count = 1)
  └─ return <p>{count}</p>   // → 变成 1

3.3 React 为什么要这么设计?

README 的注释给了一个精确的答案:

"组件里面的状态比较多,x, y, z 坐标、移动......多个状态同时改。React 状态的更新是靠 React 组件函数的重新运行来实现的。"

想象一个游戏场景,每帧要同时更新玩家的 x、y、z 坐标:

jsx 复制代码
// 用户移动 → 三个状态一起变
setX(x + 1);
setY(y + 2);
setZ(z + 0.5);

如果每次 set 都立即触发重新渲染:

bash 复制代码
setX → 渲染(x 变了,y/z 还是旧的 → 不一致的画面)
setY → 渲染(y 变了,x 已经新的了 → 又不一样)
setZ → 渲染(z 变了)

三次渲染不仅浪费,画面还会出现中间态的不一致。React 的做法是:

复制代码
setX ─┐
setY ─┼─→ 攒到本轮代码结束 → 合并 → 一次渲染(x, y, z 同时更新)
setZ ─┘

这就是 batch(批处理) ------把同一轮事件循环中的多次 setState 压缩成一次重渲染。React 18 之后,不仅事件处理器,setTimeout、Promise、原生事件里的 setState 也都自动批处理。

3.4 闭包陷阱:为什么三次调用都是 0+1

这是理解关键中的关键。问题不在 React,在 JavaScript 的求值顺序

js 复制代码
setCount(count + 1);
//        ^^^^^^^^^
//        这里 count 是 0。JS 先算出 0+1=1,然后把"1"传给 setCount
//        React 收到的是"1",它不知道这"1"是怎么来的

三行代码本质上写的是:

js 复制代码
setCount(0 + 1);  // → setCount(1)
setCount(0 + 1);  // → setCount(1)
setCount(0 + 1);  // → setCount(1)

每次传给 setCount 的参数都是 1。React 合并后发现三次都一样,最终结果就是 1。

这不是 React 的 bug,而是 闭包快照 的必然结果------countaddCount 函数创建时就绑定到了当前渲染的值,本轮执行期间不会变。

3.5 解决方案:函数式更新

如果你真的想连加三次,需要让 React 基于最新值来算,而不是基于闭包里的旧快照:

jsx 复制代码
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);
setCount(prevCount => prevCount + 1);

对比:

scss 复制代码
传值:                             传函数:
setCount(0 + 1) → setCount(1)      setCount(prev → 0 + 1) → 最终 1
setCount(0 + 1) → setCount(1)      setCount(prev → 1 + 1) → 最终 2
setCount(0 + 1) → setCount(1)      setCount(prev → 2 + 1) → 最终 3

React:"三次都是 1,设 1"           React:"1→2→3,设 3"

传函数时,React 不是直接收一个值,而是收"一个计算公式"。它会在内部按顺序调用:

js 复制代码
// React 内部的简化逻辑
let nextState = currentState;
nextState = updater1(nextState);  // 0 → 1
nextState = updater2(nextState);  // 1 → 2
nextState = updater3(nextState);  // 2 → 3
// nextState = 3

这样每次计算都基于上一步的结果,而不是闭包里那个不会变的旧值。

一句话总结

  • setCount(count + 1) → 读的是旧快照,传的是"绝对值"
  • setCount(prev => prev + 1) → 读的是最新值,传的是"算法"

四、惰性初始化:你的 useState 可能在做无用功

4.1 问题场景

App.jsxheavyComputation

jsx 复制代码
function heavyComputation() {
  console.log('开始执行 heavyComputation...');
  // 网页性能优化指标 --- performance 性能表现 API
  const startTime = performance.now();  // 当前时间
  const result = [];
  for (let i = 0; i < 10000; i++) {
    result.push({ id: i, name: `用户-${i}` });
  }
  const duration = performance.now() - startTime;
  console.log(duration);
  return result;
}

这个函数生成 10000 条用户数据,耗时在几十到几百毫秒。部署到生产环境还需要从 localStorage 读缓存、解析 JSON 等------都属于"昂贵计算"。

4.2 两种写法,天壤之别

jsx 复制代码
// ❌ bad:每次都执行
const [users] = useState(heavyComputation());

// ✅ good:懒执行,React 只在挂载时执行一次
const [users] = useState(() => heavyComputation());

一眼看上去,区别只是一个箭头函数包裹。但对性能的影响是巨大的。

4.3 底层原理:JS 传参机制说了算

理解这个区别,不需要看 React 源码,JS 基础就够了。

JavaScript 采用 Applicative Order(先求参数值,再传参) 的求值策略:

js 复制代码
// 写法 A
useState(heavyComputation());
//        ^^^^^^^^^^^^^^^^^^
//        带 (),JS 引擎立刻执行 heavyComputation
//        耗时 50ms,返回一个大数组
//        useState 收到的参数是这个大数组

// 写法 B
useState(() => heavyComputation());
//        ^^^^^^^^^^^^^^^^^^^^^^^^
//        这是一个箭头函数表达式,JS 引擎创建一个函数对象(几乎零耗时)
//        它里面的代码还没被调用
//        useState 收到的参数是一个函数引用

useState 内部的逻辑大致是这样的(简化版):

js 复制代码
// React useState 内部简化实现
let isFirstRender = true;
let memorizedState;

function useState(initialValue) {
  if (isFirstRender) {
    // 首次渲染:判断是不是函数
    if (typeof initialValue === 'function') {
      memorizedState = initialValue();  // 是函数 → React 调用它,拿到结果
    } else {
      memorizedState = initialValue;    // 不是函数 → 直接用
    }
  }
  // 后续渲染:直接返回已有状态,initialValue 看都不看

  function setState(newValue) { /* ... */ }

  return [memorizedState, setState];
}

这揭示了关键事实:

时机 useState(heavyComputation()) useState(() => heavyComputation())
首次渲染 执行 heavyComputation,耗时 React 调用回调,执行 heavyComputation,耗时
第二次渲染 又执行 heavyComputation!耗时相同! React 跳过回调,不执行,零耗时
第 N 次渲染 每次都执行! N × 耗时 每次都不执行

对于传值写法,heavyComputation() 在进入 useState 之前就执行完了------React 根本拦不住它。后续渲染中,JS 依然会执行 heavyComputation()(因为它是函数调用的参数表达式),得到一个大数组,然后传给 useStateuseState 内部虽然把参数丢弃了------但计算已经做完了,浪费已经发生了

对于传函数写法,() => heavyComputation() 只是创建一个箭头函数对象(零开销),React 在 isFirstRender = false 时直接跳过,根本不会调用它。

核心差异不在 React 源码,在 JS 的求值阶段。括号(调用)决定一切。

4.4 用 performance API 验证

App.jsx 中注释里提到的 performance.now() 做了什么?让我们把它用起来做对比:

jsx 复制代码
function heavyComputation() {
  const startTime = performance.now();
  const result = [];
  for (let i = 0; i < 10000; i++) {
    result.push({ id: i, name: `用户-${i}` });
  }
  const duration = performance.now() - startTime;
  console.log(`heavyComputation 耗时: ${duration}ms`);
  return result;
}

// 如果是 useState(heavyComputation()):
// → 输入框每输入一个字母 → 组件重渲染 → heavyComputation 再跑一次 → 又耗时
// → 打字都卡!

// 如果是 useState(() => heavyComputation()):
// → 首次挂载:执行一次
// → 后续输入:不执行 → 丝般顺滑

performance.now() 返回的是高精度时间戳(微秒级),比 Date.now()(毫秒级)更适合做性能测量。在真实项目中,这类测量数据通常会通过 PerformanceObserver 或 Navigation Timing API 上报到监控系统,这就是注释中"网页性能优化指标 performance 性能表现 API"的含义。

4.5 什么情况需要用惰性初始化?

简单的不用,昂贵的才用:

jsx 复制代码
// 不需要惰性初始化
const [name, setName] = useState('');           // 字符串 --- 零成本
const [count, setCount] = useState(0);          // 数字 --- 零成本
const [list, setList] = useState([1, 2, 3]);    // 短数组 --- 几乎零成本

// 需要惰性初始化
const [users] = useState(() => heavyComputation());  // 10000 条数据
const [cache] = useState(() => {
  const saved = localStorage.getItem('app-cache');
  return saved ? JSON.parse(saved) : defaultValue;   // I/O 操作
});

五、全链路串联:从 JSX 到页面

看完了四个核心概念,我们把整个项目串起来看一遍。

5.1 入口

html 复制代码
<!-- index.html -->
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>

5.2 挂载

jsx 复制代码
// main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

createRoot 创建 React 18 的并发根节点,StrictMode 在开发环境下会双重调用某些方法帮你发现副作用问题(这也是为什么开发时 useState 的初始函数会被调用两次------React 在验证你的初始化函数是纯函数)。

5.3 App.jsx --- 用户过滤系统

jsx 复制代码
function App() {
  // 惰性初始化:只在挂载时计算一次
  const [users] = useState(() => heavyComputation());

  // 过滤关键字
  const [filterText, setFilterText] = useState('');

  // 计算属性:由 users 和 filterText 派生
  const filteredUsers = users.filter(user =>
    user.name.includes(filterText)
  );

  return (
    <div style={{ padding: '20px' }}>
      <h2>用户列表</h2>
      <input
        type="text"
        placeholder="输入用户名过滤"
        value={filterText}
        onChange={(e) => setFilterText(e.target.value)}
      />
      <p>当前显示 {filteredUsers.length} 个用户</p>
      <ul style={{ maxHeight: '300px', overflowY: 'auto' }}>
        {filteredUsers.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

用户输入 "张三" 的流程:

scss 复制代码
onChange 触发
  → setFilterText('张三')          // 异步调度
  → 本轮结束
  → React 重渲染 App()
    → users 不变(惰性初始化跳过)
    → filterText 变为 '张三'
    → filteredUsers = users.filter(...)  // users 中 name 包含 '张三' 的项
    → Virtual DOM diff → 更新真实 DOM

5.4 App2.jsx --- 计数器

jsx 复制代码
function APP() {
  const [count, setCount] = useState(0);   // 初始值 = 0

  const addCount = () => {
    // 函数式更新:每次基于最新值计算
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
  };

  return (
    <>
      <p>当前计数:{count}</p>
      <button onClick={addCount}>+3</button>
    </>
  );
}

六、知识地图

把今天覆盖的四个概念串成一张图:

javascript 复制代码
                    ┌──────────────────┐
                    │     useState     │
                    │   hooks 带头大哥  │
                    └────────┬─────────┘
                             │
          ┌──────────────────┼──────────────────┐
          │                  │                  │
    参数:初始值|函数    返回值:[state, setter]   作用:响应式数据
          │                                     │
    ┌─────┴─────┐                          ┌────┴────┐
    │  惰性初始化  │                          │  异步更新  │
    │  lazy init  │                          │  async    │
    └─────┬─────┘                          └────┬────┘
          │                                     │
   JS 传参机制决定                      React 批量处理 (batch)
   传函数 vs 传值                       闭包快照 vs 函数式更新
          │                                     │
    ┌─────┴─────┐                          ┌────┴────┐
    │ 性能优化    │                          │  数据一致性 │
    │ 只计算一次  │                          │  一次渲染  │
    └───────────┘                          └─────────┘

          ┌──────────────────┐
          │    Fragment      │
          │  <></> / <React.Fragment>  │
          └────────┬─────────┘
                   │
          ┌────────┴────────┐
          │  原生成对照        │
          │  DocumentFragment │
          └────────┬────────┘
                   │
          ┌────────┴────────┐
          │  批量操作 DOM     │
          │  减少重排次数     │
          └─────────────────┘

七、一句话小结

  • useState(值) vs useState(() => 值) --- 括号决定函数要不要提前执行,惰性初始化的本质是把执行控制权交给 React
  • <></> Fragment --- 虚拟 DOM 中的 DocumentFragment,分组不污染 DOM
  • setCount(count + 1) 异步 --- React 批处理更新以保障性能和数据一致性
  • setCount(prev => prev + 1) 函数式更新 --- 绕过闭包快照,拿到最新值

当你下次写 React 的时候,看到 useState(() => heavyComputation()) 心里就知道它在做什么了:不是多此一举的包装,而是把"立即求值"变成了 React 控制下的"按需求值"。


相关推荐
慢功夫2 小时前
💡第七篇:VSCode语言服务中,代码跳转和诊断是怎么做的?
前端·visual studio code
Csvn2 小时前
✨ TypeScript `satisfies` 操作符实战——5 个让代码更「聪明」的生产场景
前端
Healer9182 小时前
vue3页面缓存-keepAlive
前端
一个有理想的摸鱼选手2 小时前
在三维地球上以另一种视角感受巴威台风
前端·gis·ai编程
烬羽2 小时前
递归老写崩?一个"退回"公式,把回溯题变成填空题
javascript·深度学习·算法
程序员黑豆4 小时前
鸿蒙应用开发:Grid组件实现九宫格布局教程
前端·华为·harmonyos
浮江雾4 小时前
Flutter第十七节-----路由管理(3)
android·开发语言·前端·javascript·flutter·入门
阿懂在掘金4 小时前
企业级命令式弹窗方案:三行代码适配已有 Dialog
前端·vue.js·前端框架
Revolution614 小时前
测试接口为什么进了生产包:前端环境变量到底在什么时候生效
前端·前端工程化