适用场景:前端秋招 / 实习面试 / JavaScript 手写题
核心目标:不要死记每一道题,而是掌握背后的几种通用模型。
一、为什么要单独整理"异步手写题"?
前端面试中的很多 JavaScript 手写题,看起来形式完全不同:
sleepPromise.all- Promise 串行执行
- Pipeline
- TaskQueue
- LazyMan
- Scheduler
- 并发限制
- retry
- timeout
- 请求去重
但本质上,这些题并不是十几个独立知识点。
它们大部分都建立在以下几个核心能力之上:
text
Promise
↓
async / await
↓
异步任务串行执行
↓
任务队列
↓
链式调用
↓
Pipeline / LazyMan
↓
并发调度
因此,正确的刷题方法不是:
text
背 50 道题
而是:
text
掌握 5~6 个母模板
↓
看到题目识别模型
↓
根据需求做变形
本文按照由浅入深的方式,整理一套适合前端秋招的 7 天 / 12 题刷题路线。
二、整体刷题路线
建议按照下面的顺序刷:
text
Day 1:Promise 基础
① sleep
② Promise.all
Day 2:异步串行
③ 多任务串行执行
④ 上一步输出作为下一步输入
Day 3:异常控制
⑤ timeout
⑥ retry
Day 4:任务队列
⑦ TaskQueue
⑧ Pipeline
Day 5:链式异步
⑨ LazyMan / CodingMan
Day 6:并发控制
⑩ Scheduler
⑪ mapLimit
Day 7:综合工程题
⑫ 请求去重
三、Day 1:Promise 基础
1. 实现 sleep
题目
实现下面的效果:
js
await sleep(2000);
console.log('2 秒后输出');
思路分析
sleep 的本质非常简单:
text
setTimeout
↓
时间到了
↓
resolve()
所以:
js
new Promise(resolve => {
setTimeout(resolve, ms);
});
参考答案
js
function sleep(ms) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
使用:
js
async function test() {
console.log('start');
await sleep(2000);
console.log('end');
}
test();
速记
text
sleep
=
setTimeout
+
Promise
四、实现 Promise.all
2. 手写 Promise.all
题目
实现:
js
myPromiseAll([
Promise.resolve(1),
Promise.resolve(2),
3
]);
最终结果:
js
[1, 2, 3]
思路分析
需要解决三个问题:
text
① 所有 Promise 可以并行执行
② 保存每一个 Promise 的执行结果
③ 所有任务完成后统一 resolve
其中最容易出错的是:
text
Promise 完成顺序
≠
最终返回结果顺序
例如:
text
任务 A:3 秒
任务 B:1 秒
任务 C:2 秒
实际完成顺序:
text
B → C → A
但最终结果依然必须是:
text
[A, B, C]
所以不能简单使用:
js
result.push(value);
应该使用:
js
result[index] = value;
参考答案
js
function myPromiseAll(promises) {
return new Promise((resolve, reject) => {
const result = [];
let count = 0;
if (promises.length === 0) {
resolve([]);
return;
}
promises.forEach((promise, index) => {
Promise.resolve(promise)
.then(value => {
result[index] = value;
count++;
if (count === promises.length) {
resolve(result);
}
})
.catch(reject);
});
});
}
核心考点
text
Promise.resolve()
→ 兼容普通值和 Promise
result[index]
→ 保证结果顺序
count
→ 判断是否全部完成
reject
→ 任意一个失败就失败
五、Day 2:异步串行
3. 多个异步任务串行执行
题目
给定:
js
const tasks = [
() => Promise.resolve(1),
() => Promise.resolve(2),
() => Promise.resolve(3)
];
要求执行顺序:
text
task1
↓
task2
↓
task3
思路分析
最简单的方式就是:
js
for...of + await
因为:
js
await task();
当前任务没有执行结束之前,不会进入下一轮循环。
参考答案
js
async function runTasks(tasks) {
const result = [];
for (const task of tasks) {
const value = await task();
result.push(value);
}
return result;
}
执行模型
text
for
↓
await task1
↓
await task2
↓
await task3
六、上一步输出作为下一步输入
4. 数据流水线
题目
给定:
js
const tasks = [
async value => value + 1,
async value => value * 2,
async value => value - 3
];
输入:
js
10
要求:
text
10
↓ +1
11
↓ ×2
22
↓ -3
19
思路分析
只需要维护一个变量:
js
value
每一次执行:
js
value = await task(value);
上一步的输出,就成为下一步的输入。
参考答案
js
async function runTasks(tasks, initialValue) {
let value = initialValue;
for (const task of tasks) {
value = await task(value);
}
return value;
}
最核心的一行
js
value = await task(value);
实际上可以拆成:
js
const result = await task(value);
value = result;
所以整个数据流是:
text
value0
↓
task1
↓
value1
↓
task2
↓
value2
↓
task3
↓
value3
这个模型就是后面 Pipeline 的核心。
七、Day 3:异常与失败控制
5. 实现 Promise timeout
题目
实现:
js
timeout(fetchData(), 3000);
要求:
text
3 秒内完成
→ 返回正常结果
超过 3 秒
→ reject
思路分析
这里其实是在进行一场竞赛:
text
正常任务 Promise
VS
超时 Promise
谁先完成,就采用谁的结果。
因此天然想到:
js
Promise.race()
参考答案
js
function timeout(promise, ms) {
const timer = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error('timeout'));
}, ms);
});
return Promise.race([
promise,
timer
]);
}
核心模型
text
Promise.race([
正常任务,
超时任务
])
八、实现 retry
6. 失败自动重试
题目
实现:
js
retry(task, 3);
任务失败时进行重试。
思路分析
本质是:
text
执行任务
↓
成功
→ return
失败
↓
还有机会?
↓
继续执行
所以核心是:
text
for
+
try / catch
参考答案
js
async function retry(task, times) {
let lastError;
for (let i = 0; i < times; i++) {
try {
return await task();
} catch (error) {
lastError = error;
}
}
throw lastError;
}
面试容易追问
下面这句:
js
retry(task, 3);
到底代表:
text
总共执行 3 次
还是:
text
第一次执行
+
失败后再重试 3 次
总共 4 次
取决于题目定义。
实际工程中,更建议使用:
js
maxAttempts
这种参数名,避免语义歧义。
九、Day 4:任务队列
7. 实现 TaskQueue
题目
实现:
js
queue.add(task1);
queue.add(task2);
queue.add(task3);
queue.run();
要求:
text
task1
↓
task2
↓
task3
思路分析
这里要分清两个阶段。
第一阶段:添加任务
js
this.tasks.push(task);
只是在:
text
保存任务
并不是立即执行。
第二阶段:执行任务
js
for...of + await
负责真正消费队列。
参考答案
js
class TaskQueue {
constructor() {
this.tasks = [];
}
add(task) {
this.tasks.push(task);
return this;
}
async run() {
for (const task of this.tasks) {
await task();
}
}
}
核心模型
text
add()
↓
任务入队
run()
↓
消费任务
十、实现 Pipeline
8. Pipeline
题目
实现:
js
const pipeline = new Pipeline();
pipeline
.add(async value => value + 1)
.add(async value => value * 2)
.add(async value => value - 3);
const result = await pipeline.run(10);
最终:
js
result === 19
思路分析
Pipeline 实际上是在 TaskQueue 基础上增加了两个能力。
第一:支持链式调用
js
pipeline
.add(...)
.add(...)
.add(...);
实现方式:
js
return this;
第二:传递上一步结果
text
上一个任务输出
↓
下一个任务输入
所以:
js
value = await handler(value);
参考答案
js
class Pipeline {
constructor() {
this.handlers = [];
}
add(handler) {
if (typeof handler !== 'function') {
throw new TypeError('handler must be a function');
}
this.handlers.push(handler);
return this;
}
async run(initialValue) {
let value = initialValue;
for (const handler of this.handlers) {
value = await handler(value);
}
return value;
}
}
Pipeline 四个核心考点
text
handlers
→ 保存任务
push()
→ 任务入队
return this
→ 链式调用
value = await handler(value)
→ 串行执行 + 数据传递
Pipeline 真正需要理解的,其实就是最后一句:
js
value = await handler(value);
十一、Pipeline 的 cancel 怎么理解?
有些题会要求:
js
pipeline.cancel();
一种简单写法:
js
class Pipeline {
constructor() {
this.handlers = [];
this._canceled = false;
}
cancel() {
this._canceled = true;
}
}
然后:
js
for (const handler of this.handlers) {
if (this._canceled) {
throw new Error('Pipeline canceled');
}
value = await handler(value);
}
需要注意:
js
if (this._canceled)
不是实时监听。
它只是在代码执行到这一行的时候检查一次。
所以:
text
task1 正在执行
↓
调用 cancel()
↓
task1 不会立即停止
↓
task1 执行结束
↓
下一轮循环发现 canceled
↓
停止后续任务
因此这种 cancel 更准确的含义是:
阻止后续任务继续执行。
如果想真正取消当前 fetch 等异步任务,需要进一步使用:
text
AbortController
+
AbortSignal
但对于普通秋招手写题,这已经属于进阶扩展,理解概念即可。
十二、Day 5:链式异步调用
9. 实现 LazyMan
题目
实现:
js
LazyMan('Tom')
.sleep(3)
.eat('apple')
.sleep(2)
.eat('banana');
期望执行:
text
Hi Tom
等待 3 秒
Eat apple
等待 2 秒
Eat banana
思路分析
重点:
调用:
js
sleep()
时不要立刻 sleep。
而是:
text
调用 sleep
↓
创建任务函数
↓
任务入队
↓
最后统一执行
所以 LazyMan 和 Pipeline 的本质其实非常接近。
参考答案
js
class LazyManClass {
constructor(name) {
this.tasks = [];
this.tasks.push(async () => {
console.log(`Hi ${name}`);
});
Promise.resolve().then(() => {
this.run();
});
}
eat(food) {
this.tasks.push(async () => {
console.log(`Eat ${food}`);
});
return this;
}
sleep(seconds) {
this.tasks.push(async () => {
await new Promise(resolve => {
setTimeout(resolve, seconds * 1000);
});
});
return this;
}
async run() {
for (const task of this.tasks) {
await task();
}
}
}
function LazyMan(name) {
return new LazyManClass(name);
}
Pipeline 与 LazyMan 的区别
Pipeline
text
value
↓
task1
↓
value
↓
task2
↓
value
需要传递结果。
LazyMan
text
task1
↓
task2
↓
task3
通常不需要传递 value。
二者共同点
text
任务数组
+
任务入队
+
return this
+
for...of + await
所以它们其实属于同一类题。
十三、Day 6:并发控制
并发控制是异步手写题里非常重要的一层。
10. 实现 Scheduler
题目
假设:
js
const scheduler = new Scheduler(2);
scheduler.add(task1);
scheduler.add(task2);
scheduler.add(task3);
scheduler.add(task4);
要求任何时刻最多只能同时执行:
text
2 个任务
思路分析
维护两个状态:
js
queue
表示:
text
还没执行的任务
以及:
js
runningCount
表示:
text
当前正在执行的任务数量
核心条件:
js
runningCount < limit
如果满足:
text
可以继续启动任务
否则:
text
必须等待
参考答案
js
class Scheduler {
constructor(limit) {
this.limit = limit;
this.runningCount = 0;
this.queue = [];
}
add(task) {
return new Promise((resolve, reject) => {
this.queue.push({
task,
resolve,
reject
});
this.run();
});
}
run() {
while (
this.runningCount < this.limit &&
this.queue.length > 0
) {
const {
task,
resolve,
reject
} = this.queue.shift();
this.runningCount++;
Promise.resolve()
.then(task)
.then(resolve)
.catch(reject)
.finally(() => {
this.runningCount--;
this.run();
});
}
}
}
执行过程
假设:
js
limit = 2;
初始:
text
queue:
task1
task2
task3
task4
running = 0
首先执行:
text
task1
task2
此时:
text
running = 2
task3 和 task4 等待。
当 task2 完成:
text
running = 1
Scheduler 立刻发现:
text
running < limit
于是启动:
text
task3
又变成:
text
running = 2
Scheduler 核心
js
this.runningCount++;
表示:
text
占用一个并发槽位
任务结束后:
js
this.runningCount--;
表示:
text
释放一个并发槽位
然后:
js
this.run();
继续调度下一个任务。
十四、实现 mapLimit
11. 限制并发数量
题目
实现:
js
await mapLimit(
[1, 2, 3, 4, 5],
2,
async item => {
return fetchSomething(item);
}
);
要求:
text
最多同时执行 2 个任务
最终返回:
js
[
result1,
result2,
result3,
result4,
result5
]
思路分析
一个很好理解的方法是:
创建固定数量的 worker。
例如:
js
limit = 2;
那么创建:
text
worker1
worker2
两个 worker 不断从任务池中领取任务。
参考答案
js
async function mapLimit(items, limit, fn) {
const result = new Array(items.length);
let index = 0;
async function worker() {
while (index < items.length) {
const currentIndex = index++;
result[currentIndex] = await fn(
items[currentIndex]
);
}
}
const workers = [];
for (
let i = 0;
i < Math.min(limit, items.length);
i++
) {
workers.push(worker());
}
await Promise.all(workers);
return result;
}
执行示意
假设:
text
5 个任务
并发数 = 2
可以理解为:
text
worker1:
task1
↓
task3
↓
task5
worker2:
task2
↓
task4
两个 worker 同时工作。
因此同时最多只有:
text
2 个异步任务
十五、Day 7:综合工程题
12. 请求去重
题目
如果连续调用:
js
request('/user/1');
request('/user/1');
request('/user/1');
不希望发送三次相同请求。
而应该:
text
第一次
→ 真正发送请求
第二次
→ 复用第一次 Promise
第三次
→ 继续复用第一次 Promise
思路分析
最适合的数据结构:
js
Map
保存:
text
请求 key
↓
正在执行的 Promise
参考答案
js
const pendingRequests = new Map();
function request(url) {
if (pendingRequests.has(url)) {
return pendingRequests.get(url);
}
const promise = fetch(url)
.finally(() => {
pendingRequests.delete(url);
});
pendingRequests.set(url, promise);
return promise;
}
为什么 finally 里要 delete?
请求执行期间:
text
pendingRequests
/user/1
↓
Promise
可以让后续相同请求直接复用。
但请求结束以后,这个 Promise 就没有继续缓存的必要。
所以:
js
finally(() => {
pendingRequests.delete(url);
});
否则以后所有:
js
request('/user/1');
都会一直拿到旧的 Promise。
十六、真正需要掌握的 6 个母模板
刷完 12 道题后,不建议背 12 套代码。
真正需要记住的是下面 6 个模型。
模板 1:延迟模型
js
new Promise(resolve => {
setTimeout(resolve, ms);
});
对应:
text
sleep
delay
timeout
模板 2:串行模型
js
for (const task of tasks) {
await task();
}
对应:
text
Promise 串行
TaskQueue
LazyMan
模板 3:数据流水线模型
js
let value = initialValue;
for (const task of tasks) {
value = await task(value);
}
对应:
text
Pipeline
composeAsync
数据处理流水线
模板 4:链式调用模型
js
add(task) {
this.tasks.push(task);
return this;
}
对应:
text
Pipeline
LazyMan
Builder
模板 5:并发控制模型
核心思想:
js
if (running < limit) {
running++;
task().finally(() => {
running--;
runNext();
});
}
对应:
text
Scheduler
并发请求
请求池
任务调度
模板 6:请求缓存 / 去重模型
js
const map = new Map();
if (map.has(key)) {
return map.get(key);
}
const promise = request();
map.set(key, promise);
对应:
text
请求去重
资源加载去重
Promise 缓存
十七、正确刷题方式
一道题真正掌握的标准,不是:
我看懂答案了。
而是:
我看到题目以后,知道它属于哪个模型。
建议每道题严格执行下面的流程。
第一遍:只看题目
自己写:
text
15~20 分钟
先不要看答案。
第二遍:只看思路
如果写不出来:
text
只看解题思路
仍然不要看完整代码。
第三遍:继续独立实现
根据思路重新写一遍。
第四遍:对照答案
重点找:
text
变量设计是否不同?
异常是否处理?
边界条件是否漏掉?
有没有更简洁的模型?
第五遍:关闭答案
重新完整手写。
第二天:再次手写
真正有效的复习是:
text
隔天重新写
而不是当天连续看五遍。
十八、秋招优先级
如果时间有限,可以按照下面的优先级准备。
第一优先级:必须熟练
text
★★★★★
Promise.all
sleep
Promise 串行
Pipeline
LazyMan
Scheduler
并发限制
这些题型之间关联非常强。
第二优先级:必须会
text
★★★★
retry
timeout
EventEmitter
debounce
throttle
第三优先级:进阶
text
★★★
请求去重
缓存
AbortController
完整 Promise 实现
完整手写 Promise 的代码量比较大,实际秋招准备中优先级通常没有:
text
Promise.all
Scheduler
并发控制
那么高。
十九、完整知识演化路线
最终建议建立下面这条知识线:
text
Promise
↓
async / await
↓
异步串行
↓
TaskQueue
↓
Pipeline
↓
LazyMan
↓
Scheduler
↓
并发控制
这几道题并不是孤立的。
Promise 串行
js
for (const task of tasks) {
await task();
}
加入结果传递:
js
value = await task(value);
就变成:
text
Pipeline
再加入:
js
return this;
就可以支持:
text
链式调用
再把各种:
text
eat
sleep
sleepFirst
包装成任务:
text
LazyMan
再增加:
text
running
limit
queue
就进入:
text
Scheduler / 并发控制
所以整个体系实际上是在不断增加状态和约束。
二十、最后总结
前端异步手写题最重要的不是背代码,而是识别模式。
建议牢牢记住:
text
sleep
→ setTimeout + Promise
串行
→ for...of + await
Pipeline
→ value = await task(value)
链式调用
→ return this
LazyMan
→ 任务入队 + 延迟统一执行
Scheduler
→ queue + running + limit
请求去重
→ Map + Promise 缓存
最终目标应该是:
text
看到题目
↓
判断属于哪个模型
↓
写出骨架
↓
再补边界条件
如果能做到这一点,绝大多数前端秋招里的异步手写题,就不再是"新题",而只是这些基本模型的不同组合。
推荐刷题顺序
最后再浓缩成一版:
text
1. sleep
2. Promise.all
3. Promise 串行
4. 上一步结果传给下一步
5. timeout
6. retry
7. TaskQueue
8. Pipeline
9. LazyMan
10. Scheduler
11. mapLimit
12. 请求去重
先把这 12 道题做到:
text
不看答案能独立写出 8~10 道
再继续补:
text
EventEmitter
debounce
throttle
call
apply
bind
new
instanceof
deepClone
这样就可以逐步形成一套完整的前端秋招 JavaScript 手写题体系。