变式四 · 递归读取评论的评论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.
这一页有什么On this page9
- 01 数据形状:一个类型引用自己The shape of the data: a type that refers to itself
- 02 递归组件:终止条件不用写 ifA recursive component: you do not need an if to stop it
- 03 递归统计:一行 reduceCounting with recursion: one line of reduce
- 04 真正的难点:给第四层加一条回复The real difficulty: adding a reply four levels down
- 05 完整答案The complete answer
- 06 怎么验证How to check it
- 练习 · 动手做Practice
- 常见错误Common mistakes
- 迁移模式Transfer
- 写出一个递归渲染自身的组件,并说清终止条件在哪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
评论嵌套、目录树、组织架构、文件夹 —— 树形数据是 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.
数据形状:一个类型引用自己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.
递归组件:终止条件不用写 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.
递归统计:一行 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.
真正的难点:给第四层加一条回复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 } 包了一层新壳, 但里面的 body、author 等值是共享的, 更深的子树对象也是复用的。这正是 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 (
repliesempty),mapreturns 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.
完整答案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.
怎么验证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”.
动手做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 the recursive call itself, and the fourth is one level further down.
这是这道题真正的难点。目标节点可能在任意深度, 要返回一棵新树,而且原树一个字节都不能改。
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.
- 找到 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
看答案之前,先确认你已经自己动手写过一遍。看懂别人的答案和自己写出来,是两种能力。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.
给深层评论加回复,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.
初学者常见的几种写法错误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.
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.例外:如果题目要求「一键全部折叠」,那才需要提上去。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.
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.
换一道题也能用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.
- 「评论的评论」= 类型里有个字段指回自己;深度不存数据,渲染时用参数传。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.
- 递归组件在自己的 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.
- 递归统计的骨架是「自己 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.
- 树的不可变更新: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.
- 只重建从根到目标的路径,不要深拷贝整棵树 —— 否则 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.