文章目录
一、useState —— 响应式数据状态
1.1 什么是"状态"?
在聊 useState 之前,我们先搞清楚一个概念:什么是状态(State)?
简单说,状态就是组件在某一时刻的数据快照。比如一个计数器当前是几、输入框里打了什么字、用户列表有多少条数据——这些都是状态。状态一旦改变,React 就会自动重新渲染组件,让界面与数据保持同步。这就是"响应式"的含义:数据变了,UI 自动跟着变。
类比:状态就像电视剧的当前帧——每一帧都是那一瞬间的画面,帧变了,观众看到的画面就变了。
1.2 Hooks 函数式编程的"带头大哥"
2019 年 React 16.8 正式推出 Hooks,useState 是第一个也是使用频率最高的 Hook。它的出现让函数组件从此拥有了自己的状态管理能力,不再需要写 Class 组件。
import { useState } from 'react';
function App() {
const [count, setCount] = useState(0);
// count:当前状态值 0:初始值
// setCount:修改状态的函数
return (
<>
<p>当前计数:{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</>
);
}
这是最精简的 useState 示例,核心就三步:引入 → 声明 → 使用。
1.3 参数详解:初始值 | 函数
useState(参数) 接收一个参数,这个参数决定了状态的初始值。它有两种传法:
| 传法 | 写法 | 适用场景 |
|---|---|---|
| 直接传值 | useState(0)、useState('hello') | 初始值简单、计算成本低 |
| 传函数 | useState(() => 计算初始值) | 初始值需要复杂计算(详见第四章) |
先看"直接传值":
const [name, setName] = useState('张三'); // 字符串
const [age, setAge] = useState(25); // 数字
const [list, setList] = useState([1, 2, 3]); // 数组
const [user, setUser] = useState({ id: 1 }); // 对象
再看"传函数"(惰性初始化):
// ❌ 错误做法:每次组件渲染都会执行 heavyComputation()
const [users] = useState(heavyComputation());
// ✅ 正确做法:只在组件首次挂载时执行一次
const [users] = useState(() => heavyComputation());
这个区别非常关键,第四章会展开讲。现在你只需要记住:如果初始值需要"算"出来,就传函数;如果初始值就是一个现成的值,就传值。
1.4 返回值:[state, setState]
useState 返回一个长度为 2 的数组,我们用解构赋值来接收:
const [count, setCount] = useState(0);
| 返回值 | 是什么 | 能做什么 |
|---|---|---|
第一个元素 count | 当前状态的值 | 在 JSX 中渲染、传给子组件、参与计算 |
第二个元素 setCount | 修改状态的函数 | 调用它 → 状态更新 → 组件重新渲染 |
核心规则(铁律):
- 永远不要直接修改 state:
count = 5是无效的,必须用setCount(5)。 - 状态是只读的:React 通过
setState函数接管了修改权,这样才能在合适的时机触发重新渲染。 - state 和 setState 的命名约定:
[something, setSomething],这是社区约定,不是语法要求,但强烈建议遵守。
二、Fragment 组件 —— 隐形的容器
2.1 为什么需要 Fragment?
React 组件的 return 语句有一个硬性规则:必须返回单个根元素。以前我们习惯用 <div> 来包一层,但这样会在 DOM 中多出一个无意义的节点。
// ❌ 不能这样写——多个根元素会报错
return (
<p>第一段</p>
<p>第二段</p>
);
// 🤔 以前的做法:包一层 div
return (
<div>
<p>第一段</p>
<p>第二段</p>
</div>
);
多出的 <div> 会污染 DOM 结构,影响 CSS 布局(尤其是 Flexbox / Grid),还会让语义变得不清晰。
2.2 Fragment 怎么用?
Fragment 就是一个不会在 DOM 中生成任何真实节点的虚拟容器:
import { Fragment } from 'react';
function App() {
return (
<Fragment>
<p>第一段</p>
<p>第二段</p>
</Fragment>
);
}
或者用更简洁的简写语法(空标签):
function App() {
return (
<>
<p>第一段</p>
<p>第二段</p>
</>
);
}
两种写法效果完全一样。最终渲染到 #root 后,页面上只有两个 <p> 标签,Fragment 本身"功成身退"——它完成了挂载子元素的使命,自己不会留下任何痕迹。
React Fragment 的灵感来源:浏览器原生 DocumentFragment
其实 React 的 Fragment 并非凭空发明,浏览器原生就提供了类似的机制——document.createDocumentFragment(),也叫"文档碎片"。它同样是一个没有实体、只存在于内存中的虚拟容器,用于批量挂载 DOM 元素,避免多次回流(reflow)带来的性能损耗:
<ul id="list"></ul>
<script>
const data = ["任务1", "任务2", "任务3"];
const oList = document.querySelector('#list');
// 创建"文档碎片"——和 React 的 <></> 一样,没有实体
const fragment = document.createDocumentFragment();
for (const task of data) {
const item = document.createElement('li'); // 在 JS 内存中创建
item.innerText = task;
fragment.appendChild(item); // 先挂到"碎片"上,不会触发页面渲染
}
// 所有 li 一次性挂到页面上,只触发一次回流
oList.appendChild(fragment); // fragment 自己不会留在 DOM 中
</script>
对比理解:
React <Fragment> / <>...</> | 原生 document.createDocumentFragment() | |
|---|---|---|
| 本质 | 虚拟容器,不生成真实 DOM 节点 | 内存中的临时容器,不生成真实 DOM 节点 |
| 作用 | 满足"单根元素"规则,不污染 DOM | 批量操作 DOM,减少回流次数 |
| 最终去向 | 渲染后"功成身退",子元素直接挂到父节点 | appendChild 后自身消失,子元素挂到目标节点 |
两个"Fragment"的设计思想一脉相承:用一个透明的容器组织一批子元素,任务完成后自己消失,只留下子元素。
注意:简写
<></>不能带 key 属性,如果你在列表渲染中需要用 key,必须写完整的<Fragment key={...}>。
三、setState 的异步调度更新
这是 useState 最容易踩坑的地方,也是 React 状态管理的核心设计思想。
3.1 "异步"到底是什么意思?
看这段代码,猜猜控制台打印什么?
function App() {
const [count, setCount] = useState(0);
const addCount = () => {
setCount(count + 1);
console.log(count); // 你猜这里打印什么?
};
return (
<>
<p>当前计数:{count}</p>
<button onClick={addCount}>+1</button>
</>
);
}
答案:打印 0,不是 1。
原因:setCount 调用后,React 不会立刻修改 count 的值。它只是把"更新"这件事排进一个任务队列,等当前代码全部执行完毕后,才会批量处理这些更新,然后重新运行组件函数,此时 count 才能拿到新值。
用时间线来看:
用户点击按钮
↓
addCount 开始执行
↓
setCount(count + 1) → React:"好的,我记下了,等会统一处理"
↓
console.log(count) → 此时 count 仍是旧值 0
↓
addCount 执行完毕
↓
React:"开始批量更新状态..."
↓
组件重新渲染,count 变为 1
↓
页面显示 "当前计数:1"
一句话总结:
setState是异步调度的,调用它不会立即改变 state,当前作用域内拿到的仍然是旧值。
3.2 批量更新与合并机制
再看一段更有意思的代码:
const addCount = () => {
setCount(count + 1); // count = 0, 期望变成 1
setCount(count + 1); // count = 0, 期望变成 2
setCount(count + 1); // count = 0, 期望变成 3
};
实际结果:count 只加了 1,不是 3。
为什么?因为三次 setCount 都在同一个事件处理函数中执行,此时 count 自始至终都是 0:
setCount(0 + 1) → React 记录:count → 1
setCount(0 + 1) → React 记录:count → 1
setCount(0 + 1) → React 记录:count → 1
React 发现三次更新都是把 count 设为 1,于是合并为一次,只渲染一次——这叫做批量更新(Batching),目的是性能优化。如果每次 setCount 都立即重新渲染,页面会频繁重绘,性能很差。
核心机制:React 18+ 会自动对所有事件处理函数、setTimeout、Promise 中的状态更新进行批量处理。同一轮事件循环中的多次
setState会被合并。
| 版本 | 批量更新范围 |
|---|---|
| React 17 及之前 | 仅在 React 事件处理函数中合并 |
| React 18+ | 在所有地方(事件、setTimeout、Promise、原生事件)都合并 |
3.3 函数式更新:打破闭包陷阱
如果确实需要基于最新状态累加,怎么办?—— 给 setState 传一个函数:
const addCount = () => {
setCount(prevCount => prevCount + 1); // prevCount = 0 → 返回 1
setCount(prevCount => prevCount + 1); // prevCount = 1 → 返回 2
setCount(prevCount => prevCount + 1); // prevCount = 2 → 返回 3
};
// 最终 count = 3 ✅
函数式更新的工作原理:
prevCount => prevCount + 1是一个更新函数,它接收的参数prevCount是上一次更新后的最新状态,而不是闭包中捕获的旧值。- React 会将更新函数放入队列,按顺序依次执行,每个函数都基于前一个函数的返回值来计算:
setCount(prev => prev + 1) → 0 + 1 = 1
setCount(prev => prev + 1) → 1 + 1 = 2 (prev 已经是 1 了!)
setCount(prev => prev + 1) → 2 + 1 = 3 (prev 已经是 2 了!)
什么时候用函数式更新? 当新状态依赖旧状态时,一律用函数式更新。比如计数器累加、数组 push、对象展开合并等场景。这能避免因闭包捕获旧值导致的 bug。
两种更新方式的对比:
| 方式 | 写法 | 基于的值 | 适用场景 |
|---|---|---|---|
| 传值更新 | setCount(count + 1) | 当前作用域的旧 count | 不依赖旧值,直接设置新值 |
| 函数式更新 | setCount(prev => prev + 1) | React 保证的最新状态 | 新值依赖旧值(累加、追加) |
四、惰性初始化 —— useState 传函数的真正用途
回到第一章提过的问题:useState 的参数什么时候传值,什么时候传函数?
4.1 问题的本质
看这个场景:组件需要 10000 条用户数据作为初始状态:
// 模拟耗时计算
function heavyComputation() {
console.log('开始执行 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;
}
4.2 两种写法的天差地别
// ❌ 错误:每次渲染都会执行 heavyComputation()
const [users] = useState(heavyComputation());
// ✅ 正确:只在首次挂载时执行一次
const [users] = useState(() => heavyComputation());
为什么第一种写法是错的?
当你写 useState(heavyComputation()) 时,heavyComputation() 会立即执行,然后把返回值传给 useState。这意味着每次组件渲染,这行代码都会被执行,heavyComputation() 都会重新跑一次——哪怕 React 最终只使用第一次的返回值。
而 useState(() => heavyComputation()) 传的是一个函数引用,React 在内部判断这是"惰性初始化"模式,只在组件首次挂载时调用它一次。后续组件因为状态变化重新渲染时,React 发现初次初始化已经完成,会直接忽略这个函数。
4.3 执行次数验证
打开控制台,分别运行两种写法,然后修改 filterText 触发重新渲染:
| 写法 | heavyComputation 执行次数 | 控制台输出 |
|---|---|---|
useState(heavyComputation()) | 每次渲染都执行 | N 行 “开始执行…” |
useState(() => heavyComputation()) | 仅挂载时执行 1 次 | 1 行 “开始执行…” |
区别非常明显。对于 10000 条数据尚且能忍受,但如果是复杂算法、大量数据、随机逻辑生成,第一种写法会让页面卡到无法使用。
4.4 完整示例
import { useState } from 'react';
function heavyComputation() {
console.log('开始执行 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;
}
function App() {
const [filterText, setFilterText] = useState('');
// ✅ 惰性初始化:传函数,只在挂载时执行一次
const [users] = useState(() => heavyComputation());
// 基于 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;
数据流理解:组件首次渲染 → 执行 heavyComputation 生成 10000 条数据 → 存入 users → 用户在输入框打字 → filterText 更新 → 组件重新渲染 → 不再执行 heavyComputation → filteredUsers 根据新 filterText 重新过滤 → UI 更新。
五、全文总结
5.1 核心知识点复盘
| 知识点 | 一句话总结 |
|---|---|
| useState 本质 | 让函数组件拥有自己的响应式状态,数据变 → UI 自动变 |
| 参数两种形态 | 简单值直接传;复杂计算传函数(惰性初始化) |
| 返回值结构 | [state, setState],数组解构,命名约定驼峰 |
| Fragment | 虚拟容器,不生成 DOM 节点,简写 <>...</> |
| setState 异步性 | 调用后不立即改值,React 统一批量调度 |
| 批量更新 | 同一事件循环中的多次 setState 会被合并,减少渲染次数 |
| 函数式更新 | setCount(prev => prev + 1),基于最新状态计算,解决闭包问题 |
| 惰性初始化 | useState(() => 重计算),只在挂载时执行一次 |
5.2 常见问题 / 避坑指南
Q1:为什么 setCount 后立刻 console.log(count) 还是旧值?
因为 setCount 是异步调度,不会立即更新 count。需要在新值的地方用,应该放在组件渲染时打印,或者用 useEffect 监听变化。
// ❌ 错误期望
setCount(5);
console.log(count); // 还是旧值
// ✅ 正确方式:在渲染时打印
console.log('当前 count:', count); // 组件重新渲染时会执行这行
Q2:为什么连续调用三次 setCount(count + 1) 只加了 1?
因为三次调用的 count 都是闭包中的同一个旧值。解决方式是使用函数式更新:
// ❌ 基于旧值
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// ✅ 基于最新值
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
Q3:useState(heavyComputation()) 和 useState(() => heavyComputation()) 到底差在哪?
前者 heavyComputation() 会每次渲染都执行(虽然 React 只用第一次的值);后者传的是函数引用,React 只在首次挂载时调用一次。当初始值计算成本高时,必须用后者,否则严重浪费性能。
Q4:对象/数组状态怎么正确更新?
状态是不可变的(immutable),必须创建新对象/新数组,不能直接修改原值:
// ❌ 错误:直接修改
user.name = '李四';
setUser(user); // React 发现是同一个引用,不会重新渲染!
// ✅ 正确:创建新对象
setUser({ ...user, name: '李四' });
// ✅ 数组追加
setList([...list, newItem]);
// ✅ 数组删除
setList(list.filter(item => item.id !== targetId));
Q5:什么时候应该拆分成多个 useState,什么时候合并成一个?
- 如果状态之间相互独立、更新频率也不同,拆开更合适(比如
useState管理用户名、useState管理密码)。 - 如果状态总是一起变化,放在一个对象里更方便(比如表单数据
{ name, age, email })。 - 没有绝对的对错,但拆得细一点通常更灵活,更新时不用手动合并其余字段。
最后的话:
useState是 React 函数组件的入口,理解了它的异步调度、批量更新和惰性初始化,就打通了 React 状态管理的第一关。多写代码、多打断点、多在控制台观察状态变化,才能从"会用"到"理解"。

882

被折叠的 条评论
为什么被折叠?



