先看一段浏览器里的 JavaScript:
console.log("start");
setTimeout(() => console.log("timeout"), 0);
Promise.resolve().then(() => console.log("promise"));
console.log("end");
输出是:
start
end
promise
timeout
setTimeout(..., 0) 明明更早出现,为什么反而最后执行?
答案不在“0 毫秒”里,而在三件事上:当前同步代码要先运行完,Promise 回调进入微任务队列,定时器回调则要等到后续任务。理解这三层关系,Callback、Promise、async/await 以及 Node.js 里的各种顺序题就有了共同的分析基础。
1. 异步改变的是等待方式
先把几组经常混用的概念拆开。
- 同步 / 异步描述调用方何时拿到结果。同步调用在返回前给出结果;异步调用先返回,结果以后通过回调、Promise 或事件继续传递。
- 阻塞 / 非阻塞描述线程在等待期间能否继续做别的事。阻塞会让执行线程停在那里,非阻塞则把等待交出去。
- 串行 / 并行描述任务是否同时推进。异步代码可以串行执行,也可以发起并发;异步本身不等于多线程并行。
例如冲咖啡:按下咖啡机后站在原地等,是同步且阻塞;按下后先去烤面包,咖啡完成再回来取,是异步且非阻塞。咖啡机和烤面包机是否真的同时工作,则是另一个维度的问题。
JavaScript 常被称为单线程语言,更准确地说,是同一个 JavaScript agent 在任一时刻只执行一个 Job,并且当前 Job 会运行到完成。网络、定时器、文件系统等能力通常由浏览器或 Node.js 这样的宿主提供;等待结束后,宿主再把后续工作安排回来。

同步 / 异步关注结果何时回来,阻塞 / 非阻塞关注等待时线程能否继续工作。
可以先记住一个最小模型:JavaScript 负责执行当前代码,宿主负责等待,队列负责保存后续工作,事件循环负责选择下一次执行机会。
2. 浏览器如何安排一次事件循环
浏览器里的事件循环可以先压缩成三个动作:
- 选择一个可运行的 task,例如脚本、定时器回调或部分事件回调。
- 这个 task 执行结束后,清空 microtask queue。
- 浏览器获得渲染机会,然后进入下一轮。
常见的微任务来源包括 Promise reaction、queueMicrotask() 和 MutationObserver。async/await 也建立在 Promise 之上:执行到 await 时,函数暂停;被等待的 Promise settle 后,函数剩余部分作为后续 Job 恢复。
现在回看开头的输出顺序:
start和end属于当前脚本 task,先连续执行。.then(...)注册的回调进入微任务队列。- 定时器到期只代表回调具备了排队资格,它仍要等待后续 task。
- 当前脚本结束后,浏览器先清空微任务,于是
promise先于timeout。
这也解释了一个常见现象:如果微任务不断追加新的微任务,浏览器就迟迟拿不到渲染机会,页面依然会卡。异步 API 不会自动让主线程变空闲,真正耗时的 JavaScript 仍会占住调用栈。

当前 task 结束后会先进行微任务检查点;微任务清空,浏览器才可能渲染并进入下一轮。
3. Node.js 为什么又不一样
Node.js 也运行 JavaScript,但宿主机制与浏览器不同。它通过 libuv 提供事件循环和异步 I/O 能力:
- 不同类型的回调在不同 phase 中执行。
- 网络 I/O 会尽量使用操作系统提供的高效机制。
- 一些无法直接异步化的工作会交给线程池,例如部分文件系统操作和 DNS lookup。
- 活跃的 timer、socket、server 等资源还会影响进程是否继续存活。
理解 Node.js 的关键,是把“JavaScript 只有一个主执行线程”和“宿主可以并发处理等待工作”分开。

浏览器更关注任务、微任务与渲染;Node.js 更关注 I/O phase、process.nextTick、Promise microtask 以及进程存活。
3.1 process.nextTick() 与 Promise microtask
在 Node.js CommonJS 中,下面这段代码通常输出:
console.log("start");
process.nextTick(() => console.log("nextTick"));
Promise.resolve().then(() => console.log("promise"));
console.log("end");
start
end
nextTick
promise
process.nextTick() 严格来说不是事件循环的一个 phase。它的队列会在当前操作完成后、事件循环继续推进前被处理,优先级高于 Promise microtask。递归地安排太多 nextTick,可能让 I/O 长时间得不到机会。
运行环境也要写清楚。同样的顶层代码放进 ES Module 时,模块本身可能已经运行在 Promise microtask 的上下文里,顺序会与 CommonJS 不同。
3.2 setTimeout(0) 与 setImmediate()
下面这段顶层代码的先后顺序不能脱离环境硬背:
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
两者受事件循环进入时机、计时精度和运行上下文影响。若在 I/O 回调中安排,setImmediate() 通常会在下一轮 timers 之前的 check phase 执行。Node.js 20 起,timers 的执行时机也随 libuv 1.45.0 调整,因此版本信息是顺序分析的一部分。
4. 从 Callback 到 async/await
事件循环解释“什么时候继续执行”,Callback、Promise 和 async/await 则是在表达“结果出来以后做什么”。它们处理的是同一条依赖链,只是组织方式逐渐变得清晰。
假设业务要先读取用户,再根据用户读取订单。回调写法会把控制流嵌进每一层:
loadUser(1, (userError, user) => {
if (userError) return handleError(userError);
loadOrders(user.id, (orderError, orders) => {
if (orderError) return handleError(orderError);
renderOrders(orders);
});
});
Promise 把“将来得到结果”变成一个可以组合的值。链式调用让正常路径保持线性,错误也能沿链传播:
function assertOk(response) {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response;
}
fetch("/api/users/1")
.then(assertOk)
.then((response) => response.json())
.then((user) => fetch(`/api/users/${user.id}/orders`))
.then(assertOk)
.then((response) => response.json())
.then(renderOrders)
.catch(handleError);
这里显式检查了 response.ok,因为 fetch() 收到 404 或 500 时通常不会自动 reject;只有网络错误等情况才会直接进入 rejection。
async/await 继续沿用 Promise 的语义,只把代码改写成更接近普通顺序流程的样子:
async function loadUserAndOrders(userId) {
const userResponse = assertOk(await fetch(`/api/users/${userId}`));
const user = await userResponse.json();
const orderResponse = assertOk(
await fetch(`/api/users/${user.id}/orders`),
);
return orderResponse.json();
}
async function main() {
try {
const orders = await loadUserAndOrders(1);
renderOrders(orders);
} catch (error) {
handleError(error);
}
}
main();
从历史上看,jQuery Deferred 很早就把状态和回调组织进统一对象;ES2015 标准化了 Promise 与 Generator;ES2017 引入 async/await。Generator 展示了函数可以暂停并恢复,是重要的过渡机制,但日常业务代码通常直接使用 Promise 与 async/await。

语法不断变化,核心目标始终是更清楚地表达依赖关系、结果传递和错误路径。
5. 真正常出错的是依赖关系
5.1 该串行的任务被并发了
如果任务存在前后依赖,就应该显式串行:
for (const item of items) {
await save(item);
}
如果任务彼此独立,可以一起发起,再统一等待:
const results = await Promise.all(items.map((item) => save(item)));
下面这种写法经常制造误解,因为 forEach 不会等待回调返回的 Promise:
items.forEach(async (item) => {
await save(item);
});
console.log("done"); // 此时保存操作可能尚未完成
5.2 用定时器猜完成时间
把 setTimeout 当成“等一会儿应该就好了”,本质上是在猜。定时器只能表达“不早于某个时间再尝试执行”,无法证明依赖已经完成。可靠的代码应该等待真正的完成信号,例如 Promise settle、事件触发或明确的回调。
5.3 错误跨过了异步边界
外层 try/catch 捕获不到未来 task 里抛出的异常:
try {
setTimeout(() => {
throw new Error("boom");
}, 0);
} catch (error) {
// 不会执行
}
错误需要在回调内部处理,或转换为 Promise rejection 后沿链传播。每个 Promise 也都应该被 await、return、组合或显式 .catch();悬空的 Promise 会让错误和生命周期一起失控。
5.4 主线程仍然被占满
把代码放进 async function 不会自动把计算移到后台。大循环、重计算和同步 I/O 仍会阻塞当前 JavaScript 线程。需要时应拆分工作、让出执行权,或者使用 Worker / Worker Threads 等真正的并行能力。
6. 一段异步代码应该怎么分析
先不要急着猜最终输出。更稳妥的办法是分三遍看:
第一遍只画控制边界:当前同步段在哪里结束,哪些操作交给宿主,后续代码会进入哪类队列。第二遍沿数据依赖往下走,确认哪些步骤必须等待、哪些可以并发,以及错误由谁接住。第三遍才加入调度细节,标注浏览器或 Node.js、CommonJS 或 ES Module,以及可能影响顺序的运行时版本。

从环境和同步段开始,沿着异步边界、队列、依赖和错误路径推进,最后再判断进程与调度结果。
最后再模拟顺序:当前调用栈先运行到空,随后处理约定的微任务检查点,再让事件循环选择下一个 task 或 phase。这样分析得到的结论,既能解释日志顺序,也能指出代码里真正不可靠的依赖。
7. 异步最后解决的是时间解耦
把视野从一段代码拉到整个系统,消息队列、事件驱动服务和任务调度器都在处理相似的问题:工作何时完成、结果如何通知、失败由谁接住、背压如何控制。事件循环是理解这些系统的一个很小但很典型的入口。
具体的输出顺序可能随宿主和版本变化,分析方法则相对稳定:先找同步执行段,再找宿主与队列,最后沿依赖链和错误链确认行为。掌握这套方法之后,异步代码就不再是一组需要背诵的规则,而是一张可以逐层展开的时间关系图。
References
- ECMAScript® 2026 Language Specification: Jobs and Host Operations
- WHATWG HTML Standard: Event loops
- MDN: JavaScript execution model
- MDN: Microtask guide
- MDN: Promise
- MDN: async function
- MDN: Using the Fetch API
- Node.js: The Node.js Event Loop
- Node.js: Understanding process.nextTick()
- jQuery API: deferred.promise()
Discussion
留言与讨论
想法、补充和不同意见都欢迎。