
引言部分:为什么你写的 setTimeout 总是不准时?
先看一道经典面试题,请在不运行的情况下说出输出顺序:
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
});
console.log('5');
// 你的答案:_______
如果你的答案是 1 3 5 2 4 或者 1 2 3 4 5,那这篇文章就是为你准备的。正确答案是 1 3 5 4 2。
为什么 setTimeout 明明是 0 毫秒,却排在 then 后面?这背后藏着 JavaScript 异步机制最核心的秘密——事件循环中的优先级。
一、从操作系统说起:进程是董事长,线程是经理
要理解 JS 的异步,得先搞清楚操作系统怎么"分活儿"。
打个比方:把操作系统比作一家公司。
- 进程(Process) 是公司的董事长,操作系统给每个进程分配一个唯一的 PID(Process ID),董事长手里握着公司全部资源——内存、文件句柄、网络端口,他说了算
- 线程(Thread) 是部门经理,真正干活的人。一个进程可以启动一个主线程,也可以启动多个子线程
- CPU 执行时间是公司的预算,不能让你一个人霸占——操作系统用时间片轮询的方式,每隔几十毫秒就把 CPU 从进程 A 手里抢过来,分配给进程 B,谁也别想独占
像 C++、Java 这些系统级语言,天生支持多线程。就像一家公司有市场部、研发部、财务部三个部门同时干活:
// C++/Java 可以这样干——三个变量,三个线程并发声明
// 线程1:let a = 1;
// 线程2:let b = 2;
// 线程3:let c = 3;
// 理论上只需2步(并发执行),效率极高
但多线程带来了巨大的心智负担——部门之间要协调、要开会、要抢会议室(线程同步、互斥锁、死锁)。写多线程代码就像同时让三个经理在一张纸上写字还不许涂改——想想就头疼。
二、JS 的设计哲学:只要一个收银员的便利店
JavaScript 从诞生之初就走了另一条路——单线程。
// JS 只能这样干——老老实实,一步一步来
let a = 1;
let b = 2;
let c = 3;
console.log(a + b + c);
为什么这么"傻"?这不是 Brendan Eich 偷懒,而是一个深思熟虑的设计哲学选择:用简单换可控。
JS 的设计哲学:足够简单。永远不用担心两个操作同时修改同一个变量。
代价当然有——来个耗时计算,整个页面直接卡死:
// 一个耗时同步任务,10秒内页面完全"假死"
let result = 0;
for (let i = 0; i < 10000000000; i++) {
result += i;
}
// 这10秒里,用户点击、滚动、输入,统统没反应——收银员在处理一个"超级顾客"
console.log(result);
那问题来了:现实中我们有一大堆"耗时但不费脑"的活儿——setTimeout 等几秒、fetch 等网络返回、事件监听等用户点击。单线程怎么处理这些?
答案就是——收了订单先放一边,别堵着收银台。

三、JS 执行机制的完整流程
不管是浏览器里的 <script> 标签,还是 Node.js/Bun 的服务端代码,JS 的执行流程都是同一套:
第一步:操作系统启动一个进程(分配 PID,配发资源)
↓
第二步:进程启动一个主线程(JS 只需要一个经理干活,简单!)
↓
第三步:主线程开始执行代码
├── 遇到同步代码 → 立刻执行,快速跑完
│ (用户需要看到的页面内容在这里渲染)
│
└── 遇到异步任务(setTimeout、fetch、事件监听)
→ 不执行,跳过!扔到 Event Loop 里去排队
→ 继续往下跑同步代码
↓
第四步:同步代码全部执行完毕,调用栈清空
↓
第五步:Event Loop 把之前排队等着的异步回调,一个一个拿出来执行
用收银员的比喻重讲一遍:
收银员不会自己去后厨做咖啡。他把咖啡订单交给后厨(Web APIs),继续服务下一位顾客(同步代码)。后厨做完了把咖啡放到取餐区(任务队列)。收银员只有在处理完眼前所有顾客后,才去取餐区把做好的端出来。
核心规则:调用栈清空后,Event Loop 才从任务队列中取回调执行。
看一段最简单的代码验证:
console.log('start'); // 同步,立刻执行
setTimeout(() => {
console.log('timer'); // 异步回调,1秒后才进队列
}, 1000);
console.log('end'); // 同步,立刻执行
执行流程一步一步拆:
输出:start → end → timer
这就是很多人初学 JS 时觉得"反直觉"的地方——明明写在中间,却最后输出。但理解了 Event Loop 的"先跳过、回头再取"机制,反直觉就变成了直觉。
JS 有哪些异步任务?
总结一下,JS 中常见的异步任务包括:
| 定时器 | setTimeout、setInterval | 到时间了把回调塞进队列 |
| 网络请求 | fetch、XMLHttpRequest | 请求回来了把回调塞进队列 |
| 事件监听 | click、scroll、keydown | 用户触发了把回调塞进队列 |
| I/O 操作 | 文件读写(Node.js) | 读写完了把回调塞进队列 |
它们的模式都一样:主线程只管"发起",真正的等待由底层去扛,扛完了把回调丢回来。
四、微任务与宏任务:同一个取餐区,两条队伍
4.1 两条队伍的区别
上面的故事其实只讲了一半。真正的深度在于:任务队列不止一条。
实际上有两支队伍:
| 宏任务 (Macro Task) | setTimeout、setInterval、I/O 回调、UI 渲染 | 普通顾客,按号排队 |
| 微任务 (Micro Task) | Promise.then/catch/finally、MutationObserver、queueMicrotask | VIP 顾客,随到随优先 |
规则升级为:
每执行完一个宏任务,都必须把微任务队列清空,才能执行下一个宏任务。
而且——清空微任务的过程中,新加入的微任务也会在本轮被清掉(直到队列为空)。
4.2 面试题的真相
现在回头看引言那道题:
console.log('1'); // ① 同步,立刻输出
setTimeout(() => { // ② 注册宏任务A
console.log('2');
}, 0);
new Promise((resolve) => {
console.log('3'); // ③ executor 是同步的!立刻输出
resolve(); // ④ 把 then 回调注册进微任务队列
}).then(() => {
console.log('4'); // ⑤ 这是微任务a
});
console.log('5'); // ⑥ 同步,立刻输出
// ========== 本轮宏任务(script整体)结束 ==========
// 微任务队列:[微任务a]
// → 执行微任务a:输出 4
// 微任务队列清空 ✓
// ========== 取出下一个宏任务 ==========
// → 执行宏任务A:输出 2
最终输出:1 3 5 4 2
关键知识点:new Promise 的 executor 函数是同步执行的,只有 .then() 里的回调才是异步微任务。
很多人就栽在这一步——以为 Promise 整个都是异步的,其实 Promise 是一个容器,executor 只是同步"造容器",容器的内容(异步结果)触发的回调(then/catch)才走微任务通道。
五、深入 Promise:一个"承诺"容器的里里外外
5.1 Promise 是什么?
Promise 是 ES6 引入的,用于控制异步任务执行流程的最佳机制。它不是用来"创造异步"的,而是用来管理异步的。
打个比方:Promise 是一个**“耗时性任务的容器”**。它像你在餐厅点餐后拿到的小票——票本身是同步拿到手的(executor 立即执行),但菜做好没有,你得等(异步)。
实例化一个 Promise 需要传入一个函数——executor(执行器):
const p = new Promise((resolve, reject) => {
// 这个函数叫 executor,它会立即执行!
console.log('承诺已立下');
// 里面放你的耗时异步任务
setTimeout(() => {
// resolve(666); // 成功:手动调用 resolve,数据传给 then
reject('网络出错'); // 失败:手动调用 reject,错误原因传给 catch
}, 2000);
});
// Promise 原型链上挂着三个关键方法:
console.log(p.__proto__); // then, catch, finally 都在上面
p
.then((data) => {
console.log(data); // resolve(666) 的值到这里
console.log('成功了');
})
.catch((err) => {
console.log(err); // reject('网络出错') 的值到这里
console.log('失败了');
})
.finally(() => {
console.log('不管成败,我都会执行'); // finally 总是执行
});
核心要点:
new Promise(executor)
│
▼
executor 立即执行(同步)
│
├── resolve(result) ──→ .then(callback) 被调用(微任务)
│
└── reject(reason) ──→ .catch(callback) 被调用(微任务)
│
└──→ .finally() 兜底执行(微任务)
注意:resolve 和 reject 都需要你在 executor 里面手动调用。引擎不会自动知道你啥时候成功——你得在耗时任务完成的那一刻自己喊一声"好了"。
5.2 Promise 是一个状态机
Promise 本质上是一个状态机,只有三种状态:
pending (等待中——小票拿到了,菜还没好)
/ \\
▼ ▼
fulfilled rejected
(已兑现) (已拒绝)
状态一旦改变就凝固了——不可逆,不可二次修改。就像你在餐厅的承诺小票:菜要么端上来(fulfilled),要么告诉你卖完了(rejected),不存在"上了一半又说没货"。
5.3 手写一个 Promise
理解 Promise 最好的方式,就是自己造一个:
class MyPromise {
constructor(executor) {
this.state = 'pending'; // 当前状态
this.value = undefined; // 成功的值(从 resolve 来)
this.reason = undefined; // 失败的原因(从 reject 来)
this.onFulfilledCallbacks = []; // 收集 .then() 的回调
this.onRejectedCallbacks = []; // 收集 .catch() 的回调
const resolve = (value) => {
if (this.state !== 'pending') return; // 状态凝固,不可逆
this.state = 'fulfilled';
this.value = value;
// 触发所有提前注册的 then 回调(适用于异步场景)
this.onFulfilledCallbacks.forEach(fn => fn());
};
const reject = (reason) => {
if (this.state !== 'pending') return; // 状态凝固
this.state = 'rejected';
this.reason = reason;
this.onRejectedCallbacks.forEach(fn => fn());
};
try {
executor(resolve, reject); // executor 立即执行!
} catch (err) {
reject(err); // executor 里抛异常 → 自动 reject
}
}
then(onFulfilled, onRejected) {
// 核心:then 必须返回一个新 Promise,链式调用全靠这个
return new MyPromise((resolve, reject) => {
const handleFulfilled = () => {
queueMicrotask(() => { // 关键!包装成微任务
try {
const result = onFulfilled(this.value);
if (result instanceof MyPromise) {
result.then(resolve, reject); // 返回 Promise 则"穿透"
} else {
resolve(result); // 返回值包装为新 Promise 的成功值
}
} catch (err) {
reject(err);
}
});
};
if (this.state === 'fulfilled') {
handleFulfilled(); // 已经解决了,直接加入微任务队列
} else if (this.state === 'pending') {
this.onFulfilledCallbacks.push(handleFulfilled); // 还没解决,先存着
}
});
}
}
这份简化实现揭示了 Promise 的四个核心秘密:
5.4 Promise 静态方法的本质
理解了基本结构,那些 Promise.all、Promise.race 不过是"批量操作的语法糖":
// Promise.all:所有承诺都兑现,才算成功(有一个失败就整体失败)
// 本质:维护一个计数器,每个 then 里 counter++,
// counter === 总数时 resolve(results)
// Promise.race:谁先跑完听谁的(别的不管)
// 本质:给每个 promise 都绑定同样的 resolve/reject,
// 第一个触发的就能改状态,后面的因为状态凝固而失效
// Promise.allSettled:等所有人跑完,不管成败
// 本质:Promise.all 的"不抛弃不放弃"版,
// result.status 区分 'fulfilled' / 'rejected',永远 resolve
// Promise.any:任意一个成功就成功,全失败了才报错
// 本质:race 的"只要成功不要失败"版
六、实战:用 Promise 控制执行流程
6.1 场景:先拿用户列表,再拿每个用户详情
你的笔记里提到了一个非常经典的异步控制问题:
A. fetch users API 获取所有用户 B. fetch 每一个用户的详细信息
这个场景的核心痛点:B 必须在 A 完成后才能开始。用 Promise 来解决:
// 第一步:拿到所有用户
fetch('/api/users')
.then(response => response.json())
.then(users => {
// 第二步:每个用户都要查详情,又是一个并发 Promise
const detailPromises = users.map(user =>
fetch(`/api/users/${user.id}`).then(res => res.json())
);
return Promise.all(detailPromises); // 等所有详情都拿到
})
.then(allUserDetails => {
console.log('所有用户详情到手:', allUserDetails);
})
.catch(err => {
console.log('某一步出错了:', err);
});
这就是 Promise 的核心价值——控制异步任务的执行流程,让以前"回调地狱"的代码变得像流水线一样清晰。
6.2 fetch 的底层就是 Promise
fetch 是浏览器内置的 HTTP 请求 API,它的返回值就是 Promise:
console.log('start');
fetch('https://api.github.com/users/xz878787', {
method: 'POST',
})
.then((data) => {
console.log(data); // 这里拿到 Response 对象
})
.catch((err) => {
console.log('网络挂了:', err); // 只有网络级别的错误才会进这里
});
console.log('end');
// 输出:start → end → Response对象
fetch 有一个坑需要注意:catch 只捕获网络错误(断网、DNS 解析失败)。HTTP 状态码 404、500 不会触发 catch,需要自己判断 response.ok。
6.3 用 Promise 手写 sleep 函数
JS 系统本身不支持 sleep()(其他语言像 Python 有 time.sleep()),但我们可以用 Promise 轻易封装一个:
function sleep(ms) {
const p = new Promise((resolve, reject) => {
setTimeout(() => {
resolve(); // 时间到了,标记为"完成"
}, ms);
});
return p; // 把承诺返回出去,让调用方可以 .then()
}
// 使用:2秒后才执行后续代码
sleep(2000).then(() => {
console.log('2秒后执行');
});
配合 async/await 会让它更像原生的 sleep:
async function main() {
console.log('开始');
await sleep(2000); // 线程不会阻塞,Event Loop 照常运转
console.log('2秒后');
}
关键在于:sleep 用 setTimeout + Promise 的组合,实现了看起来是同步的暂停,实际上是异步的不阻塞——这就是 Promise 容器的妙用。
七、async/await:Generator 的语法糖衣
7.1 从回调地狱到同步写法
async/await 是 ES2017 引入的,是目前写异步代码最舒服的方式:
async function fetchUser() {
const response = await fetch('/api/user');
const user = await response.json();
return user;
}
这背后其实是 Generator + Promise 的语法糖。用 Generator 来模拟:
function* fetchUserGen() {
const response = yield fetch('/api/user');
const user = yield response.json();
return user;
}
// 需要一个"自动执行器"来递归推进 Generator
function runGenerator(gen) {
const g = gen();
function step(nextValue) {
const result = g.next(nextValue);
if (result.done) return Promise.resolve(result.value);
return Promise.resolve(result.value).then(
value => step(value), // 成功 → 推进到下一个 yield
err => g.throw(err) // 失败 → 抛出异常
);
}
return step();
}
await 的本质就是 yield——暂停当前函数,把控制权还给事件循环,等 Promise resolve 之后再恢复。区别是 async/await 内置了执行器,不需要手动写 runGenerator。
7.2 await 的微任务时机
await 后面的代码实际上被拆成了微任务。看个例子:
console.log('script start');
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end'); // ← 这行等于 .then(() => { ... })
}
async function async2() {
console.log('async2');
}
async1();
new Promise((resolve) => {
console.log('promise executor');
resolve();
}).then(() => {
console.log('promise then');
});
console.log('script end');
输出:
script start
async1 start
async2
promise executor
script end
async1 end
promise then
一步一步拆:
规律:await foo() 时,foo() 内部的同步代码是立刻执行的,只有 foo() 后面的代码变成了微任务。
八、终极面试题:所有知识点串起来
以下代码综合考察了 Event Loop、微任务/宏任务、Promise、async/await。在看答案之前,自己试着推一遍:
console.log('1');
setTimeout(() => {
console.log('2');
Promise.resolve().then(() => {
console.log('3');
});
}, 0);
new Promise((resolve) => {
console.log('4');
resolve();
}).then(() => {
console.log('5');
setTimeout(() => {
console.log('6');
}, 0);
});
console.log('7');
async function asyncFn() {
console.log('8');
await Promise.resolve();
console.log('9');
}
asyncFn();
答案是:1 4 7 8 5 9 2 3 6
用一张表完整拆解:
| 1 | 同步 console.log('1') | — | — | 1 |
| 2 | 注册 setTimeout(宏任务 T2) | [T2] | — | — |
| 3 | Promise executor 同步执行 → 输出 4 | [T2] | — | 4 |
| 4 | resolve() → 注册 .then()(微任务 M5) | [T2] | [M5] | — |
| 5 | 同步 console.log('7') | [T2] | [M5] | 7 |
| 6 | asyncFn() 同步部分 → 输出 8 | [T2] | [M5] | 8 |
| 7 | await Promise.resolve() → 挂起,注册恢复点 M9 | [T2] | [M5, M9] | — |
| 8 | 本轮宏任务结束,清空微任务 | |||
| 9 | 执行 M5 → 输出 5;注册新的 setTimeout(宏任务 T6) | [T2, T6] | [M9] | 5 |
| 10 | 执行 M9 → 输出 9 | [T2, T6] | 清空 ✓ | 9 |
| 11 | 下一轮:执行宏任务 T2 → 输出 2 | [T6] | — | 2 |
| 12 | T2 里注册微任务 M3 | [T6] | [M3] | — |
| 13 | T2 结束,清空微任务 → 执行 M3,输出 3 | [T6] | 清空 ✓ | 3 |
| 14 | 再下一轮:执行宏任务 T6 → 输出 6 | 清空 ✓ | — | 6 |
核心规律,记住这三条就不会再错:
九、追根溯源:为什么要有微任务?
最后反推一个根本问题:浏览器为什么要在宏任务之外,设计一个能"插队"的微任务?
答案在于用户感知到的响应速度。
假设没有微任务,Promise 回调和 setTimeout 一个待遇——统统进宏任务队列排队。那么这段代码:
button.addEventListener('click', () => {
Promise.resolve().then(() => {
updateUI(); // 更新界面
});
});
用户点完按钮后,updateUI 要等到下一个宏任务——中间可能被另一个 setTimeout 或 I/O 回调插队。用户就会感觉到"怎么点了没反应,过了一下才变"。
有了微任务,updateUI 在当前宏任务结束后立刻执行,赶在浏览器重绘之前就把数据更新好了。用户感觉就是——秒响应。
打个比方:宏任务是前台对外开放的办事窗口,微任务是内部员工专用的快速通道。 引擎需要保证自己的"善后工作"(Promise 链、DOM 观察)不被外部回调打断,所以给自己留了这条通道。
十、总结
JavaScript 的异步机制,本质是一套单线程下的任务调度艺术:
进程 → 主线程 → 同步代码先跑完 → Event Loop 取异步回调
│
┌────────────┴────────────┐
▼ ▼
微任务队列 宏任务队列
(VIP,本轮清空) (普通,下轮执行)




