Skip to main content

Node.js

  • node 如何充分利用多核 CPU?
  • node 是如何实现多个进程监听同一个端口的?为什么不会报“端口被占用”?参考 https://juejin.cn/post/6911456081336074253
  • deno 相对 node 有哪些优化?
  • node 中的 buffer 是什么? Stream 是什么? 流的种类有哪些?听说过 pull-stream 么?

架构

模块加载机制

  1. 计算绝对路径
  2. 加载模块 2.1 优先从缓存中加载 2.2 加载核心模块 2.3 按路径加载模块 2.4 加载第三方模块。会从当前目录的node_modules往上查找,直到根目录。后两者会以绝对路径为 key 生成新的缓存

加载模块(2.3/2.4)

得到模块的绝对路径后,就开始加载模块了。首先确定模块的后缀名,不同的文件对应不同的加载方法,以 .js 和 .json 为例

Module._extensions[".js"] = function (module, filename) {
var content = fs.readFileSync(filename, "utf8");
module._compile(stripBOM(content), filename);
};
Module._extensions[".json"] = function (module, filename) {
var content = fs.readFileSync(filename, "utf8");
try {
module.exports = JSON.parse(stripBOM(content));
} catch (err) {
err.message = filename + ": " + err.message;
throw err;
}
};

解析 js 模块的关键函数:

Module.prototype._compile = function (content, filename) {
compiledWrapper(content, this.exports, this.require, this);
};
var compiledWrapper = function (content, exports, require, module) {
eval(content);
};

模块的加载其实就是注入 exports、require、module 三个全局变量,然后执行模块的源码,最后输出模块的 exports 值

CommonJS 是如何解决循环引用问题的?

// main.js
const child = require('./child.js')
console.log('执行 main.js: ', child)
module.exports = 'main'
// child.js
const main = require('./main.js')
console.log('执行 child.js: ', main)
module.exports = 'child'
// 执行 main.js , 输出如下:
// 解析:执行到 child.js 时,main.js 还没有执行完,默认导出空对象
执行 child.js: {}
执行 main.js: child

commonjs - Node Guidebook

事件循环

事件循环概览如下所示:

   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

Node.js 的事件循环是实现非阻塞 I/O 的基础。以上每个阶段都有一个 FIFO 队列来执行回调,当一个事件完成的时候,内核会通知 Node.js 将回调函数添加到队列中。每个阶段最后都会执行当前阶段产生的微任务(包括 nextTick)

timers

进入这个阶段,主线程会检查时间是否满足定时器(setTimeout、setInterval)触发的条件,如果满足就执行相应的回调函数,否则离开这个阶段

pending callbacks

此阶段执行某些系统操作的回调,例如 TCP 错误

idle、prepare

这个阶段只供 libuv 内部调用

poll

用于处理大部分 I/O 回调和等待 I/O 事件 进入 poll 且不存在到期定时器,此阶段可能会导致主线程阻塞,该阶段会分为以下两种情况:

  1. poll 队列不为空,同步执行队列
  2. poll 队列为空,如果执行了setImmediate,则结束 poll 阶段,进入 check 阶段;如果没有,事件循环将阻塞并等待回调添加到 poll 队列中执行

一旦 poll 队列为空,事件循环将检查到期的定时器,如果有定时器准备好,就回滚到计时器阶段以执行这些计时器的回调;否则会一直停留在这个阶段,等待 I/O 请求返回结果

check

执行 setImmediate 回调

close

该阶段执行关闭请求的回调函数,比如 socket.on('close', callback)

微任务

微任务分类:

  • process.nextTick(优先级更高)
  • Promise.then

微任务会在主线之后和事件循环的每个阶段之后立即执行

issues

把 setTimeout 和 setImmediate 放入同一个 I/O 回调函数内,setImmediate 总是被优先调用

fs.readFile(__filename, () => {
setTimeout(() => {
console.log("timeout");
}, 0);
setImmediate(() => {
console.log("immediate");
});
});

原因是 fs.readFile 的回调是在 poll 阶段执行的,该阶段过后会先轮到 check 阶段,即执行 setImmediate 回调,下一轮事件循环才执行 setTimeout 的回调

那为什么在回调函数外这两者的执行顺序不确定呢?

  • 执行同步代码(初始化)
  • 执行到setTimeout,虽然设置的是 0 毫秒触发,但是被 Node.js 强制改为 1 毫秒,将回调加入times阶段的队列
  • 执行到setImmediate,将回调加入check阶段的队列
  • 同步代码执行完毕,进入 Event Loop
  • 先进入times阶段,检查当前时间是否过去了 1 毫秒,如果过了 1 毫秒,满足setTimeout条件,执行回调,如果没过 1 毫秒,跳过
  • 跳过空的阶段,进入check阶段,执行setImmediate回调

异常监控【WIP】

  • process.on('uncaughtException')
  • process.on('unhandledRejection')

周边库【WIP】

  • pm2
  • nodemon
  • ts-node