DrillLab
第 05 / 09 节LESSON 05 / 09约 14 分钟~14 min

数组与对象:不可变更新三件套Arrays and objects: three ways to update without changing the original

增、删、改一个列表,在 React 里为什么必须「造新的」而不是「改旧的」。Adding to, deleting from and editing a list: why React needs a new array instead of a changed one.

2 个练习2 exercises地基 · 第 2 部分Foundations · Part 2
这一页有什么On this page8
学完这节你会After this lesson you can
  • 熟练用展开语法新增、filter 删除、map 就地替换Use spread syntax to add, filter to delete, and map to replace an item in place
  • 解释「不可变更新」是什么意思,以及为什么 React 需要它Explain what it means to update without changing the original, and why React needs it
  • 会用解构从对象里取值、给组件 props 取值Use destructuring to read values out of an object and out of component props
  • 看到一段列表操作,能判断它改的是原数组还是新数组Look at some list code and say whether it changes the original array or builds a new one
这在考试里考什么What the exam does with this

Q1 的三道题,本质就是这三个操作各一次:Add 用展开、Delete 用 filter、Edit 用 map。GraphQL 那边的 createOrder 也要用 map 给每个 item 补价格。学会这一节,两门考试的数据操作部分就都通了。The three parts of Q1 are one of each operation: Add uses spread, Delete uses filter, Edit uses map. On the GraphQL side, createOrder also uses map to add a price to every item. Learn this lesson and the data handling of both exams is covered.

这节课要看的真实文件Real files this lesson looks at1 项 · 1 个可以展开看原文1 items · 1 can be opened
react-notes-app/src/components/NoteManager/index.tsx三个操作的真实用法都在这里The real use of all three operations is here
TSXindex.tsx源项目From source
1import { useState } from "react";
2import type { Note } from "../../types/Note";
3import NoteForm from "../NoteForm";
4import NoteTable from "../NoteTable";
5
6const NoteManager: React.FC = () => {
7 const [notes, setNotes] = useState<Note[]>([]);
8 const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
9
10 const handleSubmitNote = (submittedNote: Note) => {
11 if (noteToEdit) {
12 setNotes((prev) =>
13 prev.map((note) =>
14 note.id === submittedNote.id ? submittedNote : note,
15 ),
16 );
17 setNoteToEdit(null);
18 } else {
19 setNotes((prev) => [...prev, submittedNote]);
20 }
21 };
22
23 const handleDelete = (id: number) => {
24 setNotes((prev) => prev.filter((note) => note.id !== id));
25 };
26
27 const handleEdit = (note: Note) => {
28 setNoteToEdit(note);
29 };
30
31 return (
32 <div
33 className="layout-column align-items-center justify-content-start"
34 data-testid="note-manager"
35 >
36 <NoteForm onSubmit={handleSubmitNote} noteToEdit={noteToEdit} />
37 <NoteTable notes={notes} onDelete={handleDelete} onEdit={handleEdit} />
38 </div>
39 );
40};
41
42export default NoteManager;
Source: react-notes-app/src/components/NoteManager/index.tsx
§01

为什么不能直接改Why you cannot change the original directly

React 判断「要不要重新渲染」的方法,是比较「新旧是不是同一个东西」。React decides whether to render again by checking whether the new value is the same object as the old one.

先看这两段的区别:

左边 push改动原数组。数组变长了, 但它还是同一个数组 —— 内存里同一个地址。 右边的展开语法造了一个全新的数组, 内容是「旧的所有元素 + 新元素」。

React 在 setState 之后会做一次判断: 新值和旧值是不是同一个对象? 如果是同一个,它就认为「没变化」,直接跳过重新渲染。push 之后新旧是同一个数组,React 看不出变化, 界面就不更新 —— 数据其实变了,屏幕上却没反应。这是新手最常见的 bug。

所以规矩是:永远造新的,不改旧的。这个做法叫不可变更新(immutable update)

Start with the difference between these two:

On the left, push mutates the original array. The array got longer, but it is still the same array — the same address in memory. On the right, spread syntax builds a brand new array whose contents are “every old element, plus the new one”.

After setState, React runs one check: are the new value and the old value the same object? If they are, it decides nothing changed and skips the re-render. After push, old and new are the same array, React sees no change, and the UI does not update — the data really did change, and the screen just sits there. This is the most common beginner bug there is.

So the rule is: always build a new one, never edit the old one. The practice has a name: immutable update.

TypeScript示意Illustrative
1// ✗ 改动原数组 —— React 看不出变化
2notes.push(newNote);
3setNotes(notes);
4
5// ✓ 造一个新数组 —— React 能看出变化
6setNotes([...notes, newNote]);
1// ✗ changing the original array — React sees no change
2notes.push(newNote);
3setNotes(notes);
4
5// ✓ building a new array — React sees the change
6setNotes([...notes, newNote]);
§02

三件套:新增 / 删除 / 就地替换The three operations: add, delete, replace in place

Q1 的三道题就是这三行。The three parts of Q1 are these three lines.

这三段是从 NoteManager 里原样摘出来的, 也就是这道题的标准答案:

注意三处细节:

  • setNotes(prev => ...) —— 传的是个函数,拿到的 prev 是「此刻最新的」值。 比直接用外面的 notes 变量安全(后面 useState 那节细讲)。
  • 删除用 !== 而不是 === ——filter 保留的是「返回 true 的元素」, 所以条件要写「不是要删的那个」。
  • 更新用 map 而不是「先删再加」 ——map 逐个走过每个元素,是目标就换成新的、不是就原样保留。 这样顺序不变。而「先 filter 掉再 push 新的」会把这条 挪到末尾 —— 题目明确要求「原位置更新」,那样就错了。

These three are lifted verbatim out of NoteManager — they are the model answer for this question:

Three details to notice:

  • setNotes(prev => ...) — you pass a function, and the prev it hands you is the value as of right now. Safer than reaching for the outer notes variable (the useState lesson goes into this).
  • Delete uses !==, not === filter keeps the elements whose callback returns true, so the condition has to read “not the one being deleted”.
  • Update uses map, not “delete then append”map walks every element, swapping in the new one where it matches and leaving the rest as they were. That keeps the order intact. Filtering the old one out and pushing the new one moves that row to the end — and the task explicitly asks for an update in place, so that would be wrong.
TSX三个操作(摘自 NoteManager)The three operations (taken from NoteManager)源项目From source
1// 新增:旧的全都要,末尾加一个
2setNotes((prev) => [...prev, submittedNote]);
3
4// 删除:留下 id 不等于目标的
5setNotes((prev) => prev.filter((note) => note.id !== id));
6
7// 就地替换:是目标就换成新的,不是就原样留着
8setNotes((prev) =>
9 prev.map((note) => (note.id === submittedNote.id ? submittedNote : note)),
10);
1// add: keep every old one, put one at the end
2setNotes((prev) => [...prev, submittedNote]);
3
4// delete: keep the ones whose id is not the target
5setNotes((prev) => prev.filter((note) => note.id !== id));
6
7// replace in place: swap the target, leave the rest untouched
8setNotes((prev) =>
9 prev.map((note) => (note.id === submittedNote.id ? submittedNote : note)),
10);
Source: react-notes-app/src/components/NoteManager/index.tsx
§03

map / filter / find:三个都返回什么map / filter / find: what each one returns

这三个方法长得像,但返回的东西完全不同。混淆它们会写出很难查的 bug:

方法返回长度典型用途
map新数组和原数组一样长逐个变形:渲染成 JSX、替换某一项、给每项补字段
filter新数组可能更短筛掉不要的:删除、搜索
find单个元素undefined找一个:按 id 取某条数据

这三个在两个考试里都真实出现过。mapNoteTable 里把 notes 渲染成行;filterOrderDataSource.getOrdersByUserId 里按 userId 筛订单;findOrderDataSource.getOrder 里按 id 取一条。

These three look alike, but what they hand back is completely different. Mixing them up produces bugs that are painful to track down:

MethodReturnsLengthTypical use
mapa new arraythe same length as the originalReshape each item: render to JSX, replace one entry, add a field to every entry
filtera new arraypossibly shorterDrop what you do not want: delete, search
findone element or undefinedFind one: fetch a single record by id

All three show up for real in both exams. map renders notes into rows inside NoteTable; filter picks orders by userId in OrderDataSource.getOrdersByUserId; find pulls one record by id in OrderDataSource.getOrder.

JavaScriptorderDataSource.js(节选)orderDataSource.js (excerpt)源项目From source
1async getOrder(id) {
2 await new Promise(resolve => setTimeout(resolve, 10));
3 return this.orders.find(order => order.id === id); // 一条,或 undefined
4}
5
6async getOrdersByUserId(userId) {
7 await new Promise(resolve => setTimeout(resolve, 10));
8 return this.orders.filter(order => order.userId === userId); // 数组,可能是空的
9}
1async getOrder(id) {
2 await new Promise(resolve => setTimeout(resolve, 10));
3 return this.orders.find(order => order.id === id); // one, or undefined
4}
5
6async getOrdersByUserId(userId) {
7 await new Promise(resolve => setTimeout(resolve, 10));
8 return this.orders.filter(order => order.userId === userId); // an array, possibly empty
9}
Source: graphql-federation-practice/node-subgraph/src/dataSources/orderDataSource.js
注意 find 找不到时返回 undefined,而 filter 找不到时返回空数组 []。这个区别在写 resolver 时至关重要 —— schema 里写了 [Order!]! 的字段绝对不能返回 undefined。Note that find returns undefined when it finds nothing, while filter returns an empty array []. That difference matters a lot when you write a resolver — a field declared [Order!]! in the schema must never return undefined.
§04

对象展开:改一个字段,其他原样Object spread: change one field, keep the rest

数组用 [...arr, x],对象用 {...obj, key: v}。 后面的键会覆盖前面的:

这个写法在 Federation 那道题里有实际用途:createOrder 收到的 item 只有productIdquantity, 需要补上 price 才能算总价。

Arrays use [...arr, x]; objects use {...obj, key: v}. Later keys overwrite earlier ones:

This has a real use in the Federation question: the item that createOrder receives only carries productId and quantity, and you have to add price before you can work out a total.

JavaScript给每个 item 补上 priceAdding price to every item已跑通Verified
1// 我为 Federation 那题写的参考解法(已跑通 10/10 测试)
2const pricedItems = await Promise.all(
3 items.map(async item => ({
4 productId: item.productId,
5 quantity: item.quantity,
6 price: await dataSources.inventoryDataSource.getProductPrice(item.productId)
7 }))
8);
1// my reference answer for the Federation question (10/10 tests pass)
2const pricedItems = await Promise.all(
3 items.map(async item => ({
4 productId: item.productId,
5 quantity: item.quantity,
6 price: await dataSources.inventoryDataSource.getProductPrice(item.productId)
7 }))
8);
map 的回调是 async,所以返回的是「一堆 Promise」,必须用 Promise.all 等它们全部完成。这是 map + async 组合的固定套路 —— 只写 map 不加 Promise.all 是很常见的错。The callback of map is async, so it returns a set of Promises, and Promise.all is needed to wait for all of them. This is the fixed pattern for map plus async — writing map without Promise.all is a very common mistake.
§05

解构:从对象里一次取好几个值Destructuring: take several values out of an object at once

你会在两个考试的每一个组件和 resolver 里看到它:

{ onSubmit, noteToEdit } 写在函数参数位置, 意思是「传进来的那个对象里,把 onSubmitnoteToEdit 这两个键取出来当局部变量」。 resolver 的第三个参数 context 也是这么拆的。

You will run into it in every component and every resolver across both exams:

{ onSubmit, noteToEdit } written in the parameter position means “out of the object handed to me, pull the onSubmit and noteToEdit keys out as local variables”. A resolver’s third parameter, context, gets taken apart the same way.

TSX已跑通Verified
1// React:从 props 里解构
2const NoteForm: React.FC<NoteFormProps> = ({ onSubmit, noteToEdit }) => { ... }
3
4// GraphQL:从 context 里解构
5async orders(user, _, { dataSources, loaders, correlationId }) { ... }
1// React: destructuring out of props
2const NoteForm: React.FC<NoteFormProps> = ({ onSubmit, noteToEdit }) => { ... }
3
4// GraphQL: destructuring out of context
5async orders(user, _, { dataSources, loaders, correlationId }) { ... }
GraphQL resolver 那行里的 _ 只是个「我不用这个参数」的约定写法(那个位置是 args)。它不是特殊语法,就是个普通变量名。The _ on the GraphQL resolver line is only a convention meaning "I do not use this parameter" (that position holds args). It is not special syntax, just an ordinary variable name.
练习Practice

动手做Get your hands on it

填空只是过渡。真正掌握的标准,是在没有答案的时候从头写出来 —— 所以做完 L2 之后一定要往 L3、L4 走。Filling blanks is a stepping stone. The real bar is writing it from nothing, so once L2 is comfortable, push on to L3 and L4.

L2填空Fill the blanks补全 Q1 的三个数据操作Fill in the three data operations of Q1

这是 NoteManager 里三个 handler 的真实代码, 挖掉了决定行为的关键词。想清楚每个操作要「保留多少条」再填。

This is the real code of the three handlers in NoteManager, with the words that decide the behaviour removed. Before you fill each one in, work out how many items that operation has to keep.

TSXsrc/components/NoteManager/index.tsx4 个空4 blanks
1const handleSubmitNote = (submittedNote: Note) => {
2 if (noteToEdit) {
3 setNotes((prev) =>
4 prev.((note) =>
5 note.id === submittedNote.id ? submittedNote : note,
6 ),
7 );
8 setNoteToEdit(null);
9 } else {
10 setNotes((prev) => [, submittedNote]);
11 }
12};
13
14const handleDelete = (id: number) => {
15 setNotes((prev) => prev.((note) => note.id id));
16};
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
L2Debug LabDebug LabDebug Lab · 数据加进去了,界面没反应Debug Lab · the data went in, the screen did not move

这一类 bug 最难查,因为它不报错。 先看现象,判断类型,再找病灶 —— 别跳步。

This kind of bug is the hardest to find, because it reports no error. Read the symptom, classify it, then locate it. Do not skip a step.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
# 没有任何报错。控制台干净。 # 现象:填好表单点 Add,表格里什么都不出现。 # 在 handleSubmitNote 里 console.log(notes) —— 长度确实在增加。 notes.length before: 0 notes.length after : 1 ← 数据真的进去了 (但 <NoteTable /> 渲染出来的行数始终是 0)# No error at all. The console is clean. # Symptom: fill in the form, click Add, and nothing shows up in the table. # Add console.log(notes) inside handleSubmitNote — the length really does grow. notes.length before: 0 notes.length after : 1 ← the data really did go in (But <NoteTable /> always renders 0 rows.)
TSX有问题的写法The broken version示意Illustrative
1const handleSubmitNote = (submittedNote: Note) => {
2 notes.push(submittedNote);
3 setNotes(notes);
4};
第 2 步 · 这是什么类型的错误Step 2 · what kind of error is this
错例Wrong

初学者常见的几种写法错误Mistakes beginners actually make

下面每一段都是「能编译、但结果不对」或者「一跑就炸」的真实写法。先自己看出问题在哪,再看解释。Every snippet below either compiles and gives the wrong answer, or blows up on the first run. Spot the problem yourself before reading the explanation.

TSX示意Illustrative
1// ✗ 更新时先删再加 —— 顺序变了
2setNotes((prev) => [
3 ...prev.filter((n) => n.id !== submittedNote.id),
4 submittedNote,
5]);
1// ✗ deleting then adding on update — the order changes
2setNotes((prev) => [
3 ...prev.filter((n) => n.id !== submittedNote.id),
4 submittedNote,
5]);
这段能让测试通过(测试里只有一条数据,看不出顺序),但违反了题目要求的「原位置更新」。有两条以上数据时,被编辑的那条会跳到最后一行。测试过了不等于做对了 —— 这是这两个 assessment 反复出现的主题。This passes the tests, because the test has only one item and the order is not visible. But it breaks what the question asks for: update the item where it already is. With two or more items, the edited one jumps to the last row. Passing the tests is not the same as getting it right — that point comes back again and again in both exams.
TSX示意Illustrative
1// ✗ filter 条件写反了
2setNotes((prev) => prev.filter((note) => note.id === id));
1// ✗ the filter condition is backwards
2setNotes((prev) => prev.filter((note) => note.id === id));
这会「只留下要删的那一条」,把其余全删掉。filter保留的是回调返回 true 的元素,所以删除操作的条件必须是!==This keeps only the item you wanted to remove, and drops all the others. filter keeps the elements whose callback returns true, so the condition for deleting has to be !==.
迁移Transfer

换一道题也能用Works on other problems too

考试不会原题重考。真正能带走的是「看到这种信号 → 伸手去拿这个解法」。The exam will not reuse the same question. What you take away is the reflex: see this signal, reach for that solution.

「新增一条到列表」Add one item to a list
[...prev, item][...prev, item]
「删除某一条」Delete one item
prev.filter(x => x.id !== id)prev.filter(x => x.id !== id)
「更新某一条,位置不变」Update one item and keep its position
prev.map(x => x.id === id ? next : x)prev.map(x => x.id === id ? next : x)
「给每一项补上一个字段」Add one field to every item
map + 对象展开(异步就再套 Promise.all)map plus object spread, wrapped in Promise.all if the work is async
数据变了但界面不动The data changed but the interface did not
查是不是 push / splice / 直接赋值改了原对象Check whether push, splice or a direct assignment changed the original
这节的要点What to take away
  1. React 靠「是不是同一个对象」判断变化,所以必须造新的、不改旧的。React looks at whether it is the same object to decide that something changed, so build a new one and leave the old one alone.
  2. 增用展开 [...prev, x],删用 filter(!==),改用 map(三元)。Add with spread [...prev, x], delete with filter and !==, edit with map and a conditional.
  3. map 长度不变、filter 可能变短、find 返回单个或 undefined。map keeps the length, filter can make it shorter, find returns one item or undefined.
  4. map 里用 async,外面一定要套 Promise.all。If you use async inside map, you must wrap the result in Promise.all.
  5. 「数据对但界面不动」是改了原对象的典型症状,而且不会报错。Right data with a frozen interface is the usual sign that you changed the original object, and nothing reports an error.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises2 个,就在这一页上面 —— 别攒着最后一起做2 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson异步:Promise、await、all 和 allSettledAsync: Promise, await, all and allSettled
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 两个考试项目的目录,逐个说明The directory layout of both exam projects