Node.js 基于单线程 JavaScript 运行时,依靠事件循环处理文件读取、网络请求、数据库查询等非阻塞 I/O。理解六个阶段、宏任务与微任务的优先级,以及 EventEmitter 的注册和触发方式,是排查异步回调顺序问题的基础。
一、事件循环的六个阶段
事件循环按阶段轮转,每个阶段维护自己的回调队列:
1. Timers:执行 setTimeout() 和 setInterval() 到期的回调。
2. I/O Callbacks:处理部分延迟的 I/O 回调,例如某些系统错误回调。
3. Idle、prepare:Node.js 内部使用,业务代码通常不直接关心。
4. Poll:检索新的 I/O 事件,执行与 I/O 相关的回调,是核心阶段;没有新事件时会检查其他阶段。
5. Check:执行 setImmediate() 回调。
6. Close Callbacks:处理关闭类回调,例如 socket.on('close', ...)。
流程上,任务先进入事件循环队列,事件循环按阶段顺序处理;poll 阶段可能等待新事件到达。setImmediate() 的回调在 check 阶段执行,setTimeout() 的回调在 timers 阶段执行,因此二者顺序受当前事件循环状态影响。
二、setTimeout 与 setImmediate 的示例- setTimeout(() => {
- console.log('Timeout callback');
- }, 0);
- setImmediate(() => {
- console.log('Immediate callback');
- });
- console.log('Main thread execution');
复制代码 输出时,Main thread execution 会先打印,因为主线程同步代码先执行。随后 Immediate callback 与 Timeout callback 的先后取决于事件循环状态;原文示例说明一般 setImmediate() 会先执行。排查这类顺序问题时,不要只凭字面上的 0 毫秒判断,应结合 timers 与 check 阶段的调度位置分析。
三、宏任务与微任务
宏任务包括 setTimeout、setInterval、setImmediate 和 I/O 操作等;微任务包括 process.nextTick 和 Promise.then。微任务优先级高于宏任务,会在当前阶段的回调结束后立即执行。- setTimeout(() => {
- console.log('Timeout callback');
- }, 0);
- Promise.resolve().then(() => {
- console.log('Promise callback');
- });
- console.log('Main thread execution');
复制代码 输出顺序为:
Main thread execution
Promise callback
Timeout callback
同步代码先执行;当前操作结束后先清空微任务队列,Promise 回调先于定时器宏任务执行。
四、process.nextTick 的优先级
process.nextTick() 会在当前操作结束后、下一个阶段开始前执行,属于微任务,并且优先级高于 Promise。- process.nextTick(() => {
- console.log('Next tick callback');
- });
- console.log('Main thread execution');
复制代码 输出:
Main thread execution
Next tick callback
把四类任务放在一起比较时,可用下面的代码观察顺序:- setTimeout(() => console.log('setTimeout'), 0);
- setImmediate(() => console.log('setImmediate'));
- Promise.resolve().then(() => console.log('Promise'));
- process.nextTick(() => console.log('nextTick'));
复制代码 输出顺序为 nextTick、Promise,然后 setTimeout 或 setImmediate 取决于事件循环状态。也就是说,nextTick 队列先于 Promise 微任务,宏任务中的 timers 与 check 先后要结合阶段判断。
五、EventEmitter 与事件驱动
Node.js 的事件驱动编程主要通过 events 模块内置的 EventEmitter 类实现。事件相当于观察者模式中的主题,注册到事件上的处理函数相当于观察者;通过继承 EventEmitter 可以创建自己的事件发射器,并注册、触发事件。
基本流程是:用 on() 注册事件和处理器,用 emit() 触发事件,事件循环调度相应回调执行。- const EventEmitter = require('events');
- const myEmitter = new EventEmitter();
- myEmitter.on('greet', () => {
- console.log('Hello, world!');
- });
- myEmitter.emit('greet');
复制代码 输出 Hello, world!。
再看一个多事件串联的例子:- var events = require('events');
- var eventEmitter = new events.EventEmitter();
- var connectHandler = function connected() {
- console.log('连接成功。');
- eventEmitter.emit('data_received');
- };
- eventEmitter.on('connection', connectHandler);
- eventEmitter.on('data_received', function() {
- console.log('数据接收成功。');
- });
- eventEmitter.emit('connection');
- console.log('程序执行完毕。');
复制代码 执行 node main.js 后输出:
连接成功。
数据接收成功。
程序执行完毕。
这里 connection 事件触发 connectHandler,connectHandler 内部再触发 data_received,最终同步代码继续打印。
六、异步文件读取与错误优先回调
Node.js 异步函数通常把回调函数作为最后一个参数,回调的第一个参数是错误对象。下面的代码用 fs.readFile() 读取 input.txt:- var fs = require('fs');
- fs.readFile('input.txt', function(err, data) {
- if (err) {
- console.log(err.stack);
- return;
- }
- console.log(data.toString());
- });
- console.log('程序执行完毕');
复制代码 文件存在时,先打印程序执行完毕,再输出文件内容;文件不存在时,异步回调收到错误对象,输出类似 Error: ENOENT, open 'input.txt' 的错误信息。这个顺序说明 fs.readFile() 不会阻塞后面的同步代码,错误处理必须放在回调内部完成。
七、实践中的关注点
事件循环负责非阻塞 I/O 的调度,六个阶段决定回调进入哪个队列;process.nextTick 与 Promise.then 属于微任务,其中 process.nextTick 优先级更高;setTimeout、setImmediate 和 I/O 回调属于宏任务,前后顺序要看 timers、poll、check 阶段。EventEmitter 的 on() 与 emit() 把事件注册和触发解耦,适合处理连接、数据到达、关闭等异步事件。异步 API 的错误优先回调写法,则要求在回调中先判断 err,再处理 data。 |