缺口四 · Kanban 看板:一次改两个数组Gap 4 · a Kanban board: changing two arrays in one update
跨列移动是 CRUD 的升级版 —— 源列删、目标列加,必须在一次操作里完成。Moving a card between columns is CRUD one step up: remove from one column and add to another, in a single update.
这一页有什么On this page4
- 把「移动一张卡」写成一个纯函数,一次返回完整的新 boardWrite moving a card as one pure function that returns the whole new board at once
- 说清为什么不能写成「先删再加」两次 setStateExplain why it must not be two setState calls, one to remove and one to add
- 让没被碰到的列复用原数组引用Let the columns you did not touch keep their original array reference
- 处理「没动」和「找不到卡」两种边界Handle the two edge cases: nothing moved, and the card was not found
Kanban 是 Hard 档的常见题,但拖拽只是外壳 —— 面试官真正看的是你怎么组织这次「同时影响两处」的状态更新。写成纯函数的人和在组件里堆两次 setState 的人,一眼就能分出来。Kanban is a common hard problem, but the dragging is only the wrapper — what the interviewer looks at is how you organise one update that changes two places at once. Whoever writes it as a pure function and whoever stacks two setState calls inside the component are easy to tell apart.
数据形状:用 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.
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. Writingreturn { ...board }costs you one wasted render.- When
findcomes 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 “
setBoardto delete, thensetBoardto add” and, even though React 18 batches, the moment any earlyreturnor validation slips in between, the card vanishes. - Later, when you wire up real drag and drop, undo, or server sync, this function works unchanged.
动手做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.
一次操作同时改两个数组,而且不许碰原 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.
- 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
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.
换一道题也能用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.
- board 用 Record<ColumnId, Card[]>,配计算属性名一次改两个键;列的顺序单独放常量。Use Record<ColumnId, Card[]> for the board, with computed property names to change two keys at once; keep the column order in a separate constant.
- moveCard 必须是纯函数:可以脱离 React 单测,而且不可能产生中间态。moveCard has to be a pure function: it can be unit tested without React, and it cannot produce a half-finished state.
- 两个 early return 要返回原引用而不是 { ...board },否则白渲染一次。The two early returns should give back the original reference, not { ...board }, or you pay for a render that changes nothing.
- { ...board } 只浅拷贝顶层 —— 未被碰到的列自动复用原数组,和评论树「只重建路径」同理。{ ...board } copies only the top level — the columns you did not touch keep their original arrays, the same idea as rebuilding only one path in a comment tree.
- 别写成两次 setState「先删再加」:一旦中间插入校验或提前 return,卡片就会消失。Do not write it as two setState calls, one to remove and one to add: as soon as a check or an early return slips in between, the card disappears.