DrillLab

手写 EventEmitter(on/off/once/emit)Write EventEmitter yourself (on / off / once / emit)

JavaScript中等 · Medium约 20 分钟~20 min浏览器里能跑Runs in the browser
§01

题面The problem

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

把空骨架填成完整的 EventEmitter:on / off / once / emit。 重点:once 触发时不能挤掉同一事件的其他监听器。Fill the empty skeleton in to a complete EventEmitter: on, off, once and emit. The point to watch: when a once listener fires, it must not push aside the other listeners on the same event.
验收标准Acceptance criteria
  • on 注册;emit 按注册顺序调用所有监听器并传参on registers a listener; emit calls every listener in registration order and passes the arguments along
  • off 只移除指定的那一个监听器off removes only the one listener it was given
  • once 只触发一次,且不挤掉同一事件的其他监听器once fires exactly once, and does not push aside the other listeners on the same event
  • emit 返回「有没有人在听」emit returns whether anyone was listening

预计 20 分钟。这个数字是照「读完题就开始写、不查资料」估的 —— 第一次超时很正常,第二次要压进去。Budget 20 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

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

展开《异步与结构:Promise.all、EventEmitter、LRU》(3 段 · 约 35 分钟)Expand “异步与结构:Promise.all、EventEmitter、LRU” (3 sections · ~35 min)

完整那一节(含练习、常见错误、迁移模式)在The full lesson (with exercises, common mistakes and transfer patterns) is at 异步与结构:Promise.all、EventEmitter、LRU

§01

Promise.all:下标写入 + 计数器Promise.all: write by index, and count how many are done

Implement Promise.all and Promise.allSettled by hand

一句话:结果数组按输入下标写入 (results[i] = value,不是 push), 一个计数器数还剩几个没完成,归零时 resolve ——顺序由输入决定,不是完成先后

三个细节,每个都有测试:

  • 空数组立刻 resolve。计数器从 0 开始等归零, 会等一个永远不来的事件 —— 先判空。
  • 混普通值。每一项先 Promise.resolve(item)包一层,普通值和 thenable 就都统一了。
  • 短路失败。.then(onOk, reject)的第二个参数直接传 reject —— 第一个失败立刻让整体失败,不等慢的那些

allSettled 就是一层变换:把每一项包成 「永远成功、结果里带 status」的 Promise, 再交给自己写的 promiseAll会追问:「all 和 allSettled 各用在哪」—— 结果互相依赖、缺一不可用 all(一败即停止等待); 各自独立、要逐个报告成败用 allSettled(比如批量上传的结果面板)。

In one line: write results by input index (results[i] = value, never push), keep one counter of how many are still pending, resolve when it hits zero — order comes from the input, not from who finishes first.

Three details, each with its own test:

  • An empty array resolves immediately. A counter that starts at zero and waits to reach zero waits forever — check for empty first.
  • Plain values are allowed. Wrap every item in Promise.resolve(item) and plain values and thenables become one case.
  • Fail fast. Pass reject straight in as the second argument of .then(onOk, reject) — the first rejection fails the whole thing, without waiting for the slow ones.

allSettled is one transformation away: wrap each item into a promise that always fulfills and carries a status, then feed those to your own promiseAll. Follow-up: “when do you use which” — results that depend on each other and are useless when incomplete: all (stop waiting on first failure); independent jobs that each need a success report: allSettled (a batch-upload results panel).

TypeScriptpromiseAll.ts(参考解法 —— scratchpad vitest 6 / 6,含 allSettled)promiseAll.ts (reference solution — scratchpad vitest 6 / 6, allSettled included)已跑通Verified
1export function promiseAll<T>(items: (T | Promise<T>)[]): Promise<T[]> {
2 return new Promise((resolve, reject) => {
3 const results: T[] = new Array(items.length);
4 let remaining = items.length;
5 if (remaining === 0) {
6 resolve(results); // 空数组:立刻完成,别让计数器等一个永远不来的 0
7 return;
8 }
9 items.forEach((item, i) => {
10 Promise.resolve(item).then((value) => {
11 results[i] = value; // 按下标写 —— 顺序由输入决定,不是完成先后
12 remaining -= 1;
13 if (remaining === 0) resolve(results);
14 }, reject); // 任何一个失败,整体立刻失败
15 });
16 });
17}
1export function promiseAll<T>(items: (T | Promise<T>)[]): Promise<T[]> {
2 return new Promise((resolve, reject) => {
3 const results: T[] = new Array(items.length);
4 let remaining = items.length;
5 if (remaining === 0) {
6 resolve(results); // Empty input: finish now, or the counter waits for a zero that never arrives
7 return;
8 }
9 items.forEach((item, i) => {
10 Promise.resolve(item).then((value) => {
11 results[i] = value; // Write by index — the input decides the order, not who finished first
12 remaining -= 1;
13 if (remaining === 0) resolve(results);
14 }, reject); // If any one of them fails, the whole thing fails right away
15 });
16 });
17}
§02

EventEmitter:拷贝一份再遍历EventEmitter: copy the list, then walk the copy

Implement an EventEmitter with on, off, once and emit

一句话:Map<事件名, 监听器数组>存订阅;唯一的坑在 emit ——遍历前先 [...list] 拷贝一份

为什么:once 的实现是包一层 wrapper, 触发时先 off 掉自己再调原函数。如果 emit 直接遍历原数组,wrapper 自删会让数组当场缩短,它旁边的监听器被跳过 —— 测试里「once 不挤掉邻居」那条抓的就是这个。 这是「遍历中修改集合」这个通用陷阱在面试题里的标准形态。

会追问:「off 传的函数和 on 的不是同一个引用怎么办」 —— 匿名函数没法 off,这是 API 设计的固有约束,和 DOM 的removeEventListener 一样;所以 once 的 wrapper 必须在内部持有自己的引用。「监听器抛错要不要影响后面的」—— Node 的 EventEmitter 会直接抛断;健壮版本可以 try/catch 逐个隔离,说出取舍即可。

In one line: subscriptions live in a Map<event, listener[]>; the only trap is in emit copy the list with [...list] before iterating.

Why: once is a wrapper that first offs itself, then calls the real function. If emit iterates the original array, that self-removal shortens the array mid-loop and the listener next to it gets skipped — the “once does not knock out its neighbors” test exists precisely for this. It is the classic “mutating a collection while iterating it” trap in its interview form.

Follow-up: “what if off receives a different reference than on did” — anonymous functions cannot be removed; that is an inherent constraint of the API, same as DOM’s removeEventListener, which is why the once wrapper must hold its own reference internally. “should one throwing listener stop the rest” — Node’s EventEmitter lets it throw through; a hardened version isolates each call in try/catch. Naming the trade-off is enough.

TypeScriptemitter.ts(emit 的关键 —— 完整版 6 / 6)emitter.ts (the key part of emit — the full version scores 6 / 6)已跑通Verified
1emit(event: string, ...args: unknown[]): boolean {
2 const list = this.listeners.get(event);
3 if (!list || list.length === 0) return false;
4 // 拷贝一份再遍历 —— once 触发时会 off 自己,
5 // 直接遍历原数组会让它旁边的监听器被跳过
6 for (const fn of [...list]) fn(...args);
7 return true;
8}
1emit(event: string, ...args: unknown[]): boolean {
2 const list = this.listeners.get(event);
3 if (!list || list.length === 0) return false;
4 // Copy the array before walking it — a once listener removes itself while it runs,
5 // and walking the original array would then skip the listener next to it
6 for (const fn of [...list]) fn(...args);
7 return true;
8}
§03

LRU:Map 的插入序就是现成的链表LRU: the insertion order of a Map is already the linked list you need

Implement an LRU cache without writing a linked list

一句话:JS 的 Map 按插入序遍历, 所以「删掉再放回 = 挪到最新那头」「迭代器第一个键 = 最旧的那条」—— 两条性质拼起来,LRU 十五行写完,不需要手搓双向链表

两个操作各自的责任:get 命中时删掉再放回(读也算「用过」);put 已存在先删再放(更新也刷新),放完超容量就删map.keys().next().value

会追问(区分 senior 的一问):「教科书版为什么用哈希表 + 双向链表?」—— 因为那是语言无关的答案:O(1) 查找靠哈希表,O(1) 挪位和淘汰靠链表。 JS 的 Map 恰好把两者捏在一起了(规范保证插入序)。 能先给 Map 版、再讲清教科书版的数据结构原理, 比只会背链表版强得多 —— 这说明你知道自己在用什么。

In one line: a JS Map iterates in insertion order, so “delete then re-set = move to the fresh end” and “the iterator’s first key = the stalest entry” — put those two properties together and LRU is fifteen lines, no hand-rolled doubly linked list required.

Each operation’s duty: get on a hit deletes and re-sets (reading counts as use); put deletes first if the key exists (updates refresh too), and after setting, evicts map.keys().next().value when over capacity.

Follow-up (the senior separator): “why does the textbook version use a hash map plus a doubly linked list?” — because that is the language-agnostic answer: O(1) lookup from the hash map, O(1) reordering and eviction from the list. JS’s Map happens to fuse the two (insertion order is guaranteed by the spec). Giving the Map version first and then explaining the textbook structure beats reciting the linked-list version cold — it shows you know what you are standing on.

TypeScriptlru.ts(参考解法 —— scratchpad vitest 5 / 5)lru.ts (reference solution — scratchpad vitest 5 / 5)已跑通Verified
1export class LRUCache<K, V> {
2 private map = new Map<K, V>();
3
4 constructor(private capacity: number) {
5 if (capacity < 1) throw new Error("capacity must be at least 1");
6 }
7
8 get(key: K): V | undefined {
9 if (!this.map.has(key)) return undefined;
10 const value = this.map.get(key) as V;
11 this.map.delete(key); // 删掉再放回 = 挪到「最新」那头
12 this.map.set(key, value);
13 return value;
14 }
15
16 put(key: K, value: V): void {
17 if (this.map.has(key)) this.map.delete(key);
18 this.map.set(key, value);
19 if (this.map.size > this.capacity) {
20 // Map 按插入序遍历 —— 迭代器的第一个键就是最久没被碰过的
21 const oldest = this.map.keys().next().value as K;
22 this.map.delete(oldest);
23 }
24 }
25}
1export class LRUCache<K, V> {
2 private map = new Map<K, V>();
3
4 constructor(private capacity: number) {
5 if (capacity < 1) throw new Error("capacity must be at least 1");
6 }
7
8 get(key: K): V | undefined {
9 if (!this.map.has(key)) return undefined;
10 const value = this.map.get(key) as V;
11 this.map.delete(key); // Delete then re-insert = move it to the newest end
12 this.map.set(key, value);
13 return value;
14 }
15
16 put(key: K, value: V): void {
17 if (this.map.has(key)) this.map.delete(key);
18 this.map.set(key, value);
19 if (this.map.size > this.capacity) {
20 // A Map iterates in insertion order — the first key is the one untouched for longest
21 const oldest = this.map.keys().next().value as K;
22 this.map.delete(oldest);
23 }
24 }
25}
§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.