DrillLab
第 22 / 25 节LESSON 22 / 25约 35 分钟~35 min

数据与函数:deepClone、flatten、curryData and functions: deepClone, flatten, curry

三道递归题。递归的出口、防循环的登记、不污染的攒参数。Three recursion problems: where recursion stops, the record that guards against cycles, and collecting arguments without leaking them.

3 个练习3 exercises面试 · 第 8 部分Interview · Part 8
这一页有什么On this page6
学完这节你会After this lesson you can
  • 手写 deepClone:分支覆盖 Date / Map / Set / 数组 / 对象,循环引用不爆栈Write deepClone by hand, with branches for Date / Map / Set / array / object, and no stack overflow on a circular reference
  • 说清 JSON.parse(JSON.stringify(x)) 为什么不算深拷贝的答案Explain why JSON.parse(JSON.stringify(x)) does not count as an answer to deep clone
  • 手写 flatten,depth 语义与 Array.prototype.flat 一致Write flatten by hand, with depth behaving the same way as Array.prototype.flat
  • 手写 curry,部分应用可复用、互不污染Write curry by hand, so a partly applied function can be reused and does not affect the others
这在考试里考什么What the exam does with this

deepClone 是「递归 + 分支 + 防循环」三合一的经典题,面试官用它一次看三个能力。flatten 考递归出口的干净程度。curry 考闭包攒参数 —— 写成共享数组就会在「复用部分应用」这一问上当场翻车。deepClone packs three things into one classic problem — recursion, type branches, and guarding against cycles — so the interviewer sees three skills at once. flatten tests how cleanly you stop the recursion. curry tests collecting arguments in a closure: write it with one shared array and you fail on the spot when asked to reuse a partly applied function.

§01

deepClone:先登记,再递归deepClone: record it first, then recurse

Write a deepClone that survives circular references

一句话:按类型分支递归克隆;每造出一个新容器,立刻WeakMap 里登记 「原对象 → 它的克隆」,循环引用一进来就直接还登记过的克隆 —— 这就是不爆栈的全部原理。

分支顺序:原始值和 null 原样返回 → 查 seenDateMap /Set → 数组 → 普通对象。「先登记再递归子节点」的次序不能反: 反了,a.self = a 这种结构会在登记之前就递归回自己。

会追问(必问):「为什么不用JSON.parse(JSON.stringify(x))?」—— 丢 undefined 和函数、Date 变字符串、Map / Set 变空对象、循环引用直接抛TypeError。「那 structuredClone 呢?」—— 生产代码优先用它(原生、支持循环引用),但它克隆不了函数和 DOM 节点,而且这道题考的就是你自己会不会写。

In one line: recurse by type, and the moment you create a new container, register “original → its clone” in a WeakMap — when a circular reference comes back around, you hand out the registered clone instead of recursing forever. That is the entire trick.

Branch order: primitives and null pass through → check seenDateMap / Set → arrays → plain objects. “Register first, recurse into children second” must not be flipped: flipped, a structure like a.self = a recurses back into itself before the registration exists.

Follow-up (always asked): “Why not JSON.parse(JSON.stringify(x))?” — it drops undefined and functions, turns Date into a string, turns Map / Set into empty objects, and throws a TypeError on circular references. “And structuredClone?” — prefer it in production code (native, handles cycles), but it cannot clone functions or DOM nodes, and this question exists to see whether you can write the thing yourself.

TypeScriptdeepClone.ts(核心分支 —— 完整版含 Map/Set,scratchpad vitest 6 / 6)deepClone.ts (the core branches — the full version also handles Map and Set; scratchpad vitest 6 / 6)已跑通Verified
1export function deepClone<T>(value: T, seen = new WeakMap<object, unknown>()): T {
2 if (value === null || typeof value !== "object") return value;
3
4 const obj = value as unknown as object;
5 if (seen.has(obj)) return seen.get(obj) as T; // 见过 -> 直接还它的克隆(防循环)
6
7 if (value instanceof Date) return new Date(value.getTime()) as unknown as T;
8
9 if (Array.isArray(value)) {
10 const out: unknown[] = [];
11 seen.set(obj, out); // 【先登记再递归】—— 循环引用就是靠这一行不爆栈
12 for (const v of value) out.push(deepClone(v, seen));
13 return out as unknown as T;
14 }
15
16 const out: Record<string, unknown> = {};
17 seen.set(obj, out);
18 for (const key of Object.keys(value)) {
19 out[key] = deepClone((value as Record<string, unknown>)[key], seen);
20 }
21 return out as T;
22}
1export function deepClone<T>(value: T, seen = new WeakMap<object, unknown>()): T {
2 if (value === null || typeof value !== "object") return value;
3
4 const obj = value as unknown as object;
5 if (seen.has(obj)) return seen.get(obj) as T; // seen it -> hand back its clone (stops cycles)
6
7 if (value instanceof Date) return new Date(value.getTime()) as unknown as T;
8
9 if (Array.isArray(value)) {
10 const out: unknown[] = [];
11 seen.set(obj, out); // [record first, recurse after] — this line is why a cycle does not recurse forever
12 for (const v of value) out.push(deepClone(v, seen));
13 return out as unknown as T;
14 }
15
16 const out: Record<string, unknown> = {};
17 seen.set(obj, out);
18 for (const key of Object.keys(value)) {
19 out[key] = deepClone((value as Record<string, unknown>)[key], seen);
20 }
21 return out as T;
22}
§02

flatten:递归的出口就是 depthflatten: depth is what stops the recursion

Write a flatten with a depth parameter

一句话:遍历数组,遇到「是数组且 depth > 0」 就递归展开(depth 减一),否则原样收进结果 ——depth 本身就是递归的出口。

两个语义细节要和原生 Array.prototype.flat 对齐, 面试官就在这两处等你:默认 depth 是 1(不是 Infinity),depth 0 返回浅拷贝(不是原数组的引用 —— 不改输入是底线)。

会追问:「不用递归写一遍」—— 用栈:[...arr.map(v => [v, depth])] 形式的工作栈, 弹出时是数组且层数没用完就把子项带着层数压回去。 递归版清晰、迭代版不吃调用栈,说得出取舍就够了。

In one line: walk the array; when an item “is an array and depth > 0”, recurse into it with depth minus one, otherwise push it as-is — depth itself is the recursion exit.

Two semantic details must match the native Array.prototype.flat, and this is exactly where the interviewer waits for you: the default depth is 1 (not Infinity), and depth 0 returns a shallow copy (not the original reference — never mutate the input).

Follow-up: “Now without recursion” — use a work stack of [value, remainingDepth] pairs; when you pop an array with depth left, push its children back with depth minus one. Recursion reads better, iteration does not consume the call stack — naming that trade-off is all they want.

§03

curry:攒参数必须造新数组curry: collecting arguments means building a new array each time

Write a curry; why must partial applications not share state

一句话:攒到的参数够 fn.length就执行,不够就返回一个「接着攒」的新函数 —— 攒的动作必须是 (...more) => curried(...args, ...more)这种拼新数组,不能往共享数组上 push

为什么这么严:const add1 = curried(1) 之后,add1(2, 3)add1(10, 20)必须都从 [1] 出发。push 版第一次调用后共享数组变成[1, 2, 3],第二次 add1(10, 20)实际拿到五个参数 —— 部分应用被污染了。 测试里专门有一条抓这个。

会追问:fn.length 有什么坑?」—— 它数不到默认参数和 rest 参数((a, b = 1) => 的 length 是 1),所以带默认参数的函数 curry 不动;说得出这一句, 这道题就答干净了。

In one line: once the collected arguments reach fn.length, run; otherwise return a new collector — and collecting must build a fresh array, (...more) => curried(...args, ...more), never a push onto something shared.

Why so strict: after const add1 = curried(1), both add1(2, 3) and add1(10, 20) must start from [1]. With push, the shared array becomes [1, 2, 3] after the first call, so the second call really receives five arguments — the partial application is polluted. One test exists specifically to catch this.

Follow-up: “What is the catch with fn.length?” — it does not count default or rest parameters ((a, b = 1) => {} has length 1), so functions with defaults do not curry cleanly. Saying that one sentence closes the question.

TypeScriptcurry.ts(参考解法 —— scratchpad vitest 3 / 3)curry.ts (reference solution — scratchpad vitest 3 / 3)已跑通Verified
1export function curry<T extends (...args: never[]) => unknown>(fn: T) {
2 return function curried(...args: unknown[]): unknown {
3 if (args.length >= fn.length) {
4 return fn(...(args as never[]));
5 }
6 // 每次都返回新函数、拼出新数组 —— add1 复用一百次也互不污染
7 return (...more: unknown[]) => curried(...args, ...more);
8 };
9}
1export function curry<T extends (...args: never[]) => unknown>(fn: T) {
2 return function curried(...args: unknown[]): unknown {
3 if (args.length >= fn.length) {
4 return fn(...(args as never[]));
5 }
6 // Every step returns a new function and builds a new array — reuse add1 a hundred times and the calls stay separate
7 return (...more: unknown[]) => curried(...args, ...more);
8 };
9}
练习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.

L3写整块Write a block手写 deepClone(防循环)Write deepClone by hand (cycle-safe)
把「直接返回原值」的半成品写成完整的 deepClone:分支覆盖 Date / Map / Set / 数组 / 普通对象,循环引用不爆栈。不许用 JSON.parse(JSON.stringify(x))。Grow this return-the-input version into a full deepClone: branches for Date, Map, Set, arrays and plain objects, and a circular reference must not recurse forever. JSON.parse(JSON.stringify(x)) is not allowed.
要求Requirements
  • 原始值和 null 原样返回Primitives and null come back unchanged
  • 嵌套对象 / 数组逐层克隆,每一层都是新引用Nested objects and arrays are cloned level by level, and every level is a new reference
  • Date 克隆成新 Date;Map / Set 深克隆A Date becomes a new Date; Map and Set are deep-cloned
  • 循环引用不爆栈(WeakMap 登记「原对象 → 克隆」)A circular reference does not recurse forever (a WeakMap records original to clone)
  • 不许用 JSON.parse(JSON.stringify(x))JSON.parse(JSON.stringify(x)) is not allowed
TypeScriptdeepClone.ts
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.

L3写整块Write a block手写 flatten(depth 语义对齐原生 flat)Write flatten by hand (depth behaves like the built-in flat)
把「只做浅拷贝」的半成品写成真正的 flatten:默认压一层, depth 控制层数,Infinity 全压平,不改输入。不许调用原生 .flat()Grow this shallow-copy version into a real flatten: one level by default, depth decides how many levels, Infinity flattens everything, and the input is never changed. Calling the built-in .flat() is not allowed.
要求Requirements
  • 默认 depth 为 1,与 Array.prototype.flat 一致depth is 1 by default, the same as Array.prototype.flat
  • depth 控制展开层数,Infinity 全压平depth decides how many levels are opened up; Infinity flattens everything
  • depth 0 返回浅拷贝,不是原数组引用depth 0 returns a shallow copy, not a reference to the input array
  • 不改输入数组;不许调用原生 .flat()The input array is never changed, and the built-in .flat() is not allowed
TypeScriptflatten.ts
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.

L3写整块Write a block手写 curry(部分应用可复用)Write curry by hand (partial applications stay reusable)
把「直接返回 fn」的半成品写成真正的 curry:参数攒够fn.length 就执行,可任意分组, 部分应用复用互不污染。Grow this return-fn-directly version into a real curry: run as soon as fn.length arguments have arrived, accept them in any grouping, and let a partial application be reused without one call affecting another.
要求Requirements
  • 攒够 fn.length 个参数就执行Runs as soon as fn.length arguments have arrived
  • 参数可以任意分组:c(1)(2)(3) / c(1, 2)(3) / c(1)(2, 3)Arguments may come in any grouping: c(1)(2)(3), c(1, 2)(3), c(1)(2, 3)
  • 部分应用可复用:const add1 = c(1) 之后多次调用互不污染A partial application is reusable: after const add1 = c(1), calling add1 many times gives independent results
TypeScriptcurry.ts
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.

错例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.

TypeScriptJSON.parse(JSON.stringify(x)) 的全部代价Everything JSON.parse(JSON.stringify(x)) costs you示意Illustrative
1// ✕ 「JSON 大法」—— 面试里给出这个答案基本等于没写
2const clone = JSON.parse(JSON.stringify(source));
3
4// 它悄悄弄丢 / 改坏的东西:
5// { a: undefined } -> {} (键直接消失)
6// { fn: () => {} } -> {} (函数消失)
7// { d: new Date() } -> { d: "2026-..." }(Date 变字符串)
8// { m: new Map([...]) } -> { m: {} } (Map/Set 变空对象)
9// a.self = a -> TypeError (循环引用直接抛错)
1// ✕ the JSON trick — giving this as your answer in an interview counts as not answering
2const clone = JSON.parse(JSON.stringify(source));
3
4// What it quietly drops or damages:
5// { a: undefined } -> {} (the key is simply gone)
6// { fn: () => {} } -> {} (the function is gone)
7// { d: new Date() } -> { d: "2026-..." }(the Date became a string)
8// { m: new Map([...]) } -> { m: {} } (Map and Set became empty objects)
9// a.self = a -> TypeError (a circular reference throws)
这个写法在面试里不是「简洁的答案」,是暴露你没写过深拷贝。 它的每一条代价都可能在生产里变成静默数据损坏 —— 尤其是undefined 键消失和 Date 变字符串这两条, 坏了都没报错。练习的检查器直接把它列为禁用写法。In an interview this is not a short answer, it shows you have never written a deep clone. Every cost on that list can turn into data that is quietly wrong in production — above all the two where a undefined key disappears and a Date becomes a string, because neither reports an error. The checker in the exercise rejects this form outright.
迁移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.

克隆 / 序列化类题目提到「循环引用」A clone or serialize problem mentions circular references
WeakMap 登记「原对象 → 结果」,先登记再递归Record source object to result in a WeakMap; record first, then recurse
「和原生 API 行为一致」"behave the same way as the built-in API"
先把原生的默认值和边界抄下来(flat 默认 1、depth 0 浅拷贝)Write down the built-in defaults and edge cases first (flat defaults to 1, depth 0 is a shallow copy)
闭包攒东西 + 要求可复用A closure collects values and the result has to be reusable
造新数组 / 新对象,绝不 push 共享的Build a new array or object every time; never push into a shared one
这节的要点What to take away
  1. deepClone 的灵魂:先登记再递归。分支顺序:原始值 → seen → Date → Map/Set → 数组 → 对象。The heart of deepClone: record first, then recurse. Branch order: primitive value, then seen, then Date, then Map/Set, then array, then object.
  2. JSON.parse(JSON.stringify(x)) 的五宗罪要背下来 —— 这是必问的追问。Memorise the five things JSON.parse(JSON.stringify(x)) breaks — the follow-up on this always comes.
  3. flatten 的出口就是 depth;默认 1、depth 0 浅拷贝,语义对齐原生。depth is what stops flatten; the default is 1, depth 0 is a shallow copy, matching the built-in.
  4. curry 攒参数必须拼新数组 —— push 版会污染部分应用,有测试专门抓。curry has to join arguments into a new array — the push version leaks into other partly applied functions, and a test looks for exactly that.
  5. fn.length 数不到默认参数和 rest 参数 —— 说得出这句就答干净了。fn.length does not count default parameters or a rest parameter — saying that line finishes the answer cleanly.

接下来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异步与结构:Promise.all、EventEmitter、LRUAsync and structure: Promise.all, EventEmitter, LRU
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 计时两兄弟:debounce 与 throttleTwo timing helpers: debounce and throttle