DrillLab
第 08 / 21 节LESSON 08 / 21约 13 分钟~13 min

先读题:三个任务、一条硬约束、四个测试Read the question first: three tasks, one rule you must not break, four tests

在写第一行代码之前,把题目、约束和判卷标准全部摸清。Before writing a single line of code, get clear on the task, the constraints, and how it is graded.

3 个练习3 exercisesReact · 第 3 部分React · Part 3
这一页有什么On this page7
学完这节你会After this lesson you can
  • 用自己的话复述三个 Task 的验收标准Restate the acceptance criteria of the three tasks in your own words
  • 知道「不得修改任何 data-testid」具体意味着什么不能动Know exactly what you may not touch when the task says do not change any data-testid
  • 会跑测试,并且知道跑的是哪四条Run the tests, and know which four tests are running
  • 认出题目里没写但测试在查的那一条Spot the one requirement the task text leaves out but the tests still check
这在考试里考什么What the exam does with this

这一节本身就是考点。考场上最贵的错误不是写错代码,是「没读清题就开始写」——比如把删除写成按 title 删、把更新写成删了再加。This lesson is itself part of the exam. The most expensive mistake is not bad code, it is starting to write before you have read the task properly: deleting by title instead of by id, or updating an item by removing it and adding it again.

这节课要看的真实文件Real files this lesson looks at高亮行 = 需要你动手改的文件Highlighted rows are the files you edit
react-notes-app/README.md三个 Task 的原文与约束The three tasks as written, with their constraints
TextREADME.md源项目From source
1# React + TypeScript 模拟面试(两题)
2
3## 环境启动
4npm install
5npm run dev <- Q1,浏览器打开提示的 localhost 地址
6npm run q2 <- Q2 的测试台(实现完 taskRunner.ts 后运行验证)
7
8## Q1: Notes Manager (CRUD)
9文件: src/components/**
10按代码里的 TODO 完成三个任务:
11- Task 1 Add: 提交表单 -> 新 note 进入表格
12- Task 2 Delete: 点 Delete -> 该行按 id 被移除
13- Task 3 Edit: 点 Edit -> 内容回填进表单、按钮变 Update ->
14 提交 -> 原位置更新该 note、退出编辑模式
15约束: 不得修改任何 data-testid。
16
17## Q2: 并发限制的异步任务调度器
18文件: q2/taskRunner.ts (实现) q2/demo.ts (验证)
19要求: 见 taskRunner.ts 顶部注释。
20验证标准: npm run q2 的输出里 "running now" 永远不超过 2,
21且最终结果数组与任务原始顺序一致、失败任务以 rejected 形式出现。
Source: react-notes-app/README.md
react-notes-app/src/NoteManager.test.tsx四个判卷测试The four tests that decide the marks
TSXNoteManager.test.tsx源项目From source
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import NoteManager from "./components/NoteManager";
4
5test("adds a note", async () => {
6 render(<NoteManager />);
7 await userEvent.type(screen.getByTestId("form-input"), "My Title");
8 await userEvent.type(screen.getByTestId("form-textarea"), "My Content");
9 await userEvent.click(screen.getByTestId("form-submit-button"));
10
11 expect(screen.getByTestId("notes-list")).toHaveTextContent("My Title");
12});
13
14test("submit button disabled when inputs empty", () => {
15 render(<NoteManager />);
16 expect(screen.getByTestId("form-submit-button")).toBeDisabled();
17});
18
19test("deletes a note", async () => {
20 render(<NoteManager />);
21 await userEvent.type(screen.getByTestId("form-input"), "ToDelete");
22 await userEvent.type(screen.getByTestId("form-textarea"), "x");
23 await userEvent.click(screen.getByTestId("form-submit-button"));
24 await userEvent.click(screen.getByRole("button", { name: "Delete" }));
25
26 expect(screen.getByTestId("notes-list")).not.toHaveTextContent("ToDelete");
27});
28
29test("edits a note in place", async () => {
30 render(<NoteManager />);
31 await userEvent.type(screen.getByTestId("form-input"), "Old");
32 await userEvent.type(screen.getByTestId("form-textarea"), "c1");
33 await userEvent.click(screen.getByTestId("form-submit-button"));
34
35 await userEvent.click(screen.getByRole("button", { name: "Edit" }));
36 expect(screen.getByTestId("form-submit-button")).toHaveTextContent("Update");
37
38 const input = screen.getByTestId("form-input");
39 await userEvent.clear(input);
40 await userEvent.type(input, "New");
41 await userEvent.click(screen.getByTestId("form-submit-button"));
42
43 expect(screen.getByTestId("notes-list")).toHaveTextContent("New");
44 expect(screen.getByTestId("notes-list")).not.toHaveTextContent("Old");
45});
Source: react-notes-app/src/NoteManager.test.tsx
react-notes-app/src/components/NoteManager/index.tsx三道题的落点

提醒:源项目在磁盘上是做完的版本 —— 下面就是答案。想自己先写一遍的话,现在关上。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
§01

题目原文The task text as it is given

先看没有加工过的版本。Read the version that has not been reworded yet.

这是 react-notes-app/README.md 里 Q1 部分的原文, 一个字没改:

This is the Q1 section of react-notes-app/README.md, word for word:

TextREADME.md(Q1 原文)README.md (the original text of Q1)源项目From source
1## Q1: Notes Manager (CRUD)
2文件: src/components/**
3按代码里的 TODO 完成三个任务:
4- Task 1 Add: 提交表单 -> 新 note 进入表格
5- Task 2 Delete: 点 Delete -> 该行按 id 被移除
6- Task 3 Edit: 点 Edit -> 内容回填进表单、按钮变 Update ->
7 提交 -> 原位置更新该 note、退出编辑模式
8约束: 不得修改任何 data-testid。
1## Q1: Notes Manager (CRUD)
2Files: src/components/**
3Follow the TODOs in the code and finish three tasks:
4- Task 1 Add: submit the form -> the new note appears in the table
5- Task 2 Delete: click Delete -> that row is removed by id
6- Task 3 Edit: click Edit -> fields refill, the button becomes Update ->
7 submit -> the note updates in place, edit mode ends
8Rule: do not change any data-testid.
Source: react-notes-app/README.md
§02

用初学者能看懂的话重写一遍The same task written in plainer words

题目里每个词都是要求。挑出来逐条对应。Every word in the task is a requirement. Pull them out one at a time.

题目里的词它到底在要求什么对应的技术动作
「新 note 进入表格」列表末尾多一条,原有的都还在[...prev, note]
按 id 被移除」比较的依据必须是 id,不是 title、不是下标。 同名笔记只删对的那条。filter(n => n.id !== id)
「内容回填进表单」输入框里出现这条笔记原有的 title 和 contentuseEffect(…, [noteToEdit])
「按钮变 Update」按钮文字从 Add 变成 Update,字面一致{noteToEdit ? "Update" : "Add"}
原位置更新」改完这条还在原来那一行,顺序不变。这排除了「先删再加」的写法。map(…) 三元替换
「退出编辑模式」提交后按钮回到 Add,表单清空,不再处于编辑态setNoteToEdit(null)

「按 id」和「原位置」是这道题真正的分水岭。两个要求都指向同一件事:出题人想看你会不会用filtermap 精确操作数组, 而不是用「删掉再塞回去」这种糊弄过去的写法。

The words in the briefWhat they actually demandThe technical move
“the new note enters the table”One more row at the end, everything already there stays[...prev, note]
“removed by idThe comparison has to be the id, not the title, not the index. With two same-named notes, only the right one goes.filter(n => n.id !== id)
“content filled back into the form”The inputs show this note’s existing title and contentuseEffect(…, [noteToEdit])
“the button turns into Update”The button text goes from Add to Update, spelled exactly so{noteToEdit ? "Update" : "Add"}
“updated in placeAfter the edit it is still on the same row, order unchanged. This rules out delete-then-append.map(…) with a ternary
“leave edit mode”After submit the button is Add again, the form is empty, and you are no longer editingsetNoteToEdit(null)

“By id” and “in place” are the real dividing lines here. Both point at the same thing: the author wants to see whether you can work on an array precisely with filter and map, instead of fudging it with “delete it and stuff it back in”.

§03

「不得修改任何 data-testid」具体是什么意思What do not change any data-testid actually means

不只是「别改那串字符」,还包括「别让那个元素消失」。It is not only do not change that string. It also means do not let that element disappear.

项目里一共 6 个 data-testid, 测试全靠它们定位元素:

testid在哪个元素上测试用它做什么
note-managerNoteManager 最外层 div(当前测试没直接用)
note-formform 元素(当前测试没直接用)
form-input标题输入框往里打字、clear 后重打
form-textarea内容文本域往里打字
form-submit-button提交按钮点击、断言 disabled、断言文字是 Update
notes-listtbody断言它的 textContent 含 / 不含某段文字

延伸出三条不能碰的红线:

  1. 不能改字符串。form-input 改成 title-input, 测试 getByTestId("form-input") 直接抛错。
  2. 不能让元素条件性消失。比如给空列表加一个「暂无笔记」的分支、把 tbody整个换掉 —— getByTestId("notes-list")会找不到元素而抛错,而第 3 个测试恰恰要在删除后断言它。
  3. 行内按钮的文字也是隐性契约。测试用 getByRole("button", { name: "Delete" })定位,所以 Delete / Edit这两个词也不能改(改成 Remove 就找不到了)。 这一条 README 没写,只能从测试里读出来。

The project has 6 data-testid attributes in total, and the tests find every element through them:

testidWhich element it sits onWhat the tests do with it
note-managerThe outermost div of NoteManager(not used directly by the current tests)
note-formThe form element(not used directly by the current tests)
form-inputThe title inputType into it, clear it and type again
form-textareaThe content textareaType into it
form-submit-buttonThe submit buttonClick it, assert disabled, assert the text is Update
notes-listThe tbodyAssert its textContent does / does not contain a piece of text

Three red lines follow from that:

  1. You cannot change the string. Rename form-input to title-input and getByTestId("form-input") throws on the spot.
  2. You cannot let the element disappear conditionally. Say you add a “no notes yet” branch for the empty list, or swap out the whole tbody getByTestId("notes-list") finds nothing and throws, and the third test asserts on it right after a delete.
  3. The inline button text is an implicit contract too. The tests locate them with getByRole("button", { name: "Delete" }), so the words Delete / Edit are frozen as well (rename one to Remove and it is gone). The README does not say this; you can only read it out of the tests.
§04

先跑一遍测试,拿到基线Run the tests first, to get a baseline

改代码之前先知道现在是什么状态 —— 这个习惯值几十分。Know where you stand before you change any code. This habit is worth a lot of points.

上一门课讲过:这个项目没有 test scriptnpm test 会报 Missing script。要用 npx。

本机实测的结果如下 —— 注意这是项目当前磁盘状态的结果。 如果你拿到的是挖空版(TODO 还在), 这里会看到 3 个失败、1 个通过(只有 disabled 那条会过)。

An earlier module said it: this project has no test script, so npm test reports Missing script. Use npx.

Here is what it printed on this machine — note that this is the result for the project as it sits on disk. If what you got is the hollowed-out version with the TODOs still in place, you would see 3 failures and 1 pass (only the disabled one gets through).

Terminal本机实测Run on a real machine源项目From source
1$ cd react-notes-app
2$ npm install
3$ npx vitest run
4
5 RUN v4.1.10 react-notes-app
6
7 Test Files 1 passed (1)
8 Tests 4 passed (4)
9 Duration 1.19s
10
11# 顺便确认另外两件事:
12$ npm test
13npm error Missing script: "test" ← 没有这个 script,用 npx
14
15$ npm run build
16src/NoteManager.test.tsx(5,1): error TS2582: Cannot find name 'test'.
17...共 10 条 ← 项目自带的配置缺陷,与你的实现无关
1$ cd react-notes-app
2$ npm install
3$ npx vitest run
4
5 RUN v4.1.10 react-notes-app
6
7 Test Files 1 passed (1)
8 Tests 4 passed (4)
9 Duration 1.19s
10
11# Two more things worth checking while you are here:
12$ npm test
13npm error Missing script: "test" ← 没有这个 script,用 npx
14
15$ npm run build
16src/NoteManager.test.tsx(5,1): error TS2582: Cannot find name 'test'.
17...10 in totala defect in the given setup, nothing to do with your code
Source: react-notes-app
§05

三道题的落点:一个文件,三个函数Where the three tasks land: one file, three functions

先把要改的地方框出来,再动手。Mark the places you have to change, then start writing.

NoteFormNoteTableNoteItem 三个文件都不需要改 —— 它们已经完整了。所有逻辑都落在NoteManager 的三个 handler 上:

  • handleSubmitNote → Task 1 + Task 3 的后半
  • handleDelete → Task 2
  • handleEdit → Task 3 的前半

下面是这个文件的完整最终形态(也就是参考答案)。先别细看 —— 接下来三节会一题一题推导出来。 现在只要看清「结构长什么样」。

NoteForm, NoteTable and NoteItem need no changes at all — they are already complete. All the logic lands on the three handlers inside NoteManager:

  • handleSubmitNote → Task 1 plus the back half of Task 3
  • handleDelete → Task 2
  • handleEdit → the front half of Task 3

Below is the finished shape of that file — the reference answer. Do not study it yet — the next three lessons derive it one task at a time. For now just see what the structure looks like.

TSXsrc/components/NoteManager/index.tsx(完整参考答案)src/components/NoteManager/index.tsx (complete reference answer)源项目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
练习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.

L1认出来Spot it哪一处改动会让测试挂掉Which change makes a test fail

README 只说了「不得修改任何 data-testid」。 下面哪些改动也会让现有测试失败?(多选)

The README says only that no data-testid may be changed. Which of the changes below also break the existing tests? (more than one)

这题是多选。More than one answer is correct.

先选一个选项Pick an option first
L1认出来Spot it题目没写但测试在查的是哪一条The requirement the task never states but a test checks

README 的三个 Task 里没提到某个要求, 但四个测试里有一条专门在查它。是哪个?

One requirement is never mentioned in the three Tasks of the README, yet one of the four tests checks exactly that. Which one is it?

先选一个选项Pick an option first
L1排顺序Order it把上手顺序排对Put the starting steps in order

拿到这个项目,最合理的动作顺序是什么?

You just received this project. What is the most sensible order to work in?

1写代码:三个 handler 逐个实现Write the code: implement the three handlers one at a time
2npm install,然后 npx vitest run 拿到基线npm install, then npx vitest run to get a baseline
3读 NoteForm / NoteTable / NoteItem,确认它们已经完整、不用改Read NoteForm / NoteTable / NoteItem and confirm they are already complete and need no change
4读 README + 读测试文件,抄下所有验收标准Read the README and the test file, and write down every acceptance criterion
5npx vitest run 验证,再 npm run dev 手动点一遍Verify with npx vitest run, then npm run dev and click through it by hand
6读 types/Note.ts,确认数据形状和 id 的类型Read types/Note.ts to confirm the shape of the data and the type of id
迁移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.

题目里出现「按 X」The task says by X
比较依据必须是 X,别用别的字段凑The comparison has to use X, not some other field that happens to work
题目里出现「原位置」「顺序不变」The task says in place or the order stays the same
用 map 替换,不能删了再加Replace with map. Do not remove the item and add it again
看到 data-testidYou see a data-testid
字符串和元素存在性都不能动Neither the string nor the presence of the element may change
拿到新项目You are handed a new project
先跑基线测试,再读题,再读类型Run the tests for a baseline, then read the task, then read the types
这节的要点What to take away
  1. 三个 Task 的分水岭是「按 id」和「原位置」两个词。The two phrases that separate the three tasks are by id and in place.
  2. data-testid 不能改字符串,也不能让那个元素条件性消失。You may not change a data-testid string, and you may not let that element disappear under some condition.
  3. 行内按钮文字 Delete / Edit / Update 是测试依赖的隐性契约。The row button labels Delete, Edit, and Update are an unwritten contract the tests depend on.
  4. 第 2 个测试查的 disabled 是 README 没写的要求 —— 测试也是题面。The disabled check in the second test is a requirement the README never states. The tests are part of the task too.
  5. 三道题全部落在 NoteManager 的三个 handler 上,其余三个组件不用改。All three tasks land on three handlers in NoteManager. The other three components need no changes.

接下来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 1 · Add:提交表单,新笔记进入表格Task 1 · Add: submit the form and the new note appears in the table
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 派生数据与状态提升:什么不该做成 stateValues you can compute, and lifting state up: what should not be state