变式一 · Todo ListVariation 1 · Todo List
和 Notes Manager 同一套骨架,多了一个布尔字段、一个筛选、两个批量操作。The same skeleton as the Notes Manager, plus one boolean field, one filter, and two bulk actions.
这一页有什么On this page9
- 01 数据形状:只比 Note 多一个布尔字段The shape of the data: one boolean field more than Note
- 02 翻转一条:map + 对象展开Flipping one item: map plus object spread
- 03 三个派生数据,一个 state 都不加Three derived values, and not one new state
- 04 两个批量操作The two bulk actions
- 05 完整答案The complete answer
- 06 怎么验证How to check it
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 用 map + 对象展开就地翻转一条数据的布尔字段Flip the boolean field on one item using map plus object spread
- 把「剩余几项」「是否全部完成」「筛选后的列表」都写成派生数据Write how many are left, whether everything is done, and the filtered list as derived values
- 实现全选 / 取消全选和「清除已完成」Implement select all / clear all, and clear completed
- 说清筛选态下的删除为什么必须作用于原始数据Explain why a delete under an active filter must act on the original data
Todo List 是 React 面试与 assessment 出现频率最高的一道题。它考的东西和真实 Q1 完全重合(受控输入、三种不可变更新、派生数据),只是多了 toggle 和 filter 两个变式。做完这道题,Q1 那类题就不会再有陌生感。The Todo List is the question that shows up most often in React interviews and exams. What it tests overlaps completely with the real Q1: controlled inputs, the three ways to update data without changing the original, and derived values. It only adds two extra moves, toggle and filter. Once you have done this one, Q1 style questions no longer feel new.
数据形状:只比 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.
动手做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.
四个空。第 2 个是「只改一个字段」的写法,第 4 个考的是 「全选」和「反选」的区别。
Four blanks. The second is how you change one field only. The fourth is about the difference between select-all and invert-selection.
已有 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.
- visible 是派生数据,不许用 useState 或 useEffectvisible is derived data: no useState and no useEffect
- filter 为 "all" 时显示全部,"active" 显示未完成,"done" 显示已完成When filter is "all" show everything, "active" shows the unfinished ones, "done" shows the finished ones
- clearDone 移除所有已完成项,保留未完成项clearDone removes every finished item and keeps the unfinished ones
- 写操作必须作用于 todos,不能作用于 visibleA write has to act on todos, never on visible
- 不许修改原数组Do not change the original array
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.
初学者常见的几种写法错误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.
visible 里只有已完成项。 这一行会把所有未完成项一起丢掉。写操作永远作用于完整数据。When the filter is set to done,
visible holds only the finished items. This line throws away every unfinished item as well.A write always acts on the full data.
换一道题也能用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.
- Todo 比 Note 只多一个布尔字段,于是多出「翻转」这个动作。Todo has exactly one field more than Note, a boolean, and that field adds the toggle action.
- 翻转用 map + 对象展开;不可变要一路到底,不能只换外层数组。Toggle with map plus object spread. The new object has to go all the way down, not stop at the outer array.
- visible / remaining / allDone 三个都是派生数据,一个 state 都不加。visible, remaining, and allDone are all derived values. None of them needs its own state.
- 全选是「统一目标值」,不是「各自翻转」,否则变成反选。Select all means one shared target value, not flipping each item on its own. Flipping each item inverts the selection instead.
- 筛选态下的写操作必须作用于完整数据,否则会丢掉被筛掉的项。Under an active filter a write must act on the full data, or the filtered-out items are lost.