本文以
useState为主线,串联 DocumentFragment、异步批量更新、函数式更新、懒初始化等核心知识点,带你真正理解 React 状态管理的"为什么"而非仅仅是"怎么用"。
前言
如果你刚接触 React Hooks,大概率第一个学会的就是 useState。它简单得不像话——一行代码就能让组件"活"起来:
const [count, setCount] = useState(0);
但真的就这么简单吗?如果你只是"会用",那么下面这些问题你未必答得上来:
- 为什么连续三次
setCount(count + 1)只加了 1,而不是 3? - 为什么
console.log(count)打印的总是旧值? useState(heavyComputation())和useState(() => heavyComputation())有什么区别?- React 的 Fragment 和原生 DOM 的 DocumentFragment 有什么内在关联?
这篇文章,我们就从这几个问题出发,把 useState 及其周边知识一次性讲透。
一、Fragment:从 DOM 到 React 的"虚拟容器"
在聊 useState 之前,我们先看一段原生 JavaScript 代码——它很好地诠释了"批量操作"这一核心思想。
1.1 原生 DOM 的 DocumentFragment
<ul id="list"></ul>
<script>
const data = ["任务1", "任务2", "任务3"];
const oList = document.querySelector('#list');
// 创建一个文档碎片——它存在于内存中,没有实体 DOM 节点
const fragment = document.createDocumentFragment();
for (const task of data) {
const item = document.createElement('li');
item.innerText = task;
// 注意:这里不是逐个 append 到 oList,而是挂到 fragment 上
fragment.appendChild(item);
}
// 最后一次性挂载——只触发一次页面重排重绘
oList.appendChild(fragment);
</script>
DocumentFragment 是什么?它是一个"虚拟"的容器节点,存在于内存中而非 DOM 树上。你把子元素一个个挂到它上面,最后一次性插入到真实 DOM——fragment 本身不会出现在页面上,它功成身退,只留下它的子节点们。
这样做的好处是什么?性能。如果每次 appendChild 都直接操作真实 DOM,浏览器就需要反复计算布局(reflow)和重新绘制(repaint)。而先用 fragment 在内存中组装好,再一次提交,就只触发一次重排重绘。
1.2 React 的 Fragment:同一种思想
React 中的 <Fragment>(或简写 <>...</>)和 DocumentFragment 的理念如出一辙:
function App() {
return (
<>
<p>当前计数:{count}</p>
<button onClick={addCount}>+3</button>
</>
);
}
<>...</> 是一个"虚拟容器":
- 它不会在真实 DOM 中生成任何额外的标签(想想如果换成
<div>会多一层嵌套)。 - 它的作用是承载多个子元素,一次性挂载到父容器(如
#root)。 - 挂载完成后,Fragment 本身"功成身退",页面上只留下子元素。
| 特性 | DocumentFragment | React Fragment |
|---|---|---|
| 是否生成实体节点 | ❌ 不生成 | ❌ 不生成 |
| 作用 | 批量挂载,减少重排 | 分组子元素,避免多余 DOM |
| 挂载方式 | appendChild(fragment) | React 调和渲染 |
| 最终结果 | 子节点直接成为父节点孩子 | 子组件直接渲染 |
两种技术跨越了十几年,但核心的工程思想是一致的:能批量就别逐个,能省略就别多写。
二、useState 基础:响应式数据的入口
const [count, setCount] = useState(0);
这行代码拆开来看:
- 参数:初始值。可以是直接值(
0、"hello"),也可以是一个函数(后面细讲)。 - 返回值:一个长度为 2 的数组,
[state, setState]。用解构赋值接住,这是 Hooks 函数式编程的招牌写法。
useState 是 React Hooks 体系的"带头大哥"——它是最基础、最常用的 Hook,由此开启了函数组件的响应式时代。在 Class 组件时代,你得写 this.state + this.setState;而 Hooks 让你用更纯粹的 JavaScript 函数来表达状态逻辑,没有 this,没有生命周期方法的层层嵌套。
三、setState 的异步本质:为什么 count 没有立刻变?
这是新手最容易踩的坑。看这段代码:
const [count, setCount] = useState(0);
const addCount = () => {
setCount(count + 1); // 你以为 count 变成 1 了
console.log(count); // 但这里打印的还是 0 !
};
为什么? 因为 setCount 是异步调度更新——它不会立刻修改 count 的值,而是把更新任务放入一个队列,等当前代码全部执行完毕后,再统一触发组件重渲染。在重渲染时,useState 才会返回新的 count 值。
所以,在当前作用域内,count 始终是旧值——它就是一个普通的 JavaScript 常量,被闭包捕获了而已。
3.1 批量合并(Batching):性能优化的关键
再看一个更让人困惑的例子:
const addCount = () => {
setCount(count + 1); // 0 + 1 = 1
setCount(count + 1); // 还是 0 + 1 = 1
setCount(count + 1); // 还是 0 + 1 = 1
};
// 结果:count 只增加了 1,而不是 3!
三次调用 setCount(count + 1),但三次拿到的 count 都是 0(闭包中的旧值)。React 会将这些更新合并(batch),同一轮内对同一状态的多次更新只会以最后一次为准,避免不必要的重渲染。这是 React 内部的性能优化策略。
类比一下:就像当年的 DocumentFragment 把多个 DOM 操作合并成一次提交——React 把同一轮内的多次 setState 合并成一次重渲染。异曲同工。
四、函数式更新:打破闭包的枷锁
如果我就是需要在一次操作里让 count 加 3,怎么办?
答案是:传函数,而非值。
const addCount = () => {
setCount(prevCount => prevCount + 1); // 基于上一次的结果:0 → 1
setCount(prevCount => prevCount + 1); // 基于上一次的结果:1 → 2
setCount(prevCount => prevCount + 1); // 基于上一次的结果:2 → 3
};
// 结果:count 增加了 3 ✓
当你传入一个**更新函数(updater function)**时,React 的行为发生了变化:
- 它不是直接拿当前渲染周期的闭包值来覆盖;
- 而是将你的函数放入更新队列,在真正执行更新时,依次把前一次的结果传给你的函数;
- 这样每一次都是从"最新的中间状态"出发,而不是一直盯着那个旧的闭包值。
用一张表对比:
| 写法 | 含义 | 适用场景 |
|---|---|---|
setCount(count + 1) | 基于当前渲染周期的值计算 | 不依赖连续更新 |
setCount(prev => prev + 1) | 基于React 内部最新的状态计算 | 需要多次连续更新同一状态 |
金科玉律:当你的新状态依赖旧状态时,始终用函数式更新。
五、Lazy Initialization:昂贵计算只做一次
useState 的参数不一定要是一个值,也可以是一个初始化函数。看这个场景:
function heavyComputation() {
console.log('开始执行 heavyComputation...');
const startTime = performance.now();
const result = [];
for (let i = 0; i < 1000; i++) {
result.push({ id: i, name: `用户-${i}` });
}
const duration = performance.now() - startTime;
console.log(`耗时: ${duration}ms`);
return result;
}
function App() {
// ❌ 坏写法:每次组件渲染都会执行 heavyComputation()
const [users] = useState(heavyComputation());
// ✅ 好写法:只在组件挂载时执行一次
const [users] = useState(() => heavyComputation());
}
区别是什么?
-
useState(heavyComputation()):先调用heavyComputation()得到返回值,再把返回值传给useState。也就是说,每次函数组件执行,heavyComputation()都会跑一次——哪怕useState内部会忽略后续的值。 -
useState(() => heavyComputation()):传入一个函数引用,React 只在首次挂载时调用它来获取初始值。后续重渲染时,React 看到传的是函数,会直接跳过,不再执行。
这也是 React 官方文档强调的 lazy initial state(懒初始化)。当你需要从 localStorage 读取、需要复杂计算、需要生成大量模拟数据时,都应该用这个模式。
对比一下效果:
// 每次渲染都打日志
const [users] = useState(heavyComputation());
// 控制台:开始执行 heavyComputation... ← 每次输入过滤文字都会再打印!
// 只在挂载时打一次
const [users] = useState(() => heavyComputation());
// 控制台:开始执行 heavyComputation... ← 只打印一次,过滤时不再执行
结合用户过滤功能来看完整场景:
function App() {
// 懒初始化:1000 个用户数据只在挂载时生成一次
const [users] = useState(() => heavyComputation());
const [filterText, setFilterText] = useState('');
// computed:基于状态的计算属性
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>
{filteredUsers.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
这里 filteredUsers 就是 React 中的"计算属性"——它不由 useState 管理,而是由现有状态推导而来。每次 filterText 变化触发重渲染时,filteredUsers 会自动重新计算,而 users 因为使用了懒初始化,不会重复生成。
六、一张图总结 useState 的完整心智模型
┌─────────────────────────────────────────────────────────┐
│ useState 核心概念 │
├───────────────┬─────────────────────────────────────────┤
│ 基础语法 │ const [state, setState] = useState(初始值) │
├───────────────┼─────────────────────────────────────────┤
│ 初始值 │ 直接值 → useState(0) │
│ │ 函数(懒) → useState(() => heavyWork()) │
├───────────────┼─────────────────────────────────────────┤
│ 更新方式 │ 传值 → setCount(count + 1) │
│ │ 传函数 → setCount(prev => prev + 1) │
├───────────────┼─────────────────────────────────────────┤
│ 执行时机 │ 异步批量处理,本轮代码跑完才更新 │
├───────────────┼─────────────────────────────────────────┤
│ 性能考量 │ 批量合并(Batching)减少不必要的渲染 │
│ │ 懒初始化(Lazy Init)避免重复的昂贵计算 │
└───────────────┴─────────────────────────────────────────┘
七、写在最后
回到开头的几个问题,现在你应该能回答了:
-
为什么连续三次
setCount(count + 1)只加了 1? → 因为三次调用拿到的count都是同一个闭包中的旧值(0),React 还会合并它们。用setCount(prev => prev + 1)即可解决。 -
为什么
console.log(count)打印的是旧值? → 因为setCount是异步的,当前作用域内的count不会立即改变。新值在下一次渲染中才能拿到。 -
useState(fn())vsuseState(() => fn())的区别? → 前者每次渲染都执行fn()(浪费),后者只在首次挂载时执行(懒初始化)。 -
Fragment 和 DocumentFragment 有什么关系? → 思想上同源:都是"不生成实体节点的虚拟容器",都是一次性批量挂载子元素,目的都是为了性能。
useState 看似简单,但它的设计蕴含了 React 团队对性能优化、函数式编程和开发者体验的深度思考。从 DOM 的 DocumentFragment 到 React 的批量更新,从闭包陷阱到函数式更新,理解了这些"为什么",你才能真正驾驭 React 的状态管理。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论!也欢迎在掘金关注我,一起交流前端技术 🚀