对象与数组的不可变更新:用新引用表达哪里变了
问题:改了负责人,为什么旧记录也跟着变
对象和数组变量保存引用。若直接写 task.owner.name = ...,你修改的是旧 state 已经指向的对象;旧快照、日志或别的数组若共享这条引用,也会一起“穿越变化”。React state 应当被当作只读快照。
完整实验:任务编辑器
jsx
import { useState } from 'react'
const initialTasks = [
{ id: 1, title: '核对库存', done: false, owner: { name: 'Lin' } },
{ id: 2, title: '打印标签', done: true, owner: { name: 'Ming' } },
]
export default function App() {
const [tasks, setTasks] = useState(initialTasks)
function changeOwner(id, name) {
setTasks(current => current.map(task => (
task.id === id
? { ...task, owner: { ...task.owner, name } }
: task
)))
}
function toggleTask(id) {
setTasks(current => current.map(task => (
task.id === id ? { ...task, done: !task.done } : task
)))
}
function insertTask() {
const task = {
id: crypto.randomUUID(),
title: '新任务',
done: false,
owner: { name: '待分配' },
}
setTasks(current => [task, ...current])
}
function removeTask(id) {
setTasks(current => current.filter(task => task.id !== id))
}
return (
<main>
<h1>任务编辑器</h1>
<button onClick={insertTask}>在开头插入</button>
<ul>
{tasks.map(task => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.done}
onChange={() => toggleTask(task.id)}
/>
{task.title}
</label>
<input
aria-label={`${task.title} 负责人`}
value={task.owner.name}
onChange={event => changeOwner(task.id, event.target.value)}
/>
<button onClick={() => removeTask(task.id)}>删除</button>
</li>
))}
</ul>
</main>
)
}所有更新都从更新函数的 current 参数读取待定最新 state,返回新的数组。添加、删除、替换各有合适的非修改操作:
| 需求 | 返回新数组 | 避免用于旧 state |
|---|---|---|
| 添加 | [item, ...current]、concat | push |
| 删除 | filter | splice |
| 替换一项 | map | 给 current[index] 赋值 |
| 改顺序 | [...current].sort()、toSorted() | 直接 current.sort() |
嵌套更新为什么要复制两层
修改第一项负责人名字时,引用关系应当是:
text
旧 tasks ─→ 旧 task#1 ─→ 旧 owner
新 tasks ─→ 新 task#1 ─→ 新 owner
└→ 旧 task#2(未变化,安全复用)数组展开只复制数组,{ ...task } 只复制 task 的第一层。因此 owner.name 变化时,要同时创建新 owner、新 task 和新 tasks。未变化的 task#2 可以复用;不可变更新不等于深拷贝整个状态树。
下面是独立的纯 JavaScript 实验,可整段放进控制台或 .mjs 文件。它自带输入,不依赖 Vite 模块内部的 initialTasks:
js
function rename(tasks, id, name) {
return tasks.map(task => task.id === id
? { ...task, owner: { ...task.owner, name } }
: task
)
}
function check(condition, message) {
if (!condition) throw new Error(message)
}
const before = [
{ id: 1, owner: { name: 'Lin' } },
{ id: 2, owner: { name: 'Ming' } },
]
const after = rename(before, 1, 'Qiao')
check(after !== before, '外层数组应是新引用')
check(after[0] !== before[0], '变化项应是新引用')
check(after[0].owner !== before[0].owner, '变化的嵌套对象应是新引用')
check(after[1] === before[1], '未变化项应复用引用')
check(before[0].owner.name === 'Lin', '旧快照不能被改写')
check(after[0].owner.name === 'Qiao', '新快照应包含修改')制造反例:只复制数组
下面代码创建了新数组,却仍修改了共享的 task 对象:
js
const next = [...tasks]
next[0].owner.name = 'Qiao'
setTasks(next)next[0] === tasks[0],所以旧 state 也被改了。“顶层引用变了”不是完整保证;必须沿实际变化路径逐层复制。
条件变化:对象在两个数组里出现
若“我的任务”和“全部任务”数组都包含同一个 task 对象,修改其中对象会污染另一列表。应让规范化数据只有一个明确所有者,或每次更新都创建新对象并让需要的集合接收新引用。不要用“它们在不同数组里”误判对象也独立。
边界
- 局部、刚创建且尚未进入 state 的对象可以安全填充;进入 state 后按只读处理。
Object.freeze可在开发实验中帮助发现修改,但它是浅冻结,也不是 React state 更新方案。- 不可变更新让旧快照可靠、引用比较有意义;它不是要求每次都复制整个应用。
- 深度嵌套频繁更新通常提示状态结构过深。先考虑扁平化,而不是不断堆展开语法。
练习:库存批次更新函数
写一个完整 App.jsx,并定义纯函数 updateBatch(items, itemId, batchId, quantity)。
契约:
- state 形状为
[{ id, name, batches: [{ id, quantity }] }];quantity 必须是大于等于0的整数,否则原样返回旧数组; - 找到目标批次时,只为目标批次、目标商品和外层数组创建新引用;其他商品和其他批次复用原引用;
- 找不到商品或批次时原样返回旧数组;
- 页面提供数字输入以更新一个批次,并显示所有库存;在模块加载时用抛错断言验证旧输入未被修改及引用契约。
完成后对照答案 02。