React03:useState 与不可变更新

第 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 课) 受控组件

🏢 企业实战要点

  1. state 拆分原则 :会一起变的字段放一个对象(表单),独立变化的拆开(loading / error / list 分开),不要把整页数据塞进一个大对象。
  2. code review 必查项 :xxx.push(...) / xxx.splice(...) / obj.field = 1 出现在 state 上直接打回;同理「set 之后立刻读 state」的写法也是错的。
  3. 依赖旧值一律函数式更新 :定时器、异步回调、循环里尤其要写 setXxx(prev => ...),因为回调里捕获的快照早已过期(这是真实项目里「点了没反应 / 少加了几次」的头号原因)。
  4. state 里不要存能算出来的东西 :过滤后的列表、合计金额、全选状态、new Date() 格式化结果 ------ 存了就是两份数据,必然不同步。
  5. 表单用一个对象 state + patch 函数 :setForm({ ...form, ...part }) 是企业标配写法;字段超过十几个或联动复杂时再拆。
  6. 命名规范 :[value, setValue] 布尔值用 isXxx / hasXxx(isVisible, setIsVisible),列表用复数(items, setItems),禁止 [a, b] = useState() 这种无名解构。
  7. 不要在渲染函数里 set state:会造成渲染期间更新渲染(React 会警告甚至死循环);副作用放事件处理或 useEffect。
  8. 深层不可变更新写不动时:先考虑把数据拍平(按 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 会变成只读。

相关推荐
蒲公英内测分发1 小时前
智能手表卖出去后,包装、说明书和官网里的 App 下载入口怎么统一?
前端·人工智能·测试工具·智能家居·智能硬件·智能手表
溪语流沙1 小时前
【Web全栈进阶】监控与告警:线上系统的“体检报告”
前端·vue.js·python·fastapi
鬼手点金1 小时前
单轮对话:最简单的 API 调用
java·服务器·前端·javascript·react.js·servlet·tomcat
燕落南枝2 小时前
CSS 字体样式:颜色、字体族、倾斜、加粗与装饰
前端·css
鬼手点金2 小时前
消息的四种角色
java·前端·人工智能·git·深度学习
陈随易2 小时前
副业搞钱之代办永不过时
前端·后端·程序员
古夕2 小时前
同样的 font-weight,为什么这个项目的字体就是更细?
前端·javascript
程序员20072 小时前
抽卡玄学?不,是数学:保底机制下出货概率到底怎么算
前端
excel2 小时前
请求很慢,问题一定出在服务器吗?看懂浏览器网络耗时
前端·后端