DrillLab

Kanban 看板:一次改两个数组Kanban board: changing two arrays at once

React困难 · Hard约 40 分钟~40 min浏览器里能跑Runs in the browser
§01

题面The problem

先把要求读完,再动手。Read every requirement before you start.

一次操作同时改两个数组,而且不许碰原 board。 检查器会查两个边界和「未动的列复用引用」。

One action changes two arrays, and the board you were handed must not be touched. The checker looks at the two edge cases and at whether untouched columns keep their original reference.

验收标准Acceptance criteria
  • from === to 时返回同一个引用,不造新对象When from === to, return the same reference and build no new object
  • 找不到卡时返回同一个引用When the card is not found, return the same reference
  • 源列用 filter 去掉,目标列用展开追加Drop it from the source column with filter, and append to the target column with spread
  • 只改这两列,其余列复用原数组Only those two columns change; the rest keep their original arrays
  • 不许 push / splice / 直接赋值No push, no splice, no direct assignment

预计 40 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 40 minutes. Overrunning on the first pass is normal; the second pass should fit.

§02

工作区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.

3 个起始文件 · 目标 4 passed3 starter files · target 4 passed
自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《缺口四 · Kanban 看板:一次改两个数组》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “缺口四 · Kanban 看板:一次改两个数组” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《缺口四 · Kanban 看板:一次改两个数组》(2 段 · 约 20 分钟)Expand “缺口四 · Kanban 看板:一次改两个数组” (2 sections · ~20 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 缺口四 · Kanban 看板:一次改两个数组

§01

数据形状:用 Record 而不是数组套数组The shape of the data: a Record, not an array of arrays

board 是「列 id → 卡片数组」的映射。The board maps a column id to an array of cards.

Record<ColumnId, Card[]>Column[](每个 column 里有 cards)好用, 因为按列 id 直接取, 不用先 find 找到列再改。

而且展开语法配计算属性名 正好能一次改两个键{ ...board, [from]: …, [to]: … }。 如果是数组套数组, 同样的事要写两次 map, 明显更绕。

列的顺序单独放一个常量数组COLUMNS), 因为「顺序」是展示逻辑, 不该混进数据结构里 —— 这样加一列只改一处。

Record<ColumnId, Card[]> is easier to work with than Column[] (where each column holds its own cards), because you index straight by column id instead of running find to locate the column first.

And spread syntax plus computed property names changes two keys in one go: { ...board, [from]: ..., [to]: ... }. With arrays inside arrays the same thing takes two map calls, clearly more roundabout.

Keep the column order in its own constant array (COLUMNS), because “order” is display logic and should not be mixed into the data structure — that way adding a column touches one place.

TypeScriptsrc/types/Card.ts已跑通Verified
1export type ColumnId = "todo" | "doing" | "done";
2
3export type Card = { id: number; title: string };
4
5export type Board = Record<ColumnId, Card[]>;
§02

moveCard:这道题的全部难点moveCard: the whole difficulty of this problem

函数体十行,四个关键决定。每一个都有理由。Ten lines of code, four decisions that matter. Each one has a reason.

逐行读:

  • if (from === to) return board——返回原引用, React 直接跳过重渲染。 写成 return { ...board }会白渲染一次。
  • find 找不到也原样返回—— 别抛错,也别返回半个 board。
  • [from]: board[from].filter(...)—— 源列去掉,新数组。
  • [to]: [...board[to], card]—— 目标列追加,新数组。

关键性质:{ ...board }只浅拷贝顶层, 所以没被列出来的列(比如done)复用的是原来那个数组引用。 这正是我们想要的 —— 和评论树那道题「只重建路径上的节点」 是完全同一个思路

测试里专门验证了这三件事: 原 board 深冻结后调用不抛错、next.done === b.done(未动的复用)、next.todo !== b.todo(动过的是新的)。

为什么必须是纯函数、不能在组件里写:

  • 可以脱离 React 单测—— 六条测试里三条根本不渲染组件。
  • 不可能产生中间态。 写成「先 setBoard 删、 再 setBoard 加」, 虽然 React 18 会批处理, 但一旦中间插入任何提前return 或校验, 卡片就凭空消失了
  • 以后要接真拖拽、撤销、 或者服务端同步,这个函数原样能用

Line by line:

  • if (from === to) return board return the original reference and React skips the re-render outright. Writing return { ...board } costs you one wasted render.
  • When find comes up empty, return the board as it is — do not throw, and do not return half a board.
  • [from]: board[from].filter(...) — the source column drops it, new array.
  • [to]: [...board[to], card] — the target column appends, new array.

The key property: { ...board } only copies the top level, so columns you did not list (say done) reuse the array reference they already had. That is exactly what we want, and exactly the same idea as “rebuild only the nodes on the path” in the comment-tree question.

The test verifies these three things: calling it with a deep-frozen original board does not throw, next.done === b.done (untouched columns are reused), and next.todo !== b.todo (touched ones are new).

Why it has to be a pure function instead of living inside the component:

  • It can be unit tested without React — three of the six tests never render a component.
  • An in-between state becomes impossible. Write it as “setBoard to delete, then setBoard to add” and, even though React 18 batches, the moment any early return or validation slips in between, the card vanishes.
  • Later, when you wire up real drag and drop, undo, or server sync, this function works unchanged.
TSXsrc/components/Kanban/index.tsx(实测通过)src/components/Kanban/index.tsx (passes as measured)已跑通Verified
1import React, { useState } from "react";
2import type { Board, Card, ColumnId } from "../../types/Card";
3
4export const COLUMNS: { id: ColumnId; label: string }[] = [
5 { id: "todo", label: "待办" },
6 { id: "doing", label: "进行中" },
7 { id: "done", label: "已完成" },
8];
9
10/**
11 * 把一张卡从一列移到另一列,返回全新的 board。
12 * 关键:一次操作要同时改两个数组,两边都必须是新数组。
13 */
14export function moveCard(
15 board: Board,
16 from: ColumnId,
17 to: ColumnId,
18 cardId: number,
19): Board {
20 if (from === to) return board; // 没动就原样返回
21
22 const card = board[from].find((c) => c.id === cardId);
23 if (!card) return board; // 找不到也原样返回
24
25 return {
26 ...board,
27 [from]: board[from].filter((c) => c.id !== cardId), // 源列去掉
28 [to]: [...board[to], card], // 目标列追加
29 };
30}
31
32const Kanban: React.FC<{ initial: Board }> = ({ initial }) => {
33 const [board, setBoard] = useState<Board>(initial);
34 const [text, setText] = useState("");
35
36 const add = (e: React.FormEvent) => {
37 e.preventDefault();
38 if (text.trim() === "") return;
39 const card: Card = { id: Date.now(), title: text.trim() };
40 setBoard((prev) => ({ ...prev, todo: [...prev.todo, card] }));
41 setText("");
42 };
43
44 const move = (from: ColumnId, to: ColumnId, id: number) =>
45 setBoard((prev) => moveCard(prev, from, to, id));
46
47 return (
48 <div data-testid="kanban">
49 <form onSubmit={add}>
50 <input value={text} onChange={(e) => setText(e.target.value)} data-testid="card-input" />
51 <button type="submit" disabled={text.trim() === ""} data-testid="card-submit">
52 Add
53 </button>
54 </form>
55
56 {COLUMNS.map((col, ci) => (
57 <section key={col.id} data-testid={`col-${col.id}`}>
58 <h3>
59 {col.label}
60 <span data-testid={`count-${col.id}`}>{board[col.id].length}</span>
61 </h3>
62 <ul>
63 {board[col.id].map((c) => (
64 <li key={c.id} data-testid={`card-${c.id}`} data-col={col.id}>
65 <span>{c.title}</span>
66 {ci > 0 && (
67 <button
68 aria-label={`把 ${c.title} 左移`}
69 onClick={() => move(col.id, COLUMNS[ci - 1].id, c.id)}
70 >
71
72 </button>
73 )}
74 {ci < COLUMNS.length - 1 && (
75 <button
76 aria-label={`把 ${c.title} 右移`}
77 onClick={() => move(col.id, COLUMNS[ci + 1].id, c.id)}
78 >
79
80 </button>
81 )}
82 </li>
83 ))}
84 </ul>
85 </section>
86 ))}
87 </div>
88 );
89};
90
91export default Kanban;
1import React, { useState } from "react";
2import type { Board, Card, ColumnId } from "../../types/Card";
3
4export const COLUMNS: { id: ColumnId; label: string }[] = [
5 { id: "todo", label: "待办" },
6 { id: "doing", label: "进行中" },
7 { id: "done", label: "已完成" },
8];
9
10/**
11 * Move one card from one column to another and return a brand new board.
12 * The key point: one action changes two arrays, and both of them have to be new arrays.
13 */
14export function moveCard(
15 board: Board,
16 from: ColumnId,
17 to: ColumnId,
18 cardId: number,
19): Board {
20 if (from === to) return board; // nothing moved, so return it as it is
21
22 const card = board[from].find((c) => c.id === cardId);
23 if (!card) return board; // not found either, so return it as it is
24
25 return {
26 ...board,
27 [from]: board[from].filter((c) => c.id !== cardId), // drop it from the source column
28 [to]: [...board[to], card], // append it to the target column
29 };
30}
31
32const Kanban: React.FC<{ initial: Board }> = ({ initial }) => {
33 const [board, setBoard] = useState<Board>(initial);
34 const [text, setText] = useState("");
35
36 const add = (e: React.FormEvent) => {
37 e.preventDefault();
38 if (text.trim() === "") return;
39 const card: Card = { id: Date.now(), title: text.trim() };
40 setBoard((prev) => ({ ...prev, todo: [...prev.todo, card] }));
41 setText("");
42 };
43
44 const move = (from: ColumnId, to: ColumnId, id: number) =>
45 setBoard((prev) => moveCard(prev, from, to, id));
46
47 return (
48 <div data-testid="kanban">
49 <form onSubmit={add}>
50 <input value={text} onChange={(e) => setText(e.target.value)} data-testid="card-input" />
51 <button type="submit" disabled={text.trim() === ""} data-testid="card-submit">
52 Add
53 </button>
54 </form>
55
56 {COLUMNS.map((col, ci) => (
57 <section key={col.id} data-testid={`col-${col.id}`}>
58 <h3>
59 {col.label}
60 <span data-testid={`count-${col.id}`}>{board[col.id].length}</span>
61 </h3>
62 <ul>
63 {board[col.id].map((c) => (
64 <li key={c.id} data-testid={`card-${c.id}`} data-col={col.id}>
65 <span>{c.title}</span>
66 {ci > 0 && (
67 <button
68 aria-label={`把 ${c.title} 左移`}
69 onClick={() => move(col.id, COLUMNS[ci - 1].id, c.id)}
70 >
71
72 </button>
73 )}
74 {ci < COLUMNS.length - 1 && (
75 <button
76 aria-label={`把 ${c.title} 右移`}
77 onClick={() => move(col.id, COLUMNS[ci + 1].id, c.id)}
78 >
79
80 </button>
81 )}
82 </li>
83 ))}
84 </ul>
85 </section>
86 ))}
87 </div>
88 );
89};
90
91export default Kanban;
TSX两种错法Two wrong versions示意Illustrative
1// ✗ 两次 setState:中间态风险 + 没法单测
2const move = (from, to, id) => {
3 const card = board[from].find((c) => c.id === id);
4 setBoard((b) => ({ ...b, [from]: b[from].filter((c) => c.id !== id) }));
5 setBoard((b) => ({ ...b, [to]: [...b[to], card] })); // card 从旧 board 拿的
6};
7
8// ✗ 没动也造新对象:白渲染一次
9if (from === to) return { ...board };
1// ✗ two setState calls: risk of a half-updated state, and no way to unit-test it
2const move = (from, to, id) => {
3 const card = board[from].find((c) => c.id === id);
4 setBoard((b) => ({ ...b, [from]: b[from].filter((c) => c.id !== id) }));
5 setBoard((b) => ({ ...b, [to]: [...b[to], card] })); // card came from the old board
6};
7
8// ✗ builds a new object even when nothing moved: one render for nothing
9if (from === to) return { ...board };
§04

参考答案Reference solution

提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.

提示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.

这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。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.