开篇
学 React,第一个真正容易卡住的点,通常不是 JSX,也不是组件,而是 useState。
你可能已经会写:
jsx
const [count, setCount] = useState(0);
页面也能正常显示。
但当你连续写三次:
jsx
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
你期待数字加 3,结果页面只加了 1。
这时候很多人就开始怀疑人生:
setState 到底是不是异步的?
为什么打印出来还是旧值?
为什么传函数就能加 3?
useState(() => xxx)又是在干什么?
这篇文章就围绕一个极简 React basic 项目,把 useState 的几个核心点讲清楚:
useState到底是什么- 为什么初始值可以传函数
- 状态更新为什么不会立刻反映到当前变量上
- React 批处理为什么会让连续三次
setCount(count + 1)只加 1 - 函数式更新为什么能解决这个问题
- 受控组件和数据驱动 UI 是怎么串起来的
一、先用一句话理解 useState
useState 是 React 函数组件里保存状态的 Hook。
它做的事可以简单理解为:
给组件一份"会触发重新渲染的数据"。
普通变量变了,React 不知道。
状态变了,React 知道。
所以:
jsx
let count = 0;
这种普通变量,改了不会自动刷新页面。
而:
jsx
const [count, setCount] = useState(0);
这里的 count 是状态,调用 setCount 后,React 会安排组件重新渲染,页面也会跟着更新。
useState 的返回值是一个数组:
jsx
const [state, setState] = useState(initialState);
state:当前这次渲染拿到的状态值setState:告诉 React 状态要更新的函数
为什么叫 Hook?
因为它"钩住"了 React 的内部状态系统。你在函数组件里调用 useState,React 就能把这份状态和当前组件关联起来。
二、Fragment:不要把它和 DocumentFragment 完全画等号
项目里有一个 test.html,演示了浏览器原生的 DocumentFragment:
html
<ul id="list"></ul>
<script>
const data = ["任务一", "任务二", "任务三"];
const oList = document.querySelector("#list");
const fragment = document.createDocumentFragment();
for (const task of data) {
const item = document.createElement("li");
item.innerText = task;
fragment.appendChild(item);
}
oList.appendChild(fragment);
</script>
这段代码的思路很清楚:
先把多个 li 放进 fragment,最后再一次性挂到真实 DOM 上。
这样可以减少频繁操作真实 DOM 的成本。
React 里也有一个类似的概念:Fragment。
jsx
<>
<h1>标题</h1>
<p>内容</p>
</>
它的作用是:
包裹多个子元素,但不额外生成一层真实 DOM 节点。
不过这里要说严谨一点:
React Fragment 和浏览器原生的
DocumentFragment不是一回事,不能简单说 React Fragment 的原生实现就是createDocumentFragment()。
更准确的理解是:
DocumentFragment是浏览器提供的原生 DOM API- React Fragment 是 React 提供的组件结构能力
- 它们的共同点是:都可以避免多出一个无意义的包裹节点
所以在文章里,可以把 DocumentFragment 当作帮助理解 Fragment 的类比,但不要把它说成 React Fragment 的底层实现。
三、useState 懒初始化:为什么要传函数?
项目里有一个耗时计算函数:
jsx
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(`heavyComputation 耗时: ${duration}ms`);
return result;
}
这里用 performance.now() 记录执行耗时。它返回的是毫秒单位的高精度时间戳,适合做前端性能分析。
接下来重点来了。
下面两种写法,看起来只差一个箭头函数:
jsx
// 不推荐:每次组件渲染都会先执行 heavyComputation()
const [users] = useState(heavyComputation());
// 推荐:只在组件首次初始化 state 时执行
const [users] = useState(() => heavyComputation());
为什么第一种不推荐?
因为 JavaScript 会先执行函数调用:
jsx
heavyComputation()
然后把执行结果传给 useState。
也就是说,只要组件函数重新执行,这个耗时函数就会重新跑一遍。
即使 React 后续渲染时不会再使用这个初始值,你这个函数调用也已经发生了。
第二种写法:
jsx
useState(() => heavyComputation())
传给 React 的不是计算结果,而是一个初始化函数。
React 会在组件首次初始化 state 时调用它,拿到初始值。后续组件重新渲染时,这个初始化函数不会再执行。
这就叫 useState 的懒初始化。
用大白话说:
text
useState(heavyComputation())
是:
每次渲染都先算一遍,只是 React 后面不一定用。
而:
text
useState(() => heavyComputation())
是:
先把计算方法交给 React,让 React 只在需要初始化时算一次。
什么时候需要懒初始化?
- 初始数据生成成本比较高
- 需要读取
localStorage - 需要构造比较大的数组或对象
- 初始值只需要在组件第一次挂载时确定
如果只是:
jsx
useState(0)
useState("")
useState(false)
这种简单初始值,就没必要包一层函数。
四、setState 真的"异步"吗?
很多教程会直接说:
setState是异步的。
这句话能帮助新手理解,但如果面试时只这么说,其实还不够准确。
更严谨的说法是:
调用
setState不会立刻修改当前这次渲染里的状态变量;它会告诉 React 安排一次状态更新,React 会在合适的时机批量处理并重新渲染组件。
看这个例子:
jsx
const addCount = () => {
setCount(count + 1);
console.log(count);
};
假设当前页面显示的是 0。
点击按钮后,执行 setCount(count + 1)。
很多人以为下一行:
jsx
console.log(count);
应该打印 1。
但实际打印的还是 0。
原因是:
当前函数里的
count,来自当前这次渲染。它不会因为你调用了setCount就在原地变成新值。
React 的状态更新不是这样工作的:
text
setCount → 立刻改 count 变量
而是这样:
text
setCount → React 记录更新 → 重新执行组件函数 → 新一轮渲染拿到新 count

所以你要记住:
state 像一张快照。当前渲染里的 state,不会在当前函数执行过程中被原地改掉。
五、为什么连续 setCount 三次只加 1?
来看最经典的问题:
jsx
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
如果当前 count 是 0,你以为这是:
text
0 → 1 → 2 → 3
但在当前这次渲染里,count 一直都是 0。
所以这三行实际更像是:
jsx
setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);
也就是:
jsx
setCount(1);
setCount(1);
setCount(1);
React 收到的是三次"把 count 设置成 1"的更新。
最后重新渲染后,结果自然就是 1。
这背后还有一个重要机制:批处理。
React 会把同一轮事件里的多个状态更新合并处理,避免每调用一次 setState 就重新渲染一次。
这是性能优化,不是 bug。
如果每次点击里有多个状态更新:
jsx
setX(newX);
setY(newY);
setVisible(true);
React 不会傻乎乎地渲染三次,而是尽量合并成一次渲染。
这就是批处理的价值。
六、函数式更新:如何正确实现 +3?
如果你真的想让 count 连续加 3,应该这样写:
jsx
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
setCount((prevCount) => prevCount + 1);
这叫函数式更新。
setCount 不只可以接收一个新值,也可以接收一个函数:
jsx
setCount((prevState) => nextState);
这个函数的参数 prevState,是 React 在处理更新队列时传进来的最新状态。
执行过程可以理解为:
text
初始 count = 0
第一次:prevCount = 0 → 返回 1
第二次:prevCount = 1 → 返回 2
第三次:prevCount = 2 → 返回 3
最终 count = 3
所以两种写法的区别是:
jsx
setCount(count + 1);
使用的是当前渲染里的 count 快照。
而:
jsx
setCount((prev) => prev + 1);
使用的是 React 更新队列里的最新状态。
记住一个原则:
只要新状态依赖旧状态,就优先使用函数式更新。
比如:
jsx
setCount((prev) => prev + 1);
setList((prev) => [...prev, newItem]);
setUser((prev) => ({ ...prev, name: "新名字" }));
这类写法都更稳。
七、把概念串起来:用户列表过滤
回到项目里的业务逻辑:
jsx
const [users] = useState(() => heavyComputation());
const [filterText, setFilterText] = useState("");
const filteredUsers = users.filter((user) =>
user.name.includes(filterText)
);
这几行代码把前面的知识点串起来了。
第一行:
jsx
const [users] = useState(() => heavyComputation());
用懒初始化生成用户列表。
因为 users 初始值是一个较大的数组,所以用函数包起来,让它只在初始化时执行一次。
第二行:
jsx
const [filterText, setFilterText] = useState("");
保存输入框的搜索文本。
第三段:
jsx
const filteredUsers = users.filter((user) =>
user.name.includes(filterText)
);
这里没有再用 useState 保存 filteredUsers。
为什么?
因为它可以从已有状态推导出来:
text
filteredUsers = users + filterText 计算出来的结果
这种数据通常不需要单独存 state。
否则你还要维护两份状态的一致性,反而更容易出 bug。
JSX 部分:
jsx
<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>
这里是典型的受控组件:
value由 React 状态filterText控制- 用户输入时触发
onChange onChange调用setFilterText- 状态变化后组件重新渲染
filteredUsers重新计算- 页面自动更新
完整数据流是:
text
用户输入
↓
onChange 触发
↓
setFilterText 更新状态
↓
React 重新执行组件函数
↓
filteredUsers 重新计算
↓
列表和数量自动更新
整个过程没有手动操作 DOM。
你只是在描述:
数据是什么,界面应该长什么样。
数据变了,React 负责更新界面。
这就是 React 的核心思维。
八、常见误区
误区一:setState 会立刻修改变量
不会。
当前渲染里的 state 是快照。
调用 setState 只是安排下一次渲染,不会把当前函数作用域里的变量原地改掉。
误区二:useState 传函数等于把函数作为状态值
不一定。
如果你写:
jsx
useState(() => heavyComputation())
React 会把这个函数当作初始化函数,并调用它得到初始 state。
如果你真的想把函数本身作为 state,需要再包一层:
jsx
const [fn] = useState(() => {
return () => console.log("hello");
});
误区三:所有计算结果都要放进 state
不需要。
像 filteredUsers 这种能从 users 和 filterText 推导出来的数据,直接计算就行。
不要为了"看起来更 React"而滥用 state。
误区四:Fragment 就是 DocumentFragment
不准确。
它们都能避免多余包裹节点,但一个是 React 的语法能力,一个是浏览器 DOM API。
可以类比理解,但不要直接画等号。
九、面试怎么答?
如果面试官问:
useState是什么?
可以这样答:
useState是 React 函数组件中保存状态的 Hook。它返回一个状态值和一个更新函数。调用更新函数后,React 会安排组件重新渲染,并在下一次渲染中返回新的状态值。
如果面试官问:
为什么连续三次
setCount(count + 1)只加 1?
可以这样答:
因为当前渲染里的
count是一个快照,三次setCount(count + 1)使用的都是同一个旧值。假设旧值是 0,那么三次传入的都是 1。React 会批量处理这些更新,最终结果就是 1。如果要基于上一次更新结果继续计算,应该使用函数式更新:setCount(prev => prev + 1)。
如果面试官问:
useState(() => initialValue)有什么用?
可以这样答:
这是懒初始化写法。传入的函数只会在组件初始化 state 时执行一次,适合初始值计算成本较高的场景。它可以避免组件每次重新渲染时都重复执行昂贵计算。
总结
| 知识点 | 结论 |
|---|---|
useState |
给函数组件保存会触发重新渲染的状态 |
| 返回值 | [state, setState] |
| state 特点 | 当前渲染里的 state 是快照 |
setState |
不会立刻修改当前变量,而是安排更新 |
| 批处理 | 多次状态更新会尽量合并,减少渲染次数 |
| 函数式更新 | 新状态依赖旧状态时使用,更稳定 |
| 懒初始化 | useState(() => expensive()) 只在初始化时执行 |
| Fragment | 不额外生成 DOM 节点,但不等同于 DocumentFragment |
useState 看起来只是一个 API,但它背后其实包含了 React 最核心的思维:
状态是一张快照,更新是一次调度,渲染是状态变化后的结果。
理解了这句话,再看批处理、函数式更新、受控组件、数据驱动 UI,就不会觉得它们是零散知识点了。
它们讲的都是同一件事:
不要直接改界面,先改状态;状态变了,界面自然会跟着变。