R12第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

对象状态与嵌套更新

对象状态与嵌套更新 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。

学习目标

状态一变成嵌套对象,不可变更新就麻烦起来:一层的展开好写,两层以上的「层层新建」容易写漏。这一课用「章节 + 课时」的编辑数据带你把嵌套更新写扎实,并说清浅复制和直接修改各自会闯什么祸。

问题场景

课程编辑器里的数据长这样:

const [course, setCourse] = useState({
  title: "React 入门",
  sections: [
    { id: "s1", title: "章节一", lessons: [{ id: "l1", title: "课时 1", done: false }] },
  ],
});

需求是点一下课时切换完成状态。第一版图省事直接改了:

function toggleLesson(lessonId) {
  const lesson = course.sections[0].lessons.find((l) => l.id === lessonId);
  lesson.done = !lesson.done; // 直接改了状态里的对象
  setCourse(course);          // 再把同一个对象原样传回去
}

界面纹丝不动。setCourse(course) 传进去的还是同一个引用,React 一比:「新旧状态相等,没变化」,根本不渲染。改了内容但没换引用,React 就当什么都没发生。

核心解释

不可变更新的核心规则:要更新谁,就新建谁;它的每一层祖先也要新建。展开运算符 { ...obj } 只复制第一层,嵌套的对象和数组复制的仍然是引用——这就是浅复制,写嵌套更新时必须一层层展开:

function toggleLesson(course, lessonId) {
  return {
    ...course,
    sections: course.sections.map((section) => ({
      ...section,
      lessons: section.lessons.map((lesson) =>
        lesson.id === lessonId ? { ...lesson, done: !lesson.done } : lesson
      ),
    })),
  };
}

逐层读:外层 { ...course } 新建课程对象;sections.map 新建数组,每个 section 都展开新建;lessons.map 再往下走一层,只有命中的课时新建 { ...lesson, done: !lesson.done },其余课时对象保持原引用。这样 React 在任何一层比较都能看到「新引用」,更新链条完整。

「只有命中的才新建」是刻意的:没变的对象保留原引用,能让 React(以及将来的 memo 比较)快速跳过它们,这也是不可变数据换来的红利。

层级深、更新点多的表单类数据,官方建议考虑 useImmer 这类库让代码写成「直接改」的直觉形式——但库之前先把原生写法练熟,本课的实验只用展开运算符。

示例拆解

实验区是同一个编辑数据的两种更新写法,关键点如下:

  • 数据两章、每章两课时,界面列出全部课时和完成状态;
  • 原地改版:lesson.done = !lesson.done 后 setCourse(course)——点什么界面都不动,数据其实已经变了(再触发一次别的渲染就会「跳变」,最迷惑的形态);
  • 展开版:上面的 toggleLesson 完整跑通,每次点击界面立即翻转;
  • 每个课时只新建命中的那个对象,面板显示「未变课时引用保持原样」,对应不可变更新的性价比;
  • 「改标题」按钮演示另一条常用路径:只展开一层 setCourse({ ...course, title })。

验证方式:原地改版点完课时再点「改标题」,完成状态突然集体跳出来——说明数据早被改了,只是 React 一直没渲染它。

动手任务

打开本课实验区,完成三个小改动:

  1. 加「新增课时」按钮:往指定章节的 lessons 数组里追加一条,注意数组和新 section 都要新建。
  2. 给 section 加「重命名」功能,练习在 sections.map 里只新建命中的 section。
  3. 把展开版的 lessons.map 里漏写 { ...lesson } 展开(直接返回改后的旧对象)观察界面变化,理解每一层为什么要新建。

常见错误

  • 直接修改嵌套对象再 setState 同一引用:React 认为状态没变,不渲染;更新时命中对象必须新建。
  • 以为 { ...obj } 深拷贝了:浅复制只到第一层,嵌套层还是共享引用;更新哪层就在哪层展开。
  • 展开时把没改的层也全量新建:功能对,但每次更新所有对象身份都变,破坏了「未变引用保持原样」的优化空间;只新建命中路径。
  • 在 state 里存方法或 class 实例:展开复制会丢方法绑定,复杂数据优先存纯数据结构,行为放组件里。

检查与复盘

完成下方检查题确认理解。自查清单:

  • 能说清「直接修改 + set 同一引用」为什么不触发渲染
  • 三层嵌套的定向更新能一次写对:每层 map + 展开,命中才新建
  • 明白浅复制与深复制的区别,知道 JSON.parse(JSON.stringify()) 为什么不该用来拷贝状态

参考资料

动手实验
动手实验
预览
点击「运行」在隔离沙箱中预览
检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

阅读代码(React 18+)。初始显示「北京 / 朝阳」。点击「搬家」按钮后显示什么?

function Address() {
  const [user, setUser] = useState({
    name: '阿明',
    address: { city: '北京', district: '朝阳' },
  });

  function move() {
    setUser({
      ...user,
      address: { ...user.address, district: '海淀' },
    });
  }

  return (
    <p>
      {user.address.city} / {user.address.district}
    </p>
  );
}
Q2单选简单

更新 state 里的对象或数组时,为什么推荐「创建新对象」而不是直接修改原对象?

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...