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

异步与结构:Promise.all、EventEmitter、LRUAsync and structure: Promise.all, EventEmitter, LRU

下标写入保顺序、拷贝列表再遍历、Map 的插入序当链表用。Write by index to keep the order, copy the list before you walk it, and use the insertion order of a Map as a linked list.

3 个练习3 exercises面试 · 第 8 部分Interview · Part 8
这一页有什么On this page6
学完这节你会After this lesson you can
  • 手写 Promise.all:按输入顺序收结果、首个失败立刻整体失败Write Promise.all by hand: results in input order, and the first failure fails the whole thing at once
  • 手写 Promise.allSettled,并说清它和 all 的语义差别Write Promise.allSettled by hand, and say how it differs from all
  • 手写 EventEmitter:on / off / once / emit,once 不挤掉邻居Write an EventEmitter by hand with on / off / once / emit, where once does not drop its neighbours
  • 手写 LRUCache:利用 Map 的插入序,不手搓双向链表Write an LRUCache by hand using the insertion order of a Map, with no hand-built doubly linked list
这在考试里考什么What the exam does with this

Promise.all 是异步手写题的第一名,考点全在两个细节:结果顺序和短路失败。EventEmitter 考「遍历中修改列表」这个老陷阱。LRU 是数据结构题里最常见的一道 —— 知道 Map 按插入序遍历,就能把它从 40 行压到 15 行。Promise.all is the most common async write-it-yourself question, and it turns on two details: the order of the results, and failing immediately on the first error. EventEmitter tests an old trap, changing a list while you walk it. LRU is the data structure question you see most often — once you know a Map iterates in insertion order, it drops from 40 lines to 15.

§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}
练习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手写 Promise.all + allSettledWrite Promise.all and allSettled by hand
把两个「直接 resolve 空数组」的半成品写成真的。不许调用原生 Promise.all / Promise.allSettled。Both half-finished functions just resolve with an empty array. Make them real. Calling the built-in Promise.all or Promise.allSettled is not allowed.
要求Requirements
  • 结果按输入顺序排,不是完成顺序(下标写入,不许 push)Results follow the input order, not the finishing order (write by index; push is not allowed)
  • 空数组立刻 resolve([])An empty array resolves with [] straight away
  • 数组里混普通值也行Plain values mixed into the array are fine
  • 任何一个 reject,整体立刻 reject,不等慢的If any one rejects, the whole thing rejects at once and does not wait for the slow ones
  • allSettled 永不 reject,逐项报 { status, value | reason }allSettled never rejects; it reports { status, value | reason } for each item
TypeScriptpromiseAll.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手写 EventEmitterWrite an EventEmitter by hand
把空骨架填成完整的 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.
要求Requirements
  • 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
TypeScriptemitter.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手写 LRUCache(用 Map,不写链表)Write an LRUCache by hand (use a Map, no linked list)
把「什么都没存」的骨架写成真正的 LRU:超容量淘汰最久未使用, get 和 put 都要刷新「最近用过」。This skeleton stores nothing. Turn it into a real LRU: over capacity, drop the entry that has gone unused the longest, and both get and put must mark an entry as recently used.
要求Requirements
  • get / put 基本读写;get 不到返回 undefinedBasic reads and writes through get and put; a miss on get returns undefined
  • 超容量时淘汰最久未使用的那条Over capacity, drop the entry that has gone unused the longest
  • get 命中要刷新「最近用过」A hit on get marks that entry as recently used
  • put 已存在的 key:更新值并刷新put on a key that already exists updates the value and marks it as recently used
  • capacity 为 1 也要正确A capacity of 1 still behaves correctly
TypeScriptlru.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.

TypeScriptPromise.all 手写题的第一名错法The number one wrong answer to the Promise.all question示意Illustrative
1// ✕ 用 push 收结果 —— 顺序变成「谁先完成谁在前」
2items.forEach((item) => {
3 Promise.resolve(item).then((value) => {
4 results.push(value); // 快的插队了
5 if (results.length === items.length) resolve(results);
6 }, reject);
7});
8
9// promiseAll([slow("a"), fast("b")]) 会得到 ["b", "a"] —— 测试第一条就红
1// ✕ collecting results with push — the order becomes "whoever finished first comes first"
2items.forEach((item) => {
3 Promise.resolve(item).then((value) => {
4 results.push(value); // the fast one cut the line
5 if (results.length === items.length) resolve(results);
6 }, reject);
7});
8
9// promiseAll([slow("a"), fast("b")]) gives you ["b", "a"] — the very first test goes red
Promise.all 的合同是结果顺序 = 输入顺序, 和完成先后无关 —— 调用方靠下标对应输入和输出。push 版在并发下顺序随机,而且这个 bug 在「恰好都一样快」的本地测试里 经常测不出来,上了生产才炸。永远用results[i] = value 按下标写。The contract of Promise.all is that the results come back in input order, no matter which one finishes first — the caller matches input to output by index. The push version gives a random order once the calls really run in parallel, and this bug often does not show up in a local test where every call takes the same time, so it only appears in production. Always write by index with results[i] = value.
迁移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 parallel problem where the results have to line up with the input
下标写入 + 计数器,别 pushWrite by index and keep a counter; do not push
回调 / 监听器列表在触发中会变A list of callbacks or listeners changes while it is being run
遍历前拷贝一份([...list])Copy it before you walk it ([...list])
要 O(1) 的「最近使用」语义You need O(1) most-recently-used behaviour
JS 里先想 Map 的插入序,再讲教科书的哈希 + 链表In JavaScript reach for the insertion order of a Map first, then explain the textbook hash map plus linked list
「XX 和 XX 的语义差别」式追问A follow-up of the form "how do X and Y differ"
一败即停 vs 逐项报告 —— 用场景答,不用定义答Stop on the first failure versus report on every item — answer with a scenario, not a definition
这节的要点What to take away
  1. Promise.all 三件套:下标写入、空数组先判、reject 直接当 then 的第二参 —— 短路失败。Three parts to Promise.all: write by index, check for an empty array first, and pass reject as the second argument of then so the first failure ends it.
  2. allSettled = 每项包成「永远成功带 status」再交给 all;场景差别要用例子答。allSettled = wrap every item so it always succeeds and carries a status, then hand them to all; answer the difference with an example.
  3. EventEmitter 唯一的坑:emit 拷贝列表再遍历,once 的自删才不会挤掉邻居。The one trap in EventEmitter: emit copies the list before walking it, so a once listener removing itself does not drop its neighbours.
  4. LRU 用 Map 的插入序:删掉再放回 = 刷新,迭代器第一个键 = 最旧。LRU with the insertion order of a Map: delete then set again to refresh, and the first key from the iterator is the oldest.
  5. 教科书版哈希表 + 双向链表是语言无关的答案 —— 两个版本都要会讲。The textbook hash map plus doubly linked list is the language-independent answer — be able to explain both versions.

接下来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 lessonUtility Types:会用,还要会手写Utility types: use them, and write them yourself
    下一节Next lesson
读完并且做过上面的练习了吗?Read it and worked through the exercises above?
上一节:Previous: 数据与函数:deepClone、flatten、curryData and functions: deepClone, flatten, curry