第 03 课 · useState 与不可变更新
🎯 本课目标
- 会用
useState声明状态、用setXxx更新,理解 setXxx 命名规范 - 建立「快照」心智:连续两次
setCount(count + 1)只加 1,函数式更新setCount(c => c + 1)才加 2 - 彻底掌握不可变更新:对象 / 数组必须换新值,直接 push、直接改属性无效(Vue 转 React 第一大坑)
- 会用展开运算符 + filter / map 完成增删改,会用「一个 state 对象」管表单
- 理解批处理:一次事件里多次 set 合并成一次渲染
- 记住派生数据不存 state,渲染时直接算(对应 computed)
1. 声明与更新:useState 是 ref 的替代品
Vue 里你这样写
ts
import { ref } from 'vue'
const count = ref(0)
function add() {
count.value++ // 直接改,界面自动更新
}
React 里这样写
tsx
import { useState } from 'react'
function Counter() {
// count 是当前值,setCount 是「改它的唯一入口」
const [count, setCount] = useState(0)
function add() {
setCount(count + 1) // 不是改 count,而是「申请用新值 1 重新渲染」
}
return <button onClick={add}>点了 {count} 次</button>
}
关键差异与注意点
useState(初始值)返回「当前值 + 更新函数」的数组,解构命名规范:[xxx, setXxx](count/setCount、form/setForm、loading/setLoading)- Vue 的 ref 是「可变盒子」,React 的 state 是「本次渲染的常量」------永远不要写
count++或count = 1,改不动也不报错(普通变量而已) - 更新函数
setXxx名字固定带 set 前缀;一个组件里可以有多个 useState,各管各的 - 初始值只在首次渲染生效,之后的重新渲染会忽略它(所以不要用「重新 useState」来重置状态,要用 setXxx 或 key)
- state 必须通过 setXxx 改,React 才知道要重新渲染 ------ 这一句就是 React 状态管理的全部底层约定
2. 「快照」心智:为什么连续两次 setCount(count + 1) 只加 1
Vue 里你这样写
ts
count.value++
count.value++
count.value++ // 3 次,结果真的是 +3(响应式系统当场改值)
React 里这样写
tsx
const [count, setCount] = useState(0)
function handleClick() {
// ❌ 三行读到的都是本次渲染的快照 count(比如 0),排队的是 1、1、1 → 结果只加 1
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
// ✅ 函数式更新:把「怎么算」交给 React,每次基于队列里最新的值算 → 正确 +3
setCount((c) => c + 1)
setCount((c) => c + 1)
setCount((c) => c + 1)
}
关键差异与注意点
- 快照模型 :渲染时 state 被拍成一张照片,事件处理函数里读到的永远是「本次渲染」那张;
setXxx是给下一次渲染排队,不改当前照片 - 所以
setCount(count + 1)之后的console.log(count)还是旧值 ------ setState 不是赋值,是「预约」 - 经验法则:新值依赖旧值时,一律写函数式更新
setXxx(prev => ...);不依赖时两种都行,但统一用函数式更省心 - 函数式更新必须纯:不能在里面发请求、改 DOM、改外部变量(React 可能调用它多次)
3. 不可变更新(本课重点,Vue 转 React 第一大坑)
Vue 里你这样写
ts
const cart = reactive<CartItem[]>([])
cart.push(newItem) // 直接改,界面更新
cart[0].qty++ // 直接改属性,界面更新
cart.splice(1, 1) // 直接删,界面更新
React 里这样写
tsx
const [items, setItems] = useState<CartItem[]>([])
// 增:造一个新数组(旧数组原封不动)
setItems([...items, newItem])
// 有则加数量(map 造新数组,命中的项造新对象)
setItems(items.map((item) => (item.id === newItem.id ? { ...item, qty: item.qty + 1 } : item)))
// 删:filter 造新数组
setItems(items.filter((item) => item.id !== id))
// 改:map + 展开覆盖目标字段
setItems(items.map((item) => (item.id === id ? { ...item, checked: !item.checked } : item)))
// ❌ 下面这些在 Vue 里天经地义,在 React 里全部无效(界面不动):
// items.push(newItem)
// items[0].qty += 1
// items.splice(1, 1)
关键差异与注意点
- 原理一句话:React 用
Object.is比较前后 state 的引用 ,引用没变就认为「状态没变」,直接跳过渲染。push改的是原数组内容,引用没换 → React 无感 - 后果比「不动」更糟:数据其实已经被你改了,等别的状态触发重渲染时,改动会突然冒出来 ------ 时序相关的诡异 bug
- 速记表:
- 数组增:
[...items, x];删:items.filter(...);改:items.map(...);截取/拼接:slice/concat - 对象改属性:
{ ...obj, key: newValue };嵌套对象:一层层展开{ ...form, user: { ...form.user, age: 18 } }
- 数组增:
- 对比记忆:Vue 的响应式让你「改内容」,React 让你「换引用」 ------ 这是两个框架最大的使用差异,肌肉记忆必须重练
- 深层嵌套数据特别麻烦时,企业里会引入 Immer(
produce让你用「直接改」的写法产出不可变结果),思路要知道
4. 批处理:多次 set 合并成一次渲染
React 里这样写
tsx
function handleSave() {
setLoading(true)
setError(null)
setForm({ ...form, name: '张三' })
// 这三次 set 会合并成「一次重新渲染」:React 19 / 18 在事件处理、
// 超时、原生事件里都自动批处理,不需要手动优化
}
关键差异与注意点
- 一次事件里 set 十次也只渲染一次,所以「拆多个 state」不会导致性能问题 ------ 表单字段、loading、error 拆开存反而更清晰
- 批处理后,处理函数执行完才统一重渲染,所以「set 之后立刻读 DOM」读不到新内容(要用 useEffect,第 05 课)
- React 18 之前只有 React 事件里才批处理(setTimeout / 原生事件里不批),18 起全场景自动批处理 ------ 老项目资料里那些「手动 unbatched updates」可以不用管了
5. set 相同值会跳过渲染(Object.is)
React 里这样写
tsx
const [name, setName] = useState('张三')
function onClick() {
setName('张三') // 值和当前一样(Object.is 相等),React 跳过这次渲染
setName('李四') // 值变了,正常重渲染
}
关键差异与注意点
- React 用
Object.is判断新值是否等于旧值,相等就放弃重渲染 ------ 所以「set 一个没变的值」是安全的 - 但只对原始类型有用:对象 / 数组哪怕内容一模一样,只要引用不同就会重渲染(这也是第 3 节必须换新值的原因的另一面)
- 因此不要把
[]、{}当常量用:setItems([])每次都是新数组,必然触发渲染;模块级常量const EMPTY: Item[] = []复用同一引用则可跳过
6. 派生数据不要存 state(对应 computed)
Vue 里你这样写
ts
const tasks = ref<Task[]>([...])
const allDone = computed(() => tasks.value.length > 0 && tasks.value.every((t) => t.done))
React 里这样写
tsx
const [tasks, setTasks] = useState<Task[]>(INIT_TASKS)
// ✅ 渲染时直接算:tasks 一变,重新渲染时它自然是新值,永远不会不同步
const allDone = tasks.length > 0 && tasks.every((t) => t.done)
const doneCount = tasks.filter((t) => t.done).length
const visible = tasks.filter((t) => t.title.includes(keyword.trim()))
// ❌ 存成 state 就多了一份要手动维护的数据,一旦忘了同步就是 bug
// const [allDone, setAllDone] = useState(false)
关键差异与注意点
- React 没有 computed:渲染函数本身就是「一个大 computed」,每次渲染全量重算,派生值写在函数体里即可
- 只有「计算量确实大」(万级列表过滤、复杂聚合)才用
useMemo缓存(第 07 课),普通业务先写直接算 - 判断标准:这个值能不能由现有 state + props 推出来?能 → 不要建 state
- 同理:能由 props 推出的值也不要存 state(会造成两份数据不同步,是 code review 高频问题)
📊 Vue ↔ React 对照速查表
| Vue 3 | React | 说明 |
|---|---|---|
const count = ref(0) |
const [count, setCount] = useState(0) |
解构出「值 + 更新函数」 |
count.value++ |
setCount(count + 1) |
不是改值,是预约渲染 |
连续三次 count.value++ 得 +3 |
连续三次 setCount(count + 1) 得 +1 |
快照模型 |
| 依赖旧值的更新 | setCount(c => c + 1) 函数式更新 |
依赖旧值一律用这个 |
reactive 对象直接改属性 |
setForm({ ...form, key: value }) |
必须换新对象 |
数组 push / splice |
[...items, x] / items.filter(...) / items.map(...) |
必须换新数组 |
computed |
渲染时直接算(必要时 useMemo) |
派生值不建 state |
watch 数据联动 |
直接算 / useEffect |
能算出来的不用 watch |
| 多个 ref 各管一项 | 多个 useState / 一个对象 state | 都可以,按聚合度选 |
| 改值立刻可读 | set 之后读到的还是旧值 | 渲染是之后统一发生的 |
| 同值不触发更新(依赖收集) | Object.is 相同则跳过渲染 |
对象比引用 |
v-model 双向绑定 |
value + onChange(第 04 课) |
受控组件 |
🏢 企业实战要点
- state 拆分原则 :会一起变的字段放一个对象(表单),独立变化的拆开(
loading/error/list分开),不要把整页数据塞进一个大对象。 - code review 必查项 :
xxx.push(...)/xxx.splice(...)/obj.field = 1出现在 state 上直接打回;同理「set 之后立刻读 state」的写法也是错的。 - 依赖旧值一律函数式更新 :定时器、异步回调、循环里尤其要写
setXxx(prev => ...),因为回调里捕获的快照早已过期(这是真实项目里「点了没反应 / 少加了几次」的头号原因)。 - state 里不要存能算出来的东西 :过滤后的列表、合计金额、全选状态、
new Date()格式化结果 ------ 存了就是两份数据,必然不同步。 - 表单用一个对象 state + patch 函数 :
setForm({ ...form, ...part })是企业标配写法;字段超过十几个或联动复杂时再拆。 - 命名规范 :
[value, setValue]布尔值用isXxx / hasXxx(isVisible, setIsVisible),列表用复数(items, setItems),禁止[a, b] = useState()这种无名解构。 - 不要在渲染函数里 set state:会造成渲染期间更新渲染(React 会警告甚至死循环);副作用放事件处理或 useEffect。
- 深层不可变更新写不动时:先考虑把数据拍平(按 id 存对象 + 一个顺序数组),实在不行团队统一引入 Immer,不要各写各的深展开套娃。
💼 面试高频
1. setState 是同步的还是异步的?
标准答法:setState 本身是「调度一次重新渲染」的调用,执行后状态并不会立刻变,你在同一行之后读到的仍是本次渲染的快照;React 会把同一批次里的多次 setState 收集起来,批处理 后在合适的时机统一重渲染。React 18 之后所有场景(事件、setTimeout、原生事件、Promise 回调)都自动批处理。所以答案不是「同步/异步」二选一,而是「同步调用、异步生效、按批合并」。需要基于最新值计算时就用函数式更新 setXxx(prev => ...)。
2. 为什么 React 要求不可变更新?
第一,React 靠 Object.is 比较新旧 state 的引用来决定要不要重渲染,直接 push / 改属性引用不变,React 认为状态没变,界面不更新,而数据其实已经被改了,之后任何一次重渲染都会把改动突然带出来,形成难查的时序 bug。第二,不可变让「状态变化」变得可追踪可比较:prev 和 next 是两个独立快照,才能做时间旅行调试、memo 浅比较优化、并发渲染下的安全回放。Vue 靠响应式代理追踪字段级修改所以可以直接改,React 没有这层追踪,就用「换引用」这个更简单粗暴的约定。
3. 什么时候必须用函数式更新?
新值依赖旧 state、且这段代码可能「不是在本次渲染的快照上执行」时:定时器回调、Promise/接口回调、事件里连续多次 set、子组件通过回调改父状态、批量操作循环里 set。因为那些回调执行时读到的 count 是它创建那一刻的快照,早过期了;setCount(c => c + 1) 把计算推迟到 React 真正应用更新时,基于队列里最新的值算,永远不会串。无脑全用函数式更新也不会错,团队统一即可。
4. React 的 state 和 Vue 的响应式本质差异是什么?
Vue:数据本身是响应式代理,谁用了这个数据就被记录下来,改数据 → 精准通知依赖它的组件/计算属性更新,开发者「改就完了」。React:数据是普通变量,每次状态变化都从变化的组件开始重新执行整个函数,用新 props/state 算出新的 JSX,再 diff 出最小 DOM 改动;开发者要负责「告诉 React 变了」(setXxx + 换引用)并自己控制哪些部分不用重算(memo/useMemo)。一句话:Vue 是「推」的依赖追踪,React 是「拉」的全量重算。
5. 为什么派生数据不该存进 state?
state 的意义是「会变、且推不出来」的最小数据集。派生值(过滤结果、合计、全选)都能由现有 state 推出,存进去等于维护两份数据:数据源变了你必须记得手动同步,忘一次就是界面与数据不一致的 bug。React 每次渲染都重算,派生值直接写在渲染函数里天然同步;只有计算量大到影响渲染时才用 useMemo 做缓存,缓存的是计算结果,而不是第二份状态。
📌 小结 + 下节预告
useState= 「当前值 + 更新函数」;setXxx 是改状态的唯一入口,set 之后读到的是旧值- state 是快照:依赖旧值必须
setXxx(prev => ...),连续 set 才能正确叠加 - 不可变更新是铁律 :数组用展开 / filter / map,对象用
{ ...obj, field: v },直接 push / 改属性界面不动 - 一次事件里多次 set 自动批处理成一次渲染;set 相同值(Object.is 相等)会跳过渲染
- 派生数据渲染时直接算,不要复制进 state ------ 渲染函数就是你的 computed
下一课讲事件处理与受控表单 :v-model 没了,<input> 的值怎么接、怎么改?重点讲清「受控组件」这个 React 核心概念(value + onChange 的闭环)、多表单字段的事件复用,以及为什么 React 里 input 不写 onChange 会变成只读。