Todo List
题面The problem
先把要求读完,再动手。Read every requirement before you start.
已有 todos 和 filter 两个 state。 写出可见列表和「清除已完成」,注意筛选态下的写操作该作用于谁。
You already have the two states todos and filter. Write the visible list and the clear-completed action, and think about which list a write should act on while a filter is on.
- 输入框为空或只有空格时,Add 按钮 disabledThe Add button is disabled while the input is empty or holds only spaces
- 提交后清空输入框,新条目追加到末尾Submitting clears the input and appends the new item to the end of the list
- 勾选切换单条的 done —— map + 对象展开,不改原对象The checkbox toggles done on one item: use map plus object spread, and never change the original object
- Delete 用 filter 删掉单条,不许 spliceDelete removes a single item with filter, never with splice
- visible / remaining / allDone 三个都是派生数据,不许再开 statevisible, remaining and allDone are all derived values. Do not add state for them
- 筛选 all / active / done 只影响显示,切回 all 时所有条目都还在The all / active / done filter only changes what is shown. Switching back to all brings every item back
- Check all / Uncheck all 按「当前是否已全部完成」整体反转Check all / Uncheck all flips every item at once, based on whether they are all done right now
- Clear completed 只删已完成的Clear completed removes only the items that are already done
预计 25 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 25 minutes. Overrunning on the first pass is normal; the second pass should fit.
工作区Workspace
工作区是一个真的浏览器沙箱:左边写代码,右边实时预览,下面一个「跑测试」按钮。测试和本机那套是同一批断言,转写成了浏览器里能跑的写法。The workspace is a real in-browser sandbox: edit on the left, live preview on the right, one Run button below. The assertions are the same ones that pass on a real machine, rewritten for the browser runner.
需要联网。Requires an internet connection. 打包器和 npm 依赖都在 CodeSandbox 的远程服务上(评估过程见 docs/sandpack-evaluation.md),断网这块就起不来 —— 那就照下面的命令在本机跑。The bundler and the npm packages come from CodeSandbox's remote service, so this panel needs network access.
展开讲解Walkthrough
下面是《变式一 · Todo List》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “变式一 · Todo List” — the same content as in the course, not a rewritten summary. Expand it when you stall.
展开《变式一 · Todo List》(6 段 · 约 14 分钟)Expand “变式一 · Todo List” (6 sections · ~14 min)
完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式一 · Todo List。
数据形状:只比 Note 多一个布尔字段The shape of the data: one boolean field more than Note
先看类型,其余都是从它推出来的。Start with the type. Everything else follows from it.
和 Note 对比一下:Todo 多了一个done: boolean,于是多出「翻转」这个操作;Filter 是个字面量联合,用来存筛选条件。
注意 Filter 不属于任何一条 todo —— 它是「界面当前怎么看这份数据」,所以它是独立的一个 state, 而不是 todo 上的字段。这个区分在很多人那里是模糊的。
Put it next to Note: Todo adds exactly one field, done: boolean, and that one field buys you a new operation — toggling. Filter is a literal union that holds the current filter.
Notice that Filter belongs to no single todo — it is “how the UI is looking at this data right now”, so it is its own piece of state, not a field on a todo. Plenty of people are fuzzy about that line.
翻转一条:map + 对象展开Flipping one item: map plus object spread
这是三件套之外的第四个动作,但底层还是 map。This is a fourth action beyond the usual three, but underneath it is still map.
Q1 的「就地更新」是整条替换(note.id === x ? submittedNote : note)。 这里只想改一个字段,所以用对象展开 + 覆盖:
{ ...t, done: !t.done } 造了一个新对象: 旧字段全部照抄,只把 done 换成反过来的值。
为什么不能直接 t.done = !t.done?那是在改原对象。虽然数组是新的(map 返回新数组), 但里面那个 todo 对象还是同一个引用 —— 如果哪天你给列表项加了 React.memo, 它会认为「props 没变」而不重渲染,于是勾选框不动。不可变更新要一路到底,不能只做外层。
The “update in place” in Q1 replaced the whole item (note.id === x ? submittedNote : note). Here you only want to change one field, so you use object spread plus an override:
{ ...t, done: !t.done } builds a new object: copy every old field, then swap done for its opposite.
Why not just t.done = !t.done? Because that mutates the original object. The array is new (map returns a new array), but the todo object inside is still the same reference — the day you wrap list items in React.memo, it decides “props did not change”, skips the re-render, and the checkbox stops moving. Immutable updates have to go all the way down, not just the outer layer.
三个派生数据,一个 state 都不加Three derived values, and not one new state
这道题最容易过度设计的地方是「剩余几项」和「筛选后的列表」—— 很多人会为它们各开一个 useState, 再用 useEffect 同步。全都不需要。
visible / remaining /allDone 三个值都能从 todos 和filter 当场算出来,每次渲染重算,永远不会不一致。
关键陷阱:所有写操作都要作用于 todos, 不是 visible。筛选到「已完成」时点删除,如果你写setTodos(visible.filter(...)), 那些被筛掉的未完成项会全部消失。这一条在模拟考 A 里也考过, 是同一个坑。
The easiest place to over-engineer this question is “how many left” and “the filtered list” — a lot of people give each one its own useState and then sync them with a useEffect. None of that is needed.
visible / remaining / allDone can all be computed on the spot from todos and filter, recomputed on every render, so they can never fall out of sync.
The trap that matters: every write goes through todos, not visible. Filter down to “done” and hit delete, and if you wrote setTodos(visible.filter(...)), every unfinished item that got filtered out disappears. Mock exam A tests the same trap.
两个批量操作The two bulk actions
全选 / 取消全选的正确语义是 「以当前是否已全部完成为准,整体反转」—— 而不是「每一条各自翻转」。后者在混合状态下会得到一半勾一半不勾, 不符合用户预期。
清除已完成就是一次 filter, 和删除单条是同一个动作,只是条件不同。
Check all / uncheck all means “flip everything to one target value, decided by whether they are all done right now” — not “flip each item on its own”. The second reading leaves a mixed list half checked and half unchecked, which is not what the user expects.
Clear completed is one filter call. Same action as deleting a single item, different condition.
完整答案The complete answer
7 个测试全过。All 7 tests pass.
注意 data-testid 用了模板字符串生成 (`filter-${f}`)—— 三个筛选按钮共用一段渲染代码, 这是列表渲染的常见写法。
Note that data-testid is generated with a template string (`filter-${f}`) — the three filter buttons share one piece of render code, which is the normal way to render a list.
怎么验证How to check it
这就是跑出 7/7 的那个测试文件,原样贴在这里。This is the test file that produced 7 of 7, pasted exactly as it is.
想真正练这道题,就在一个空的 Vite + React + TS 项目里 (react-notes-app 的脚手架直接能用)新建src/types/Todo.ts 和src/components/TodoList/index.tsx, 把下面这个测试文件放到 src/TodoList.test.tsx, 然后自己把组件写出来。
注意测试是按 data-testid 和aria-label 找元素的 ——这些名字必须和测试对上, 这也是真实 assessment 的规矩(Q1 的六个 testid 一个都不能改)。
第 5 条 filters without losing data是专门抓「筛选态下改坏了底层数据」的:筛完再切回all,两条都得还在。
To really practise this one, open an empty Vite + React + TS project (the react-notes-app scaffold works as is), create src/types/Todo.ts and src/components/TodoList/index.tsx, drop the test file below into src/TodoList.test.tsx, and write the component yourself.
The tests find elements by data-testid and aria-label — those names have to match the tests, which is the rule in the real assessment too (you cannot rename a single one of the six testids in Q1).
Test 5, filters without losing data, exists to catch “the filter broke the underlying data”: filter, switch back to all, and both items must still be there.
参考答案Reference solution
提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.
这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。This answer really was run here and its tests passed. But write it yourself first — reading an answer and producing one are two different skills, and the exam tests the second.