回调函数与事件循环

张玥 2026年9月16日 阅读时间 30分钟
JavaScript 异步编程 事件循环 回调函数 Promise
异步编程之回调函数与事件循环

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 中最早、也最基础的异步解决方案,因为函数是一等公民,可以作为值传递。

/* 1. 最简单的回调 */
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,将来执行
函数是一等公民 控制反转 err-first

回调的哲学是"控制反转":你把一段逻辑交给别人,由别人决定何时调用。这带来了灵活性,也带来了风险——你无法保证它会被调用、会被调用几次、会以什么顺序调用。

回调地狱:金字塔的诅咒

当多个异步操作存在依赖关系时,回调会层层嵌套,形成难以阅读和维护的"回调地狱"(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);
      });
    });
  });
});
getUser
getOrders
getOrderDetail
getProduct
render

缩进越来越深,错误处理重复,流程无法复用

回调地狱的核心问题不只是"丑":错误处理要在每一层重复写、无法用 try/catch 捕获异步错误、无法方便地做并行或竞速、无法中断流程。于是 Promise 应运而生。

Promise:把嵌套拉平成链式

Promise 代表一个"未来才会知道结果"的值,它有三种状态:pending(进行中)、fulfilled(已成功)、rejected(已失败)。状态一旦改变就不可逆,且会永久保持。

/* Promise 链式调用 */
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()]);
pending fulfilled / rejected
all
全部成功
race
最快结果
any
首个成功
allSettled
全部落定

状态不可逆:一旦落定,永久保持

事件循环: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 */
/* 同步 → 微任务 → 宏任务 */
调用栈
同步代码逐行执行
Web APIs
定时器 / 网络 / DOM
完成后把回调推入队列
宏任务队列
setTimeout / I/O
微任务队列
Promise / queueMicrotask
Event Loop 不断检查:栈空 → 清空微任务 → 取一个宏任务

事件循环的运行规则可以概括为一句话:同步代码全部执行完毕(调用栈清空)后,先清空所有微任务,再取出一个宏任务执行,执行完再清空微任务,如此循环往复。这个顺序决定了你在控制台看到的一切。

宏任务与微任务全解析

任务分为两大类,它们的优先级与执行时机完全不同。记住这条铁律:微任务的优先级永远高于宏任务,且每个宏任务执行完毕后,都会把微任务队列"榨干"。

/* 微任务嵌套:会在本轮全部执行完 */
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 */
宏任务(MacroTask)
setTimeout setInterval setImmediate I/O UI 渲染 MessageChannel
微任务(MicroTask)
Promise.then catch / finally queueMicrotask MutationObserver process.nextTick(Node)

一个常见的误区是: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);
一帧的完整流程(约 16.6ms)
宏任务
微任务
rAF
样式/布局
绘制
rAF
绘制前执行
rIC
空闲时执行

长任务会挤掉渲染时间,导致掉帧

async/await:语法糖背后的真相

async/await 让异步代码看起来像同步代码,但它的底层依然是 Promise 和微任务。await 之后的代码,相当于被放进了 .then() 回调里。

/* async 函数总是返回 Promise */
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()]);
await 的等价转换
async function f() {
  const r = await p;
  use(r);
}
// 大致等价于
function f() {
  return p.then(r => { use(r); });
}
自动 try/catch 顺序可读 本质是微任务

注意:await 会暂停当前 async 函数的执行,但不会阻塞整个主线程。暂停的只是函数内部的后续代码,它们会被包装成微任务,等 Promise 落定后重新排入队列。

Node.js 事件循环的六个阶段

Node.js 的事件循环由 libuv 实现,比浏览器复杂得多,分为六个阶段依次循环执行。每个阶段都有一个 FIFO 回调队列,当队列耗尽或达到回调数量上限时,进入下一阶段。

/* Node 中的执行顺序示例 */
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'));
});
1. timers — 执行 setTimeout / setInterval 到期的回调
2. pending callbacks — 处理上一轮延迟的 I/O 回调
3. idle, prepare — 仅 libuv 内部使用
4. poll — 获取新 I/O 事件,执行 I/O 回调(最重要)
5. check — 执行 setImmediate 回调
6. close callbacks — 处理 socket 等关闭回调

六个阶段循环往复,每阶段之间清空 nextTick 与微任务队列

常见陷阱与面试高频题

事件循环是面试重灾区,也是生产环境 bug 的高发地带。下面这些坑,几乎每个前端都踩过。

/* 陷阱1:循环中的 var */
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); // 永远清不完,页面卡死
}
输出顺序自测
console.log(1);
setTimeout(() => console.log(2));
Promise.resolve().then(() => console.log(3));
queueMicrotask(() => console.log(4));
console.log(5);
✅ 答案:1 → 5 → 3 → 4 → 2
三大铁律
  1. 同步代码永远最先执行
  2. 微任务永远优先于宏任务
  3. 每个宏任务后,微任务队列必须清空

调试与性能优化实践

理解了原理,还要落到实战。以下是处理长任务、优化异步流程的常用手段。

/* 1. 用 Performance 面板查看长任务 */
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(); // 取消请求
Performance
长任务火焰图
Network
请求瀑布流
Coverage
代码使用率
Lighthouse
性能评分

让每一次异步等待都可被观测、可被优化

异步编程最佳实践

  • 优先使用 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 是表达方式,而事件循环,才是这一切背后真正运转的引擎。