Skip to content

对象与数组的不可变更新:用新引用表达哪里变了 ​

问题:改了负责人,为什么旧记录也跟着变 ​

对象和数组变量保存引用。若直接写 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]、concatpush
删除filtersplice
替换一项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。

下一篇 ​

状态结构、保留与重置

用心记录每一步成长