异步编程核心
从回调到 Promise,理解 JavaScript 的异步世界
🎯 学习目标
学完本章,你将能够:
- 能够解释 JavaScript 事件循环的执行顺序,预测宏任务和微任务的输出
- 能够使用 Promise 处理异步操作,理解其三态(pending、fulfilled、rejected)
- 能够使用 then/catch 链式调用处理 Promise 的成功和失败
- 能够使用 Promise.all/race/allSettled/any 组合多个异步任务
- 能够将回调函数风格的 API 转换为 Promise 风格(promisify)
一个烧脑的问题
下面这段代码,输出顺序是什么?
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve().then(() => console.log("C"));
console.log("D");
如果你的答案是 A B C D,那你并不孤单——大多数人第一次都会猜错。
实际输出是 A D C B。setTimeout 延迟明明是 0,为什么反而最后才执行?Promise 又凭什么插队?
要回答这个问题,需要理解 JavaScript 最核心的机制:事件循环。这一章,我们把异步编程从里到外彻底搞清楚。
Part 1: 同步与异步
单线程也能同时处理很多事
JavaScript 是单线程的,一次只能做一件事。那网络请求、文件读写这些耗时操作怎么办?如果等它完成再往下走,整个页面就冻住了。答案就是异步:先发起请求,不等着,等结果回来了再处理。这一部分先把"同步"和"异步"的区别讲透,为后面的事件循环和 Promise 打好基础。
单线程的 JS
为什么一个线程能同时处理那么多请求?
单线程意味着 JavaScript 只有一个调用栈,代码一行一行执行,前面的事没做完,后面就得等。
用生活场景说:你是一个餐厅服务员,一次只能端一盘菜。如果客人点了 10 道菜,你得一道一道上,中间不能分身。
这个比喻只是为了方便理解。实际上 JS 引擎背后有浏览器的 Web API 线程池和 Node.js 的 libuv 线程池来处理耗时任务,并不是真的"一个人干所有活"。
// 同步——按顺序,阻塞
console.log("1");
console.log("2");
console.log("3"); // 输出: 1 2 3
// 异步——不阻塞后续代码
console.log("1");
setTimeout(() => console.log("2"), 0);
console.log("3"); // 输出: 1 3 2
为什么需要异步
不异步,你的程序就"卡住了"
如果所有操作都是同步的,一个网络请求要等 3 秒,整个页面就冻结 3 秒——按钮点不了,动画不动,用户体验灾难。
网络请求
fetch API、AJAX 调用
文件读写
Node.js 的 fs 模块
定时器
setTimeout / setInterval
用户事件
click / scroll / input
Part 2: 事件循环
JavaScript 异步的核心引擎
回到开场那道题:setTimeout 延迟为 0 为什么最后执行?Promise.then 凭什么能插队?答案藏在事件循环里。它是 JavaScript 调度任务的核心机制:先跑完同步代码,再清空微任务队列,最后才取宏任务。理解了这个"同步→微任务→宏任务"的循环,任何执行顺序题都能一步步推导出来。
事件循环全景
一张图看懂 JS 如何调度任务

图:JavaScript 事件循环全景流程图
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个从上到下再循环回上方的闭环流程图,包含四个核心节点:①"同步代码(Call Stack)"(蓝色)、②"检查微任务队列"(橙色)、③"执行全部微任务(Promise.then / queueMicrotask)"(绿色)、④"取出一个宏任务(setTimeout / I/O)"(红色) - 各元素关系:同步代码执行完毕 → 检查微任务队列 → 如有微任务则执行全部微任务并回到检查步骤 → 微任务清空后取出一个宏任务 → 回到同步代码执行。形成"同步→微任务→宏任务"的无限循环 - 关键标注:每条箭头旁标注触发条件,如"执行完毕""有微任务""微任务清空";在循环外围用大字标注"事件循环 Event Loop" - 配色建议:浅蓝/浅灰背景,深色文字,节点分别用蓝(#4a90d9)、橙(#f5a623)、绿(#7ed321)、红(#d0021b)填充,关键箭头用橙色标注 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
循环规则很简单:同步代码 → 清空微任务 → 取一个宏任务 → 清空微任务 → 取下一个宏任务。如此反复。
记住这个顺序,后面所有代码题都能迎刃而解。
宏任务与微任务
两种任务,两种优先级
| 宏任务 Macrotask | 微任务 Microtask |
|---|---|
| setTimeout / setInterval | Promise.then / catch / finally |
| I/O 操作 | queueMicrotask() |
| UI 渲染 | MutationObserver |
| setImmediate (Node.js) | process.nextTick (Node.js) |
| requestAnimationFrame |
关键区别:微任务在当前宏任务结束后、下一个宏任务开始前全部执行完。所以微任务优先级高于宏任务。
执行顺序详解
用流程图跟踪开场那道题

图:A D C B 执行顺序追踪流程图
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:从左到右的水平流程图,分为三个阶段区域。第一阶段"同步阶段"(蓝色背景框)内含四个步骤:console.log('A') → 注册 setTimeout 回调 → 注册 Promise.then 回调 → console.log('D')。第二阶段"微任务阶段"(绿色背景框)包含一个节点:Promise.then 输出 C。第三阶段"宏任务阶段"(红色背景框)包含一个节点:setTimeout 输出 B - 各元素关系:同步阶段的四个步骤从左到右依次执行,同步阶段结束后进入微任务阶段执行 C,微任务清空后进入宏任务阶段执行 B。用粗箭头连接三个阶段 - 关键标注:在每个阶段上方标注阶段名称和颜色含义;最终在最上方用大字展示最终输出顺序"A D C B";在 setTimeout 和 Promise.then 注册节点旁标注"仅注册,不执行" - 配色建议:浅蓝/浅灰背景,深色文字,同步节点蓝色(#4a90d9),微任务节点绿色(#7ed321),宏任务节点红色(#d0021b),阶段间箭头用橙色标注 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
// 完整执行过程
// 1. 同步: A → D
// 2. 微任务: C (Promise.then)
// 3. 宏任务: B (setTimeout)
// 输出: A D C B
复杂示例
多轮循环的完整追踪
console.log("script start");
setTimeout(() => console.log("timeout 1"), 0);
Promise.resolve()
.then(() => console.log("promise 1"))
.then(() => console.log("promise 2"));
queueMicrotask(() => console.log("microtask"));
setTimeout(() => console.log("timeout 2"), 0);
console.log("script end");
输出顺序:
script start → script end → promise 1 → microtask → promise 2 → timeout 1 → timeout 2
.then(() => console.log("promise 2")) 是在第一个 then 回调执行后才注册的,但它依然是微任务,会在当前微任务清空阶段被执行,不会等到宏任务之后。
Part 3: 回调地狱
代码写着写着就变成了一座金字塔
最早的异步方式是回调函数——把"完成后要做的事"以参数传进去。能用,但如果一个异步操作依赖上一个的结果呢?回调套回调,代码缩进越来越深,最终变成一座"金字塔"。错误处理分散在每一层,根本没法用 try/catch。先看看这个痛点有多严重,才明白后面 Promise 为什么是救星。
回调地狱
嵌套越来越深,代码越来越乱
// 连续依赖的异步操作——"金字塔"
getUser(userId, (err, user) => {
if (err) { handleError(err); return; }
getOrders(user.id, (err, orders) => {
if (err) { handleError(err); return; }
getOrderDetails(orders[0].id, (err, details) => {
if (err) { handleError(err); return; }
getProduct(details.productId, (err, product) => {
if (err) { handleError(err); return; }
console.log(product);
});
});
});
});
回调的五大痛点:
- 嵌套过深——代码呈金字塔状,难以阅读
- 错误处理复杂——每层都要 if (err)
- 无法 return/throw——异步回调中的 return 只是退出回调
- 信任问题——回调可能被调用多次或不调用
- 无法 try/catch——异步错误捕获不到
Part 4: Promise
一个"未来的值"——要么成功,要么失败
回调地狱的解药就是 Promise。它把嵌套的回调拉平成链式的 .then().then().catch(),每一步返回一个新的 Promise,错误可以统一捕获。这一部分从 Promise 的三种状态讲起,到链式调用的机制,帮你彻底搞懂"一个值怎么代表未来"。
Promise 三态
pending → fulfilled 或 rejected,不可逆
Promise 是一个对象,代表一个异步操作的最终结果。它有三种状态:

图:Promise 三态转换图
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:三个圆形/圆角矩形节点,从左到右排列。左侧"pending(等待中)"节点为橙色,中间"fulfilled(已完成)"节点为绿色,下方"rejected(已拒绝)"节点为红色 - 各元素关系:pending 节点通过一条标注"resolve()"的实线箭头指向 fulfilled 节点;pending 节点通过另一条标注"reject()"的实线箭头指向 rejected 节点。在 fulfilled 和 rejected 旁用红色叉号标注"不可逆",不要画回退箭头 - 关键标注:在三个节点内分别标注状态名和中文名;在 pending 上方标注"初始状态";在 fulfilled 右侧标注".then() 可获取值";在 rejected 下方标注".catch() 可捕获原因";用虚线框围住 fulfilled 和 rejected 并标注"已定型 settled" - 配色建议:浅蓝/浅灰背景,深色文字,pending 橙色(#f5a623),fulfilled 绿色(#7ed321),rejected 红色(#d0021b),虚线箭头用灰色 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
let p = new Promise((resolve, reject) => {
resolve("成功");
resolve("再次成功"); // 被忽略,状态已确定
});
// 快捷方法
let p1 = Promise.resolve(42);
let p2 = Promise.reject(new Error("出错了"));
then/catch
链式调用,告别金字塔
Promise 的核心能力是链式调用:每次 .then() 返回一个新的 Promise,可以一直链下去。
fetchUser(1)
.then(user => fetchOrders(user.id))
.then(orders => fetchOrderDetail(orders[0].id))
.then(detail => console.log(detail))
.catch(err => console.error("任一环节出错:", err))
.finally(() => console.log("请求结束"));
then 的返回值
返回普通值还是 Promise,区别在哪?
Promise.resolve(1)
.then(val => val + 1)
// 返回普通值 → 自动包装为 resolved Promise
.then(val => Promise.resolve(val + 1))
// 显式返回 Promise
.then(val => { throw new Error("oops"); })
// 抛出错误 → 变为 rejected
.catch(err => {
console.log(err.message); // "oops"
return "recovered"; // catch 中返回值,链继续
})
.then(val => console.log(val)); // "recovered"
.catch() 会"吞掉"错误并返回一个 resolved 的 Promise。如果你希望错误继续传播,需要在 catch 中重新 throw。
Part 5: 组合方法
多个 Promise 同时运行,怎么管理?
页面加载时需要同时请求用户信息、配置和权限——三个请求互不依赖,串行发太慢了。Promise.all 可以让它们并行跑,全部完成后再继续。但如果有一个失败就全盘放弃?还是想等所有结果不管成败?Promise.race、Promise.any、Promise.allSettled 四个方法各有适用场景,这一部分逐一讲清楚。
Promise.all
全部成功才成功,一个失败就失败
let p1 = fetch("/api/users");
let p2 = fetch("/api/posts");
let p3 = fetch("/api/comments");
Promise.all([p1, p2, p3])
.then(([users, posts, comments]) => {
console.log("全部加载完成");
})
.catch(err => console.log("有一个失败了:", err));
使用场景:页面需要多个接口数据才能渲染,必须全部到齐才行。
四种组合
all / allSettled / race / any 各有用途
| 方法 | 成功条件 | 失败条件 |
|---|---|---|
Promise.all | 全部成功 | 一个失败就失败 |
Promise.allSettled | 永远成功 | 永远不会失败 |
Promise.race | 最先完成的 | 最先完成的(不论成败) |
Promise.any | 最先成功的 | 全部失败才失败 |
// race 实现请求超时
Promise.race([
fetch("/api/data"),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("超时")), 5000)
)
]);
Part 6: Promise 化
把回调风格的函数"包装"成 Promise
Node.js 早期 API 都是回调风格 fs.readFile(path, (err, data) => {}),没法用 .then() 链式调用。手动包装一个 new Promise 可以解决,但每个函数都写一遍太繁琐。Node.js 提供了 util.promisify 一键转换,理解它的原理后,你也能写出自己的 promisify 工具。
手动 promisify
把回调函数包装成 Promise
// 包装 delay
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
delay(1000).then(() => console.log("1秒后"));
// 包装 Node.js 回调函数
function readFilePromise(path, encoding) {
return new Promise((resolve, reject) => {
fs.readFile(path, encoding, (err, data) => {
if (err) reject(err);
else resolve(data);
});
});
}
util.promisify
Node.js 内置的转换工具
$ node
const util = require('util');
const fs = require('fs');
const readFile = util.promisify(fs.readFile);
readFile('data.txt', 'utf-8')
.then(data => console.log(data))
.catch(err => console.error(err));
// 通用 promisify 实现
function promisify(fn) {
return function(...args) {
return new Promise((resolve, reject) => {
fn(...args, (err, ...result) => {
if (err) reject(err);
else resolve(result.length > 1 ? result : result[0]);
});
});
};
}
事件循环实战
用 Mermaid 追踪完整执行流程

图:多轮事件循环完整执行追踪图
【AI 生图提示词】注意:本图只展示第 1 轮循环的同步阶段和微任务清空阶段,宏任务阶段留到下一轮。请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:一个纵向的详细流程图,用大虚线框标注"第1轮事件循环"。框内分为两个区域:上半部分"同步执行"(蓝色背景)包含四个步骤从上到下:console.log('script start') → 注册 setTimeout 回调(标注"放入宏任务队列")→ 注册 Promise.then 回调(标注"放入微任务队列")→ console.log('script end')。下半部分"微任务清空"(绿色背景)包含四个步骤:执行 promise 1 → 注册新的 Promise.then → 执行 queueMicrotask 回调 → 执行 promise 2。微任务清空后,流程指向虚线框底部的"本轮结束"标识,旁注"宏任务将在下一轮执行" - 各元素关系:同步执行完成后箭头指向微任务清空阶段;微任务全部清空后箭头指向虚线框底部"本轮结束"标识。每个节点按执行编号标注顺序(①②③...) - 关键标注:在"注册新的 Promise.then"节点旁标注易错提示:"第二个 .then 在第一个 then 执行后才注册,但仍属于本轮微任务";在虚线框底部标注"第 1 轮结束,宏任务留待下一轮" - 配色建议:浅蓝/浅灰背景,深色文字,同步节点蓝色(#4a90d9),微任务节点绿色(#7ed321),关键箭头用橙色标注 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
常见异步错误
问题一:Promise 链中断
原因:忘记 return 内部的 Promise。
// 错误
.then(user => {
fetchOrders(user.id); // 忘记 return!
})
.then(orders => console.log(orders)); // undefined
// 正确
.then(user => {
return fetchOrders(user.id); // 加上 return
})
问题二:forEach 中的 async
原因:forEach 不会等待 async 回调完成。
// 错误:不会等待
items.forEach(async (item) => {
await process(item);
});
// 正确:用 for...of
for (let item of items) {
await process(item);
}
取消请求
用 AbortController 主动终止异步操作
AbortController 是浏览器和 Node.js 提供的取消机制。它创建一个 AbortSignal 对象,可以传递给 fetch 等异步操作来取消它们。
const controller = new AbortController();
const signal = controller.signal;
// 取消 fetch 请求
fetch('/api/data', { signal })
.then(res => res.json())
.then(data => console.log(data))
.catch(err => {
if (err.name === 'AbortError') {
console.log('请求已被取消');
}
});
// 5 秒后自动取消
setTimeout(() => controller.abort(), 5000);
// 给 Promise 加超时
function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ms);
return fetch(url, { signal: controller.signal })
.then(res => {
clearTimeout(timeout);
return res.json();
})
.catch(err => {
clearTimeout(timeout);
if (err.name === 'AbortError') {
throw new Error(`请求超时(${ms}ms)`);
}
throw err;
});
}
controller.abort() 只能调用一次,调用后 signal.aborted 变为 true,所有使用该 signal 的请求都会被取消。
微任务陷阱
await 和 .then 的微妙时机
await 后面的代码不在同步栈中执行,而是被包装成微任务。这意味着:
async function foo() {
console.log("A");
await Promise.resolve();
console.log("B"); // 这行在微任务中执行!
}
foo();
console.log("C");
// 输出: A C B(不是 A B C)
多个 .then 的注册时机也容易让人困惑:
const p = Promise.resolve();
p.then(() => console.log("A"));
p.then(() => console.log("B"));
p.then(() => console.log("C"));
console.log("D");
// 输出: D A B C
// 三个 .then 各自独立注册,按注册顺序排入微任务队列
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve()
.then(() => console.log("3"))
.then(() => setTimeout(() => console.log("4"), 0))
.then(() => console.log("5"));
(async () => {
console.log("6");
await Promise.resolve();
console.log("7");
})();
console.log("8");
答案:1 6 8 3 7 5 2 4。推导关键:async 函数中 await 前的代码是同步执行的;await 后的代码是微任务;第二个 .then 注册的 setTimeout 要到宏任务阶段才执行。
试一试
练习一
任务:预测下面代码的输出顺序。
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve()
.then(() => console.log("promise 1"))
.then(() => console.log("promise 2"));
console.log("end");
参考答案
start → end → promise 1 → promise 2 → timeout
练习二
任务:用 Promise 封装一个 loadImage(url) 函数,图片加载成功就 resolve,失败就 reject。
// 提示:用 new Image() 和 onload/onerror
// 你的代码:
参考答案
function loadImage(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`加载失败: ${url}`));
img.src = url;
});
}
练习三
任务:实现一个 retry(fn, retries, delay) 函数,在请求失败时自动重试指定次数,每次重试间隔 delay 毫秒。
// retry(fn, retries, delay)
// fn: 返回 Promise 的函数
// retries: 最大重试次数
// delay: 每次重试间隔(毫秒)
// 你的代码:
参考答案
function retry(fn, retries, delay) {
return fn().catch(err => {
if (retries <= 0) throw err;
console.log(`失败,${delay}ms 后重试(剩余 ${retries} 次)`);
return new Promise(resolve => {
setTimeout(resolve, delay);
}).then(() => retry(fn, retries - 1, delay));
});
}
// 使用示例
retry(
() => fetch('https://api.example.com/data').then(r => r.json()),
3,
1000
);
📝 要点总结
本章核心知识点
事件循环
单线程通过事件循环处理异步任务
宏任务与微任务
setTimeout 是宏任务,Promise.then 是微任务
Promise 三态
pending、fulfilled、rejected,不可逆
链式调用
then 返回新 Promise,支持链式操作
组合方法
all、race、allSettled、any 处理并发
下一章
Promise 让异步代码不再嵌套,但链式调用仍然有"视觉噪声"。下一章我们学习 async/await——用同步的写法写异步代码。