DrillLab
第 18 / 21 节LESSON 18 / 21约 20 分钟~20 min

变式四 · 递归读取评论的评论Variation 4 · reading replies to replies with recursion

组件自己渲染自己;难点其实不在渲染,而在「给第四层加一条回复」怎么不改原树。A component renders itself. The hard part is not the rendering, it is adding a reply four levels down without changing the original tree.

3 个练习3 exercisesReact · 第 5 部分React · Part 5
这一页有什么On this page9
学完这节你会After this lesson you can
  • 写出一个递归渲染自身的组件,并说清终止条件在哪Write a component that renders itself, and say exactly where the recursion stops
  • 递归统计树里的总条数Count every item in the tree with recursion
  • 实现「往任意深度的节点下加回复」的不可变更新Add a reply under a node at any depth without changing the original tree
  • 解释为什么只重建路径上的节点、而不是深拷贝整棵树Explain why you rebuild only the nodes on the path instead of deep-copying the whole tree
这在考试里考什么What the exam does with this

评论嵌套、目录树、组织架构、文件夹 —— 树形数据是 assessment 里的常客,而且它同时考「递归组件」和「嵌套结构的不可变更新」两件事。后者是前面所有 CRUD 题的升级版:数组的不可变更新大家都会了,树的还得再想一层。Nested comments, directory trees, org charts, folders: tree data shows up in exams all the time, and it tests two things at once — a recursive component, and updating a nested structure without changing the original. The second one is a step up from every CRUD question so far. Everyone can do it for an array; a tree needs one more level of thought.

§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});
练习Practice

动手做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.

L2填空Fill the blanks补全递归统计与递归渲染Fill in the recursive count and the recursive renderDrillLab 自出Written by DrillLab

四个空。第 2 个是递归调用本身,第 4 个是「往下一层」。

Four blanks. The second is the recursive call itself, and the fourth is one level further down.

TSXsrc/components/CommentTree/index.tsx4 个空4 blanks
1// 递归统计总条数
2export function countComments(nodes: Comment[]): number {
3 return nodes.reduce((sum, n) => sum + + (n.replies), 0);
4}
5
6// 递归渲染
7{comment.replies.length > 0 && (
8 <ul>
9 {comment.replies.map((child) => (
10 <
11 key={child.id}
12 comment={child}
13 depth={}
14 onReply={onReply}
15 />
16 ))}
17 </ul>
18)}
把 4 个空都填上才能检查(还差 4 个)Fill all 4 blanks to check (4 to go)
L3写整块Write a block写出树形数据的不可变更新Write an immutable update for tree dataDrillLab 自出Written by DrillLab

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

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.

要求Requirements
  • 找到 id === parentId 的节点,把 reply 追加到它的 replies 末尾Find the node whose id === parentId and append reply to the end of its replies
  • 返回新数组、新节点对象,不修改原数据Return a new array and new node objects, without changing the original data
  • 目标可能在任意深度,需要递归往下找The target can be at any depth, so recurse downwards to find it
  • 不许用 JSON.parse(JSON.stringify(...)) 深拷贝Do not deep-copy with JSON.parse(JSON.stringify(...))
  • 不许用 push / splice / 直接赋值Do not use push / splice / direct assignment
TypeScriptsrc/components/CommentTree/index.tsx
This check is textual: it looks for the right constructs, it does not run your code
提示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.

看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.

L3Debug LabDebug LabDebug Lab · 回复加进去了,界面不动Debug Lab · the reply went in and the screen never movedDrillLab 自出Written by DrillLab

给深层评论加回复,console.log 打出来的树里 新回复确实在,但界面没变化。控制台干净。

You add a reply to a deep comment. The tree printed by console.log really does contain the new reply, but the screen does not change. The console is clean.

第 1 步 · 先看现象和代码,别急着改Step 1 · read the symptom and the code before you touch anything
# 没有任何报错。 $ npx vitest run src/CommentTree.test.tsx ✕ addReply 挂到深层节点,且不改原树 TypeError: Cannot add property 0, object is not extensible (测试把原树深冻结了,实现试图直接修改它) ✕ 给三层的评论再回复,落在正确的位置 Unable to find an element with the text: 第四层 # 手动复现:点某条评论的 Reply、输入、发送 # console.log(comments) -> 新回复确实在树里 # 屏幕 -> 一点变化都没有# No error at all. $ npx vitest run src/CommentTree.test.tsx ✕ addReply 挂到深层节点,且不改原树 TypeError: Cannot add property 0, object is not extensible (The test deep-froze the original tree; the implementation edits it in place.) ✕ 给三层的评论再回复,落在正确的位置 Unable to find an element with the text: 第四层 # Manual repro: click Reply on a comment, type something, send it # console.log(comments) -> the new reply really is in the tree # the screen -> nothing changes at all
TSXsrc/components/CommentTree/index.tsx示意Illustrative
1function addReply(nodes: Comment[], parentId: number, reply: Comment) {
2 for (const node of nodes) {
3 if (node.id === parentId) {
4 node.replies.push(reply); // 找到就塞进去
5 return nodes;
6 }
7 addReply(node.replies, parentId, reply);
8 }
9 return nodes;
10}
11
12const handleReply = (parentId: number, text: string) => {
13 const reply = { id: Date.now(), author: "我", body: text, replies: [] };
14 setComments(addReply(comments, parentId, reply));
15};
1function addReply(nodes: Comment[], parentId: number, reply: Comment) {
2 for (const node of nodes) {
3 if (node.id === parentId) {
4 node.replies.push(reply); // found it, so push it in
5 return nodes;
6 }
7 addReply(node.replies, parentId, reply);
8 }
9 return nodes;
10}
11
12const handleReply = (parentId: number, text: string) => {
13 const reply = { id: Date.now(), author: "我", body: text, replies: [] };
14 setComments(addReply(comments, parentId, reply));
15};
第 2 步 · 这是什么类型的错误Step 2 · what kind of error is this
错例Wrong

初学者常见的几种写法错误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.

TSX示意Illustrative
1// ✗ 把 depth 存进数据
2type Comment = { id: number; body: string; depth: number; replies: Comment[] };
1// ✗ Storing depth in the data
2type Comment = { id: number; body: string; depth: number; replies: Comment[] };
深度是「节点在树里的位置」,是渲染时算出来的。 存进数据之后,任何移动/嵌套操作都要递归维护它, 一漏就和实际结构不一致。用参数往下传 depth + 1 就够了。Depth is where the node sits in the tree, and it is worked out while rendering. Store it in the data and every move or nesting operation has to update it recursively; miss one and it no longer matches the real structure. Passing depth + 1 down as an argument is enough.
TSX示意Illustrative
1// ✗ 折叠状态提到顶层
2const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
1// ✗ Lifting the collapsed state to the top
2const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
「这一条折没折」只有它自己关心,属于典型的局部 state。 提到顶层要维护一个 id 集合,还得处理 Set 的不可变更新, 纯属自找麻烦。
例外:如果题目要求「一键全部折叠」,那才需要提上去。
Whether one item is collapsed matters only to that item, which makes it a textbook piece of local state. Lifting it to the top means keeping a set of ids and replacing that Set on every change — trouble you did not have to ask for.
One exception: if the task asks for collapse everything with one button, then it does have to move up.
TypeScript示意Illustrative
1// ✗ 用深拷贝图省事
2const next = structuredClone(comments);
3findNode(next, parentId).replies.push(reply);
4setComments(next);
1// ✗ Reaching for a deep copy to save effort
2const next = structuredClone(comments);
3findNode(next, parentId).replies.push(reply);
4setComments(next);
结果是对的,原树也没被改 —— 所以测试可能全过。 但每个节点的引用都变了, 用了 React.memo 的子树全部重渲染, 大树上会明显卡。而且深拷贝本身在大数据上很贵。
只重建路径才是这道题想考的。
The result is right and the original tree is untouched, so every test may pass. But every node now has a new reference, so every subtree wrapped in React.memo re-renders, and a large tree visibly stalls. A deep copy is expensive on large data on its own too.
Rebuilding only the path is what this question is asking for.
迁移Transfer

换一道题也能用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.

「评论的评论」「目录树」「组织架构」Comments on comments, directory trees, org charts
类型自引用 + 递归组件A type that refers to itself, plus a recursive component
递归组件怎么停How a recursive component stops
空数组 map 什么都不产出,天然终止map over an empty array produces nothing, so it stops by itself
「统计/查找/拍平树」Count, search, or flatten a tree
reduce 递归:自己 + 子树Recursive reduce: this node plus its subtrees
「给树里某个节点加/改/删」Add, edit, or delete one node inside a tree
map 递归,只重建路径上的节点Recursive map; rebuild only the nodes on the path
需要缩进或层级样式You need indentation or per-level styling
depth 参数往下传,别存进数据Pass depth down as an argument; do not store it in the data
没报错 + 日志对 + 界面不动No error, the log looks right, the screen does not change
改了原对象(数组和树都一样)The original object was changed in place, in a tree just as in an array
这节的要点What to take away
  1. 「评论的评论」= 类型里有个字段指回自己;深度不存数据,渲染时用参数传。A comment on a comment means a type with a field pointing back at itself. Depth is not stored in the data; it is passed down as an argument while rendering.
  2. 递归组件在自己的 JSX 里渲染自己;空 replies 让 map 什么都不产出,递归自然终止。A recursive component renders itself inside its own JSX. Empty replies make map produce nothing, so the recursion ends on its own.
  3. 递归统计的骨架是「自己 1 条 + 子树全部」,同一模式能算深度、查找、拍平。The shape of a recursive count is: this node counts 1, plus everything in its subtrees. The same pattern computes depth, searches, and flattens.
  4. 树的不可变更新:map 递归,命中就 { ...node, replies: [...replies, reply] },未命中也要造新节点并递归子树。Updating a tree without changing the original: recursive map. On a match, { ...node, replies: [...replies, reply] }. On a miss, still build a new node and recurse into its subtrees.
  5. 只重建从根到目标的路径,不要深拷贝整棵树 —— 否则 React.memo 全失效。Rebuild only the path from the root down to the target. Do not deep-copy the whole tree, or React.memo stops helping anywhere.

接下来What next

  1. 把这一节的练习做掉Do this lesson’s exercises3 个,就在这一页上面 —— 别攒着最后一起做3 of them, further up this page — do not save them for later
    回到练习 ↑Back up to them ↑
  2. 接着看下一节Continue to the next lesson变式五 · 主题切换:Context 怎么用Variation 5 · theme switching: how to use Context
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 变式三 · fetch 取数:loading、error 与竞态Variation 3 · fetching data: loading, error, and the race between two requests