Node模块引入机制

131 阅读5分钟

在面试时(尤其是Node.js后端的面试),我们经常会遇到这样一个问题:

“介绍一下require的模块加载机制”。

🙋‍♂️🙋‍♀️:

  1. Node.js引入一个模块时,优先从缓存加载。
  2. 如果缓存中不存在,且模块名为核心模块,则直接加载。
  3. 如果是路径形式的模块名,进一步分析标识符:
    • 如果得到的是文件,则使用真实路径加载。
    • 如果得到的是目录,则解析当前目录下的package.json文件的main字段,获取入口文件。
    • 如果package.json文件不存在或main字段为空,则使用index作为文件名查找。
    • 如果最后没有找到模块,则抛出MODULE_NOT_FOUND错误。
  4. 如果是非路径形式的模块名,即自定义模块,则从当前目录下的node_modules目录开始逐级查找,直到根目录。
  5. 如果最后没有找到模块,则抛出MODULE_NOT_FOUND错误。

上述的回答虽然正确,但是还缺少一些细节。所以,我们有必要来详细了解一下Node.js中模块引入机制。

Node.js模块系统最初是基于CommonJS规范实现的,但从v12.x版本开始,原生支持了ES Module。下面,我们分别来了解两种规范的模块引入机制。

1. 基于CommonJS规范的模块系统

引入

使用require()方法引入模块。

Node.js中每个文件都是一个独立的模块,并拥有自己的作用域。模块分为:

  • 核心模块:Node.js提供的模块,在编译源代码的过程中,被编译进了二进制文件。进程启动时,部分核心模块会直接加载到内存中。核心模块又分为C/C++模块和Javascript模块。
  • 文件模块:用户编写的模块,运行时动态加载,加载速度慢于核心模块。

加载

模块加载,需要经历如下步骤:

  1. 以模块名为索引,优先从内存中加载。其中,核心模块从NativeModule._cache对象中读取,文件模块则从Module._cache对象中读取。
  2. 模块名为核心模块,如fspath等,则直接加载并写入NativeModule._cache对象中。
  3. 模块名为路径形式,如...\开头的,文件分析时会转为真实路径,如果得到的是:
    • 文件
      • 如果显示指定了扩展名,则使用真实路径加载。
      • 否则,根据.js.json.node的顺序补足扩展名,依次查找(可以通过指定文件的扩展名提高查找速度)。
    • 目录
      • 解析当前目录下的package.json文件中的main字段,获取对应文件。
      • 如果package.json文件不存或main字段为空,则默认以index作为文件名,依次补足扩展名尝试加载。
    • 如果没有找到对应文件,则抛出MODULE_NOT_FOUND错误。
  4. 非路径形式模块名,则视为自定义模块(第三方模块)
    • 查找当前目录下node_modules目录,按照上述“将目录路径作为模块名”的方式查找。
    • 逐级往上层的node_modules目录查找,直到根目录下的node_modules目录。
  5. 如果通过设置NODE_PATH环境变量添加了额外的模块查找路径,还会在NODE_PATH指定的路径中查找。
  6. 如果遍历所有模块路径都没有找到对应的模块,则抛出MODULE_NOT_FOUND错误。

注意:

  1. 不同扩展名的文件处理方式不同:
    • .js:通过fs模块同步读取文件。在编译过程中,对文件内容进行头尾封装后,通过vm.runInContext()方法执行。
    • .json: 通过fs模块同步读取文件,使用JSON.parse解析返回结果。
    • .nodec/c++编写的模块,通过dlopen()方法加载文件后返回。在*nix平台中对应的是.so文件,在win平台中对应的是.ddl文件。
  2. 在加载Javascript核心模块时也需要经历编译执行的过程。
  3. 用户模块在成功加载后,都会写入Module._cache对象中,以提高模块的二次引入的加载速度。

js文件被包装后的样子如下:

// 字符串的形式,这里只是为了便于展示
(function (exports, require, module, filename, __dirname) {
  var fs = require('fs')

  exports.readCSVFile = function (filename) {
    const rows = []

    //TODO....

    return rows
  }
})

这也是为什么我们可以直接在js文件中使用requireexports__dirname等变量的原因。

此外,借助Node.js命令行界面,可以查看当前目录下模块的查找路径:

Screenshot 2025-01-10 at 05.28.22.png

2. 基于ECMAScript规范模块系统

引入

使用import语句导入模块。

要在Node.js中使用import语法,必须满足以下条件之一:

  • 文件必须使用.mjs作为扩展名
  • 修改当前目录下的package.json文件,添加{ type: module }

加载

  • 静态导入
    • 使用import语句。
    • 编译时解析。
    • 运行时同步加载。
  • 动态导入
    • 使用import()方法。
    • 运行时解析。
    • 运行时异步加载。
    • 通常用于实现按需加载或者懒加载。

使用import()方法动态导入模块例子:

// 返回的是一个Promise对象
import('lodash')
  .then(({ default: _ }) => {
    console.log(_.random(1, 100))
  })

它通常用于实现按需加载或者懒加载。

总结

  • Node.js支持CommonJsES6模块系统,如果混合使用(不建议),则需要遵循一些规则和限制。
  • 如果使用的是较高版本的Node.js,推荐使用ES6模块系统,因为它提供更好的模块化支持。