DrillLab

递归评论树 + 树形不可变更新Recursive comment tree, updated without changing the original

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

题面The problem

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

这是这道题真正的难点。目标节点可能在任意深度, 要返回一棵新树,而且原树一个字节都不能改

This is the hard part of the question. The target node can be at any depth, you have to return a new tree, and not one byte of the original may change.

验收标准Acceptance criteria
  • countComments 递归数出总条数 —— 不是 nodes.length(那只数了顶层)countComments counts every comment recursively. nodes.length is wrong because it only counts the top level
  • maxDepth 返回最深那条路径的层数,空数组是 0maxDepth returns the depth of the deepest path. An empty array gives 0
  • addReply 找到 id === parentId 的节点,把 reply 追加到它的 replies 末尾addReply finds the node where id === parentId and appends reply to the end of its replies
  • 目标可能在任意深度,需要递归往下找;顶层的也要能加The target node can sit at any depth, so search downwards recursively. A top-level node must work too
  • 返回全新的树,原树一个字节都不能改 —— 测试会深冻结它,改了直接抛Return a brand new tree and leave the original completely untouched. The test deep-freezes it, so any change throws
  • parentId 不存在时树的内容不变When parentId does not exist anywhere, the contents of the tree stay the same
  • 同一个 parent 连加两条,按加入顺序排在后面Adding two replies to the same parent keeps them in the order they were added
  • 不许 push / splice / 直接赋值,也不许 JSON.parse(JSON.stringify(...)) 深拷贝Do not use push, splice or direct assignment, and do not deep-copy with JSON.parse(JSON.stringify(...))

预计 35 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 35 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.

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

展开讲解Walkthrough

下面是《变式四 · 递归读取评论的评论》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “变式四 · 递归读取评论的评论” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《变式四 · 递归读取评论的评论》(6 段 · 约 20 分钟)Expand “变式四 · 递归读取评论的评论” (6 sections · ~20 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 变式四 · 递归读取评论的评论

§01

数据形状:一个类型引用自己The shape of the data: a type that refers to itself

评论的评论,本质上就是一个字段指回自己的类型。A comment on a comment is really just a type with one field that points back at itself.

「评论的评论」听起来复杂,写成类型就一行:

replies 的类型是 Comment[] ——它引用了正在定义的这个类型自己。 TypeScript 允许这样递归定义,这就是树。

深度不在数据里。注意 Comment 上没有 depth 字段。 深度是「它在树里的位置」,是渲染时算出来的, 不该存进数据 —— 存了就要在移动节点时维护, 而且很容易和实际结构不一致。

“Comments on comments” sounds complicated; written as a type it is one line:

replies is typed Comment[] it refers to the very type being defined. TypeScript allows that recursion, and that is what a tree is.

Depth is not in the data. Notice there is no depth field on Comment. Depth is “where the node sits in the tree”, worked out while rendering. It should not be stored — store it and you have to maintain it whenever a node moves, and it drifts out of sync with the real structure very easily.

TypeScriptsrc/types/Comment.ts已跑通Verified
1export type Comment = {
2 id: number;
3 author: string;
4 body: string;
5 replies: Comment[]; // 自己引用自己 —— 这就是「树」
6};
1export type Comment = {
2 id: number;
3 author: string;
4 body: string;
5 replies: Comment[]; // it refers to itself, and that is what makes it a tree
6};
§02

递归组件:终止条件不用写 ifA recursive component: you do not need an if to stop it

很多人卡在「递归怎么停」,其实 map 已经帮你停了。Many people get stuck on how the recursion stops. map already stops it for you.

递归组件就是在自己的 JSX 里渲染自己

终止条件在哪?comment.replies.map(...) 这一句里。 当 replies 是空数组时, map 什么都不产出,于是不再有新的 CommentNode被创建 —— 递归自然停住,不需要写if (depth > N) return null 之类的东西

depth 靠参数往下传。每往下一层就 depth + 1, 用来做缩进和 data-depth。 根节点从 0 开始。

key 还是 child.id, 和普通列表一样 —— 同一层里唯一就够了,不需要全树唯一。

一个真实的注意点:如果数据可能有环(A 的回复里有 A),递归会栈溢出。 真实接口一般不会,但如果题目提到「数据来自用户输入」, 提一句「加一个 visited 集合或最大深度兜底」是加分的。

A recursive component renders itself inside its own JSX:

Where is the base case? In the line comment.replies.map(...). When replies is an empty array, map produces nothing, so no new CommentNode is created — the recursion stops by itself and you do not need anything like if (depth > N) return null.

Depth is passed down as a prop. One level down is depth + 1, used for the indent and for data-depth. The root starts at 0.

The key is still child.id, same as any list — unique among siblings is enough, it does not have to be unique across the whole tree.

One real caveat: if the data can contain a cycle (A shows up inside its own replies), the recursion blows the stack. A real API usually will not, but if the question mentions “the data comes from user input”, saying “add a visited set or a max depth as a backstop” earns points.

TSX递归那几行The recursive lines已跑通Verified
1{/* 递归:自己渲染自己。
2 终止条件不用写 if —— replies 为空时 map 什么都不产出,递归自然停。 */}
3{open && comment.replies.length > 0 && (
4 <ul>
5 {comment.replies.map((child) => (
6 <CommentNode
7 key={child.id}
8 comment={child}
9 depth={depth + 1} // 往下一层
10 onReply={onReply}
11 />
12 ))}
13 </ul>
14)}
1{/* 递归:自己渲染自己。
2 终止条件不用写 if —— replies 为空时 map 什么都不产出,递归自然停。 */}
3{open && comment.replies.length > 0 && (
4 <ul>
5 {comment.replies.map((child) => (
6 <CommentNode
7 key={child.id}
8 comment={child}
9 depth={depth + 1} // one level further down
10 onReply={onReply}
11 />
12 ))}
13 </ul>
14)}
§03

递归统计:一行 reduceCounting with recursion: one line of reduce

「总共多少条评论(含所有层级)」是这类题的常见附加要求。 写成递归只有一行:

读法:每个节点贡献「自己这 1 条 + 它子树的全部」。 空数组时 reduce 直接返回初始值 0, 递归在这里终止。

同一个模式可以套出很多东西:最大深度 (1 + Math.max(...children))、 查找某个 id、把树拍平成数组。树的题目基本都是这一个骨架换个累加方式。

“How many comments in total, counting every level” is the usual add-on requirement for this kind of question. As a recursion it is one line:

How to read it: every node contributes itself, 1, plus its whole subtree. On an empty array reduce returns the initial value 0, and the recursion ends there.

The same pattern gets you plenty of other things: max depth (1 + Math.max(...children)), finding an id, flattening the tree into an array. Tree questions are mostly this one skeleton with a different accumulator.

TypeScript递归统计Counting with recursion已跑通Verified
1/** 递归统计总条数(含所有层级的回复) */
2export function countComments(nodes: Comment[]): number {
3 return nodes.reduce((sum, n) => sum + 1 + countComments(n.replies), 0);
4}
5
6// countComments([]) === 0 <- 递归终止
7// 三层嵌套 + 两个旁支 === 5
1/** Count everything recursively, replies at every level included */
2export function countComments(nodes: Comment[]): number {
3 return nodes.reduce((sum, n) => sum + 1 + countComments(n.replies), 0);
4}
5
6// countComments([]) === 0 <- where the recursion ends
7// three levels of nesting plus two side branches === 5
§04

真正的难点:给第四层加一条回复The real difficulty: adding a reply four levels down

数组的不可变更新大家都会了。树的还要再想一层。Everyone can update an array without changing the original. A tree needs one more level of thought.

要求是「往 id 为 X 的节点的 replies 里加一条」, 而 X 可能在任意深度。

为什么不能直接找到它 push 进去?那是改原对象。React 比较的是根数组的引用 —— 你改了深处的对象,根数组还是同一个,界面不更新。 (就算你顺手 setComments([...comments]) 造个新根, 原数据也已经被污染了。)

正解是递归地造新对象

这段代码值得逐句读:

  • nodes.map(...) —— 每一层都返回新数组
  • 找到目标:{ ...node, replies: [...node.replies, reply] }—— 新节点对象 + 新 replies 数组。
  • 不是目标:也要造新对象, 因为目标可能藏在它的子树里, 而 addReply(node.replies, ...) 可能返回新数组。
  • 递归到叶子(replies 为空)时,map 返回空数组,递归终止。

一个容易误解的点:这不是深拷贝。只有从根到目标那条路径上的节点是新对象; 旁边的分支虽然被 { ...node } 包了一层新壳, 但里面的 bodyauthor 等值是共享的, 更深的子树对象也是复用的。这正是 React 想要的:变了的路径引用变了,没变的部分引用不变React.memo 才能正确跳过。

测试里专门验证了这两件事:原树完全没动(用 Object.freeze深冻结,改了就抛错),以及路径上的对象确实是新引用。

The requirement is “add one entry to the replies of the node whose id is X”, and X can be at any depth.

Why not just find it and push? That mutates the original object. React compares the reference of the root array — you changed something deep inside, the root array is still the same one, and the UI does not update. (Even if you then build a fresh root with setComments([...comments]), the original data is already polluted.)

The right answer is to build new objects recursively:

This code is worth reading line by line:

  • nodes.map(...) — every level returns a new array.
  • Target found: { ...node, replies: [...node.replies, reply] } — a new node object plus a new replies array.
  • Not the target: build a new object anyway, because the target may be hiding in its subtree and addReply(node.replies, ...) may return a new array.
  • When the recursion reaches a leaf (replies empty), map returns an empty array and the recursion ends.

One thing people misread: this is not a deep copy. Only the nodes on the path from the root to the target are new objects; the branches beside it do get a new shell from { ...node }, but the values inside — body, author and the rest — are shared, and the deeper subtree objects are reused. That is exactly what React wants: references change along the path that changed and stay the same everywhere else, which is the only way React.memo can skip correctly.

The tests check both of these: the original tree was not touched at all (deep-frozen with Object.freeze, so any write throws), and the objects on the path really are new references.

TypeScriptsrc/components/CommentTree/index.tsx(两个纯函数)src/components/CommentTree/index.tsx (the two pure functions)已跑通Verified
1import type { Comment } from "../../types/Comment";
2
3/** 递归统计总条数(含所有层级的回复) */
4export function countComments(nodes: Comment[]): number {
5 return nodes.reduce((sum, n) => sum + 1 + countComments(n.replies), 0);
6}
7
8/**
9 * 往树里某个节点下面加一条回复,返回全新的树。
10 *
11 * 关键点:从根到目标那条路径上的每个节点都要造新对象,
12 * 但**不要**深拷贝整棵树 —— 没被碰到的分支应该复用原来的对象。
13 */
14export function addReply(nodes: Comment[], parentId: number, reply: Comment): Comment[] {
15 return nodes.map((node) => {
16 if (node.id === parentId) {
17 return { ...node, replies: [...node.replies, reply] };
18 }
19 // 目标可能在更深处,继续往下找
20 return { ...node, replies: addReply(node.replies, parentId, reply) };
21 });
22}
1import type { Comment } from "../../types/Comment";
2
3/** Count every comment recursively, replies at every level included */
4export function countComments(nodes: Comment[]): number {
5 return nodes.reduce((sum, n) => sum + 1 + countComments(n.replies), 0);
6}
7
8/**
9 * Add one reply under a node of the tree, and return a brand new tree.
10 *
11 * The key point: every node on the path from the root to the target becomes a
12 * new object, but **do not** deep-copy the tree. Untouched branches are reused.
13 */
14export function addReply(nodes: Comment[], parentId: number, reply: Comment): Comment[] {
15 return nodes.map((node) => {
16 if (node.id === parentId) {
17 return { ...node, replies: [...node.replies, reply] };
18 }
19 // the target may be deeper down, so keep looking
20 return { ...node, replies: addReply(node.replies, parentId, reply) };
21 });
22}
TypeScript两种错法Two ways to get it wrong示意Illustrative
1// ✗ 找到就 push —— 改了原树,界面不更新
2function addReplyBad(nodes: Comment[], parentId: number, reply: Comment) {
3 for (const n of nodes) {
4 if (n.id === parentId) { n.replies.push(reply); return; }
5 addReplyBad(n.replies, parentId, reply);
6 }
7}
8
9// ✗ 深拷贝整棵树 —— 结果对,但所有节点引用都变了,
10// React.memo 全部失效,大树上会明显卡
11const next = JSON.parse(JSON.stringify(comments));
1// ✗ push once you find it — this changes the original tree, and the screen does not update
2function addReplyBad(nodes: Comment[], parentId: number, reply: Comment) {
3 for (const n of nodes) {
4 if (n.id === parentId) { n.replies.push(reply); return; }
5 addReplyBad(n.replies, parentId, reply);
6 }
7}
8
9// ✗ Deep-copy the whole tree — the result is right, but every node reference changes,
10// React.memo stops helping at all, and a large tree feels slow
11const next = JSON.parse(JSON.stringify(comments));
§05

完整答案The complete answer

7 个测试全过,含「深层回复落在正确位置」和「原树未被修改」。All 7 tests pass, including one that a deep reply lands in the right place and one that the original tree was not changed.

折叠状态放在每个节点自己身上CommentNode 内部的 open), 不是提到顶层。因为「这一条折没折」只有它自己关心 —— 提到顶层就要维护一个 id 集合,纯属自找麻烦。

onReply 从顶层一路传下去。 树很深时这会显得啰嗦,真实项目里会用 Context 或状态库 —— 但在 assessment 里老老实实传 props 是最稳的答案, 除非题目明确要求用 Context。

Collapsed state lives on each node (open inside CommentNode), not lifted to the top. Whether this one comment is folded is nobody else’s business — lift it and you have to maintain a set of ids, which is trouble you invented for yourself.

onReply is passed all the way down from the top. On a deep tree that gets wordy, and a real project would reach for Context or a state library — but in an assessment plain, honest prop passing is the safest answer, unless the question explicitly asks for Context.

TSXsrc/components/CommentTree/index.tsx(组件部分,实测 7/7 通过)src/components/CommentTree/index.tsx (the component; 7 of 7 pass here)已跑通Verified
1interface NodeProps {
2 comment: Comment;
3 depth: number;
4 onReply: (parentId: number, text: string) => void;
5}
6
7const CommentNode: React.FC<NodeProps> = ({ comment, depth, onReply }) => {
8 const [open, setOpen] = useState(true);
9 const [replying, setReplying] = useState(false);
10 const [text, setText] = useState("");
11
12 const submit = (e: React.FormEvent<HTMLFormElement>) => {
13 e.preventDefault();
14 if (text.trim() === "") return;
15 onReply(comment.id, text.trim());
16 setText("");
17 setReplying(false);
18 };
19
20 return (
21 <li
22 data-testid={`comment-${comment.id}`}
23 data-depth={depth}
24 style={{ marginLeft: depth * 16 }}
25 >
26 <span data-testid={`author-${comment.id}`}>{comment.author}</span>
27 <span data-testid={`body-${comment.id}`}>{comment.body}</span>
28
29 <button onClick={() => setReplying((v) => !v)} aria-label={`reply to ${comment.author}`}>
30 Reply
31 </button>
32
33 {comment.replies.length > 0 && (
34 <button onClick={() => setOpen((v) => !v)} aria-label={`toggle ${comment.author}`}>
35 {open ? `Hide ${comment.replies.length}` : `Show ${comment.replies.length}`}
36 </button>
37 )}
38
39 {replying && (
40 <form onSubmit={submit}>
41 <input
42 value={text}
43 onChange={(e) => setText(e.target.value)}
44 data-testid={`reply-input-${comment.id}`}
45 />
46 <button type="submit" data-testid={`reply-submit-${comment.id}`}>Send</button>
47 </form>
48 )}
49
50 {/* 递归:自己渲染自己。
51 终止条件不用写 if —— replies 为空时 map 什么都不产出,递归自然停。 */}
52 {open && comment.replies.length > 0 && (
53 <ul>
54 {comment.replies.map((child) => (
55 <CommentNode key={child.id} comment={child} depth={depth + 1} onReply={onReply} />
56 ))}
57 </ul>
58 )}
59 </li>
60 );
61};
62
63const CommentTree: React.FC<{ initial: Comment[] }> = ({ initial }) => {
64 const [comments, setComments] = useState<Comment[]>(initial);
65
66 const handleReply = (parentId: number, text: string) => {
67 const reply: Comment = {
68 id: Date.now() + Math.random(),
69 author: "我",
70 body: text,
71 replies: [],
72 };
73 setComments((prev) => addReply(prev, parentId, reply));
74 };
75
76 return (
77 <div data-testid="comment-tree">
78 <span data-testid="total">{countComments(comments)}</span>
79 <ul>
80 {comments.map((c) => (
81 <CommentNode key={c.id} comment={c} depth={0} onReply={handleReply} />
82 ))}
83 </ul>
84 </div>
85 );
86};
87
88export default CommentTree;
1interface NodeProps {
2 comment: Comment;
3 depth: number;
4 onReply: (parentId: number, text: string) => void;
5}
6
7const CommentNode: React.FC<NodeProps> = ({ comment, depth, onReply }) => {
8 const [open, setOpen] = useState(true);
9 const [replying, setReplying] = useState(false);
10 const [text, setText] = useState("");
11
12 const submit = (e: React.FormEvent<HTMLFormElement>) => {
13 e.preventDefault();
14 if (text.trim() === "") return;
15 onReply(comment.id, text.trim());
16 setText("");
17 setReplying(false);
18 };
19
20 return (
21 <li
22 data-testid={`comment-${comment.id}`}
23 data-depth={depth}
24 style={{ marginLeft: depth * 16 }}
25 >
26 <span data-testid={`author-${comment.id}`}>{comment.author}</span>
27 <span data-testid={`body-${comment.id}`}>{comment.body}</span>
28
29 <button onClick={() => setReplying((v) => !v)} aria-label={`reply to ${comment.author}`}>
30 Reply
31 </button>
32
33 {comment.replies.length > 0 && (
34 <button onClick={() => setOpen((v) => !v)} aria-label={`toggle ${comment.author}`}>
35 {open ? `Hide ${comment.replies.length}` : `Show ${comment.replies.length}`}
36 </button>
37 )}
38
39 {replying && (
40 <form onSubmit={submit}>
41 <input
42 value={text}
43 onChange={(e) => setText(e.target.value)}
44 data-testid={`reply-input-${comment.id}`}
45 />
46 <button type="submit" data-testid={`reply-submit-${comment.id}`}>Send</button>
47 </form>
48 )}
49
50 {/* Recursion: the component renders itself.
51 No if is needed to stop it: with empty replies, map produces nothing. */}
52 {open && comment.replies.length > 0 && (
53 <ul>
54 {comment.replies.map((child) => (
55 <CommentNode key={child.id} comment={child} depth={depth + 1} onReply={onReply} />
56 ))}
57 </ul>
58 )}
59 </li>
60 );
61};
62
63const CommentTree: React.FC<{ initial: Comment[] }> = ({ initial }) => {
64 const [comments, setComments] = useState<Comment[]>(initial);
65
66 const handleReply = (parentId: number, text: string) => {
67 const reply: Comment = {
68 id: Date.now() + Math.random(),
69 author: "我",
70 body: text,
71 replies: [],
72 };
73 setComments((prev) => addReply(prev, parentId, reply));
74 };
75
76 return (
77 <div data-testid="comment-tree">
78 <span data-testid="total">{countComments(comments)}</span>
79 <ul>
80 {comments.map((c) => (
81 <CommentNode key={c.id} comment={c} depth={0} onReply={handleReply} />
82 ))}
83 </ul>
84 </div>
85 );
86};
87
88export default CommentTree;
§06

怎么验证How to check it

「有没有偷偷改原树」这件事,用深冻结一测就知道。To find out whether the original tree was quietly changed, freeze it all the way down and run the test.

deepFreeze 递归地把原树每一层都Object.freeze 掉。冻结之后任何写操作 在严格模式下(TS/ESM 默认严格)会直接抛TypeError,而不是静默失败。

所以如果你的 addReply 里有一处push 或直接赋值, 测试会报 Cannot add property 0, object is not extensible—— 不可变性从「靠人肉 review」变成了「机器能查」。 这个技巧在任何考不可变更新的题里都能用。

第 3 条测的是「只重建路径」: 路径上的节点必须 not.toBe 原来那个(新引用), 而旁边的分支内容保持一致。 这一条能把「深拷贝糊过去」的解法区分出来。

deepFreeze recursively runs Object.freeze on every level of the original tree. Once frozen, any write throws a TypeError outright in strict mode (TS/ESM are strict by default) instead of failing silently.

So if your addReply has one push or one direct assignment in it, the test reports Cannot add property 0, object is not extensible immutability goes from “somebody has to catch it in review” to “a machine checks it”. The trick works in any question about immutable updates.

Test 3 checks that only the path is rebuilt: nodes on the path must be not.toBe the originals (new references), while the branches beside it keep the same content. That test separates the real answer from “deep copy and hope”.

Terminal验证命令The command that verifies it已跑通Verified
1npx vitest run src/CommentTree.test.tsx # 7 passed
TSXsrc/CommentTree.test.tsx(DrillLab 自出,本机跑过)src/CommentTree.test.tsx (written for DrillLab, run here)已跑通Verified
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { expect, test } from "vitest";
4import CommentTree, { addReply, countComments } from "./components/CommentTree";
5import type { Comment } from "./types/Comment";
6
7/** 三层嵌套:1 → 2 → 4,外加同级的 3 和 5 */
8const tree = (): Comment[] => [
9 {
10 id: 1, author: "A", body: "顶层", replies: [
11 { id: 2, author: "B", body: "二层", replies: [
12 { id: 4, author: "D", body: "三层", replies: [] },
13 ]},
14 { id: 3, author: "C", body: "二层旁边", replies: [] },
15 ],
16 },
17 { id: 5, author: "E", body: "另一个顶层", replies: [] },
18];
19
20/** 深冻结:组件若直接改原树,会在严格模式下抛错 */
21function deepFreeze<T>(o: T): T {
22 Object.freeze(o);
23 Object.values(o as Record<string, unknown>).forEach((v) => {
24 if (v && typeof v === "object" && !Object.isFrozen(v)) deepFreeze(v);
25 });
26 return o;
27}
28
29test("countComments 递归数到所有层级", () => {
30 expect(countComments(tree())).toBe(5);
31 expect(countComments([])).toBe(0);
32});
33
34test("addReply 挂到深层节点,且不改原树", () => {
35 const original = deepFreeze(tree());
36 const next = addReply(original, 4, { id: 99, author: "F", body: "四层", replies: [] });
37
38 // 新树里挂上了
39 expect(countComments(next)).toBe(6);
40 expect(next[0].replies[0].replies[0].replies[0].id).toBe(99);
41 // 原树没动
42 expect(countComments(original)).toBe(5);
43 expect(original[0].replies[0].replies[0].replies).toHaveLength(0);
44});
45
46test("addReply 只重建路径上的节点,旁边的分支保持同一个引用", () => {
47 const original = tree();
48 const next = addReply(original, 2, { id: 88, author: "F", body: "x", replies: [] });
49
50 // 路径上的必须是新对象
51 expect(next).not.toBe(original);
52 expect(next[0]).not.toBe(original[0]);
53 expect(next[0].replies[0]).not.toBe(original[0].replies[0]);
54 // 没被碰到的叶子内容一致
55 expect(next[1].body).toBe(original[1].body);
56});
57
58test("渲染出三层,并按深度缩进", () => {
59 render(<CommentTree initial={tree()} />);
60 expect(screen.getByTestId("comment-1").getAttribute("data-depth")).toBe("0");
61 expect(screen.getByTestId("comment-2").getAttribute("data-depth")).toBe("1");
62 expect(screen.getByTestId("comment-4").getAttribute("data-depth")).toBe("2");
63 expect(screen.getByTestId("total")).toHaveTextContent("5");
64});
65
66test("空 replies 就是递归的终止条件(叶子不再往下渲染)", () => {
67 render(<CommentTree initial={[{ id: 7, author: "Z", body: "孤零零", replies: [] }]} />);
68 expect(screen.getByTestId("comment-7")).toBeInTheDocument();
69 // 叶子没有折叠按钮,因为没有子节点
70 expect(screen.queryByLabelText("toggle Z")).toBeNull();
71});
72
73test("给三层的评论再回复,落在正确的位置", async () => {
74 render(<CommentTree initial={tree()} />);
75
76 await userEvent.click(screen.getByLabelText("reply to D"));
77 await userEvent.type(screen.getByTestId("reply-input-4"), "第四层");
78 await userEvent.click(screen.getByTestId("reply-submit-4"));
79
80 expect(screen.getByTestId("total")).toHaveTextContent("6");
81 // 新节点的深度是 3,且在 comment-4 的子树里
82 const added = screen.getByText("第四层").closest("li")!;
83 expect(added.getAttribute("data-depth")).toBe("3");
84 expect(screen.getByTestId("comment-4").contains(added)).toBe(true);
85});
86
87test("折叠只藏自己的子树,不影响别人", async () => {
88 render(<CommentTree initial={tree()} />);
89 await userEvent.click(screen.getByLabelText("toggle A"));
90
91 expect(screen.queryByTestId("comment-2")).toBeNull();
92 expect(screen.queryByTestId("comment-4")).toBeNull();
93 expect(screen.getByTestId("comment-1")).toBeInTheDocument();
94 expect(screen.getByTestId("comment-5")).toBeInTheDocument(); // 另一个顶层还在
95});
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { expect, test } from "vitest";
4import CommentTree, { addReply, countComments } from "./components/CommentTree";
5import type { Comment } from "./types/Comment";
6
7/** Three levels of nesting: 1 -> 2 -> 4, plus siblings 3 and 5 */
8const tree = (): Comment[] => [
9 {
10 id: 1, author: "A", body: "顶层", replies: [
11 { id: 2, author: "B", body: "二层", replies: [
12 { id: 4, author: "D", body: "三层", replies: [] },
13 ]},
14 { id: 3, author: "C", body: "二层旁边", replies: [] },
15 ],
16 },
17 { id: 5, author: "E", body: "另一个顶层", replies: [] },
18];
19
20/** Deep freeze: if the component changes the original tree, strict mode throws */
21function deepFreeze<T>(o: T): T {
22 Object.freeze(o);
23 Object.values(o as Record<string, unknown>).forEach((v) => {
24 if (v && typeof v === "object" && !Object.isFrozen(v)) deepFreeze(v);
25 });
26 return o;
27}
28
29test("countComments 递归数到所有层级", () => {
30 expect(countComments(tree())).toBe(5);
31 expect(countComments([])).toBe(0);
32});
33
34test("addReply 挂到深层节点,且不改原树", () => {
35 const original = deepFreeze(tree());
36 const next = addReply(original, 4, { id: 99, author: "F", body: "四层", replies: [] });
37
38 // it is attached in the new tree
39 expect(countComments(next)).toBe(6);
40 expect(next[0].replies[0].replies[0].replies[0].id).toBe(99);
41 // the original tree never moved
42 expect(countComments(original)).toBe(5);
43 expect(original[0].replies[0].replies[0].replies).toHaveLength(0);
44});
45
46test("addReply 只重建路径上的节点,旁边的分支保持同一个引用", () => {
47 const original = tree();
48 const next = addReply(original, 2, { id: 88, author: "F", body: "x", replies: [] });
49
50 // the nodes on the path have to be new objects
51 expect(next).not.toBe(original);
52 expect(next[0]).not.toBe(original[0]);
53 expect(next[0].replies[0]).not.toBe(original[0].replies[0]);
54 // the untouched leaf still holds the same content
55 expect(next[1].body).toBe(original[1].body);
56});
57
58test("渲染出三层,并按深度缩进", () => {
59 render(<CommentTree initial={tree()} />);
60 expect(screen.getByTestId("comment-1").getAttribute("data-depth")).toBe("0");
61 expect(screen.getByTestId("comment-2").getAttribute("data-depth")).toBe("1");
62 expect(screen.getByTestId("comment-4").getAttribute("data-depth")).toBe("2");
63 expect(screen.getByTestId("total")).toHaveTextContent("5");
64});
65
66test("空 replies 就是递归的终止条件(叶子不再往下渲染)", () => {
67 render(<CommentTree initial={[{ id: 7, author: "Z", body: "孤零零", replies: [] }]} />);
68 expect(screen.getByTestId("comment-7")).toBeInTheDocument();
69 // a leaf has no collapse button, because it has no children
70 expect(screen.queryByLabelText("toggle Z")).toBeNull();
71});
72
73test("给三层的评论再回复,落在正确的位置", async () => {
74 render(<CommentTree initial={tree()} />);
75
76 await userEvent.click(screen.getByLabelText("reply to D"));
77 await userEvent.type(screen.getByTestId("reply-input-4"), "第四层");
78 await userEvent.click(screen.getByTestId("reply-submit-4"));
79
80 expect(screen.getByTestId("total")).toHaveTextContent("6");
81 // the new node has depth 3 and sits in the subtree of comment-4
82 const added = screen.getByText("第四层").closest("li")!;
83 expect(added.getAttribute("data-depth")).toBe("3");
84 expect(screen.getByTestId("comment-4").contains(added)).toBe(true);
85});
86
87test("折叠只藏自己的子树,不影响别人", async () => {
88 render(<CommentTree initial={tree()} />);
89 await userEvent.click(screen.getByLabelText("toggle A"));
90
91 expect(screen.queryByTestId("comment-2")).toBeNull();
92 expect(screen.queryByTestId("comment-4")).toBeNull();
93 expect(screen.getByTestId("comment-1")).toBeInTheDocument();
94 expect(screen.getByTestId("comment-5")).toBeInTheDocument(); // the other top-level one is still there
95});
§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.