Node.js 异步编程:回调函数、Promise、async/await、事件循环机制

聊到 Node.js,绕不开的就是异步编程。说实话,很多初学者刚接触 Node.js 时,最头疼的就是「回调地狱」。我自己带过不少新人,发现大家一开始都容易把异步当成同步来写,结果代码跑起来完全不是那么回事。

这一章,我就带你彻底搞懂 Node.js 的异步编程。从最原始的回调函数,到 Promise,再到现在的 async/await,最后再聊聊底层的「事件循环机制」。把这些吃透了,你写 Node.js 代码会顺手很多。

回调函数:最朴素的异步方案

回调函数是 Node.js 异步编程的基石。说白了,就是把一个函数作为参数传给另一个函数,等异步操作完成后再调用它。

举个例子,读取文件:

const fs = require('fs');

fs.readFile('./data.json', 'utf8', (err, data) => {
  if (err) {
    console.error('读文件出错了:', err);
    return;
  }
  console.log('文件内容:', data);
});

console.log('我先执行了');

你猜猜输出顺序是什么?

先打印「我先执行了」,然后才是文件内容。这就是异步——代码不会傻等着文件读完,而是先往下走,等文件读完了再回来执行回调。

我的经验: 我在项目中遇到过不少新手把异步回调写成同步,比如在回调外面直接使用 data 变量。记住,回调里的数据只能在回调里用,外面拿不到的。

但回调函数有个大问题——嵌套多了就变成「回调地狱」:

fs.readFile('a.txt', (err, dataA) => {
  fs.readFile('b.txt', (err, dataB) => {
    fs.readFile('c.txt', (err, dataC) => {
      // 这里已经三层了,再往下写就疯了
    });
  });
});

这种代码,维护起来简直是噩梦。我曾经接手过一个项目,回调嵌套了七八层,改一个逻辑要翻半天。所以后来社区就搞出了 Promise。

Promise:让异步更优雅

Promise 本质上是对回调的封装。它有三种状态:pending(进行中)fulfilled(已成功)rejected(已失败)。状态一旦改变,就不会再变了。

用 Promise 改写上面的文件读取:

const fs = require('fs').promises;

fs.readFile('a.txt', 'utf8')
  .then(data => {
    console.log('a.txt 内容:', data);
    return fs.readFile('b.txt', 'utf8');
  })
  .then(data => {
    console.log('b.txt 内容:', data);
    return fs.readFile('c.txt', 'utf8');
  })
  .then(data => {
    console.log('c.txt 内容:', data);
  })
  .catch(err => {
    console.error('出错了:', err);
  });

你看,通过 .then() 链式调用,代码变得扁平了。而且错误处理也统一到了 .catch() 里,不用每个回调都写 if (err)。

关键点: Promise 的 .then() 返回的也是一个 Promise,所以才能一直链式调用下去。如果某个 .then() 里返回了一个普通值,它会被自动包装成 Promise.resolve()。

我个人的习惯是,只要涉及异步操作,优先用 Promise 封装。哪怕是最简单的 setTimeout,我也会写成 Promise 形式:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

delay(1000).then(() => console.log('1秒后执行'));

这样写,后面配合 async/await 会非常舒服。

async/await:异步的终极形态

async/await 是 Promise 的语法糖。它让异步代码写起来像同步一样直观。我个人觉得,这是 Node.js 异步编程里最舒服的写法。

看个例子:

async function readFiles() {
  try {
    const dataA = await fs.readFile('a.txt', 'utf8');
    console.log('a.txt:', dataA);
    
    const dataB = await fs.readFile('b.txt', 'utf8');
    console.log('b.txt:', dataB);
    
    const dataC = await fs.readFile('c.txt', 'utf8');
    console.log('c.txt:', dataC);
  } catch (err) {
    console.error('出错了:', err);
  }
}

readFiles();

是不是清爽多了?没有 .then() 链,没有回调嵌套,就是一行一行往下写。但注意,await 只能在 async 函数里使用

避坑指南: 我曾经犯过一个错误——在 forEach 里用 await。结果发现 forEach 根本不支持 await,所有异步操作几乎是同时触发的。正确的做法是用 for...of 循环,或者 Promise.all()。

说到 Promise.all(),它特别适合处理「多个互不依赖的异步任务」:

async function loadAll() {
  const [user, posts, comments] = await Promise.all([
    fetchUser(),
    fetchPosts(),
    fetchComments()
  ]);
  
  return { user, posts, comments };
}

这样三个请求是并发执行的,比串行快得多。我一般在做数据聚合接口时,特别喜欢用这个模式。

事件循环机制:Node.js 的底层逻辑

好了,前面讲了怎么用异步,现在聊聊「为什么能异步」。这就要说到事件循环(Event Loop)了。

Node.js 是单线程的,但它能处理高并发,靠的就是事件循环。说白了,就是一个「先干手头的活,干完了再回来处理其他事」的机制。

事件循环有几个关键阶段:

阶段 说明 典型操作
timers 执行 setTimeout、setInterval 的回调 定时器到期
pending callbacks 执行一些系统操作的回调 TCP 错误回调
idle, prepare 内部使用 Node.js 内部机制
poll 获取新的 I/O 事件,执行 I/O 回调 文件读取、网络请求
check 执行 setImmediate 的回调 setImmediate
close callbacks 执行关闭事件的回调 socket.on('close')

每次事件循环,Node.js 会按顺序走完这些阶段。每个阶段都有一个「任务队列」,先进先出地执行。

来看个经典面试题:

console.log('1');

setTimeout(() => {
  console.log('2');
}, 0);

Promise.resolve().then(() => {
  console.log('3');
});

console.log('4');

输出顺序是什么?

答案是:1, 4, 3, 2。

为什么会这样?因为 Promise 的回调属于「微任务」(microtask),而 setTimeout 的回调属于「宏任务」(macrotask)。微任务的优先级比宏任务高。在每个宏任务执行完后,会清空所有微任务,再执行下一个宏任务。

记住这个规则: 同步代码 > 微任务(Promise.then、process.nextTick) > 宏任务(setTimeout、setInterval、setImmediate)。

我在项目中遇到过一个问题:用 process.nextTick 做递归,结果把事件循环卡死了。因为 process.nextTick 的优先级比 Promise 还高,它会一直插队,导致其他回调永远没机会执行。所以,process.nextTick 要慎用,一般用 setImmediate 更安全。

总结一下

这一章我们聊了 Node.js 异步编程的四个核心:

  • 回调函数:最基础的异步方案,但嵌套多了就是灾难
  • Promise:解决了回调地狱,提供了链式调用和统一错误处理
  • async/await:Promise 的语法糖,让异步代码像同步一样写
  • 事件循环:Node.js 异步的底层机制,理解它才能写出高性能代码

我个人建议,新项目一律用 async/await + Promise.all() 的组合。老项目如果还在用回调,可以逐步迁移。记住,异步编程的核心不是「怎么写」,而是「什么时候执行」。理解了事件循环,你就能掌控代码的执行顺序。

下一章,我们会聊聊 Node.js 的模块系统。到时候见。