前言
如果你刚入门 React,useState 绝对是你接触的第一个 Hook,也是用得最多的一个。但你真的把它吃透了吗?为什么有时候一连调三次 setCount 只加了一次?为什么有些写法会让页面卡成 PPT?
这篇文章我们从一个简单的计数器讲起,逐步深入 useState 的异步机制、懒加载优化,再到 Fragment 组件和它背后的 DOM 性能思想。配合大量可运行的示例代码,保证你看完就能用。
一、useState:函数式组件的"记忆芯片"
1.1 为什么叫 Hook?
在 React 16.8 之前,函数组件被称为"无状态组件"------它没有记忆,每次渲染都是全新的。Hooks 的出现让它"钩"住了状态。useState 就是第一个,也是最重要的 Hook。
jsx
import { useState } from 'react'
function App() {
const [count, setCount] = useState(0)
const addCount = () => {
setCount(count + 1)
}
return (
<>
<p>当前计数:{count}</p>
<button onClick={addCount}>+1</button>
</>
)
}
export default App
1.2 语法拆解
jsx
const [状态值, 更新状态的函数] = useState(初始值 | 函数)
| 部分 | 说明 |
|---|---|
useState(...) |
Hook 函数,告诉 React 这块数据需要"被记住" |
| 参数 | 初始值(简单数据),或一个返回初始值的函数(复杂计算) |
| 返回值 | 一个长度为 2 的数组 |
count |
只读的状态值,当前渲染周期内的快照 |
setCount |
修改状态的唯一入口,调用后触发组件重新渲染 |
注意:
count是通过const解构出来的,在这一轮渲染中它就是常量,直接修改它不会生效。
1.3 状态是响应式的
React 的核心理念:UI = f(state)。界面是状态的映射,状态变了,界面自动更新。你不需要手动操作 DOM,只需要修改状态。
jsx
const [filterText, setFilterText] = useState('')
const filteredUsers = users.filter(user =>
user.name.includes(filterText)
)
// 输入框一敲,filterText 变化 → 组件重新渲染 → filteredUsers 重新计算 → 列表自动更新
这就是 React 的数据驱动视图 ,也是 useState 被称作"响应式数据状态"的原因。
二、setState 的异步本质:三次 +1,为什么只加了一次?
这是新手最容易踩的坑。我们来看这段代码:
jsx
function App() {
const [count, setCount] = useState(0)
const addCount = () => {
setCount(count + 1) // count = 0,期望变成 1
setCount(count + 1) // count = 0,期望变成 2
setCount(count + 1) // count = 0,期望变成 3
console.log(count) // 打印的还是 0
}
return (
<>
<p>当前计数:{count}</p>
<button onClick={addCount}>+3</button>
</>
)
}
点击按钮后,count 从 0 变成了------1,不是 3。
2.1 为什么会这样?
两个关键原因:
① count 是常量,不会中途变
通过 const 解构出来的 count,在这个函数调用周期里就是一个常量快照。第一行 count 是 0,最后一行 count 还是 0。
② setState 是异步调度,不是立即执行
调用 setCount 不会立刻修改 count 的值------它只是把"我要更新"这个消息放进了队列,等当前代码全部跑完,React 才会批量处理这些更新并重新渲染。
scss
addCount 执行时 count = 0
setCount(0 + 1) → 告诉 React:"下回渲染把 count 设为 1"
console.log(0) → 当前 count 还是 0,log 打印 0
setCount(0 + 1) → 告诉 React:"下回渲染把 count 设为 1"(又是1,和上次一样)
setCount(0 + 1) → 告诉 React:"下回渲染把 count 设为 1"(还是1...)
React 一看:三次都是要设为 1,没必要搞三次,合并成一次 →
最终结果:count 从 0 → 1,只加了一次
2.2 为什么要合并?------性能优化
想象一下,一个复杂的页面同时改了 x、y、z 坐标,如果每次 setState 都立刻触发重渲染,那就要渲染三次。React 的策略是:本轮代码全部执行完,把所有更新合并成一次,统一渲染。
这个思想和原生 DOM 里的 DocumentFragment 如出一辙(后面会细说)。
2.3 真要想 +3 怎么办?------函数式更新
如果你确实需要"基于最新状态做连续更新",传函数而不是直接传值:
jsx
const addCount = () => {
setCount(prevCount => prevCount + 1) // prevCount = 0 → 返回 1
setCount(prevCount => prevCount + 1) // prevCount = 1 → 返回 2
setCount(prevCount => prevCount + 1) // prevCount = 2 → 返回 3
}
// 三次都生效,0 → 3,完美!
| 写法 | 逻辑 |
|---|---|
setCount(count + 1) |
基于闭包里的旧值(可能是过期的) |
setCount(prev => prev + 1) |
基于队列里的最新值(每一次都拿到最新) |
原则: 当你本次更新依赖于上一次的状态结果时,用函数式更新。简单场景两者都可以。
三、useState 的参数:初始值 vs 惰性函数
jsx
// 简单数据:直接传值
const [count, setCount] = useState(0)
const [name, setName] = useState('')
const [list, setList] = useState([])
// 复杂计算:传函数(惰性初始化)
const [users] = useState(() => heavyComputation())
3.1 为什么不能直接传 heavyComputation()?
看这段代码:
jsx
function heavyComputation() {
console.log('开始执行 heavyComputation')
const startTime = performance.now()
const result = []
for (let i = 0; i < 1000000; i++) {
result.push({ id: i, name: `用户-${i}` })
}
const duration = performance.now() - startTime
console.log('耗时:', duration)
return result
}
// ❌ 错误写法
const [users] = useState(heavyComputation())
// ✅ 正确写法
const [users] = useState(() => heavyComputation())
3.2 两种写法的执行时机
| 写法 | 首次渲染 | 后续每次重渲染 |
|---|---|---|
useState(fn()) |
执行 ✅ | 又执行 ❌(React 丢弃结果,白跑) |
useState(() => fn()) |
执行 ✅ | 跳过 ✅(React 知道这是惰性函数,不调用) |
heavyComputation() 生成了 100 万个用户,假设耗时 50ms。
- 错误写法: 首次渲染 50ms,用户敲一个字触发重渲染,又 50ms;再敲,再 50ms...输入框直接卡成 PPT。
- 正确写法: 首次渲染 50ms,后续重渲染直接跳过,耗时 0ms。
这就是惰性初始化(Lazy Initialization)------告诉 React:"这个函数很重,只在第一次帮我跑,以后别跑了。"
3.3 什么时候用惰性初始化?
- 初始状态需要经过昂贵的计算(大量数据、复杂逻辑、复杂随机算法)
- 从
localStorage读取数据 - 初始化 1000 个 NPC、10000 个用户等大批量数据
jsx
// 从 localStorage 读数据(惰性写法)
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos')
return saved ? JSON.parse(saved) : []
})
四、Fragment:不给 DOM 添乱的容器
4.1 JSX 要求单一根节点
JSX 有个硬性规定:一个组件 return 的东西只能有一个顶层元素。所以你经常得包一层:
jsx
// ❌ 这样不行(两个顶层元素)
return (
<p>当前计数:{count}</p>
<button onClick={addCount}>+3</button>
)
// ✅ 必须包一层
return (
<div>
<p>当前计数:{count}</p>
<button onClick={addCount}>+3</button>
</div>
)
4.2 但 <div> 是有代价的
每多一层 <div>,DOM 树就多一层节点:
less
#root
└── <div> ← 其实不需要的一层
├── <p> 计数 </p>
└── <button> +3 </button>
- 破坏 Flex / Grid 布局的子元素关系
- 增加 CSS 选择器嵌套深度
- 表格里不能随意包
<div>(会破坏<table>结构)
4.3 Fragment 就是"不存在的包裹层"
jsx
return (
<>
<p>当前计数:{count}</p>
<button onClick={addCount}>+3</button>
</>
)
渲染后:
less
#root
├── <p> 计数 </p>
└── <button> +3 </button>
<>...</> 是 <React.Fragment> 的简写。它只在 JSX 编译时充当"虚拟容器",到了 DOM 里不留任何痕迹。
4.4 Fragment 和 DocumentFragment 的关系
如果你写过原生 DOM,可能见过这个:
html
<script>
const data = ["任务1", "任务2", "任务3"]
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>
两者的思想一模一样:
| 概念 | React Fragment | DOM DocumentFragment |
|---|---|---|
| 是什么 | 虚拟包裹容器 | 内存中的临时容器 |
| 在哪 | JSX 编译层 | 内存层 |
| DOM 里看得见吗? | ❌ 不可见 | ❌ 不可见(挂载后消失) |
| 作用 | 少一层 div | 减少重绘次数 |
一句话: Fragment 是让你"名正言顺地不需要
<div>包裹"的语法糖,让你的 DOM 树更干净。
五、综合实战:用户筛选列表
把我们学到的知识点串在一起,看一个完整的例子:
jsx
import { useState } from 'react'
// 模拟昂贵的初始计算(生成 100w 条用户数据)
function heavyComputation() {
console.log('开始执行 heavyComputation')
const startTime = performance.now()
const result = []
for (let i = 0; i < 1000000; i++) {
result.push({ id: i, name: `用户-${i}` })
}
const duration = performance.now() - startTime
console.log('耗时:', duration, 'ms')
return result
}
function App() {
// ✅ 惰性初始化:只在首次渲染时跑一次
const [users] = useState(() => heavyComputation())
const [filterText, setFilterText] = useState('')
// 计算属性:根据 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>
)
}
export default App
知识点盘点
useState(() => heavyComputation())--- 惰性初始化,100w 数据只算一次,重渲染不重算useState('')--- 简单初始值直接传onChange={(e) => setFilterText(e.target.value)}--- 受控组件,输入框的值由 React 状态接管users.filter(...)--- 这就是计算属性,依赖filterText状态,状态变 → 自动重新算key={user.id}--- 列表渲染必须加 key,帮助 React 识别每个元素
六、总结
useState 核心要点
| 要点 | 说明 |
|---|---|
| 作用 | 给函数组件加上"状态记忆" |
| 初始值 | 简单数据直接传,复杂计算用惰性函数 () => fn() |
| 返回值 | [状态快照, 更新函数] |
| 状态是只读的 | const 解构,不可直接修改 |
| setState 是异步的 | 当前作用域内 count 仍是旧值 |
| 批量合并 | 多次相同更新会被合并,优化性能 |
| 函数式更新 | prev => prev + 1 拿到最新值,突破闭包陷阱 |
Fragment 核心要点
| 要点 | 说明 |
|---|---|
| 写法 | <>...</> 或 <Fragment>...</Fragment> |
| 作用 | JSX 语法要求的包裹容器,但不产生真实 DOM 节点 |
| 优点 | DOM 更干净,不破坏布局结构 |
| 思想来源 | 类似原生 DOM 的 DocumentFragment------内存容器,一次挂载 |
希望这篇指南能帮你在 React 的入门路上少踩几个坑。如果你正在跟着教程练习,建议把文中的每个例子都亲手敲一遍------写代码的感觉和看代码完全不同。
Happy coding! 🚀