最近在准备面试,刷到了三道经典的异步编程题:sleep、并发调度器、EventEmitter。说实话,这些东西在日常工作中很少会从零手写 —— 毕竟有 p-limit、mitt 这些成熟的轮子。但当我尝试不看答案写一遍时,才发现自己对 Promise 的理解其实还停留在"会用"的层面。
这篇文章是我重新理解这三个模式的学习笔记。与其死记代码,不如搞清楚它们背后的思维模型。
问题的起源
JavaScript 是单线程的,但前端需要处理大量异步操作:网络请求、定时器、用户交互……如何优雅地组织这些异步逻辑,是每个前端都绑不开的话题。
ES6 之前,我们用回调函数;ES6 带来了 Promise;ES7 又有了 async/await。语法在进化,但底层的思维模型其实没变——如何控制异步任务的执行时机和顺序。
这三道面试题,恰好覆盖了异步编程的三个核心场景:
graph LR
A[异步编程核心模式] --> B[延迟执行]
A --> C[并发控制]
A --> D[事件通信]
B --> B1[sleep]
C --> C1[Scheduler]
D --> D1[EventEmitter]
核心概念探索
一、sleep:理解 Promise 的本质
1.1 最简实现
// 环境:浏览器 / Node.js
// 场景:延迟执行
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// 使用
async function demo() {
console.log('start');
await sleep(1000);
console.log('1 second later');
}
就这么几行代码,但它揭示了 Promise 最核心的设计:Promise 是一个状态容器,而 resolve 是改变状态的开关。
1.2 拆解思考过程
很多人会卡在"怎么让 await 等待 1 秒"这个问题上。我的理解是,需要换一个角度思考:
错误的思维方式 -> "我要让代码暂停 1 秒" ← 这是命令式思维
正确的思维方式 -> "我要创建一个 Promise,它会在 1 秒后变成 fulfilled 状态" ← 这是声明式思维
Promise 构造函数接收一个 executor 函数,这个函数会立即执行。executor 的两个参数 resolve 和 reject 是用来改变 Promise 状态的"遥控器"。
// 环境:浏览器 / Node.js
// 场景:理解 Promise executor 的执行时机
const promise = new Promise((resolve, reject) => {
console.log('executor runs immediately'); // 这行会立即执行
setTimeout(() => {
console.log('timeout callback');
resolve('done'); // 1 秒后,Promise 状态变为 fulfilled
}, 1000);
});
console.log('after new Promise');
// 输出顺序:
// executor runs immediately
// after new Promise
// timeout callback
1.3 为什么 await 能"暂停"
await 并不是真的让代码暂停——JavaScript 依然是单线程的,不可能真的阻塞。await 的作用是:暂停当前 async 函数的执行,等 Promise 状态改变后再继续。
// 环境:浏览器 / Node.js
// 场景:理解 await 的执行机制
async function test() {
console.log('1');
await sleep(1000); // 这里 async 函数暂停,但主线程继续执行其他代码
console.log('2'); // 1 秒后继续执行
}
test();
console.log('3');
// 输出顺序:1, 3, (等待 1 秒), 2
关键洞察:
await后面的代码,本质上是被放进了 Promise 的.then()回调中。async/await 是 Promise 的语法糖,但这层糖衣让代码看起来像同步的。
二、Scheduler:并发控制的核心思想
2.1 问题场景
假设你要批量上传 100 张图片,如果同时发起 100 个请求,可能会:
- 浏览器请求并发数限制(Chrome 对同一域名最多 6 个)
- 服务器压力过大
- 内存占用飙升
所以我们需要一个"调度器",控制同时进行的任务数量。
2.2 核心实现
// 环境:浏览器 / Node.js
// 场景:限制最大并发数为 2
function Scheduler(maxConcurrent) {
this.maxConcurrent = maxConcurrent;
this.running = 0; // 当前正在执行的任务数
this.queue = []; // 等待队列
}
Scheduler.prototype.add = function (task) {
return new Promise((resolve, reject) => {
// 把"执行任务"这个动作封装成一个函数
const run = () => {
this.running++;
Promise.resolve()
.then(() => task()) // 执行任务
.then(resolve, reject) // 把任务的结果传递给外部的 Promise
.finally(() => {
this.running--;
// 任务完成后,检查队列中是否有等待的任务
if (this.queue.length > 0) {
const next = this.queue.shift();
next();
}
});
};
// 决策:立即执行,还是排队等待
if (this.running < this.maxConcurrent) {
run();
} else {
this.queue.push(run);
}
});
};
2.3 逐行拆解
这段代码的精妙之处在于 add 方法返回的 Promise。让我用一个图来表示执行流程:
flowchart TD
A[调用 add-task-] --> B{running < max?}
B -->|是| C[立即执行 run--]
B -->|否| D[push 到 queue]
C --> E[running++]
E --> F[执行 task--]
F --> G[resolve/reject]
G --> H[running--]
H --> I{queue 有任务?}
I -->|是| J[shift 并执行]
I -->|否| K[结束]
D -.-> |等待其他任务完成| J
关键洞察 1:add 返回的 Promise 不是 task() 返回的 Promise,而是一个"包装"过的 Promise。这个包装让我们能控制任务何时开始执行。
关键洞察 2:run 函数被存入队列的是函数本身,不是执行结果。这是闭包的典型应用——run 函数"记住"了对应的 resolve 和 reject。
2.4 使用示例
// 环境:浏览器 / Node.js
// 场景:模拟批量请求,最多同时 2 个
const scheduler = new Scheduler(2);
function createTask(id, delay) {
return () => new Promise((resolve) => {
console.log(`Task ${id} started`);
setTimeout(() => {
console.log(`Task ${id} finished`);
resolve(id);
}, delay);
});
}
// 添加 4 个任务
scheduler.add(createTask(1, 1000));
scheduler.add(createTask(2, 500));
scheduler.add(createTask(3, 300));
scheduler.add(createTask(4, 400));
// 输出顺序:
// Task 1 started (t=0)
// Task 2 started (t=0) ← 同时最多 2 个
// Task 2 finished (t=500)
// Task 3 started (t=500) ← Task 2 完成后,Task 3 才开始
// Task 3 finished (t=800)
// Task 4 started (t=800)
// Task 1 finished (t=1000)
// Task 4 finished (t=1200)
三、EventEmitter:发布订阅模式
3.1 为什么需要事件系统
组件之间需要通信,但如果直接互相调用,会导致紧耦合。事件系统提供了一种"松耦合"的通信方式:
A 组件 ---(emit 'dataReady')---> EventEmitter ---(notify)---> B 组件
C 组件
发布者不需要知道谁在监听,订阅者也不需要知道谁在发布。
3.2 核心实现
// 环境:浏览器 / Node.js
// 场景:实现简易事件系统
function EventEmitter() {
this._events = {}; // 存储结构:{ eventName: [handler1, handler2, ...] }
}
// 订阅事件
EventEmitter.prototype.on = function (event, handler) {
if (!this._events[event]) {
this._events[event] = [];
}
this._events[event].push(handler);
};
// 取消订阅
EventEmitter.prototype.off = function (event, handler) {
if (!this._events[event]) return;
if (handler == null) {
// 没传 handler,清空该事件的所有监听器
this._events[event] = [];
return;
}
// 过滤掉指定的 handler
this._events[event] = this._events[event].filter((h) => h !== handler);
};
// 触发事件
EventEmitter.prototype.emit = function (event, ...args) {
const handlers = this._events[event];
if (!handlers || handlers.length === 0) return;
handlers.forEach((h) => h.apply(this, args));
};
// 只监听一次
EventEmitter.prototype.once = function (event, handler) {
// 包装原 handler,执行后自动取消订阅
const wrap = (...args) => {
this.off(event, wrap); // 注意:off 的是 wrap,不是 handler
handler.apply(this, args);
};
this.on(event, wrap);
};
3.3 once 的实现技巧
once 的实现有个容易踩的坑:
// 错误实现
EventEmitter.prototype.once = function (event, handler) {
const wrap = (...args) => {
this.off(event, handler); // ❌ 错!handler 没有被 on 注册过
handler.apply(this, args);
};
this.on(event, wrap);
};
// 正确实现
EventEmitter.prototype.once = function (event, handler) {
const wrap = (...args) => {
this.off(event, wrap); // ✅ 移除的是 wrap
handler.apply(this, args);
};
this.on(event, wrap);
};
关键洞察:on(event, wrap) 注册的是 wrap,所以 off 时也要移除 wrap。这是闭包的又一个应用——wrap 函数"记住"了原始的 handler。
3.4 使用示例
// 环境:浏览器 / Node.js
// 场景:组件间通信
const emitter = new EventEmitter();
// 订阅
function handleData(data) {
console.log('Received:', data);
}
emitter.on('data', handleData);
// 只监听一次
emitter.once('ready', () => {
console.log('System ready!');
});
// 触发
emitter.emit('data', { id: 1 }); // Received: { id: 1 }
emitter.emit('ready'); // System ready!
emitter.emit('ready'); // (无输出,因为 once 只触发一次)
// 取消订阅
emitter.off('data', handleData);
emitter.emit('data', { id: 2 }); // (无输出)
实际场景思考
场景 A:图片批量上传
// 环境:浏览器
// 场景:批量上传图片,限制并发数,显示进度
async function uploadImages(files, maxConcurrent = 3) {
const scheduler = new Scheduler(maxConcurrent);
const results = [];
let completed = 0;
const uploadTasks = files.map((file, index) => {
return scheduler.add(async () => {
const result = await uploadSingleImage(file);
completed++;
console.log(`Progress: ${completed}/${files.length}`);
return result;
});
});
return Promise.all(uploadTasks);
}
// 模拟单张图片上传
function uploadSingleImage(file) {
return new Promise((resolve) => {
setTimeout(() => {
resolve({ name: file.name, url: 'https://...' });
}, Math.random() * 2000);
});
}
场景 B:带取消功能的延迟执行
// 环境:浏览器 / Node.js
// 场景:可取消的 sleep
function cancellableSleep(ms) {
let timeoutId;
let rejectFn;
const promise = new Promise((resolve, reject) => {
rejectFn = reject;
timeoutId = setTimeout(resolve, ms);
});
promise.cancel = () => {
clearTimeout(timeoutId);
rejectFn(new Error('Cancelled'));
};
return promise;
}
// 使用
const sleepPromise = cancellableSleep(5000);
sleepPromise
.then(() => console.log('Done'))
.catch((err) => console.log(err.message));
// 2 秒后取消
setTimeout(() => sleepPromise.cancel(), 2000);
// 输出:Cancelled
场景 C:用 EventEmitter 实现简易状态管理
// 环境:浏览器
// 场景:简易的全局状态管理
function createStore(initialState) {
const emitter = new EventEmitter();
let state = initialState;
return {
getState: () => state,
setState: (newState) => {
const prevState = state;
state = { ...state, ...newState };
emitter.emit('change', state, prevState);
},
subscribe: (listener) => {
emitter.on('change', listener);
// 返回取消订阅的函数
return () => emitter.off('change', listener);
}
};
}
// 使用
const store = createStore({ count: 0 });
const unsubscribe = store.subscribe((state, prevState) => {
console.log('State changed:', prevState, '->', state);
});
store.setState({ count: 1 }); // State changed: { count: 0 } -> { count: 1 }
store.setState({ count: 2 }); // State changed: { count: 1 } -> { count: 2 }
unsubscribe();
store.setState({ count: 3 }); // (无输出)
小结
这三道面试题,表面上是考手写代码,实际上是在考察对异步编程的理解深度:
| 题目 | 考察点 | 核心思维 |
|---|---|---|
| sleep | Promise 基础 | Promise 是状态容器,resolve 是状态开关 |
| Scheduler | 异步控制流 | 用队列 + 计数器实现并发限制 |
| EventEmitter | 设计模式 | 发布订阅解耦组件通信 |
我的体会是,与其死记代码,不如把每一行代码的"为什么"搞清楚。面试时能讲清楚思路,比默写出一字不差的代码更重要。
参考资料
- MDN - Promise - Promise 官方文档
- Node.js - Events - Node.js 事件模块文档
- p-limit - 常用的并发限制库
- mitt - 轻量级事件库(200 bytes)