本文从 JS 单线程模型出发,沿着「同步 → 异步 → 回调 → Promise → async/await」的演进路线,把异步流程控制讲透,并顺手理清宏任务 / 微任务的关系。
前言:为什么 JS 需要"异步"?
你有没有写过这样的代码,然后发现页面卡住了?
javascript
// 一段"傻等"的同步代码
function wait10s() {
const start = Date.now();
while (Date.now() - start < 10000) {}
}
console.log('开始');
wait10s();
console.log('结束');
运行时你会发现:「开始」打完后,浏览器白屏 10 秒,「结束」才出现。这期间用户不能点按钮、不能滚动页面,整个页面"死"了。
为什么会这样?因为 JS 天生是单线程的。一行一行顺序执行,上面的代码没跑完,下面的代码就永远没机会。可现实世界里,页面有太多"慢动作":
- 定时器
setTimeout要等时间 fetch请求要等网络- 点击事件要等用户操作
如果都按"同步排队",页面就没法用了。于是 JS 走上了一条"异步流程控制"的进化之路------从最原始的回调,到 Promise,再到 async/await,每次升级都是为了让异步代码更好写、更好读。
一、同步时代:单线程 + 阻塞,最简单也最脆弱
1.1 JS 为什么是单线程的?
和 C++、Java 这种"可以开多线程"的系统级语言不同,JS 从诞生之初就是单线程设计。原因很务实:
- JS 最早是浏览器脚本,用来操作 DOM。如果两个线程同时改同一个 DOM,谁来裁决?
- 单线程模型简单,开发者不用操心锁、竞争、死锁这些"高级麻烦"
简单的代价就是:同一时间只能干一件事 。这件事没干完,后面所有事都得等着------这就是阻塞。
javascript
let a = 1;
let b = 2;
let c = 3;
console.log(a + b + c); // 6
上面的代码就是典型的同步执行:声明 a → 声明 b → 声明 c → 打印,一步一步来,没有任何歧义。
1.2 阻塞的代价
如果所有任务都是"快"的,单线程其实很好。但一旦遇到"慢"任务(网络请求、定时器、读写文件),问题就来了:
javascript
console.log('start');
setTimeout(() => {
console.log('222');
}, 1000);
console.log('end');
输出顺序是什么?
sql
start
end
222
setTimeout 没有"卡住"后面的代码------它被 JS 引擎"跳过"了,先执行了 console.log('end'),等 1 秒到了再回头执行回调。这种"让慢任务先靠边站,等有空了再处理"的机制,就是Event Loop(事件循环)。
二、Event Loop:JS 异步的底层发动机
2.1 一句话理解 Event Loop
进程(董事长)分配资源,线程(经理)负责执行。JS 启动时:
- 浏览器 / Node 启动一个进程,有独立的 PID
- 进程启动一个主线程来跑 JS 代码(单线程)
- 同步代码立刻执行完;耗时的异步任务(定时器、fetch、事件)交给 WebAPI / 系统内核去做
- 异步任务完成后,其回调函数被塞进一个任务队列
- 主线程把同步代码全部跑完后,就去队列里把回调拿出来执行------这个"无限循环拿任务"的动作,就是 Event Loop
arduino
┌──────────────────────────────────────┐
│ JS 主线程(调用栈) │
│ 先执行所有同步代码,空了才看队列 │
└───────────────┬──────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 任务队列(Queue) │
│ setTimeout / fetch / 点击事件回调 │
└──────────────────────────────────────┘
2.2 宏任务 vs 微任务
任务队列其实不是"一条队列",而是分两类:
| 类型 | 常见成员 | 优先级 |
|---|---|---|
| 宏任务(MacroTask) | setTimeout、setInterval、I/O、DOM 事件、script 整体代码 |
低,一次 Event Loop 只取一个 |
| 微任务(MicroTask) | Promise.then/catch/finally、MutationObserver、process.nextTick(Node) |
高,在当前宏任务结束后清空整个队列 |
执行顺序口诀:同步代码 → 微任务 → 下一个宏任务 → 微任务 → ......
javascript
console.log('1'); // 同步
setTimeout(() => {
console.log('2'); // 宏任务
}, 0);
Promise.resolve()
.then(() => console.log('3')) // 微任务
.then(() => console.log('4')); // 微任务
console.log('5'); // 同步
输出:1 5 3 4 2
解析:同步 1 5 先跑完;接着清空微任务队列输出 3 4;最后才去宏任务队列拿 setTimeout 输出 2。即使 setTimeout 延时写 0,也一定在微任务之后------这是 Event Loop 的硬规则。
三、回调(Callback):第一代异步流程控制
3.1 回调的思路
既然异步任务不能立刻拿结果,那我把"拿到结果后要做的事"当成参数传给你,你做完了自己调一下------这就是回调。
javascript
// 用 setTimeout 模拟一个"请求用户数据"的异步任务
function getUser(userId, callback) {
setTimeout(() => {
callback({ id: userId, name: '张三' });
}, 1000);
}
// 把"拿到数据后做什么"塞进回调里
getUser(1, (user) => {
console.log('用户:', user.name); // 1 秒后打印"张三"
});
回调解决了"慢任务不阻塞"的问题,但它的天花板很快就到了。
3.2 回调地狱:当业务变复杂
真实业务往往不是"一次请求就完事",而是:
A 接口拿所有用户 → B 接口拿每个用户的详情 → C 接口拿详情里的订单 → ......
每一步都依赖上一步的结果,代码就会一层嵌一层:
javascript
getUsers((users) => {
users.forEach((user) => {
getUserDetail(user.id, (detail) => {
getOrders(detail.orderIds, (orders) => {
orders.forEach((order) => {
renderOrder(order, () => {
// 还要嵌套吗?
});
});
});
});
});
});
这段代码就是赫赫有名的回调地狱(Callback Hell):
- 缩进像阶梯一样往右飘,眼睛跟不上
- 每一层的变量名不能冲突,读起来像解密
- 错误处理要在每层单独写,漏一层就崩
回调的问题不是"不能用",而是复杂度随着嵌套层数指数级增长。业务一复杂,回调就扛不住了。
四、Promise + then:把"回调阶梯"拍平
4.1 Promise 是什么?
ES6(2015)引入的 Promise,直译是"承诺"------承诺一段时间后给你一个结果。它有三种状态:
| 状态 | 含义 | 说明 |
|---|---|---|
| Pending(进行中) | 任务还没做完 | 初始状态 |
| Fulfilled(已成功) | 任务成功了 | 调用 resolve(result) |
| Rejected(已失败) | 任务失败了 | 调用 reject(err) |
状态一旦从 Pending → Fulfilled 或 Pending → Rejected,就不可逆。
4.2 Promise 基本用法
实例化 Promise 时要传一个executor 函数 ,它会立即同步执行,里面通常放耗时任务:
javascript
const p = new Promise((resolve, reject) => {
console.log('许诺言'); // 同步,立刻打印
setTimeout(() => {
// 调用 resolve 表示成功,会触发 .then
// resolve(666);
reject('网络错误'); // 调用 reject 表示失败,会触发 .catch
}, 2000);
});
executor 拿到两个能力:resolve(成功时传结果给 .then)和 reject(失败时传错误给 .catch):
javascript
p
.then((data) => {
console.log(data); // 666(如果 resolve 了)
})
.catch((err) => {
console.log(err); // '网络错误'(如果 reject 了)
})
.finally(() => {
console.log('finally'); // 无论成败都会执行
});
小技巧 :
sleep函数的 Promise 版本经常用到:
javascriptfunction sleep(t) { return new Promise((resolve) => setTimeout(resolve, t)); } sleep(2000).then(() => console.log('2s 后再做'));
4.3 链式调用:Promise 的灵魂
Promise 最伟大的设计是------每个 .then 都会返回一个新的 Promise 。这意味着 .then 可以无限链下去,把嵌套的回调拍扁成一条直线:
javascript
// 回调地狱(反例)
getUsers((users) => {
getUserDetail(users[0].id, (detail) => {
getOrders(detail.orderIds, (orders) => {
render(orders);
});
});
});
// Promise 链(正解)
getUsers()
.then(users => getUserDetail(users[0].id))
.then(detail => getOrders(detail.orderIds))
.then(orders => render(orders))
.catch(err => console.error(err));
层级被拍平了,阅读顺序从上到下,符合人的直觉。
4.4 fetch 与 Promise 的搭配
浏览器内置的 fetch 底层就是 Promise,所以它天然可以链式调用:
javascript
fetch('http://localhost:3000/users')
.then(res => res.json()) // 第一步:把响应体解析为 JSON(返回新 Promise)
.then(data => { // 第二步:拿到真正的数据
console.log(data); // 渲染或存储
})
.catch(err => {
console.error('请求失败:', err);
});
踩坑提醒 :fetch 即使收到 404 / 500,Promise 依然是 fulfilled(因为"请求完成了",只是业务状态不对)。需要自己判断 res.ok:
javascript
fetch(url).then(res => {
if (!res.ok) throw new Error('HTTP ' + res.status);
return res.json();
});
4.5 Promise 的静态方法:六兄弟一次讲清
Promise 本身挂了 6 个高频静态方法,它们是面试必问、工作必用的"组合拳"。
4.5.1 Promise.resolve / Promise.reject:快速包装
把一个普通值立刻包装 成已结束的 Promise,省去写 new Promise(...) 的仪式感:
javascript
Promise.resolve(666); // 等价于 new Promise(r => r(666))
Promise.reject('err'); // 等价于 new Promise((_, r) => r('err'))
常见场景:函数需要"统一返回 Promise",但某分支是同步值,用 Promise.resolve(value) 兜底即可。
4.5.2 Promise.all:全员成功才 happy end
Promise.all([p1, p2, p3]) 传入一个 Promise 数组:
- 全部成功 → 返回的 Promise resolve,结果是按顺序排列的数组
- 只要有一个失败 → 整体立刻 reject,不再等其他 Promise,错误是第一个失败的原因
javascript
const p1 = Promise.resolve(10);
const p2 = new Promise((r) => setTimeout(() => r(20), 500));
const p3 = Promise.resolve(30);
Promise.all([p1, p2, p3])
.then(([a, b, c]) => console.log(a + b + c)); // 60(约 500ms 后)
只要有一个 reject,就直接进 catch:
javascript
const good = Promise.resolve('OK');
const bad = Promise.reject('NO');
Promise.all([good, bad])
.then(console.log)
.catch(console.error); // 'NO'------直接走第一个失败的 catch
⚠️ 这是面试高频点:all 的语义是"一损俱损",整体失败,不等其它未完成的 Promise。
4.5.3 Promise.race:谁最快就用谁
Promise.race([...]) ------ 谁先改变状态(无论成功还是失败)就以它为准,后面的结果全部忽略:
javascript
const slow = new Promise((r) => setTimeout(() => r('乌龟'), 2000));
const fast = new Promise((r) => setTimeout(() => r('兔子'), 500));
Promise.race([slow, fast]).then(console.log); // '兔子'
典型用法:请求超时控制------把真实请求和一个"超时 reject 的定时器" race 起来:
javascript
function fetchWithTimeout(url, ms = 3000) {
const timeout = new Promise((_, rej) =>
setTimeout(() => rej(new Error('请求超时')), ms)
);
return Promise.race([fetch(url), timeout]);
}
4.5.4 Promise.allSettled:全部跑完再说(ES2020)
all 的问题是"一损俱损",但很多场景你只想等所有任务跑完,不管成功失败都要拿结果 ------那就用 allSettled:
javascript
const ok = Promise.resolve('成功');
const bad = Promise.reject('失败');
Promise.allSettled([ok, bad]).then(console.log);
// 输出:
// [
// { status: 'fulfilled', value: '成功' },
// { status: 'rejected', reason: '失败' }
// ]
每个结果都用 { status, value/reason } 标识,你自己 filter 处理即可,非常适合批量上传 / 批量导入这种"允许部分失败"的场景。
4.5.5 Promise.any:只要有一个成功就行(ES2021)
与 all 正好相反:任意一个成功就 resolve ,所有都失败才 reject(并抛出 AggregateError 汇总所有失败原因):
javascript
const urls = ['/api/a', '/api/b', '/api/c']; // 任意一个能返回就行
Promise.any(urls.map(u => fetch(u)))
.then(res => res.json())
.catch(err => console.log('全部接口挂了:', err.errors));
适合做"多接口兜底 / 多 CDN 加速选最优"的场景。
4.5.6 六个静态方法的对比表
| 方法 | 成功条件 | 失败条件 | 返回结果 |
|---|---|---|---|
Promise.all |
全部成功 | 任一失败 | 结果数组 |
Promise.race |
最快的那个成功 | 最快的那个失败 | 第一个的结果 |
Promise.allSettled |
永远成功(等所有结束) | 从不 reject | {status,value/reason}[] |
Promise.any |
任一成功 | 全部失败 | 第一个成功的值 |
Promise.resolve |
立即成功 | --- | 包装好的成功 Promise |
Promise.reject |
--- | 立即失败 | 包装好的失败 Promise |
4.6 穿透 / 值透传:一个反直觉的小坑
面试里经常出现这样的代码:
javascript
Promise.resolve(100)
.then(Promise.resolve(200))
.then(console.log); // 打印什么?
答案是 100 。原因:.then 要求接收的是函数 ,如果你传的不是函数(例如直接传一个 Promise 对象),JS 会把它当作忽略 ,上一个值直接"透传"到下一个 .then。正确写法是传一个函数进去:
javascript
Promise.resolve(100)
.then(() => Promise.resolve(200)) // ✅ 传函数
.then(console.log); // 200
4.7 .then 的第二个参数 vs .catch
很多人不知道 .then 其实可以接两个函数参数:
javascript
p.then(
(res) => console.log('成功:', res),
(err) => console.log('失败:', err) // 只捕获"上一个 Promise"的错误
);
它和 .catch 的关键区别:
.then(onFulfilled, onRejected)里的onRejected只能捕获前面 Promise 本身的错误 ,捕获不到onFulfilled里新抛的错.catch能捕获整条链上之前任何一步的异常
javascript
// ❌ 第二个参数捕不到 then 内部的新错误
Promise.resolve()
.then(
() => { throw new Error('then 里出错'); },
(e) => console.log('A:', e) // 不会打印
)
.catch((e) => console.log('B:', e)); // ✅ 这里能接住
// ✅ 统一用 .catch 做链尾兜底更稳妥
最佳实践 :链尾统一用一个 .catch 兜底,不建议在 .then 里写第二个参数(除非你明确知道只想捕获前一步的错)。
五、async / await:ES8 的终极语法糖
5.1 为什么还需要 async/await?
Promise + then 已经比回调好很多了,但当流程特别长时,链还是显得有点啰嗦:
javascript
getUsers()
.then(users => {
console.log('用户数:', users.length);
return getUserDetail(users[0].id);
})
.then(detail => {
console.log('详情:', detail);
return getOrders(detail.orderIds);
})
.then(orders => render(orders));
每个 .then 都是一个独立的作用域,变量不能跨层访问。ES2017(ES8)带来的 async/await,把这条链彻底拍平成顺序语句 ------异步代码同步化。
5.2 两个关键字
async:写在函数前,让这个函数自动返回 Promise 。函数里return的值会被包成Promise.resolve(value)。await:只能在async函数里用,暂停函数执行,等右边的 Promise resolve,直接拿到结果再继续。
javascript
async function renderUsers() {
const res = await fetch('http://localhost:3000/users');
const data = await res.json();
console.log(data);
}
renderUsers();
对比一下 .then 链和 async/await:
javascript
// .then 链
fetch(url)
.then(res => res.json())
.then(data => { users = data; render(); });
// async/await
async function load() {
const res = await fetch(url);
const data = await res.json();
users = data;
render();
}
整个作用域共享变量,阅读顺序就是代码顺序,可读性直接拉满。
5.3 错误处理:try/catch 取代 .catch
在 async 函数里,.catch 换成了熟悉的 try/catch:
javascript
async function safeFetch() {
try {
const res = await fetch(url);
const data = await res.json();
return data;
} catch (err) {
console.error('出错了:', err);
}
}
不管是 fetch 失败还是 res.json() 解析失败,catch 都能兜住,一处兜底,整段安全。
5.4 async/await 的本质
划重点:async/await 本质还是 Promise ,它只是语法糖------没有 Promise 就没有 async/await。await 暂停时,JS 不会真的"卡住线程",而是把 async 函数后面的部分注册成微任务(Promise.then),等同步代码跑完再回来继续------这就是"同步错觉"。
所以:await 下面的代码,比同级的 setTimeout 先执行(因为是微任务 vs 宏任务)。
5.5 并发陷阱:别把独立请求写成串行
await 是串行 的------写了两个 await,就一定是等第一个完成再发第二个。如果两个请求之间没有依赖 ,应该用 Promise.all 并发:
javascript
// ❌ 串行:慢一倍(200ms)
async function slow() {
const a = await fetch('/api/a'); // 等 100ms
const b = await fetch('/api/b'); // 再等 100ms
}
// ✅ 并发:同时发(100ms)
async function fast() {
const [a, b] = await Promise.all([
fetch('/api/a'),
fetch('/api/b')
]);
}
判断标准 :请求之间有依赖 → 串行 await;独立 → Promise.all 并发。
六、进化史全景图
一张表总结 JS 异步流程控制的四个阶段:
| 阶段 | 方案 | 优点 | 缺点 |
|---|---|---|---|
| 1 | 同步阻塞 | 简单、直观 | 慢任务卡死线程 |
| 2 | 回调 + Event Loop | 不阻塞主线程 | 嵌套深 → 回调地狱 |
| 3 | Promise + .then | 链式拍平、错误分离、静态方法组合灵活 | 长链略啰嗦,作用域割裂 |
| 4 | async/await(ES8) | 顺序语句、可读性最高 | 独立请求要注意别写成串行 |
核心规律 :每一代方案都是在上一代的基础上,优化可读性 、降低心智负担------底层机制(单线程 + Event Loop + 宏微任务)从未变过。
七、Promise 高频面试题速解
7.1 说一下 Promise 的三种状态?
Pending(进行中)→Fulfilled(已成功,调用resolve)Pending(进行中)→Rejected(已失败,调用reject)- 状态一旦变更就不可逆,也不能再次变更
7.2 Promise.all / race / allSettled / any 的区别?
见 4.5.6 的对比表。抓关键词就不会答错:
- all:全成才成,一损俱损
- race:看谁最快,成败都算
- allSettled:全部跑完,从不 reject
- any:成一个就行,全败才 AggregateError
7.3 .then 不传函数会怎样?(穿透 / 值透传)
.then / .catch 如果传的不是函数,会被当作"忽略处理",上一步的值原样透传给下一级------典型面试题代码见 4.6 节。
7.4 .then 的第二个参数和 .catch 有啥区别?
.then(res, rej)的rej只捕捉前一步 Promise 的错,捕捉不到同层res里抛出的错.catch能捕捉它之前整条链上任意一步 的异常 → 日常推荐链尾统一.catch兜底
7.5 Promise 和 setTimeout 谁先执行?
经典题,2.2 节已讲:先同步代码,再清空微任务队列(Promise.then / catch / finally),最后才执行宏任务(setTimeout)------即使 setTimeout 延时写 0 也是如此。
八、结语:怎么选?
到这里你应该已经对 JS 异步流程控制和 Promise 有了完整的认知:
- 写简单、独立的异步逻辑 → 直接
Promise配.then/.catch - 写长流程、有业务依赖的代码 → 优先
async/await,社区主流写法 - 多个独立请求全成功才继续 →
Promise.all - 请求允许部分失败也要拿结果 (如批量导入) →
Promise.allSettled - 多个接口任取一个最快的成功 (如多 CDN) →
Promise.any - 请求超时控制 →
Promise.race([fetch, timeout]) - 面试问到 Event Loop → 口诀「同步 → 清空微任务 → 下一个宏任务 → 清空微任务 → ...」+ 宏任务 / 微任务典型 API
一句话收尾:Promise 把"未来才有的结果"变成了"现在就能链式处理的对象",而 async/await 把这条链"拍平"成了像同步代码一样的顺序语句------它们做的是同一件事,只是写法一代比一代优雅。