写了这么久 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(值)vsuseState(() => 值)--- 括号决定函数要不要提前执行,惰性初始化的本质是把执行控制权交给 React<></>Fragment --- 虚拟 DOM 中的 DocumentFragment,分组不污染 DOMsetCount(count + 1)异步 --- React 批处理更新以保障性能和数据一致性setCount(prev => prev + 1)函数式更新 --- 绕过闭包快照,拿到最新值
当你下次写 React 的时候,看到 useState(() => heavyComputation()) 心里就知道它在做什么了:不是多此一举的包装,而是把"立即求值"变成了 React 控制下的"按需求值"。