之前一直被nodejs的模块执行规则所困扰,这种困扰来自于目前CommonjsESModule在Node端能够并存的情况,什么时候能够执行ESModule,什么时候不能执行;在引用第三方包node_modules,具体的包是根据什么来区分是ESModule还是Commonjs

通过阅读了Nodejs的原文档,这些问题也随之迎刃而解,所以特此在本篇做一做记录。

注:以下内容都是基于Nodejs V20.5.0 进行编写。

Nodejs中的模块系统

  • Node环境下,如今共存着两种模块规范:ESModuleCommonjs,前者是ES6中提出模块化规范,所以为了统一整个Javascript的模块化规范,Nodejs也逐渐的由之前主流的Commonjs规范转变为ESModule,只不过为了兼容Nodejs老版本的代码,所以现在这两种模块在Node中是并存的关系,但是前者(ESModule)是趋势。
  • 关于CommonjsESMoudle的模块技术细节,例如语法区别,加载规则等在本篇不会提到,接下来将会它们在nodejs中的设置以及引用规则。

模块的执行与加载

  • Node在执行代码前会先去判断代码是ES模块还是commonjs模块,Node会从三个角度依次判断代码的模块归属。

ES模块

  • 对于ES模块,当出现以下情况时,Nodejs会将文件视为ES模块。
    1. 扩展名为.mjs的文件。
    2. 项目所归属的package.jsontype字段的值为module,此时文件扩展名为.js的文件都视为ES模块。
    3. 字符串作为参数传入 --eval,或通过 STDIN 管道传输到 node,带有标志 --input-type=module
  • .mjs扩展名的文件被视为ES模块的优先级是最高的,无论它身处何方,只要Node发现其是.mjs的文件,就会将其视作ES模块来执行。

commonjs模块

  • 对于commonjs模块,当出现以下情况时,Nodejs会将文件视为commonjs模块。
    1. 扩展名为.cjs的文件。
    2. 项目所属的package.json的type字段的值为commonjs时,此时文件扩展名为.js的文件都视为commonjs模块。
    3. 字符串作为参数传入 --eval--print,或通过 STDIN 管道传输到 node,带有标志 --input-type=commonjs
  • ES模块类似,如果文件扩展名为.cjs,那么Nodejs都会将其视作commonjs模块。
  • 如果不设置type字段,那么该项目将会默认为commonjs语法的项目

模块加载器

  • Nodejs有两种加载器用于分别解析说明符和加载ES模块和commonjs模块。

  • commonjs模块加载器:

    1. 🌟 处理require()调用,并且是同步加载模块的,
    2. 🌟 支持以文件夹作为模块
    3. 🌟 不能用于加载`ECMAScript模块(ESModule)。
    4. 🌟 解析路径时,如果未找到完全匹配项,那么将尝试添加扩展名**.js,.json,.node,然后最后再尝试解析文件夹作为模块**。
    5. .json文件视为JSON文本文件,可以直接使用require加载。
    • 特别注意上述的四个带🌟的规则,在平时日常开发中应该是能够经常遇见的。从第4点也能知道为什么使用require引用包时,可以不带文件的扩展名,因为node在解析路径时会自动添加
  • ECMAScript模块加载器:

    1. 🌟 处理import()import表达式,并且是异步加载模块的。
    2. 🌟 只接收Javascript文本文件的.js,.mjs.cjs扩展名。
    3. 🌟 可以加载JSON模块,但需要导入断言
    4. 🌟 不支持文件夹作为模块,必须完全指定目录索引。(例如./dir/index.js)
    5. 🌟 可以加载Javascript Commonjs模块,导入的cjs模块会通过cjs-module-lexer来尝试识别命名的导出,同时导入的CommonJS模块会将其URL转化为绝对路径,然后通过CommonJS模块加载器加载。
    • 需要注意的是第五点:ESModule可以加载CommonJS模块的文件,这和Commonjs语法是不同的。
  • .mjs结尾的文件总是加载为ESModule,不会管最近的package.json

  • .cjs结尾的文件总是加载为commonjs,不会管最近的package.json

包入口点

  • package.json中定义了该node项目的包信息,那么如果该package想被另一个package所引用,正如我们开发时引用第三方包那样import xxx from "xxx",那么需要在package中定义包的入口点,及入口文件。

main字段

  • main字段可以定义包的入口点,它适用于ES模块和CommonJS模块入口点,并且支持所有Nodejs版本。
  • 但是它的缺点也显而易见,只能定义一个入口点,及包的主要入口点
  • 例如我们package的文件结构如下所示。
├── package.json
├── src
│   ├── index.js
│   └── pack.js
├── test.json
└── yarn.lock
  • 设置main: "src/index.js",那么当外部在引用该package时,将会直接引用src/index.js

exports字段

  • exportsmain的现代替代方案,它不仅仅有着和main一样的功能,它还具有以下新特性:
    1. 可以定义多个入口不同环境的入口解析支持。
    2. 🌟 可以防止除exports字段指定的入口点外的其他入口点被引用。(安全)
    3. 支持条件导出(分别指定ES环境和commonjs环境所引用的文件)
  • 需要注意的是,exports字段并不支持所有的Node版本,只支持Nodejs 10以上版本的package,当有exportsmain共存的package中,exports的优先级会大于mainexports支持的前提下)。
  • 除此之外,上述的exports的第二个新特性非常值得关注,这可能是一个突破性的变化,消费者只能使用exports定义的文件入口,下面将展示一个例子。
  • 我选择使用got库来作为第二个特性的展示demo,got库的文件结构如下所示。
# got filetree
.
├── dist
│   └── source
│       ├── as-promise
│       │   ├── index.d.ts
│       │   ├── index.js
│       │   ├── types.d.ts
│       │   └── types.js
│       ├── core
│       │   ├── calculate-retry-delay.d.ts
│       │   ├── ....(还有很多文件)
│       │   └── utils
│       │       ├── get-body-size.d.ts
│       │       ├── ....(还有很多文件)
│       ├── create.js
│       ├── index.d.ts
│       ├── index.js
│       ├── types.d.ts
│       └── types.js
├── license
├── package.json
└── readme.md
  • package.jsonexports字段如下所示。
{
  // 一个语法糖写法
  "exports": "./dist/source/index.js",
  // 等同于下面写法
  "exports": {
  	".": "./dist/source/index.js"
	}
}
  • 这意味着外部引用got包时,会直接引用包中的/dist/source/index.js文件。
  • 如果此时在外界引用got中的其他包,node运行时会出现报错,如下所示。
// 外部包
import got from 'got';
import create from 'got/dist/source/create.js'
  • 运行报错如下所示。
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dist/source/create.js' is not defined by "exports" in /User/xxxxx ....
  • 可以看到,node会报[ERR_PACKAGE_PATH_NOT_EXPORTED]的错误,这正对应了之前所说的exports的第二个新特性,不允许引用除了exports指定文件的其他文件。
  • 此时给got库中exports字段增加一个create.js的引用,那么我们在外界也能直接引用create.js
{
  "exports": {
  	".": "./dist/source/index.js",
    "./create.js": "./dist/source/create.js"
	}
}
  • 然后在外部进行引用。
import got from 'got';
import create from 'got/create.js'
  • 此时执行文件,便能成功执行。

exports中为不同模块设置不同入口

  • 从刚才的demo可以看到,exports能定义多个入口,自然便可以利用这一点,为不同的模块语法定义不同的入口文件,这样能够大大提高包的兼容性。
  • 如果包想要为require() - commonjsimport() - ESmodule提供不同的模块导出,则可以写成如下的形式。
{
  "exports": {
    "import": "./dist/index.mjs",
    "require": "./dist/index.cjs"
  }
}
  • 如此设置后,外部使用不同语法引用该包时,会根据语法的不同来引用不同的文件。

以上便是在平时开发中接触的关于node-package比较多的知识点了,这一块儿知识很重要,它不仅解决了我之前关于ESModuleCommonjsnode中的执行理解混淆,还进一步解决了我在打包时对于packagejson配置的疑惑,关于更多的node-package知识点请移步node官方文档