如果说闭包是 JavaScript 最容易被问到的概念,那么异步就是最容易写错的机制。几乎每一个线上事故的复盘里,都能找到它的影子:接口返回了但页面没更新、并发请求把浏览器打崩、await 写在 forEach 里静默失效、一个未捕获的 rejection 让整个 Node 进程退出。这些问题的根源,往往不是“不会用 Promise”,而是没有真正理解 JavaScript 的单线程模型与事件循环。Promise 不是语法糖,它是一套关于“未来值”的状态机与调度协议;async/await 也不是异步的银弹,它只是把 Promise 的 then 链换了一种写法。本篇 40 分钟长文,从事件循环讲起,逐层拆解回调、Promise、async/await、并发控制、取消机制、手写实现与工程实践,并在最后给出一份可以直接贴在工位上的检查清单。
一、单线程的宿命:为什么 JavaScript 必须异步
要理解异步,先要理解一个前提:JavaScript 在浏览器里只有一个主线程负责执行 JS 代码。这个线程同时还要负责解析 HTML、计算样式、布局、绘制。它一次只能做一件事。
为什么不设计成多线程?历史原因是 1995 年 Brendan Eich 设计这门语言时,目标是在浏览器里做轻量的 DOM 操作。如果两个线程同时修改同一个 DOM 节点,就需要引入锁、原子操作、内存屏障这一整套复杂的并发控制,而这对于一个“给网页加点交互”的脚本语言来说完全不值得。单线程换来了简单,代价就是——任何一段耗时过长的同步代码,都会把整个页面冻住。
function block() {
const start = Date.now();
while (Date.now() - start < 3000) {}
}
block();
// 在这 3 秒里:点击无响应、滚动卡住、动画全部停摆
更麻烦的是,很多操作天生就“慢”:网络请求、文件读写、定时器、用户输入。它们的耗时无法预测,也不该由 JS 线程去等待。于是浏览器给出的方案是:把这些耗时的活儿交给浏览器内核的其他线程去做,JS 主线程只管发起请求和接收结果。而“发起”与“接收”之间如何衔接,就构成了整套异步机制。
事件循环:异步的心脏
事件循环(Event Loop)负责协调这一切。它的运行逻辑可以简化成一句话:主线程不断从任务队列里取出任务执行,执行完再取下一个。而任务队列又分成两种优先级:
微任务与宏任务的优先级
这是异步面试里出现频率最高的题目,也是实际开发中最容易踩坑的地方。记住一条规则就够了:
每执行完一个宏任务,先把微任务队列彻底清空(包括在执行微任务过程中新产生的微任务),然后才进入下一个宏任务。
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序:1 → 4 → 3 → 2
// 1、4 是同步代码,先执行完
// 3 在微任务队列,4 执行完后立刻清空
// 2 在宏任务队列,等微任务全部清空后才轮到它
再看一个稍微复杂的例子,检验一下是否真的理解了:
console.log('A');
Promise.resolve().then(() => console.log('B'));
}, 0);
setTimeout(() => console.log('C'), 0);
// 输出:A → B → C
// A 和 B 属于同一个宏任务周期:先执行 A,再把 B 加入微任务队列并清空
// 之后才轮到下一个宏任务 C
为什么微任务要有更高优先级
因为微任务通常承载的是“状态一致性”相关的工作。比如 Promise 链上的后续处理,必须保证在同一个宏任务周期内完成,否则可能出现“状态已更新但视图没同步”的中间态。而宏任务(比如渲染、定时器)天然允许被推迟。把微任务设计成高优先级,是为了让一次逻辑上的“原子操作”尽可能连续地完成。
但这也带来了一个副作用:如果微任务里不断产生新的微任务,队列永远清不空,页面就会卡死,而且这种卡死比同步阻塞更隐蔽,因为它发生在每一轮宏任务的缝隙里。后面的“性能与调试”一节会专门讲这个问题。
二、回调时代:能跑,但会痛
在 Promise 普及之前,异步的标准写法是回调函数:把“完成后要做什么”作为参数传进去。这个方案在简单场景下完全没有问题:
console.log('1 秒后执行');
}, 1000);
button.addEventListener('click', () => {
console.log('被点击了');
});
问题出现在多个异步操作需要按顺序串联的时候。假设我们要:根据用户 ID 拿用户信息 → 根据用户信息拿订单列表 → 根据第一个订单拿详情 → 渲染页面。用回调写出来是这样的:
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetail(orders[0].id, (err, detail) => {
if (err) return handleError(err);
getCoupon(detail.couponId, (err, coupon) => {
if (err) return handleError(err);
render(user, detail, coupon);
});
});
});
});
这就是著名的“回调地狱”(Callback Hell)。它的问题不只是缩进越来越深、越来越难看,而是有三个更本质的缺陷:
缺陷一:控制反转带来的信任问题
当你把回调函数交给第三方库或某个 API 时,你实际上把控制权交了出去。你不知道它会:
- 调用回调多少次——可能一次都不调,也可能调了三次,导致你的渲染逻辑执行多遍。
- 在什么时机调用——可能同步立即调用,也可能异步调用,两种情况下你的代码行为完全不同。
- 是否吞掉异常——回调里抛出的错误可能被库的 try/catch 吃掉,永远传不到你这里。
- 传给你什么参数——调用约定只存在于文档里,没有任何机制强制保证。
这就是所谓的“信任问题”。Promise 的设计初衷之一,正是把这部分控制权收回来:状态一旦落定就不可更改,回调最多执行一次,且永远在微任务中异步执行。
缺陷二:错误处理无法统一
回调风格通常遵循 (err, data) 约定,也就是所谓的 Node 风格回调。这意味着每一个回调里都要写一遍 if (err)。任何一个环节漏写,错误就会被静默吞掉。而且这层层的 if 让主流程被切割得七零八落,很难一眼看出“正常路径”是什么。
缺陷三:无法组合
如果想同时发起三个请求,等全部回来再处理,用回调需要自己维护一个计数器;如果想给整条链路加超时,需要额外包一层;如果想重试,需要把整个嵌套结构再包一层。回调本身是“一次性”的,它不是一个可以传递、组合、复用的值。
点击下面的按钮,对比同一段逻辑在回调与 Promise 下的写法差异:
if (err) return handleError(err);
getOrders(user.id, (err, orders) => {
if (err) return handleError(err);
getOrderDetail(orders[0].id, (err, detail) => {
if (err) return handleError(err);
render(user, detail);
});
});
});
三、Promise:一份关于未来值的契约
Promise 这个名字本身就是最好的解释:它是一个承诺——现在没有值,但未来某个时刻一定会给你一个结果,要么是成功的值,要么是失败的原因。这个“承诺”有三个不可动摇的特征。
特征一:三种状态,且只能单向流转
“状态不可逆”这一点极其重要。它意味着:即使你在 resolve 之后又调用了 reject,第二次调用会被完全忽略。这不是疏忽,而是刻意的设计——它保证了回调最多只会被执行一次,从根本上消灭了回调时代“被调用多次”的信任问题。
resolve('第一次');
reject('第二次');
resolve('第三次');
});
p.then(v => console.log(v));
// 输出:第一次
// 后面的 reject 和 resolve 全部被静默忽略
特征二:executor 是同步执行的
这是最反直觉的一点。很多初学者以为 new Promise(fn) 里的 fn 会异步执行,实际上它立即同步执行。Promise 只是提供了异步结果的容器,容器本身是当场就建好的。
const p = new Promise((resolve) => {
console.log('B'); // 同步执行!
resolve();
});
p.then(() => console.log('C')); // 微任务
console.log('D');
// 输出:A → B → D → C
所以,如果你在 executor 里写了很重的同步计算,它会和普通同步代码一样阻塞主线程。这不是 Promise 的问题,而是你把不该同步做的事放错了地方。
特征三:then 永远返回一个全新的 Promise
这是 Promise 能够链式调用的根本原因。每一次 .then() 都会创建一个新的 Promise,它的状态由回调函数的返回值决定:
- 回调返回一个普通值 → 新 Promise 以该值 fulfilled。
- 回调返回一个 Promise → 新 Promise 会“跟随”这个 Promise 的状态(称为解决过程)。
- 回调抛出异常 → 新 Promise 以该异常 rejected。
- 回调没有返回值 → 相当于返回
undefined,新 Promise 以undefinedfulfilled。
.then(v => v + 1) // 返回 2
.then(v => {
throw new Error('boom'); // 抛出 → 新 Promise 被拒绝
})
.catch(e => {
console.log(e.message); // 'boom'
return 0; // 把错误“修复”成 0,链恢复 fulfilled
})
.then(v => console.log('最终值:', v)); // 最终值:0
值穿透:为什么 then 里可以不写函数
如果你给 then 传的不是函数(比如 null、undefined、数字),它会被忽略,值会原封不动地传给下一个 then。这个特性叫“值穿透”:
.then(null)
.then(undefined)
.then(v => console.log(v)); // 'hello'
值穿透让“条件式地插入处理步骤”变得很自然,但在链式调用里也容易造成误解——看到一串 then 结果值没变,先检查中间那些回调是不是忘了写 return。
错误冒泡:一个 catch 兜住整条链
Promise 链上的错误会自动向后传递,直到遇到第一个 catch 或带两个参数的 then。这意味着你不需要在每一层都写错误处理:
.then(step2) // step2 抛错
.then(step3) // 被跳过
.then(step4) // 被跳过
.catch(err => {
// 在这里统一处理 step2 的错误
});
// 但要注意:catch 之后如果又抛出,链会再次变成 rejected
step1()
.catch(e => { throw e; }) // 这里抛出,后续依然需要 catch
.then(next); // next 不会执行
p.then(onSuccess, onError)——在 then 的第二个参数里处理错误,只能捕获它前面那一个 Promise 的错误,捕获不到 onSuccess 内部抛出的异常。
.catch() 收尾。它位于链的末尾,能捕获整条链上任何一环抛出的错误,语义也更清晰。
四、Promise 静态方法全解:并发组合的六种武器
Promise 构造函数上挂着几个静态方法,它们才是处理并发的主力。很多人只熟悉 Promise.all,但在实际业务里,另外几个方法的适用场景同样高频。
Promise.resolve 与 Promise.reject
这两个方法用于把任意值“包装”成 Promise。它们的价值在于统一接口:当你写一个可能返回 Promise、也可能返回普通值的函数时,调用方就不需要判断类型了。
Promise.resolve(p); // 如果 p 是 Promise,原样返回它
Promise.reject(new Error('x')); // 立即 rejected
// 常见用法:把同步函数包装成异步函数
function toAsync(fn) {
return (...args) => Promise.resolve().then(() => fn(...args));
}
Promise.all:全部成功才算成功
接收一个可迭代对象,返回一个新的 Promise:所有输入都 fulfilled 时,以结果数组 fulfilled;任意一个 rejected,立即以该原因 rejected。结果数组的顺序与输入顺序严格一致,与完成时间无关。
fetchUser(id),
fetchOrders(id),
fetchCoupons(id),
]);
// 三个请求并发发出,总耗时 ≈ 最慢的那个
// 但如果 fetchCoupons 失败,整个 all 立刻失败,前两个的结果拿不到
关键细节:Promise.all 在第一个失败时就返回了,但其他 Promise 并不会被取消——它们仍然在后台执行。如果你不处理它们的 rejection,还可能触发 unhandledrejection。这一点在“取消与超时”一节会展开。
Promise.allSettled:无论成败都要结果
这是 ES2020 引入的方法。它会等所有输入都落定(无论成功失败),然后返回一个对象数组,每项形如 { status, value } 或 { status, reason }。
fetchA(),
fetchB(),
fetchC(),
]);
results.forEach(r => {
if (r.status === 'fulfilled') {
console.log('成功', r.value);
} else {
console.log('失败', r.reason);
}
});
// 典型场景:批量上报埋点、批量上传文件、仪表盘多卡片独立加载 // 任何一项失败都不影响其他项的结果展示
Promise.race:谁先落定就用谁
“竞速”方法。返回第一个落定的 Promise 的结果,无论它是成功还是失败。
function withTimeout(promise, ms) {
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error('请求超时')), ms);
});
return Promise.race([promise, timeout]);
}
// 另一个用法:多个镜像源取最快的一个
const data = await Promise.race([
fetch('https://cdn-a.example.com/data.json'),
fetch('https://cdn-b.example.com/data.json'),
]);
Promise.any:第一个成功即可
ES2021 引入。any 与 race 的区别在于:race 谁先落定就返回谁,失败也算;any 会忽略失败,等到第一个成功。如果全部失败,则抛出一个 AggregateError,它的 errors 属性包含所有失败原因。
const fastest = await Promise.any([
fetch('https://api-a.com'),
fetch('https://api-b.com'),
fetch('https://api-c.com'),
]);
} catch (err) {
// err 是 AggregateError
console.log(err.errors); // 三个失败原因组成的数组
}
Promise.try:同步异常也能被捕获
这是较新的提案方法,解决的是一个很实际的痛点:Promise.resolve().then(fn) 的写法虽然能捕获 fn 内部的异常,但代码读起来有点绕。Promise.try(fn) 更直观——同步执行 fn,无论是同步抛错还是返回 rejected Promise,都会被统一捕获。
.then(data => render(data))
.catch(err => showError(err));
六个方法横向对比
| 方法 | 成功条件 | 失败条件 | 典型场景 |
|---|---|---|---|
Promise.all |
全部成功 | 任意一个失败即失败 | 首屏多个必需接口并发加载 |
Promise.allSettled |
永远成功 | 永不失败 | 批量上报、独立卡片加载 |
Promise.race |
第一个落定且成功 | 第一个落定且失败 | 超时控制、多源竞速 |
Promise.any |
第一个成功 | 全部失败 | 多镜像容错、备用接口 |
Promise.resolve |
立即成功 | — | 统一返回类型、包装同步值 |
Promise.reject |
— | 立即失败 | 统一错误出口、测试桩 |
Promise.try |
回调正常返回 | 回调抛错或返回 rejected | 包装可能同步抛错的函数 |
选择哪一个,本质上是回答一个问题:“我需要的是全部结果,还是第一个结果?我能接受部分失败吗?”——想清楚这两点,方法自然就定了。
五、async / await:让异步代码看起来像同步
ES2017 引入的 async/await 是 JavaScript 异步编程的分水岭。它没有引入任何新的运行时机制,本质上只是基于 Promise 的语法糖——但这一层糖,极大降低了异步代码的心智负担。
三个基本规则
async函数永远返回一个 Promise。函数里return的值会被自动包装成 fulfilled;抛出的异常会被自动包装成 rejected。await只能用在async函数内部(顶层 await 是模块才有的特性)。它会暂停当前函数的执行,把后续代码注册为微任务。await后面跟的不是 Promise 也没关系,会被自动包装成 Promise,同样会产生一次微任务延迟。
return 1;
}
// 等价于
function f2() {
return Promise.resolve(1);
}
console.log(f1() instanceof Promise); // true
await 到底做了什么
把 await 拆开来看,它等价于:
async function A() {
const v = await getData();
console.log(v);
return v;
}
function B() {
return getData().then(v => {
console.log(v);
return v;
});
}
理解这一点非常关键,因为它解释了很多“看起来奇怪”的行为。比如:await 之后的代码永远是异步的,哪怕 await 的是一个已经 resolved 的 Promise。它会插入到微任务队列中,而不是立即执行。
串行与并行:await 的位置决定性能
这是 async/await 使用中最容易造成性能损失的坑。看下面两段代码:
async function slow() {
const a = await fetchA();
const b = await fetchB();
const c = await fetchC();
return [a, b, c];
}
// 假设每个请求 1 秒,总耗时 3 秒
// 后一个请求要等前一个回来才发出
async function fast() {
const pa = fetchA(); // 立即发出
const pb = fetchB(); // 立即发出
const pc = fetchC(); // 立即发出
return await Promise.all([pa, pb, pc]);
}
// 总耗时约 1 秒
// 关键是先创建 Promise(发起请求),再统一 await
判断标准:这几个异步操作之间有没有数据依赖?没有依赖就应该并行。有依赖(比如 B 需要 A 返回的 ID)就只能串行,但也可以考虑先并行取回 A 的所有候选数据,再做本地筛选。
错误处理:try/catch 的边界
async/await 最大的便利之一是可以用同步的 try/catch 捕获异步错误。但要注意作用域:
try {
const user = await fetchUser();
const orders = await fetchOrders(user.id);
return { user, orders };
} catch (err) {
// 两个 await 的错误都会到这里
// 但无法区分是哪一个失败的
console.error(err);
throw err; // 继续往外抛,让调用方知道失败了
}
}
// 如果需要区分来源,缩小 try 的范围:
async function load2() {
let user;
try {
user = await fetchUser();
} catch (err) {
throw new Error('加载用户失败', { cause: err });
}
// ……
}
注意一个反直觉的点:try/catch 只能捕获 await 表达式的错误,捕获不到你没有 await 的 Promise。下面这种写法是错误的:
try { fetchData(); } catch (e) { }没有
await,fetchData 返回的 Promise 立刻返回,异常发生在未来,catch 根本等不到它。
try { await fetchData(); } catch (e) { }await 会把 Promise 的 rejected 状态“解包”成同步抛出,catch 才能接住。
返回值与 finally
不管 Promise 是成功还是失败,finally 里的代码都会执行,而且它不接收参数、不改变链上的值。它是清理工作的最佳位置:关闭 loading、释放锁、断开连接。
showLoading();
try {
return await api.save();
} finally {
hideLoading(); // 无论成功失败都会执行
}
}
// 注意:finally 里如果 return 或 throw,会覆盖之前的返回值
// 这是一个非常危险的写法,应该避免
async 函数的执行时机
一个常见的误解是认为 async 函数会“异步启动”。实际上,async 函数在调用时会同步执行到第一个 await 为止,之后的代码才会进入微任务队列。
console.log('1'); // 同步执行
await null;
console.log('2'); // 微任务
}
console.log('3');
f();
console.log('4');
// 输出:3 → 1 → 4 → 2
六、并发控制:不要让浏览器替你“抗压”
Promise.all 很方便,但它有一个致命的前提:它不会限制并发数量。如果你把 500 个请求一次性塞进 Promise.all,浏览器就会真的同时发出 500 个请求。
无限并发的三个后果
- 浏览器连接数限制:Chrome 对同一域名通常只允许 6 个并发连接,超出的请求会排队。你以为在并行,实际上后面的请求迟迟发不出去。
- 服务端压力:500 个并发请求打到后端,可能直接触发限流甚至把服务打挂。
- 内存与页面响应:大量请求的响应同时返回,会集中占用内存并产生连续的微任务,导致页面卡顿。
手写一个并发池
核心思路非常简单:维持固定数量的“工人”,每个工人循环从任务队列里取任务执行,直到队列为空。
const results = new Array(tasks.length);
let cursor = 0;
async function worker() {
while (cursor < tasks.length) {
const i = cursor++;
// 单个任务失败不影响其他任务,按需决定是否捕获
results[i] = await tasks[i]();
}
}
const workerCount = Math.min(limit, tasks.length);
await Promise.all(
Array.from({ length: workerCount }, () => worker())
);
return results;
}
// 使用:一次最多 3 个请求,500 个任务总耗时约为串行的 1/3
const data = await pool(
urls.map(url => () => fetch(url)),
3
);
为什么这里用 while 而不是 for
因为每个 worker 需要动态地从共享游标取任务。如果写成 for (let i = 0; i < limit; i++),每个 worker 就只会处理固定下标,无法在完成一个任务后继续领取下一个。用 cursor++ 这种“取号”的方式,天然实现了任务的分发。
需要说明的是,cursor++ 在这里是安全的,因为 JavaScript 是单线程的,两个 worker 不可能真正同时执行这一行。这也是为什么单线程在某些场景下反而简化了并发编程——你不需要考虑锁。
带失败隔离的并发池
上面的版本中,任意一个任务抛出异常都会让对应的 worker 提前退出,最终 Promise.all 失败,剩下的任务也不会被处理。生产环境里更常见的是“尽力而为”的版本:
const results = new Array(tasks.length);
let cursor = 0;
async function worker() {
while (cursor < tasks.length) {
const i = cursor++;
try {
results[i] = {
status: 'fulfilled',
value: await tasks[i](),
};
} catch (reason) {
results[i] = { status: 'rejected', reason };
}
}
}
await Promise.all(
Array.from({ length: Math.min(limit, tasks.length) }, () => worker())
);
return results;
}
这个版本的返回结构其实就相当于自己实现了一个带并发限制的 Promise.allSettled,在批量上传、批量导入这类场景里非常实用。
要不要引入第三方库
社区里有 p-limit、async、bluebird 等成熟方案。p-limit 的 API 极其简洁:
const limit = pLimit(3);
const results = await Promise.all(
urls.map(url => limit(() => fetch(url)))
);
它的体积很小,逻辑也很清晰。但理解手写版本的原理依然重要——只有知道池子是怎么跑起来的,你才能在出现“某个任务卡住导致整个池子停滞”这类问题时快速定位。
七、取消、超时与中断:Promise 最大的短板
Promise 有一个被广泛讨论的“缺陷”:它一旦创建就无法取消。这在早期是一个设计取舍——取消会引入复杂的语义问题(取消后状态是什么?取消的回调谁来调?)。直到 AbortController 出现,这个问题才有了一个统一的、跨 API 的解决方案。
AbortController:标准的取消原语
AbortController 由一个信号对象(signal)和一个触发方法(abort)组成。signal 可以被传递给 fetch、事件监听器,也可以被你自己封装的异步函数消费。
const { signal } = controller;
fetch('/api/data', { signal })
.then(res => res.json())
.then(data => render(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求已被取消');
} else {
showError(err);
}
});
// 3 秒后取消
setTimeout(() => controller.abort(), 3000);
用 AbortSignal 实现超时
现代浏览器提供了 AbortSignal.timeout() 静态方法,一行代码搞定超时:
fetch('/api/slow', {
signal: AbortSignal.timeout(5000),
});
// 组合多个信号:任意一个触发即中止
const combined = AbortSignal.any([
userController.signal,
AbortSignal.timeout(5000),
]);
如果需要兼容更老的环境,可以用 Promise.race 手动实现。但要记住前面提到的问题:race 只是让你不再等待慢的那个,它本身并不会中止请求。
const timer = new Promise((_, reject) => {
setTimeout(() => {
controller?.abort(); // 关键:真正中止请求
reject(new Error('TIMEOUT'));
}, ms);
});
return Promise.race([promise, timer]);
}
处理“竞态”问题
这是搜索框、标签页切换这类场景的经典问题:用户快速输入 “abc”,依次发出三个请求。如果第一个请求的响应最后才回来,界面会显示 “a” 的结果,而输入框里已经是 “abc” 了。
解决方案有三种,从简单到规范:
let seq = 0;
async function search(keyword) {
const id = ++seq;
const data = await fetch(`/api/search?q=${keyword}`);
if (id !== seq) return; // 已经有更新的请求了,丢弃本次结果
render(data);
}
// 方案二:AbortController,取消上一个请求
let current;
function search2(keyword) {
current?.abort();
current = new AbortController();
return fetch(`/api/search?q=${keyword}`, { signal: current.signal })
.then(res => res.json())
.then(render)
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
}
方案二更好,因为它不仅避免了错误的结果渲染,还真正节省了网络带宽与服务器资源。方案一只是丢弃结果,请求依然完整跑完了。
未处理的 rejection:一个隐藏的定时炸弹
当你用 Promise.race 实现超时,或者用 Promise.all 处理并发时,那些“落败”的 Promise 如果最终 rejected,就变成了无人处理的 rejection。在浏览器里会打印一条警告,在 Node.js 中默认会导致进程退出。
window.addEventListener('unhandledrejection', event => {
console.error('未处理的 rejection:', event.reason);
reportToServer(event.reason);
event.preventDefault(); // 阻止控制台默认报错
});
// Node.js:全局兜底
process.on('unhandledRejection', (reason) => {
console.error(reason);
});
// 更推荐:给可能落单的 Promise 主动加上 catch
const p = fetchData();
p.catch(() => {}); // 标记为已处理
return Promise.race([p, timeout]);
八、手写 Promise:理解状态机的最好方式
面试里高频出现的“手写 Promise”,目的从来不是让你真的去实现一个生产级库,而是检验你是否理解状态流转、回调队列、异步调度、链式传递这四个核心机制。下面按最小可用的标准来实现一遍。
第一步:状态与构造
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';
class MyPromise {
constructor(executor) {
this.state = PENDING;
this.value = undefined;
this.callbacks = []; // 待执行的回调队列
const resolve = (value) => this.settle(FULFILLED, value);
const reject = (reason) => this.settle(REJECTED, reason);
try {
executor(resolve, reject);
} catch (err) {
reject(err);
}
}
settle(state, value) {
if (this.state !== PENDING) return; // 状态不可逆
this.state = state;
this.value = value;
this.flush();
}
flush() {
this.callbacks.forEach(cb => queueMicrotask(cb));
this.callbacks = [];
}
}
这里已经体现了两个关键点:settle 里判断状态,保证只落定一次;回调通过 queueMicrotask 调度,保证异步执行。后者是最容易被忽略的细节——如果直接同步调用回调,整个 Promise 的时序语义就崩了。
第二步:then 与链式传递
// …… 接上面的构造部分
then(onFulfilled, onRejected) {
// then 必须返回一个新的 Promise,才能链式
return new MyPromise((resolve, reject) => {
const handle = () => {
const cb = this.state === FULFILLED ? onFulfilled : onRejected;
// 值穿透:不是函数就跳过
if (typeof cb !== 'function') {
this.state === FULFILLED
? resolve(this.value)
: reject(this.value);
return;
}
try {
const result = cb(this.value);
// 如果返回的是 Promise,需要“跟随”它的状态
if (result instanceof MyPromise) {
result.then(resolve, reject);
} else {
resolve(result);
}
} catch (err) {
reject(err); // 回调抛错 → 新 Promise 被拒绝
}
};
if (this.state === PENDING) {
this.callbacks.push(handle); // 还没落定,先存起来
} else {
queueMicrotask(handle); // 已落定,直接进微任务
}
});
}
catch(onRejected) {
return this.then(null, onRejected);
}
}
这段代码里有四个容易出错的细节:
- 回调必须异步执行,用
queueMicrotask而不是同步调用。 - 返回值是 Promise 时要跟随,否则链式调用会拿到一个嵌套的 Promise。
- 回调抛错要转成 reject,而不是让异常逃逸到全局。
- pending 状态要存回调,不能直接执行。
第三步:静态方法
if (value instanceof MyPromise) return value;
return new MyPromise(resolve => resolve(value));
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason));
}
static all(iterable) {
const list = [...iterable];
return new MyPromise((resolve, reject) => {
const results = [];
let count = 0;
if (list.length === 0) return resolve([]);
list.forEach((item, i) => {
MyPromise.resolve(item).then(
value => {
results[i] = value;
if (++count === list.length) resolve(results);
},
reject // 任意一个失败立即整体失败
);
});
});
}
static race(iterable) {
return new MyPromise((resolve, reject) => {
for (const item of iterable) {
MyPromise.resolve(item).then(resolve, reject);
}
});
}
注意 Promise.all 里 results[i] = value 的写法:用索引赋值而不是 push,因为 Promise 完成顺序是不确定的,用 push 会导致结果数组的顺序和输入顺序不一致。
真实 Promise 还有哪些额外能力
上面这个实现覆盖了核心机制,但真实规范还包含不少细节:
- thenable 处理:任何带有
then方法的对象都能被“同化”,并且要处理递归解析(resolve一个自身会死循环)。 - 多次调用 resolve/reject:只认第一次,后续忽略(我们已实现)。
- 回调必须异步且只执行一次。
- 错误传播:
then内部的异常必须传给下一个 Promise,而不是抛到全局。 - 性能优化:V8 对 Promise 有大量内联缓存与快速路径优化,手写版在性能上完全无法相比。
所以结论是:手写 Promise 用来理解原理非常有价值,但生产代码永远用原生实现。
九、九个高频陷阱:每一个都真实咬过人
forEach 里的 async 回调——await 完全失效
await,把并行写成了串行
then 回调里忘了 return,后续拿到 undefined
Promise 构造函数里做异步初始化,外部拿不到状态
try/catch 包没有 await 的调用
async 函数当成构造函数用
陷阱 1:forEach + async
这是新手最容易踩的坑,没有之一。
async function bad() {
[1, 2, 3].forEach(async (id) => {
const data = await fetchById(id);
console.log(data);
});
console.log('全部完成'); // 会最先打印!
}
// 正确:for...of 会按顺序 await
async function good1() {
for (const id of [1, 2, 3]) {
const data = await fetchById(id);
console.log(data);
}
console.log('全部完成');
}
// 正确:并行执行,全部完成后继续
async function good2() {
await Promise.all([1, 2, 3].map(id => fetchById(id)));
console.log('全部完成');
}
原因很简单:forEach 的签名是 forEach(callback),它不关心回调的返回值,自然也不会去 await 一个 Promise。同理,map 虽然能返回 Promise 数组,但如果你忘了 await Promise.all,依然不会等待。
陷阱 2:循环里的串行 await
即使你用了 for...of 让 await 生效了,也可能付出了性能代价。如果几次请求之间没有依赖关系,串行执行会让总耗时线性叠加:
for (const id of ids) {
results.push(await fetchById(id));
}
// 快:先全部发出,再统一等待
const results = await Promise.all(ids.map(id => fetchById(id)));
// 折中:需要限流时用并发池
const results = await pool(ids.map(id => () => fetchById(id)), 5);
陷阱 3:忘了 return
这是最隐蔽的一类 bug,因为代码不会报错,只是结果不对。
getUser()
.then(user => {
getOrders(user.id); // 忘了 return!
})
.then(orders => console.log(orders)); // undefined
// 正确
getUser()
.then(user => getOrders(user.id))
.then(orders => console.log(orders));
陷阱 4:在构造函数里做异步初始化
构造函数不能是 async,因为构造函数必须返回 this。如果你在里面发请求,外部无法知道初始化何时完成。
class Api {
constructor() {
fetch('/config').then(r => r.json()).then(c => {
this.config = c; // 何时完成?不知道
});
}
}
// 正确:用静态异步工厂方法
class Api {
static async create() {
const instance = new Api();
instance.config = await fetch('/config').then(r => r.json());
return instance;
}
}
const api = await Api.create();
陷阱 5、6、7、8、9 简析
- try/catch 包不住没有 await 的调用:前面已经展开,核心是“异常发生在未来,catch 在当下”。
- async 函数不能当构造函数:
new (async function(){})()会抛TypeError,因为 async 函数的返回值永远是 Promise,无法作为实例返回。 - 忘记处理 rejection:浏览器控制台的红色警告不是装饰,它意味着你的用户可能看到的是一个永远 loading 的界面。
- 微任务饥饿:下面这个写法会让页面彻底无响应,因为微任务队列永远清不空:
function starve() {
Promise.resolve().then(starve);
}
starve(); // 页面卡死,连渲染都轮不到 - 把 Promise 当缓存:Promise 只能解决一次,如果多个地方调用同一个函数,会重复发起请求。需要缓存的是“请求结果”或“正在进行的 Promise 实例”:
const cache = new Map();
function fetchOnce(key, fn) {
if (cache.has(key)) return cache.get(key);
const p = fn().finally(() => cache.delete(key));
cache.set(key, p);
return p;
}
十、调试与性能:让异步问题无所遁形
异步调用栈
异步代码最难调试的地方在于,出错时的调用栈是“断”的——你只看到 Promise 链末尾那一段,看不到最初的调用者。现代 DevTools 已经支持异步调用栈追踪:
- Chrome DevTools 的 Sources 面板默认开启 Async 调用栈,会在栈帧中展开
async边界,让你从错误点一路回溯到最初的触发位置。 - 可以在代码里加
console.trace(),在关键节点打印调用路径。 - 给 Promise 链的每一环加上有意义的命名,可以显著提升断点调试的效率。
const loadUser = fetch('/user');
loadUser.catch(e => { throw new Error('loadUser failed', { cause: e }); });
// Error 的 cause 选项能保留原始错误链
try {
await loadUser;
} catch (err) {
console.log(err.message); // loadUser failed
console.log(err.cause); // 原始的 fetch 错误
}
长任务与页面响应
浏览器需要每 16.7ms 渲染一帧(60fps)。如果主线程被一个耗时 200ms 的同步任务占住,这一帧就丢了,用户就会感觉到卡顿。这个超过 50ms 的任务在性能面板里被标记为“长任务”。
requestIdleCallback 或 setTimeout 分帧处理。
async function processInChunks(items, chunkSize = 200) {
for (let i = 0; i < items.length; i += chunkSize) {
const chunk = items.slice(i, i + chunkSize);
chunk.forEach(renderItem);
// 让出主线程,给浏览器一次渲染机会
await new Promise(r => setTimeout(r, 0));
}
}
微任务饥饿的识别与规避
如果页面完全卡死,但 Performance 面板里看不到任何长任务,而是在不断刷新的微任务记录,那就是微任务饥饿。常见诱因是:
- 在
then里递归调用返回 Promise 的函数,没有让出事件循环。 - 使用
queueMicrotask递归。 - 某些响应式框架的更新循环陷入死循环(A 更新触发 B 更新,B 又触发 A)。
规避方式就是在每一轮之间强制插入一个宏任务,把控制权还给浏览器:await new Promise(r => setTimeout(r, 0)),或者使用 scheduler.yield()(如果环境支持)。
网络面板看并发
打开 DevTools 的 Network 面板,切换到瀑布图视图,你可以直观地看到:
- 哪些请求是并行发出的(同一时间点开始)。
- 哪些是串行的(前一个完成才发下一个,呈阶梯状)。
- 是否存在长时间的
Stalled(排队等待连接),这通常意味着并发数超了。 - 请求的
Waterfall是否有明显的Waiting (TTFB)过长。
很多性能问题不需要看代码,只需要看这张瀑布图就能定位:如果首屏的五个接口是阶梯状排列的,那一定是漏了并行优化。
十一、工程实战:四个可以直接抄走的工具函数
前面讲了原理,这一节给出四个在真实项目里反复验证过的实现。它们都很短,但覆盖了绝大多数异步基础设施需求。
1. 带指数退避的重试
网络抖动是常态,一次失败不代表真的失败。重试时必须加退避,否则会把已经过载的服务打得更惨。
let lastError;
for (let attempt = 0; attempt < times; attempt++) {
try {
return await fn(attempt);
} catch (err) {
lastError = err;
// 最后一次失败不再等待
if (attempt === times - 1) break;
// 指数退避 + 随机抖动,避免同时重试造成雪崩
const delay = Math.min(
baseDelay * 2 ** attempt + Math.random() * 100,
maxDelay
);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
// 使用:只对可重试的错误重试
const data = await retry(async () => {
const res = await fetch('/api/data');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});
需要注意:不是所有错误都值得重试。4xx 类的错误(参数错误、权限不足)重试多少次都是一样的结果,只有网络错误、超时、5xx 才适合重试。可以在 fn 内部判断并抛出一个标记为“不可重试”的错误类型。
2. 并发去重(请求合并)
同一个接口在短时间内被多处调用,是 SPA 里的常见现象。用一张 Map 缓存“正在进行的 Promise”,就能把多次请求合并成一次:
function dedupe(key, fn) {
if (inflight.has(key)) {
return inflight.get(key); // 复用同一个 Promise
}
const promise = fn().finally(() => {
inflight.delete(key); // 完成后清理,允许下次重新请求
});
inflight.set(key, promise);
return promise;
}
// 使用
const user = await dedupe('user:123', () => fetchUser(123));
// 同一时刻再次调用 dedupe('user:123', ...) 会拿到同一个 Promise
finally 在这里非常关键:无论成功还是失败都要清理缓存,否则一次失败的请求会让这个 key 永久失效。
3. 带超时与取消的请求封装
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(new Error('TIMEOUT')), timeout);
// 外部取消信号也要能传导进来
const onAbort = () => controller.abort(signal?.reason);
signal?.addEventListener('abort', onAbort, { once: true });
try {
const res = await fetch(url, { ...options, signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
这个封装里有一个容易被忽略的细节:finally 中的 removeEventListener。如果只 addEventListener 而不移除,长时间运行的应用会不断堆积监听器,形成内存泄漏。
4. 串行队列
有些操作必须严格顺序执行:写文件、更新同一条数据库记录、操作同一个 DOM 节点。把 Promise 串成一条链,就能实现一个简单的队列:
let tail = Promise.resolve();
return function enqueue(task) {
// 无论前一个成功还是失败,都继续执行下一个
const result = tail.then(task, task);
// tail 吞掉错误,避免一个失败导致整条链断掉
tail = result.catch(() => {});
return result;
};
}
const enqueue = createQueue();
enqueue(() => writeFile('a.txt', '1'));
enqueue(() => writeFile('a.txt', '2'));
enqueue(() => writeFile('a.txt', '3'));
// 严格按 1 → 2 → 3 的顺序写入
tail.then(task, task) 这个写法很有意思:成功和失败都用同一个函数处理,保证队列不会因为一个任务失败而永久卡住。而返回的 result 保留了真实的成功/失败状态,调用方仍然可以 catch。
十二、异步编程检查清单
把全文的结论浓缩成一份可以贴在工位上的清单。每次写完异步代码,扫一眼这 20 条,能挡掉绝大多数问题。
- 知道同步代码会阻塞一切,耗时计算不放主线程。
- 分得清微任务与宏任务,能预测输出顺序。
- Promise 状态不可逆,resolve 之后再调用任何方法都无效。
- executor 是同步执行的,不要在里面写耗时逻辑。
- then 永远返回新 Promise,链式调用的基础。
- 每个 then 回调都要 return,否则下一个环节拿到 undefined。
- 用 catch 收尾,而不是 then 的第二个参数。
- 无依赖的异步操作并行执行,用 Promise.all 而不是逐个 await。
- 需要全部结果就用 allSettled,不要用 all 硬扛。
- 需要超时就用 AbortSignal.timeout,而不是只 race 一个定时器。
- 不要用 forEach 跑 async,用 for...of 或 Promise.all(map)。
- 循环内 await 前先问一句:“这几步有依赖吗?”
- 批量请求必须限流,用并发池控制在 3~6 个。
- 给可能落单的 Promise 加 catch,避免 unhandledrejection。
- 全局注册 unhandledrejection 监听,把错误上报到监控系统。
- 处理竞态:用请求序号或 AbortController 丢弃过期结果。
- 别用 async 函数当构造函数,用静态工厂方法替代。
- finally 里不要 return 或 throw,它会覆盖之前的返回值。
- 避免微任务饥饿,递归时记得插入宏任务让出主线程。
- 大数组处理要分片,每一片之间让出渲染机会。
// 1. 统一请求:超时 + 取消 + 错误规范化
async function request(url, options) { /* ... */ }
// 2. 重试:指数退避 + 抖动
async function retry(fn, opts) { /* ... */ }
// 3. 并发池:控制同时在飞的任务数
async function pool(tasks, limit) { /* ... */ }
// 4. 去重:合并同一时刻的相同请求
function dedupe(key, fn) { /* ... */ }
// 5. 串行队列:保证顺序执行
function createQueue() { /* ... */ }
// 6. 分片处理:避免长任务阻塞渲染
async function processInChunks(items, size) { /* ... */ }
异步编程的本质,是在单线程的世界里编排多个未来的时间点。Promise 提供了状态与组合的抽象,async/await 提供了阅读的便利,而事件循环决定了这一切什么时候真正发生。理解了这三层,你写出的代码就不再是“照着示例改一改”,而是清楚地知道每一行会在什么时候、以什么顺序执行。
最后送给所有正在和异步搏斗的开发者一句话:当一段异步代码行为诡异时,先别急着加 console.log,先把它的执行顺序在纸上画一遍。微任务在哪里、宏任务在哪里、哪些 Promise 还没落定——画出来,答案往往就自己浮现了。