REACT · Vol.I · LESSON 04 · 核心原语
State 与 useState:数据驱动的视图更新
state:组件自己的「实例字段」
上一篇说 Props 是「从外部传入、不可变的参数」。那组件内部的数据呢?答案是 state(状态)。
用 Java 类比最直观:
| React | Java 类比 | 说明 |
|---|---|---|
props | 构造器/方法参数 | 外部传入,只读 |
state | 实例字段(field) | 内部持有,可变化 |
setState | 字段的 setter | 改字段,并触发界面更新 |
看一个具体例子——一个「点赞按钮」:
import { useState } from "react";
function LikeButton() {
// 声明一个 state:初始值为 0
const [likes, setLikes] = useState(0);
return (
<button onClick={() => setLikes(likes + 1)}>
点赞 {likes}
</button>
);
}
useState(0) 返回一个长度为 2 的数组,我们用解构语法拆成两个:
likes—— 当前状态值(初始 0);setLikes—— 更新状态的函数,调用它 React 就会重新渲染组件。
这里 useState 就是 React 的 Hook(钩子)之一。Hook 是一类特殊的函数,只能在组件或其它 Hook 里调用,它们让函数组件「记住」数据。
你可能会疑惑:函数每次执行完局部变量就销毁了,likes 怎么记得住?答案是——数据根本不存放在你的函数里,而是存在 React 内部。React 为每个组件实例维护一份「状态档案」,useState 读的是档案里的值,setLikes 改的也是档案,然后 React 重新调用你的函数,让你用新值再渲染一遍。你的组件函数只是「执行一遍 = 拍一张快照」,真正持有状态的是 React。
组件需要一个以上 state?多调几次就行
function RegisterForm() {
const [name, setName] = useState("");
const [age, setAge] = useState(0);
const [agreed, setAgreed] = useState(false);
// ……每个 useState 都是独立的「字段」
}
每个 useState 声明一个独立字段,就像类里并排写三个字段声明。命名约定:[xxx, setXxx]——setter 永远是 set 加字段名的驼峰形式。
关键:改 state 的「正确姿势」——不可变更新
这是后端转 React 最容易踩的坑。看这段经典错误:
function List() {
const [items, setItems] = useState([1, 2, 3]);
function addWrong() {
items.push(4); // ❌ 直接改了原数组
setItems(items); // ❌ 传入的还是同一个引用
}
}
为什么不行?因为 React 判断「状态有没有变」用的是 引用相等(Object.is)。你 push 后数组引用没变,React 会认为「没变」,于是跳过重渲染——界面纹丝不动。
正确做法是不可变更新:生成一个新的数组/对象,再传进去:
function addRight() {
setItems([...items, 4]); // 展开旧数组 + 新元素 = 新数组
}
function updateObj() {
setUser({ ...user, age: 27 }); // 展开旧对象 + 覆盖字段 = 新对象
}
「修改」和「不可变替换」在 JS 里是两套不同的方法族,把这张对照表存下来,日常开发照着抄:
| 意图 | ❌ 原地修改(mutate) | ✅ 不可变(返回新引用) |
|---|---|---|
| 数组追加 | arr.push(x) | [...arr, x] |
| 数组删除 | arr.splice(i, 1) | arr.filter((_, idx) => idx !== i) |
| 按条件删 | 循环 + splice | arr.filter(t => t.id !== id) |
| 数组逐项变换 | for 循环改元素 | arr.map(t => ({ ...t, done: true })) |
| 数组排序 | arr.sort()(原地!) | [...arr].sort(...) |
| 对象改字段 | obj.age = 27 | { ...obj, age: 27 } |
规律就一句话:所有返回 void、悄悄改原数据的方法都不能直接用在 state 上;换成返回新数据的那一个。对应 Java 世界:就像 String 的 concat 返回新串,而不是 StringBuilder.append 改自己。
String 为什么设计成不可变?——因为不可变让多线程共享、缓存、比较都变得安全。React 的不可变 state 带来同样的好处:可预测、易追踪、好优化(能配合 memo 做浅比较)。不可变不是「仪式感」,是实打实的工程决策。进阶点:批处理与「函数式更新」
两个高频进阶问题,面试常考,工作中也真的会碰到。
问题 1:同一个函数里多次 setState,会渲染几次?
function App() {
const [count, setCount] = useState(0);
function handle() {
setCount(count + 1); // count 还是 0
setCount(count + 1); // count 还是 0!
}
// 结果:count 只变成 1,不是 2。两次 set 被「批处理」合并了
}
React 会把同一事件里的多次 setState 合并成一次重渲染,并且每次读取的 count 都是「本次渲染时」的旧值(这叫闭包快照)。这和数据库的「批量提交」一个道理:攒一批一起刷,比逐条刷高效得多。
问题 2:想基于「最新值」连续累加,怎么办?
function handle() {
setCount(c => c + 1); // 用「更新函数」接收上一次的值
setCount(c => c + 1); // 基于最新值再 +1
}
// 结果:count 变成 2 ✅
当你需要基于上一次 state 计算新值时,把「值」换成「函数」——setCount(c => c + 1)。React 会按顺序把更新函数依次应用。
setCount(count + 1) 像「读出来、加一、写回去」的非原子操作——两个并发请求都读到旧值,就丢了一次更新;setCount(c => c + 1) 则像数据库的 UPDATE t SET n = n + 1 或 AtomicInteger.incrementAndGet()——基于最新值原地递增,天然无竞态。只要新值依赖旧值,就用函数式,这条规则可以无脑执行。
state是组件的「实例字段」,setState是触发重渲染的「setter」。- 不可变更新:永远生成新对象/新数组,再传给 setState;原地 mutate 的方法一律不用。
- 基于旧值更新,用函数式
setState(c => c + 1),避免闭包陷阱。
每次渲染都是一张「快照」
批处理那节提到「闭包快照」,这里把它升格为一个正式的心智模型,它能解释日后困扰你的大半「灵异现象」:
组件函数的每次执行,都在一份冻结的 state 快照上进行。这次渲染里读到的所有 state/props,都是渲染开始那一刻的值;事件处理器、setTimeout 回调里读到的,也是「它被创建的那次渲染」的快照,而不是「点击发生时」的最新值。
用一道经典面试题检验一下:
function App() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setTimeout(() => {
alert(count); // 显示 0,不是 1!
}, 3000);
}
return <button onClick={handleClick}>点我 ({count})</button>;
}
答案:显示 0。因为 setTimeout 里的 count 捕获的是「这次渲染」的快照值 0;setCount 引发的是下一次渲染,和这个已经创建好的回调互不相干。想在 3 秒后拿到最新值?要么用函数式更新的思路,要么等第 12 篇的 useRef——它就是为「跨渲染共享一个可变值」准备的。
初学觉得这违反直觉,但换个角度看:快照让每次渲染都成为一次纯函数调用——输入确定,输出确定,可以放心重放、可以安全丢弃。这正是 React 并发特性(第 50 篇会谈)的地基。后端的你会立刻认出它:这就是「不可变快照 + 写时复制」,和 CopyOnWriteArrayList 是同一种思想。
最后一问:哪些数据配得上 state?
会用 useState 之后,最容易犯的错是「什么都往 state 里塞」。判断标准只有一条——它是「源」,还是「流」?源数据进 state,由源数据推导出来的一律不进:
function OrderSummary({ order, user }) {
// ❌ 冗余:总价可以从明细推出来,存了就多了一个「不同步」的机会
const [total, setTotal] = useState(0);
// ✅ 每次渲染直接算:
const total = order.items.reduce((sum, it) => sum + it.price, 0);
const paidCount = order.items.filter(it => it.paid).length;
const fullName = `${user.firstName} ${user.lastName}`;
return <p>共 {total} 元(已付 {paidCount} 项)</p>;
}
判断流程也简单,对着每个候选数据问三个问题:
- 它是从别的 state / props 算出来的吗?——是,就地计算,不进 state。
- 它在整个渲染周期内会变吗?——不会(如常量、初始配置),用普通
const即可。 - 它需要跨渲染「记住」吗?——需要,且不直接参与渲染(如定时器 id),那是
useRef的活儿(第 12 篇)。
三问都过了,才轮到 useState。state 越少,组件越好维护——这和 Java 里「字段尽量少、能局部变量就别用实例字段」是同一个道理:可变状态是所有复杂度的源头,你在后端为减少可变状态做过的所有斗争(无状态服务、不可变对象),在前端原样适用。
State 与 Props 如何配合
本章回顾
到这一篇,React 数据模型的两块积木就齐了:Props(外部只读参数)+ State(内部可变字段)。记住一条经验法则:
「数据能推出来,就不要放进 state」——state 越少,组件越好维护。
以及本篇的三个关键动作:不可变更新、新值依赖旧值时用函数式更新、把每次渲染当成一张冻结的快照。
下一篇文章我们退后一步,看看 React 到底是怎么在 state 变化时,高效地把新界面「画」出来的——虚拟 DOM 与 diff。理解了它,「为什么必须不可变」「为什么 key 那么重要」这些规矩背后的原因就全通了。
Comments · 评论