DrillLab

手写 flatten(depth 语义对齐原生)Write flatten yourself (depth behaves like the built-in)

JavaScript简单 · Easy约 15 分钟~15 min浏览器里能跑Runs in the browser
§01

题面The problem

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

把「只做浅拷贝」的半成品写成真正的 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.
验收标准Acceptance criteria
  • 默认 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

预计 15 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 15 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 个起始文件 · 目标 6 passed2 starter files · target 6 passed
自己写出来、测试全绿之后再打勾。看懂答案不算。Tick this only after you wrote it yourself and the tests went green.
§03

展开讲解Walkthrough

下面是《数据与函数:deepClone、flatten、curry》那一节的原文 —— 和课程里是同一份内容,不是另写的摘要。卡住了再展开。Below is the actual text of the lesson “数据与函数:deepClone、flatten、curry” — the same content as in the course, not a rewritten summary. Expand it when you stall.

展开《数据与函数:deepClone、flatten、curry》(3 段 · 约 35 分钟)Expand “数据与函数:deepClone、flatten、curry” (3 sections · ~35 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 数据与函数:deepClone、flatten、curry

§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}
§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.