写了这么久 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,它完全由 users 和 filterText 决定------这就是计算属性。如果你用另一个 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,而是 闭包快照 的必然结果------count 在 addCount 函数创建时就绑定到了当前渲染的值,本轮执行期间不会变。

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.jsx 的 heavyComputation:

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()(因为它是函数调用的参数表达式),得到一个大数组,然后传给 useState,useState 内部虽然把参数丢弃了------但计算已经做完了,浪费已经发生了。

对于传函数写法,() => 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 控制下的"按需求值"。


相关推荐
lovingsoft25 分钟前
Chrome Lighthouse 详解
前端·chrome
Qlly35 分钟前
全栈工程师怎么接需求?从一句「加个功能」聊到能开发、能验收
前端·后端
马可家的菠萝39 分钟前
你的微信“文件传输助手”,是不是已经成了第二个网盘?
前端·后端·架构
略略略啦40 分钟前
Vibe Coding 基于Spec DD和TDD的实践指南
前端
shmily麻瓜小菜鸡1 小时前
前端实现图片与PDF文字识别敏感信息打码方案
前端·pdf·状态模式
打工仔折腾 AI1 小时前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
汉堡大王95271 小时前
单次输出拉到100万Token:拆完 Gemini 4 Argon 的续写协议,才看懂谷歌这7个月在憋什么
前端·人工智能·后端
看浪的路人1 小时前
第9讲:大模型应用监控与可观测性——OpenTelemetry + Prometheus + Grafana 全链路追踪
java·前端·spring
内存过载1 小时前
用表格模拟甘特图:项目排期的土办法
前端·低代码
葡萄城技术团队2 小时前
用 SpreadJS 数据验证实现省市区三级联动
前端