2. 写在前面:为什么每个 JS 开发者都该吃透这两个概念
如果你写 JavaScript 写过一段时间,一定遇到过这种画面:明明代码是从上往下写的,执行顺序却完全不按套路出牌;明明 setTimeout 写在前面,后面的 Promise 却先跑完了;明明 await 已经等了一个异步任务,页面还是卡了一下才更新。这些现象背后,全是事件循环和 Promise 在起作用。
很多初学者把 Promise 当成“消除回调地狱的工具”,把事件循环当成“面试八股文”,这样理解其实很可惜。事件循环是 JavaScript 运行时的底层调度机制,Promise 是构建在它之上的异步控制流工具,两者一个是地基,一个是楼层——不理解地基,楼层盖得再漂亮也心里没底。
这篇内容适合三类人:刚学完 JS 基础、准备进阶的初学者;写过一段时间业务代码、想搞清楚异步背后原理的开发者;以及准备面试、想把事件循环和 Promise 讲明白的求职者。我会从底层模型讲起,逐步拆解到实际开发中的应用场景和坑点,你会知道为什么微任务优先于宏任务、为什么 Promise.resolve 包裹的代码总会比 setTimeout 先执行、为什么 await 后面不跟 Promise 也会产生等待效果。
全程没有晦涩的术语堆砌,遇到概念我会用生活化类比来解释,配合可运行的代码示例。看完之后,你至少能独立分析一段复杂异步代码的执行顺序,并且能在真实项目里正确使用 Promise 避免踩坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 事件循环底层模型:JavaScript 为什么需要“排队机制”
1.1 单线程的宿命与浏览器里的“老板秘书”
JavaScript 是单线程语言,这意味着它只有一个调用栈(Call Stack),同一时间只能执行一段代码。这个设计最初是为了简化 DOM 操作——如果多个线程同时修改同一个节点,浏览器根本不知道听谁的。但单线程带来一个问题:如果某段代码执行时间太长,后面的代码就一直等着,页面就会卡死。
为了解决这个问题,JavaScript 引入了异步机制。但异步任务完成后,怎么回到主线程继续执行?这就需要一个调度者,这个调度者就是事件循环(Event Loop)。
我用一个特别贴切的类比来解释:把 JS 主线程想象成一个老板,他一次只能处理一份文件。当老板遇到需要别人帮忙的事情(比如让前台查个资料、让财务做个报表),他不会站在原地干等,而是把这件事记下来,继续处理手头的下一份文件。等别人办完了,把结果交回来,老板再找时间处理。
这里的“记下来”就是任务队列,“找时间处理”就是事件循环在合适时机把回调捞回主线程执行。而浏览器就是那个前台——它不只有 JS 一个部门,还有渲染引擎、网络线程、定时器线程等,JS 通过 Web API 把任务交给这些后台线程,然后继续自己的事。
1.2 宏任务与微任务:两个不同优先级的“待办清单”
如果所有异步回调都塞进同一个队列,问题就来了:用户点击事件的回调、网络请求的回调、定时器的回调、DOM 渲染前的回调,这些任务的重要程度和紧急程度完全不同。所以事件循环设计了两个层级的队列:
- 宏任务队列(MacroTask Queue):包括
setTimeout、setInterval、I/O 操作、UI 交互事件(如 click、scroll)、requestAnimationFrame等。 - 微任务队列(MicroTask Queue):包括
Promise.then/catch/finally、queueMicrotask、MutationObserver等。
事件循环的规则是:每执行完一个宏任务,会立刻清空整个微任务队列;微任务队列清空后,如果页面需要渲染,就执行一次渲染;然后从宏任务队列里取下一个宏任务。注意关键词——每执行完“一个”宏任务,就要把微任务队列“整个”清空。
这意味着微任务的优先级显著高于宏任务。只要执行栈空了,事件循环会优先把微任务队列里的回调全部处理完,再去宏任务队列里取下一个任务。用前面老板的类比来说:老板做完了手头的一个大任务,会先把所有紧急的备忘录事项(微任务)一次性处理完,再去处理普通待办(宏任务)。
这里有一个非常磨人的细节:Promise 本身在创建时,构造函数里的代码是同步执行的,只有 then / catch / finally 里的回调才是微任务。setTimeout 的延迟时间只是“最快执行时间”,不是“保证执行时间”——如果微任务队列一直有任务,宏任务只能干等着。
javascript复制console.log('1 - 同步代码');
setTimeout(() => {
console.log('2 - 宏任务');
}, 0);
Promise.resolve()
.then(() => {
console.log('3 - 微任务');
});
console.log('4 - 同步代码');
// 输出顺序:1 -> 4 -> 3 -> 2
我见过很多同学第一次跑这段代码都很惊讶:明明 setTimeout 写在 Promise.resolve().then() 前面,为什么反而是微任务先输出?就是因为事件循环在“一个宏任务(这里宏任务指整个脚本)执行完”后,会先清空微任务队列,再去处理 setTimeout 对应的宏任务。这个顺序在浏览器和 Node.js 里基本一致(Node 的版本差异先不提,后面会说)。
2. Promise 深度解析:状态机与异步控制流
2.1 从回调地狱到 Promise:解决的不只是缩进问题
在 Promise 出现之前,JavaScript 处理异步主要靠回调函数。回调本身没问题,问题出在业务复杂之后——一个请求依赖另一个请求的结果,另一个请求又依赖上一个请求的参数,代码就变成了经典的“回调地狱”:
javascript复制requestA(function (resultA) {
requestB(resultA, function (resultB) {
requestC(resultB, function (resultC) {
requestD(resultC, function (resultD) {
// 继续嵌套...
});
});
});
});
这段代码最难受的地方还不是缩进深,而是逻辑被物理地“切碎”了。你不能像写同步代码那样用 try/catch 统一捕获错误,每个回调里都得单独判断错误;你也不能轻易地在多个异步任务之间做并行、竞争、重试等组合操作。
Promise 的价值在于:把“结果”和“等待结果的方式”拆开了。Promise 是一个状态机,代表一个尚未完成、但预期会完成的操作。它有三个状态:
- pending(待定):初始状态,既没有完成也没有失败。
- fulfilled(已兑现):操作成功完成,有结果值。
- rejected(已拒绝):操作失败,有失败原因。
状态只能从 pending 变为 fulfilled 或 rejected,且一旦改变就不可逆。这个设计非常像现实中的“下单后不可取消的订单”——你可以等着它被配送(fulfilled),或者收到取消通知(rejected),但订单状态不可能从“已完成”变回“配送中”。
2.2 手写一个迷你 Promise:理解 then 的核心机制
很多教程直接丢给你官方规范,让人反而看不进去。其实 Promise 的核心机制,用一小段简化代码就能讲清楚。我们忽略掉各种边界情况,只看主干逻辑:
javascript复制class MyPromise {
constructor(executor) {
this.state = 'pending';
this.value = undefined;
this.reason = undefined;
this.onFulfilledCallbacks = [];
this.onRejectedCallbacks = [];
const resolve = (value) => {
if (this.state === 'pending') {
this.state = 'fulfilled';
this.value = value;
// 微任务调度,确保顺序
queueMicrotask(() => {
this.onFulfilledCallbacks.forEach((cb) => cb(value));
});
}
};
const reject = (reason) => {
if (this.state === 'pending') {
this.state = 'rejected';
this.reason = reason;
queueMicrotask(() => {
this.onRejectedCallbacks.forEach((cb) => cb(reason));
});
}
};
try {
executor(resolve, reject);
} catch (error) {
reject(error);
}
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
if (this.state === 'fulfilled') {
queueMicrotask(() => {
try {
const result = onFulfilled(this.value);
resolve(result);
} catch (error) {
reject(error);
}
});
}
if (this.state === 'rejected') {
queueMicrotask(() => {
try {
const result = onRejected(this.reason);
resolve(result);
} catch (error) {
reject(error);
}
});
}
// pending 状态时,先把回调存起来
});
}
}
这段代码虽然简陋,但它体现了 Promise 几个核心机制:
第一,状态不可逆。resolve 和 reject 只有在 pending 状态时才能生效,之后再次调用会被直接忽略。这就是为什么 Promise 里写了两个 resolve 只有第一个有效。
第二,then 会返回一个新 Promise。这个设计是 Promise 能链式调用的关键。你看到的 p.then(...).then(...) 实际上每次都产生了一个新的 Promise,新 Promise 的状态由上一个 then 里回调函数的返回值决定。
第三,回调在微任务中执行。即使 Promise 已经变成 fulfilled 状态,then 注册的回调也不会同步执行,而是被放进微任务队列。这就是为什么 Promise.resolve(1).then(console.log) 永远不可能在同步代码之前输出。
很多面试里会问到“Promise 是同步还是异步”,准确答案是:Promise 的构造函数是同步执行的,then 的回调是异步执行的。理解了迷你实现,这个问题的答案就一目了然了。
2.3 链式调用与错误传递:为什么 catch 能抓住所有错误
Promise 的链式调用意味着你可以在一个 then 里处理数据,在下一个 then 里继续处理,而错误可以一路“向后传递”。错误传递的规则很简单:链上的任何一个 Promise 变成 rejected,后续的 then 的第二个参数(如果传了)或 catch 会被调用。
javascript复制fetchUser()
.then((user) => {
// 这里抛出的异常,会被链尾的 catch 捕获
throw new Error('用户数据异常');
})
.then((result) => {
// 这个 then 不会执行
console.log('不会走到这里');
})
.catch((error) => {
console.log('捕获到错误:', error.message);
});
这里有个容易忽略的点:catch 也是一个 then 的语法糖,它捕获的是它之前所有 then 链路上发生的错误,包括 Promise 本身被拒绝、回调函数抛出的异常、以及回调返回的 Promise 变成 rejected。如果 catch 回调本身没有抛出错误,它返回的新 Promise 会变成 fulfilled,后续的 then 可以继续执行。这就是“错误恢复”能力。
实际开发中,我强烈建议:在每个 Promise 链的末尾加上 catch,否则拒绝的 Promise 可能变成“未处理的 Promise 拒绝”(Unhandled Promise Rejection)。在浏览器控制台里,你会看到类似 Uncaught (in promise) Error: ... 的红色警告。这个错误在开发环境可能只是警告,但在部分环境下会导致进程异常退出(Node.js 里这种行为更严格)。
顺便提一句,很多同学分不清 .then(onFulfilled, onRejected) 和 .then(onFulfilled).catch(onRejected) 的区别。两者在错误捕获方面有一个细微差异:如果 onFulfilled 内部抛出了错误,第一种方式中传入的 onRejected 是不会捕获到该错误的(因为它在同一个 then 的第二个参数位置上,只能捕获前一个 Promise 的错误);而第二种方式的 catch 可以捕获。所以开发中推荐统一使用 .then().catch() 的模式,语义更清晰。
3. 微任务队列的优先级:await 到底在等什么
3.1 await 的语法糖本质:从生成器到状态机
ES2017 引入的 async/await 是 Promise 的语法糖,它不是为了替代 Promise,而是为了让你能以同步的方式编写异步代码。理解 await 之前,先看下面这段代码:
javascript复制async function test() {
console.log('1');
await Promise.resolve();
console.log('2');
}
如果不知道 await 背后的机制,很多人会以为执行顺序是:输出 1,等 Promise resolve,输出 2。从结果看确实如此,但关键问题是——从输出 1 到输出 2 之间,发生了什么?
事实上,await 的本质是:暂停当前 async 函数的执行,把后面的代码放到微任务队列里,等待表达式中的 Promise 完成后再继续执行。如果 await 后面的表达式不是 Promise,JavaScript 会隐式地用 Promise.resolve() 包装它。
javascript复制async function test() {
console.log('1');
await 42;
console.log('2');
}
// 等价于
function test() {
return new Promise((resolve) => {
console.log('1');
Promise.resolve(42).then(() => {
console.log('2');
resolve();
});
});
}
这种“暂停-恢复”的能力,底层其实靠的是事件循环的微任务队列。await 把函数“挂起”后,调用栈立刻空出来,主线程可以继续执行其他任务。等微任务队列被调度到时,函数才从暂停点恢复执行。这也是为什么 async 函数内部的同步代码依然同步执行、await 之后的代码才是异步执行的原因。
3.2 一个综合示例的完整推导:输出顺序题
事件循环和 Promise 的考点常常是一个复杂的输出顺序题。我们来看一个经典例子:
javascript复制async function async1() {
console.log('1');
await async2();
console.log('2');
}
async function async2() {
console.log('3');
}
console.log('4');
setTimeout(() => {
console.log('5');
}, 0);
async1();
new Promise((resolve) => {
console.log('6');
resolve();
}).then(() => {
console.log('7');
});
console.log('8');
很多同学第一次做这道题完全懵。我们按事件循环逐步推导:
第一轮宏任务(整个脚本):
- 同步执行
console.log('4'),输出 4。 - 注册
setTimeout回调,0ms 后进入宏任务队列。 - 调用
async1(),同步执行console.log('1'),输出 1。 - 执行到
await async2()。先执行async2(),它内部同步输出 3。async2返回一个已 fulfilled 的 Promise。await将后续代码console.log('2')包装成微任务,挂起async1。 - 继续同步执行
new Promise构造函数,输出 6。resolve()后,.then注册了微任务。 - 同步执行
console.log('8'),输出 8。
本轮同步代码执行完毕,调用栈清空。事件循环开始清空微任务队列:
- 微任务队列中有两个任务:首先是
async1的后续(输出 2),然后是.then的回调(输出 7)。 - 先执行输出 2,再执行输出 7。
微任务队列清空后,渲染阶段可以插入。然后事件循环从宏任务队列取出 setTimeout 回调,执行输出 5。
最终输出顺序是:4 → 1 → 3 → 6 → 8 → 2 → 7 → 5。
这里最让人困惑的是第 4 步到第 6 步之间:为什么 async2() 里的输出 3 排在 6 前面?因为调用 async2() 是一次普通的函数调用,它内部的同步代码是立即执行的。而 await 真正挂起的只是 async1 函数剩余的部分。理解了这一点,这类题目就再也不会做错了。
3.3 async return 与 return await:看起来相似,机制不同
在 async 函数里,return value 会被包装成 Promise.resolve(value),所以 async 函数的返回值天然是一个 Promise。而 return await promise 的意思则是:先等待这个 Promise 完成,再把结果返回。
两者在大多数场景下表现相同,但在 try/catch 中有显著差异:
javascript复制async function test() {
try {
return await Promise.reject(new Error('出错了'));
} catch (error) {
console.log('捕获到了:', error.message);
}
}
async function test2() {
try {
return Promise.reject(new Error('出错了'));
} catch (error) {
console.log('捕获到了:', error.message);
}
}
test 会输出“捕获到了”,而 test2 不会——因为 return Promise.reject(...) 只是把这个拒绝的 Promise 作为返回值返回,不会在 try/catch 的同步上下文中被捕获;错误会在外层调用者的 await 中抛出。这就是我在实际开发里踩过的一个坑:在 async 函数里处理错误时,如果要对一个 Promise 做 try/catch,最好确保用 await 等待它,否则 catch 形同虚设。
当然,如果你刻意想把错误抛给调用方处理,那么 return Promise.reject(...) 是完全正确的写法。这里的关键是:想清楚错误在哪一层被处理。
4. 真实开发中的 Promise 应用:并发控制与模式实践
4.1 典型业务场景:自动播放策略、资源加载、接口请求
在实际业务中,Promise 最常见的应用场景有三个。第一个是浏览器自动播放策略。在 Chrome 等浏览器中,不允许页面加载后直接调用 video.play() 或 audio.play(),必须由用户手势触发。而 play() 方法本身返回一个 Promise:
javascript复制player.play()
.then(() => {
console.log('播放成功');
})
.catch((error) => {
// error.name 通常是 NotAllowedError
console.log('播放被浏览器拦截:', error.name);
// 降级方案:显示自定义播放按钮,提示用户点击
});
如果你没有处理这个 Promise 的拒绝,控制台会报出那种常见的 Uncaught (in promise) NotAllowedError: play() failed because the user didn't interact with the document first. 的红色错误。很多开发者看到这个报错很慌,其实只是播放被浏览器策略拦截了,捕获并给出合理提示即可。
第二个场景是资源加载。图片 onload / onerror 事件可以包装成 Promise,方便在多张图片加载完成后统一执行逻辑:
javascript复制function loadImage(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`图片加载失败: ${src}`));
img.src = src;
});
}
Promise.all([loadImage('a.jpg'), loadImage('b.jpg')])
.then(([imgA, imgB]) => {
console.log('全部加载完成', imgA.width, imgB.width);
})
.catch((error) => {
console.error('至少一张图片加载失败', error.message);
});
第三个场景是接口请求。现在主流的 fetch API 本身返回 Promise,配合 async/await 可以写出非常接近同步风格的请求逻辑:
javascript复制async function loadUserInfo(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`HTTP 错误:${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
// 统一上报错误日志,并给用户友好的提示
console.error('用户信息加载失败', error);
return null;
}
}
4.2 手写并发限制器:Promise.all 与顺序控制的取舍
面试和实战中都很容易遇到一个需求:有一堆异步任务(比如上传多个文件、请求多个接口),但不想同时发起太多请求,以免后端压力过大或者浏览器连接数超限。这时可以用一个简单的“并发限制器”。
核心思路是:维护一个任务池,同时执行的任务不超过指定数量 limit,每完成一个就从待执行队列里取出下一个任务补位。
javascript复制async function runWithLimit(tasks, limit) {
const queue = [...tasks];
const workers = [];
while (queue.length > 0 && workers.length < limit) {
workers.push(executeNext(queue));
}
await Promise.all(workers);
async function executeNext(queue) {
while (queue.length > 0) {
const task = queue.shift();
await task();
}
}
}
这个实现有一个巧妙之处:executeNext 是一个自循环,每个 worker 处理完一个任务后会继续从队列里取下一个,直到队列为空。假设总共有 10 个任务,limit = 3,最初创建 3 个 worker,第一个 worker 完成任务后立即领取第 4 个任务,而不是等 3 个都完成再启动新的一批。这比“分批并行”更高效,因为每一个任务完成的时间点都不同,动态补位可以最大化利用并发额度。
Promise.all 是并发控制的基础:它接收一个 Promise 数组,等待所有 Promise 都完成,返回值也是一个数组(顺序与传入的 Promise 顺序一致)。但如果任意一个 Promise 被拒绝,整个 Promise.all 立刻拒绝,第一个拒绝的原因会被抛出。所以如果担心“一个失败就全盘失败”的场景,可以考虑 Promise.allSettled——它不关心成功或失败,而是等所有 Promise 都 settle(完成或失败)后,返回每个 Promise 的结果快照。
javascript复制const results = await Promise.allSettled([
fetch('/api/1'),
fetch('/api/2'),
fetch('/api/3'),
]);
results.forEach((result) => {
if (result.status === 'fulfilled') {
console.log('成功:', result.value);
} else {
console.log('失败:', result.reason);
}
});
4.3 Error 处理最佳实践:未处理拒绝与全局兜底
前面提到未处理的 Promise 拒绝会在控制台报红色警告。在实际项目中,这种错误往往来自用户快速交互导致的竞态条件,或者第三方库内部的未捕获错误。除了在每个链路上手动 catch,还可以设置全局兜底:
javascript复制// 浏览器环境
window.addEventListener('unhandledrejection', (event) => {
console.error('未处理的 Promise 拒绝:', event.reason);
event.preventDefault(); // 阻止默认的红色警告
});
// Node.js 环境
process.on('unhandledRejection', (reason, promise) => {
console.error('未处理的 Promise 拒绝:', reason);
});
不过全局兜底只是最后一道防线,不能替代业务代码里的主动捕获。在实际项目里,我建议为异步函数设计统一的错误处理包装器,比如:
javascript复制async function withErrorHandling(fn) {
try {
return { data: await fn(), error: null };
} catch (error) {
return { data: null, error };
}
}
const { data, error } = await withErrorHandling(() => fetchUsers());
if (error) {
// 处理错误
}
这种方式比在每个函数里写 try/catch 更清晰,也方便在统一入口上报错误日志。
5. 常见问题与排查实录:从编译报错到运行时报错
5.1 高频报错一:Uncaught (in promise) ...
这类错误的特征是控制台红色区域出现一句 Uncaught (in promise) 加上具体的错误信息。它表示有一个 Promise 被拒绝了,但代码里没有调用 .catch 或使用 await 捕获。常见场景包括:fetch 请求失败未被处理、play() 被浏览器拦截、json() 解析失败未捕获。
排查思路:先在出错位置回溯,看这个 Promise 是在哪里创建的、有没有被消费。一个实用的技巧是临时给 Promise 链末尾加上 .catch(console.error),定位错误来源。如果是大规模项目,建议用前面说的 unhandledrejection 全局监听,至少能把错误信息带一个能定位的上下文标签。
5.2 高频报错二:NotAllowedError: play() failed
这个错误我在第 4.1 节提过,这里展开讲一下排查方法。play() 返回的 Promise 被拒绝时,错误对象里通常带有 name: 'NotAllowedError'。通常原因有两个:一是浏览器自动播放策略限制,要求有用户手势;二是某些 WebView 环境对媒体权限有额外限制。
解决方案:检测到 NotAllowedError 后,把播放流程改成“点击后播放”,或者在页面上放一个初始遮罩层,用户点击后触发播放。另一个技巧是,在某些浏览器里,第一次 play() 被拒绝后,如果马上再调用一次 play()(在同一个用户手势里),可能会被允许——这听起来有点玄学,但确实有开发者用这个“双重播放”策略绕过一些浏览器的误拦截。
5.3 高频报错三:Maximum recursive updates exceeded 及相关组件警告
这个报错常出现在 Vue 项目 中,但它和 Promise 的关系非常密切——特别是在使用 async/await 更新响应式数据时。如果你在 await 之后直接修改一个由计算属性依赖的响应式数据,而这个数据又反过来影响了当前组件的渲染条件,就可能触发无限递归更新。
排查思路:先在报错信息中找到是哪个组件。用 Vue DevTools 检查组件状态是否在渲染过程中被反复修改。一个常见原因是:在 await 之后判断某个字段,然后在该字段变化时又请求新数据,再更新同一个字段,形成了循环。
解决方案:给递归更新加一个条件阈值,或者把“数据更新”和“渲染判断”拆分到不同的 watch 里,避免在一个响应式链条中来回触发。
5.4 高频报错四:Could not establish connection. Receiving end does not exist
这个错误通常在使用 chrome.runtime.connect 或浏览器扩展消息通信时出现,但在普通 Web 开发中也可能遇到——比如 iframe 跨域通信时,接收端已经销毁,消息却还在发送。
排查思路:检查消息接收方的生命周期,确认页面加载完成后再发送消息。如果接收方是 iframe 内的页面,要确保它已经加载完毕,必要时在 load 事件后再 postMessage。这类问题往往不是 Promise 本身引起的,而是消息通道的时序问题,但报错会以 Uncaught (in promise) 的形式冒出来,迷惑性很强。
5.5 实战排错案例:一个 K 线图数据加载的竞态问题
我来分享一次真实的排错经历。项目里有一个 K 线图页面,用户切换股票代码时,需要请求两个接口:一个获取基础信息,一个获取历史 K 线。正常情况是先请求基础信息,再根据基础信息里的字段请求 K 线。但用户快速切换代码时,前一个请求还没回来,后一个请求已经发出去了,最后页面上显示的数据可能是上一次代码的。
这个问题的本质是“异步竞态”——多个 Promise 同时在进行,但最后完成的不一定是最新发起的那个。解决方案有两种:
第一种是 请求序号标记法:每次发起请求前递增一个计数器,请求完成后检查计数器编号是否仍是最新的,如果不是则丢弃结果。
javascript复制let requestId = 0;
async function loadStockData(code) {
const currentRequestId = ++requestId;
const [baseInfo, klineData] = await Promise.all([
fetchBaseInfo(code),
fetchKline(code),
]);
if (currentRequestId !== requestId) {
return; // 过期请求,丢弃
}
renderChart(baseInfo, klineData);
}
第二种是 AbortController 取消法:浏览器原生支持取消 fetch 请求,在发起新请求时取消上一个。
javascript复制let currentController = null;
async function loadStockData(code) {
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const [baseInfo, klineData] = await Promise.all([
fetchBaseInfo(code, { signal: currentController.signal }),
fetchKline(code, { signal: currentController.signal }),
]);
renderChart(baseInfo, klineData);
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求已被取消');
} else {
console.error('加载失败', error);
}
}
}
第二种方案更优雅,但需要注意:AbortController 不支持取消普通的 Promise(比如自己封装的 loadImage),只对 fetch 等原生支持取消的 API 有效。对于非 fetch 场景,还是要用序号标记法。
5.6 面试中的事件循环题目:五道经典题与解答
最后整理几道我面试中常考的事件循环题目,大家可以自测一下。
题目一:
javascript复制console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
console.log('end');
答案:start -> end -> promise -> timeout。
题目二:
javascript复制setTimeout(() => console.log('timer1'), 0);
Promise.resolve().then(() => {
console.log('promise1');
setTimeout(() => console.log('timer2'), 0);
});
答案:promise1 -> timer1 -> timer2。注意 timer2 是在微任务里注册的宏任务,它在 timer1 之后执行,因为微任务执行时,timer1 已经排在宏任务队列前面了。
题目三(经典输出顺序题):
javascript复制async function a() {
console.log('a');
await b();
console.log('a2');
}
async function b() {
console.log('b');
}
a();
console.log('main');
答案:a -> b -> main -> a2。b() 是同步执行的,await 之后的部分才被挂起。
题目四:
javascript复制Promise.resolve(1)
.then((x) => x + 1)
.then((x) => { throw new Error('error'); })
.then((x) => console.log(x))
.catch((e) => console.log('caught:', e.message));
答案:caught: error。中间的 then 抛出的异常会被链尾的 catch 捕获。
题目五(宏任务微任务交错):
javascript复制setTimeout(() => console.log('A'), 0);
Promise.resolve()
.then(() => {
console.log('B');
setTimeout(() => console.log('C'), 0);
})
.then(() => console.log('D'));
答案:B -> D -> A -> C。第一轮宏任务执行完后,先清空微任务队列(B 和 D),然后执行宏任务 A,A 执行完后清理微任务(没有),再从宏任务队列取出 C。C 是在微任务里注册的,所以会排在 A 之后。
做这类题目,核心方法是记住两个规则:同步代码先执行完;每个宏任务结束后清空微任务队列。只要画出任务队列的入队和出队过程,基本不会出错。
6. 性能调优与内存管理:避免异步代码拖垮页面
6.1 定时器的误差与 requestAnimationFrame 的选择
setTimeout 的延迟时间不是精确的,它受事件循环中前面任务的影响。如果前面有一个耗时很长的同步任务,setTimeout 的回调会相应延后。如果需要做动画或者需要跟随屏幕刷新率的操作,优先使用 requestAnimationFrame,它会把回调安排在下一帧渲染之前执行。
而在动画循环中,不要用 setTimeout 模拟 60fps,更不要用嵌套的 Promise 链控制动画节奏,因为微任务和渲染的配合时机是固定的——微任务在渲染前执行,渲染频率受显示器的刷新率约束,用 JS 去模拟渲染节奏很容易出现掉帧。
6.2 大量 Promise 同时存在的内存问题
当页面中有大量异步请求未完成时,每个 Promise 都会占用内存。如果某个 Promise 永远无法 resolve 或 reject(比如请求被挂起、事件监听器未移除),这些 Promise 就变成了内存泄漏的一部分。在单页应用中,一个典型的泄漏场景是:
组件销毁后,异步请求才返回,然后回调里操作了已经销毁的 DOM 或组件状态,导致无法被垃圾回收。这个问题在 React 中表现为“对已卸载组件执行状态更新”,在 Vue 中可能表现为内存占用持续升高。
解决方案:
- 组件销毁时通过
AbortController取消未完成的请求。 - 使用一个“取消令牌”对象,在销毁时标记为已取消,回调里检查标记。
- 对于事件监听器,确保组件销毁时移除。
javascript复制// 一个简单的取消标记模式
function useCancellableAsync(asyncFn, deps) {
const cancelledRef = useRef(false);
useEffect(() => {
cancelledRef.current = false;
asyncFn().then((result) => {
if (!cancelledRef.current) {
// 更新状态
}
});
return () => {
cancelledRef.current = true;
};
}, deps);
}
6.3 微任务与渲染:为什么大量 await 会导致卡顿
微任务虽然优先级高,但并不意味着“微任务越多越好”。如果在一段代码里连续使用大量 await,比如在一个循环里对一万个元素逐个 await,每个 await 都会产生一次微任务调度的开销。更严重的是,在微任务队列被清空的过程中,页面没有机会渲染——所以如果微任务执行时间过长,用户会感觉到明显的卡顿。
javascript复制// 不推荐的写法:在一层循环里逐个 await
async function processList(list) {
for (const item of list) {
await processItem(item); // 每个 await 都是微任务,期间无法渲染
}
}
// 推荐的写法:分批次处理,让出渲染时机
async function processListInChunks(list, chunkSize = 50) {
for (let i = 0; i < list.length; i += chunkSize) {
const chunk = list.slice(i, i + chunkSize);
await Promise.all(chunk.map(processItem));
await new Promise((resolve) => setTimeout(resolve, 0)); // 让出渲染
}
}
第二段代码中,每处理一批数据后,通过 setTimeout 让出事件循环,让浏览器有机会执行一次渲染。这在处理大量 DOM 更新或大数据渲染时非常有效。
7. 一份可以“抄作业”的 Promise 实践清单
7.1 工具函数库:常用 Promise 封装
我在项目里沉淀了一套常用的 Promise 工具函数,这里分享几个最实用的。
带超时的 fetch:
javascript复制async function fetchWithTimeout(url, options = {}, timeout = 10000) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} catch (error) {
if (error.name === 'AbortError') {
throw new Error(`请求超时:${url}`);
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
带重试的请求:
javascript复制async function fetchWithRetry(url, options = {}, retries = 3) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fetch(url, options);
} catch (error) {
lastError = error;
// 指数退避:1s, 2s, 4s
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, attempt) * 1000)
);
}
}
throw lastError;
}
串行执行函数数组:
javascript复制async function runSeries(functions) {
const results = [];
for (const fn of functions) {
results.push(await fn());
}
return results;
}
7.2 异常处理清单:写 Promise 前的四条铁律
第一条:每个 Promise 链都要有 catch。不管是不是异步函数,只要出现了 Promise,就必须考虑 rejection 的处理路径。
第二条:async 函数内部如果调用了其他 async 函数,要用 await 等待它,否则错误不会被当前函数的 try/catch 捕获,并且调用方无法感知它是否完成。
第三条:不要在 then 里调用会抛出错误的同步函数而忘记捕获。如果你的 onFulfilled 回调里可能会抛异常,在后面加 .catch 是必须的。
第四条:不要对同一个 Promise 调用多次 resolve / reject。虽然规范规定只有第一次调用生效,但代码里出现这种逻辑通常是设计问题,说明状态流转不清晰。
下面给一个错误示范和正确示范的对比:
javascript复制// 错误示范:未处理的拒绝
function bad() {
const promise = fetch('/api/data');
promise.then((data) => {
console.log(data);
});
// 如果 fetch 失败,promise 被拒绝,但没有 catch —— 出现未处理拒绝
}
// 正确示范
function good() {
const promise = fetch('/api/data');
promise
.then((data) => {
console.log(data);
})
.catch((error) => {
console.error('请求失败', error);
});
}
7.3 学习路径建议:从会用到能讲明白
如果你刚接触 Promise 不久,我建议的学习路径是:先会写 Promise.all、async/await 的日常用法;然后手写一遍迷你 Promise,理解状态机和微任务调度;再看 event loop 的可视化工具(比如 Loupe)演示执行过程;最后回归业务,用实际项目里的异步场景反复练习输出顺序判断。
把这个过程走完,你就不会再怕市面上任何事件循环相关的面试题,写异步代码也会更有底气。不是靠死记硬背,而是真正理解了 JavaScript 运行时怎么调度你的代码,自然就能应对各种变化。
我个人还有一个习惯:在代码提交前,专门检查一遍所有 fetch 调用和 new Promise 的构造位置,确认每条链路都有异常处理。踩过未处理拒绝的坑以后,我深知一个静默失败的 Promise 对线上问题的排查杀伤力有多大——你看到的症状永远在别处,真正的错误藏在一条没人捕的 Promise 链上。
