递归评论树 + 树形不可变更新Recursive comment tree, updated without changing the original
题面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.
- 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.
工作区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.
展开讲解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 变式四 · 递归读取评论的评论。
数据形状:一个类型引用自己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”.
参考答案Reference solution
提示是一级一级放的。四级看完还写不出来,再开答案门。The hints come one level at a time. If all four leave you stuck, open the answer.
这份答案在本机真跑过测试。但先确认你自己动手写过一遍 —— 读懂答案和写出答案是两种能力,考场上考的是后一种。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.