JavaScript 是单线程语言,却能在浏览器中同时处理网络请求、用户交互、动画渲染与定时任务——这背后的功臣,正是"回调函数"与"事件循环"。很多开发者写了多年 JS,依然会在 setTimeout(fn, 0) 与 Promise.then 的先后顺序上栽跟头,也会在"为什么页面卡住了"这类问题前束手无策。本文将从最基础的同步与异步模型讲起,深入回调函数的本质、回调地狱的演进、事件循环的完整运转机制、宏任务与微任务的执行顺序、Promise 与 async/await 的底层调度、Node.js 事件循环的六个阶段,以及真实项目中的调试与优化技巧。30 分钟系统学习,让你真正"看见"JS 的运行轨迹。
为什么需要异步编程
要理解异步,先要理解"阻塞"。JavaScript 只有一个主线程(调用栈),任何一段耗时同步代码都会让整个页面"僵住"——点击无响应、动画卡顿、滚动失效。异步编程的目标,就是把耗时的等待从主线程中"挪走"。
function syncTask() {
const start = Date.now();
while (Date.now() - start < 3000) {}
console.log('3秒后才会执行');
}
console.log('开始');
syncTask(); // 页面在这里冻结 3 秒
console.log('结束');
/* 输出顺序:开始 → 3秒后... → 结束 */
异步的本质:把等待交给宿主环境,把结果还给主线程
浏览器提供了多种异步能力,它们由不同的"宿主 API"实现:setTimeout / setInterval 由定时器线程管理,fetch / XMLHttpRequest 由网络线程管理,DOM 事件由事件触发线程管理。这些线程完成后,会把回调函数"排队"送进任务队列,等待主线程空闲时执行。
回调函数:异步编程的起点
回调函数(Callback)就是把一个函数作为参数传给另一个函数,在合适的时机由对方"回头调用"。它是 JavaScript 中最早、也最基础的异步解决方案,因为函数是一等公民,可以作为值传递。
function greet(name, callback) {
console.log(`你好, ${name}`);
callback();
}
greet('张玥', () => console.log('回调已执行'));
/* 2. 异步回调:定时器 */
function delay(ms, cb) {
setTimeout(cb, ms);
}
delay(1000, () => console.log('1秒后执行'));
/* 3. 错误优先回调(Node.js 风格) */
fs.readFile('data.json', (err, data) => {
if (err) return console.error(err);
console.log(data);
});
Array.prototype.map、forEach,立即执行setTimeout、addEventListener,将来执行回调的哲学是"控制反转":你把一段逻辑交给别人,由别人决定何时调用。这带来了灵活性,也带来了风险——你无法保证它会被调用、会被调用几次、会以什么顺序调用。
回调地狱:金字塔的诅咒
当多个异步操作存在依赖关系时,回调会层层嵌套,形成难以阅读和维护的"回调地狱"(Callback Hell),也叫"厄运金字塔"(Pyramid of Doom)。
getUser(userId, (err, user) => {
if (err) return handle(err);
getOrders(user.id, (err, orders) => {
if (err) return handle(err);
getOrderDetail(orders[0].id, (err, detail) => {
if (err) return handle(err);
getProduct(detail.productId, (err, product) => {
if (err) return handle(err);
render(product);
});
});
});
});
缩进越来越深,错误处理重复,流程无法复用
回调地狱的核心问题不只是"丑":错误处理要在每一层重复写、无法用 try/catch 捕获异步错误、无法方便地做并行或竞速、无法中断流程。于是 Promise 应运而生。
Promise:把嵌套拉平成链式
Promise 代表一个"未来才会知道结果"的值,它有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦改变就不可逆,且会永久保持。
getUser(userId)
.then(user => getOrders(user.id))
.then(orders => getOrderDetail(orders[0].id))
.then(detail => getProduct(detail.productId))
.then(product => render(product))
.catch(err => handle(err)) // 统一错误处理
.finally(() => hideLoading());
/* 并行:全部完成 */
Promise.all([fetchA(), fetchB(), fetchC()])
.then(([a, b, c]) => console.log(a, b, c));
/* 竞速:谁快用谁 */
Promise.race([fetchFast(), timeout(3000)]);
/* 容错:拿到第一个成功的结果 */
Promise.any([mirrorA(), mirrorB()]);
/* 等待所有结果(无论成败) */
Promise.allSettled([taskA(), taskB()]);
全部成功
最快结果
首个成功
全部落定
状态不可逆:一旦落定,永久保持
事件循环:JS 的心脏
事件循环(Event Loop)是 JavaScript 实现异步的核心调度机制。理解它,需要先认识三个关键角色:调用栈(Call Stack)、任务队列(Task Queue)、Web APIs。
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
Promise.resolve().then(() => {
console.log('3');
});
console.log('4');
/* 输出:1 → 4 → 3 → 2 */
/* 同步 → 微任务 → 宏任务 */
事件循环的运行规则可以概括为一句话:同步代码全部执行完毕(调用栈清空)后,先清空所有微任务,再取出一个宏任务执行,执行完再清空微任务,如此循环往复。这个顺序决定了你在控制台看到的一切。
宏任务与微任务全解析
任务分为两大类,它们的优先级与执行时机完全不同。记住这条铁律:微任务的优先级永远高于宏任务,且每个宏任务执行完毕后,都会把微任务队列"榨干"。
setTimeout(() => console.log('宏任务1'), 0);
Promise.resolve()
.then(() => {
console.log('微任务1');
Promise.resolve().then(() => console.log('微任务2'));
})
.then(() => console.log('微任务3'));
setTimeout(() => console.log('宏任务2'), 0);
/* 输出:微任务1 → 微任务2 → 微任务3 → 宏任务1 → 宏任务2 */
一个常见的误区是:setTimeout(fn, 0) 并不是"立即执行",它的最小延迟通常是 4ms(HTML 规范规定嵌套超过 5 层后最小为 4ms),而且必须等当前宏任务和所有微任务执行完才会轮到它。
渲染时机:什么时候会重绘
浏览器并不是每执行一行代码就重绘一次。渲染发生在事件循环的特定节点:当前宏任务与其产生的所有微任务执行完毕后,浏览器才有机会进行样式计算、布局与绘制。
document.body.textContent = '初始';
/* 错误示范:阻塞 3 秒,用户只看到最终结果 */
const end = Date.now() + 3000;
while (Date.now() < end) {}
document.body.textContent = '更新后';
/* 正确示范:让出主线程 */
document.body.textContent = '加载中...';
setTimeout(() => {
document.body.textContent = '加载完成';
}, 0);
/* 动画首选:requestAnimationFrame */
function animate() {
box.style.transform = `translateX(${x++}px)`;
if (x < 300) requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
绘制前执行
空闲时执行
长任务会挤掉渲染时间,导致掉帧
async/await:语法糖背后的真相
async/await 让异步代码看起来像同步代码,但它的底层依然是 Promise 和微任务。await 之后的代码,相当于被放进了 .then() 回调里。
async function foo() {
return 1; // 等价于 Promise.resolve(1)
}
/* await 会暂停函数,让出主线程 */
async function bar() {
console.log('A');
await null; // 即使 await 非 Promise,也产生一次微任务
console.log('B');
}
console.log('C');
bar();
console.log('D');
/* 输出:C → A → D → B */
/* 并行执行,避免串行等待 */
const [a, b] = await Promise.all([fetchA(), fetchB()]);
const r = await p;
use(r);
}
// 大致等价于
function f() {
return p.then(r => { use(r); });
}
注意:await 会暂停当前 async 函数的执行,但不会阻塞整个主线程。暂停的只是函数内部的后续代码,它们会被包装成微任务,等 Promise 落定后重新排入队列。
Node.js 事件循环的六个阶段
Node.js 的事件循环由 libuv 实现,比浏览器复杂得多,分为六个阶段依次循环执行。每个阶段都有一个 FIFO 回调队列,当队列耗尽或达到回调数量上限时,进入下一阶段。
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
/* 通常输出:nextTick → promise → timeout/immediate */
/* nextTick 优先级高于 Promise 微任务 */
/* I/O 回调中:immediate 一定早于 timeout */
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
六个阶段循环往复,每阶段之间清空 nextTick 与微任务队列
常见陷阱与面试高频题
事件循环是面试重灾区,也是生产环境 bug 的高发地带。下面这些坑,几乎每个前端都踩过。
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
/* 输出 3 3 3(var 无块级作用域) */
/* 改用 let,输出 0 1 2 */
/* 陷阱2:await 在循环中串行 */
for (const id of ids) {
await fetchItem(id); // 一个接一个,很慢
}
/* 改为:await Promise.all(ids.map(fetchItem)) */
/* 陷阱3:未捕获的 Promise 拒绝 */
fetch('/api').then(r => r.json());
/* 缺少 .catch,会触发 unhandledrejection */
/* 陷阱4:微任务死循环 */
function loop() {
Promise.resolve().then(loop); // 永远清不完,页面卡死
}
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
queueMicrotask(() => console.log(4));
console.log(5);
- 同步代码永远最先执行
- 微任务永远优先于宏任务
- 每个宏任务后,微任务队列必须清空
调试与性能优化实践
理解了原理,还要落到实战。以下是处理长任务、优化异步流程的常用手段。
performance.mark('start');
await heavyTask();
performance.mark('end');
performance.measure('heavy', 'start', 'end');
/* 2. 时间切片:把长任务拆成小块 */
async function chunked(items) {
for (let i = 0; i < items.length; i++) {
process(items[i]);
if (i % 100 === 0) {
await new Promise(r => setTimeout(r));
}
}
}
/* 3. 利用空闲时间执行低优先级任务 */
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && tasks.length) {
tasks.shift()();
}
});
/* 4. 取消过期请求,避免竞态 */
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal });
ctrl.abort(); // 取消请求
让每一次异步等待都可被观测、可被优化
异步编程最佳实践
- 优先使用 async/await:可读性最好,配合
try/catch处理错误 - 并行优于串行:无依赖的请求用
Promise.all一起发出 - 永远处理错误:不要留下没有
catch的 Promise 链 - 避免长任务:超过 50ms 的同步任务会阻塞渲染,注意拆分
- 不要在微任务里写死循环:微任务队列清空之前,渲染永远不会发生
- 动画用 requestAnimationFrame:与屏幕刷新同步,避免掉帧
- 低优先级任务用 requestIdleCallback:把空闲时间利用起来
- 及时取消请求:使用
AbortController防止竞态与内存泄漏
/* 同步代码 → 微任务 → 渲染 → 宏任务 → 循环 */
/* 一个健壮的异步函数模板 */
async function loadData(url, { signal } = {}) {
try {
const res = await fetch(url, { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (err) {
if (err.name === 'AbortError') return null;
console.error('加载失败:', err);
throw err;
}
}
异步编程的难点,从来不在于"怎么写",而在于"怎么想"。当你能够在大脑中模拟出调用栈的压入弹出、微任务的排队与清空、宏任务的轮转与渲染的插入时机时,那些曾经诡异莫测的输出顺序,就会变得理所当然。回调函数是起点,Promise 是转折,async/await 是表达方式,而事件循环,才是这一切背后真正运转的引擎。