DrillLab
第 10 / 21 节LESSON 10 / 21约 11 分钟~11 min

Task 2 · Delete:点 Delete,该行按 id 被移除Task 2 · Delete: click Delete and that one row is removed by id

一行 filter。但「按 id」这三个字是有分量的。One line of filter. But the words by id carry weight.

3 个练习3 exercisesReact · 第 3 部分React · Part 3
这一页有什么On this page7
学完这节你会After this lesson you can
  • 独立写出 handleDeleteWrite handleDelete on your own
  • 解释为什么必须按 id 比较而不是 title 或下标Explain why the comparison must use the id and not the title or the index
  • 说清 filter 的条件为什么是 !== 而不是 ===Say why the filter condition is !== and not ===
  • 知道这个测试为什么测不出「按 id」这个要求Know why this test cannot catch the by id requirement
这在考试里考什么What the exam does with this

第 3 个测试查它。但那个测试只有一条数据,用 title 比较也能过 —— 这是本项目「测试过了不等于做对了」的第一个实例。The third test checks it. But that test has only one note, so comparing by title passes as well. This is the first case in this project where passing tests do not mean the code is correct.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
react-notes-app/src/components/NoteManager/index.tsxhandleDelete

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。Heads up: on disk this project is the finished version — what follows is the answer. Close this if you want to write it yourself first.

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
react-notes-app/src/components/NoteItem/index.tsxDelete 按钮在这里上报 idThe Delete button reports the id from here
TSXindex.tsx源项目From source
1import React from "react";
2import type { Note } from "../../types/Note";
3
4export interface NoteItemProps {
5 note: Note;
6 onDelete: (id: number) => void;
7 onEdit: (note: Note) => void;
8}
9
10const NoteItem: React.FC<NoteItemProps> = ({ note, onDelete, onEdit }) => {
11 return (
12 <tr>
13 <td>{note.title}</td>
14 <td>{note.content}</td>
15 <td>
16 <button onClick={() => onEdit(note)} className="outlined">
17 Edit
18 </button>
19 </td>
20 <td>
21 <button onClick={() => onDelete(note.id)} className="danger">
22 Delete
23 </button>
24 </td>
25 </tr>
26 );
27};
28
29export default NoteItem;
Source: react-notes-app/src/components/NoteItem/index.tsx
§01

这一问在要求什么What this task asks for

原文:「点 Delete → 该行按 id 被移除」。

「按 id」是出题人加的限定。它排除了两种写法:

  • 按下标删splice(index, 1))—— 下标会随列表变化,而且 splice 改的是原数组。
  • 按内容删n.title !== title)—— 两条同名笔记会被一起删掉。

NoteItem 那边也配合了这个设计 —— 它上报的就是 id

The original: “click Delete → that row is removed by id”.

“By id” is a qualifier the author added. It rules out two approaches:

  • Delete by index (splice(index, 1)) — indexes shift as the list changes, and splice mutates the original array.
  • Delete by content (n.title !== title) — two notes with the same name go out together.

NoteItem is built for this design too — what it reports up is the id itself:

TSX源项目From source
1// NoteItem:只上报 id
2<button onClick={() => onDelete(note.id)} className="danger">
3 Delete
4</button>
5
6// NoteTableProps / NoteItemProps 的类型也说明了这件事
7onDelete: (id: number) => void;
1// NoteItem: it reports the id and nothing else
2<button onClick={() => onDelete(note.id)} className="danger">
3 Delete
4</button>
5
6// The types of NoteTableProps / NoteItemProps say the same thing
7onDelete: (id: number) => void;
Source: react-notes-app/src/components/NoteItem/index.tsx 与 NoteTable/index.tsx
类型签名 (id: number) => void 是一条硬约束:你只会收到 id,收不到整条 note。所以「按 title 删」这条路在类型层面就被堵住了一半 —— 你拿不到 title。The type signature (id: number) => void is a hard limit: you receive the id, never the whole note. So deleting by title is already half blocked by the types — you never get the title.
§02

先想再写Think it through before you write

先别写代码 · 先回答这几个问题Before you write code · answer these first
1.输入是什么?—— 一个 number 类型的 id。
2.输出是什么?—— notes 的新值。
3.新值和旧值什么关系?—— 少了一条,其余顺序不变。
4.哪个数组方法「可能让数组变短」?—— filter。
5.filter 保留的是返回 true 的元素,所以条件该写「等于」还是「不等于」?

最后一问是这道题唯一会绕人的地方。filter 的语义是「留下」,不是「删掉」。 所以要删 id 相等的那条,条件必须写成「保留 id 不相等的」

先别写代码 · 先回答这几个问题Before you write code · answer these first
1.What is the input? One id, of type number.
2.What is the output? The new value of notes.
3.How does the new value relate to the old one? One item fewer, the rest in the same order.
4.Which array method can make an array shorter? filter.
5.filter keeps the elements whose callback returns true, so should the condition say equal or not equal?

That last question is the only place this task twists you around. filter means keep, not remove. So to delete the note whose id matches, the condition has to read “keep the ones whose id does not match”.

§03

实现The implementation

一行就够:

逐段读:

  • prev.filter(...) ——filter 永远返回新数组, 原数组一动不动。所以不可变更新自动满足, 不需要额外套展开语法。
  • (note) => note.id !== id —— 对每一条问一句「你的 id 是不是要删的那个? 不是 → 留下」。
  • 没有 if 判断「找不到怎么办」。 不需要 —— 如果没有匹配的 id,filter 就原样返回一份全留的新数组, 界面无变化。这是合理行为。

One line is enough:

Read it piece by piece:

  • prev.filter(...)filter always returns a new array and leaves the original alone. So the update does not change the original array, and no extra spread is needed.
  • (note) => note.id !== id — ask every note: is your id the one being deleted? No → you stay.
  • There is no if for “what if nothing matches”. None is needed — with no matching id, filter returns a new array that keeps everything, and the screen does not change. That is sensible behaviour.
TSXsrc/components/NoteManager/index.tsx源项目From source
1const handleDelete = (id: number) => {
2 setNotes((prev) => prev.filter((note) => note.id !== id));
3};
Source: react-notes-app/src/components/NoteManager/index.tsx
§04

测试的盲区:为什么它测不出「按 id」The blind spot in the test: why it cannot catch by id

这是这个项目最值得记住的一课。This is the one lesson from this project most worth remembering.

看第 3 个测试:

整个测试只有一条数据。所以下面这些写法全都能通过

  • prev.filter(n => n.id !== id) ✓ 正确
  • prev.filter(n => n.title !== "ToDelete") —— 硬编码都能过
  • [] —— 直接清空整个列表,也能过
  • prev.slice(1) —— 删第一条,也能过

所以:不要用「测试过了」当作「做对了」的证据。这道题的正确性判据是 README 里那句「按 id」, 以及你自己在 npm run dev 里加三条同名笔记 手动点一遍的结果。

这个道理在 Federation 那门考试里会以更夸张的形式重现 —— 那边有六个端点全部返回 null, 测试照样过了三个。

Look at the third test:

The whole test has exactly one note in it. Which means every one of these passes:

  • prev.filter(n => n.id !== id) ✓ correct
  • prev.filter(n => n.title !== "ToDelete") — even hard-coding gets through
  • [] — wiping the whole list gets through too
  • prev.slice(1) — dropping the first item, also through

So: never take “the tests pass” as evidence that you got it right. Correctness here is judged by that phrase “by id” in the README, plus what you see when you add three same-named notes under npm run dev and click through by hand.

The same point comes back in a far more extreme form in the Federation exam — over there six endpoints all return null and three tests still pass.

TSXsrc/NoteManager.test.tsx(第 3 个测试)src/NoteManager.test.tsx (test 3)源项目From source
1test("deletes a note", async () => {
2 render(<NoteManager />);
3 await userEvent.type(screen.getByTestId("form-input"), "ToDelete");
4 await userEvent.type(screen.getByTestId("form-textarea"), "x");
5 await userEvent.click(screen.getByTestId("form-submit-button"));
6 await userEvent.click(screen.getByRole("button", { name: "Delete" }));
7
8 expect(screen.getByTestId("notes-list")).not.toHaveTextContent("ToDelete");
9});
Source: react-notes-app/src/NoteManager.test.tsx
第 6 行 getByRole("button", { name: "Delete" }) —— 因为只有一条数据,页面上只有一个 Delete 按钮,所以 getByRole 不会因为「找到多个」而报错。有两条数据时这句就会挂,这也是测试只放一条数据的原因。Line 6 is getByRole("button", { name: "Delete" }). There is only one note, so the page holds only one Delete button and getByRole will not fail with a found-more-than-one error. With two notes this line would fail. That is why the test adds a single note.
§05

怎么自己验证「按 id」真的做对了How to check for yourself that by id really works

测试帮不上忙,就自己造一个测试不到的场景:

  1. npm run dev,打开浏览器。
  2. 三条标题完全相同的笔记, 内容分别写 1、2、3。
  3. 中间那条的 Delete。
  4. 正确:只有内容为 2 的那条消失, 1 和 3 还在,顺序不变。
    如果按 title 删:三条全没了。
    如果按下标删:可能删错行。

这种「手动造一个测试覆盖不到的场景」的能力, 比会写 filter 值钱得多。

The tests cannot help here, so build the scenario they miss yourself:

  1. npm run dev, open the browser.
  2. Add three notes with exactly the same title, with contents 1, 2 and 3.
  3. Click Delete on the middle one.
  4. Correct: only the one with content 2 disappears; 1 and 3 stay, in the same order.
    If you deleted by title: all three vanish.
    If you deleted by index: you may hit the wrong row.

This skill — building by hand a scenario the tests do not cover — is worth far more than knowing how to write filter.

练习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补全删除逻辑Fill in the delete logic

三个空。第三个空是这道题唯一会绕人的地方。

Three blanks. The third one is the only part that trips people up.

TSXsrc/components/NoteManager/index.tsx3 个空3 blanks
1const handleDelete = (id: number) => {
2 setNotes((prev) => prev.((note) => note. id));
3};
把 3 个空都填上才能检查(还差 3 个)Fill all 3 blanks to check (3 to go)
L3写整块Write a block不看答案,自己写出 Task 2Write Task 2 yourself, without looking at the answer

一行代码的题。但要一次写对,不许用 push / splice。

One line of code. But get it right the first time, and without push or splice.

要求Requirements
  • 按 id 移除对应的那一条Remove the matching note by id
  • 其余笔记全部保留,顺序不变Keep every other note, in the same order
  • 必须用函数式更新Use a functional update
  • 不许修改原数组(不许用 splice)Do not change the original array (no splice)
  • 不许按 title 或下标比较Do not compare by title or by index
TSXsrc/components/NoteManager/index.tsx
This check is textual: it looks for the right constructs, it does not run your code
提示Hints共 4 级,已看 0 级4 levels, 0 opened
先自己想两分钟。想不出来再点右上角 —— 提示是一级一级放的,不会一次给完。Think for two minutes first. Then use the button above — hints come one level at a time, never all at once.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。Before you open this, make sure you have written it yourself once. Following someone else's answer and producing your own are two different skills.

L2Debug LabDebug LabDebug Lab · 删一条,同名的全没了Debug Lab · delete one note and every note with the same title goes too

测试全过,但手动测试时发现:三条标题相同的笔记, 点其中一条的 Delete,三条一起消失。

Every test passes, but a manual check shows this: with three notes that share a title, clicking Delete on one of them makes all three disappear.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
# 测试:4 passed (4) ← 测试全过! # 手动复现步骤: # 1. 添加 "会议记录 / 内容1" # 2. 添加 "会议记录 / 内容2" # 3. 添加 "会议记录 / 内容3" # 4. 点第 2 行的 Delete # 期望:只剩「内容1」「内容3」 # 实际:表格全空# Tests: 4 passed (4) ← every test passes! # Manual repro steps: # 1. Add "会议记录 / 内容1" # 2. Add "会议记录 / 内容2" # 3. Add "会议记录 / 内容3" # 4. Click Delete on the second row # Expected: only 内容1 and 内容3 are left # Actual: the table is empty
TSX有问题的 handleDeleteThe handleDelete with the bug示意Illustrative
1const handleDelete = (id: number) => {
2 const target = notes.find((n) => n.id === id);
3 setNotes((prev) => prev.filter((note) => note.title !== target?.title));
4};
第 2 步 · 这是什么类型的错误Step 2 · what kind of error is this
迁移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.

「删除某一条」Delete one item
filter + 保留不匹配的(!==)filter, keeping the items that do not match (!==)
题目强调「按 X」The task stresses by X
比较依据只能是 XX is the only thing you may compare on
回调参数只给了 idThe callback only receives an id
说明设计上就要求你按 id 操作That means the design expects you to work by id
测试过了但心里没底The tests pass but you are not sure
手动造一个测试覆盖不到的场景(同名、多条、空列表)Build a case the tests do not cover: same title, several items, an empty list
这节的要点What to take away
  1. handleDelete 就一行:setNotes(prev => prev.filter(n => n.id !== id))。handleDelete is one line: setNotes(prev => prev.filter(n => n.id !== id)).
  2. filter 的语义是「留下」,所以删除要用不等号。filter means keep, so deleting uses the not-equal operator.
  3. 必须按 id 比较:title 不唯一,下标会变,splice 还会改原数组。Compare on the id: titles are not unique, indexes shift, and splice changes the original array.
  4. 第 3 个测试只有一条数据,硬编码甚至清空列表都能过 —— 测试不是正确性证明。The third test has only one note, so even hard-coding or emptying the list passes. A passing test is not a proof of correctness.
  5. 验证「按 id」的办法是手动加三条同名笔记,删中间那条。To check by id, add three notes with the same title by hand and delete the middle one.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises3 个,就在这一页上面 —— 别攒着最后一起做3 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lessonTask 3 · Edit:回填、改文字、就地更新、退出编辑Task 3 · Edit: refill the form, change the button text, update the row where it is, leave edit mode
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: Task 1 · Add:提交表单,新笔记进入表格Task 1 · Add: submit the form and the new note appears in the table