Node-ESM发展
Node-ESM发展
| 版本 | 特性 |
|---|---|
| v18.6.0 | Add support for chaining loaders. |
| v17.1.0, v16.14.0 | 支持import assert语法 |
| v17.0.0, v16.12.0 | Consolidate loader hooks, removed getFormat, getSource, transformSource, and getGlobalPreloadCode hooks added load and globalPreload hooks allowed returning format from either resolve or load hooks. |
| v14.8.0 | 支持 ESM 模块文件顶层await语法 |
v15.3.0, v14.17.0, v12.22.0 | ESM 正式稳定支持 |
| v14.13.0, v12.20.0 | 支持检测 CommonJS 的命名导出格式 |
| v14.0.0, v13.14.0, v12.20.0 | 移除 ESM 实验性警告提示 |
| v13.2.0, v12.17.0 | 在命令行执行mjs文件不再需要指定--experimental-modules参数 |
| v12.0.0 | 支持通过在package.json 标识"type": "module"来全局指定.js文件为 ESM 文件 |
| v8.5.0 | 仅支持import,export语法,和通过.mjs标识 ESM 文件;在命令行执行mjs文件需要额外指定node --experimental-modules参数 |
CJS 和 ESM 的区别
CJS 语法特点
- 使用
require语句加载模块,同步解析代码; - 支持加载文件夹,Node 会尝试查找文件夹内部的
package.json指定的main文件,或者index.js文件; - 支持忽略模块后缀名,按照
.js、.json、.node顺序匹配,如果没找到则尝试按照文件夹解析; - 支持加载
json文件; - 不支持加载 ESM 模块文件,包括使用 ESM 语法的模块或者
.mjs后缀的模块,会报ERR_REQUIRE_ESM的错误。
ESM 语法特点
- 使用
import或者import()语句加载模块,异步解析代码; - 不支持加载文件夹;
- 不支持忽略模块名后缀,支持
.js,.mjs,.cjs后缀; - 不支持加载
json; - 支持使用
import加载 CJS 模块,但是 ESM 模块内部不支持使用 CJS 语法
如何判定模块
简单来说,Node 主要根据package.json内部的type判断模块标准,如果定义了type:module,则.js后缀的文件会被判定为 ESM,如果指定type:commonjs或没有定义type,则.js文件则会按照 CJS 模块语法解析。
ESM 模块判定
.mjs后缀文件判定为 ESM 模块;.js后缀的文件,如果最近的package.json内部定义的有type:module,则判定为 ESM 模块;- 通过
node --input-type=module执行的命令行代码。
CJS 模块判定
.cjs后缀判定为 CJS 模块.js后缀的文件,如果最近的package.json内部定义的有type:commonjs或者没有定义type字段,则判定为 CJS 模块;- 通过
node --input-type=commonjs执行的命令行代码。
package 兼容
上面说了,ESM 模块无法通过 CJS 的require语句加载,而 CJS 模块可以通过 ESM 语法import加载,所以在基于Node 12.22.0+版本开发的时候,首选推荐使用 ESM 模块语法进行开发,最简单的方式是在项目目录的package.json下定义type:module,这样全局指定.js文件必须使用 ESM 模块语法。在这种情况下,如果使用 CJS 语法,只能在.cjs文件内部使用才行。
但是指定type:module的package无法在 CJS 模块内部使用,那么怎么解决这种问题呢?有以下两种解决方式:
1. 指定main和module
一些使用 Nodejs 开发的打包工具支持使用package.json内部的module来指定 ESM 模块入口,而原有的main字段指定 CJS 模块。例如esbuild:
{
"main": "./lib/index.cjs",
"module": "./lib/index.js"
}
但是module字段不会被 Nodejs 识别,这个方式可能带来潜在的危害——Dual package hazard。
2. 使用条件导出 exports
我们知道,使用 Nodejs 开发非执行类的package时都需要指定main字段作为程序执行的入口模块(没有main,默认和package.json同目录层级的index.js文件),但是main只支持指定单个模块路径,针对上述所说的定义了type:module的package,显然无法通过main兼容到使用 CJS 语法开发的package.
于是,Node 从12.7.0版本后为package.json拓展了exports字段,其支持指定一个对象、字符串或者字符串数组作为值,来标识不同环境下的程序入口模块,这样我们就可以使用条件导出来为package兼容 CJS 语法。下面来看下exports的使用语法。
exports语法
exports内部支持指定以下关键字字段,会按顺序进行匹配:
node-addons:此条件可用于提供使用原生 C++ 插件的入口点;node:普通 Node 模块路径;import:ESM 模块入口路径;require:CJS 模块入口路径;default:备选入口模块路径,当按照上述顺序无法匹配时,选择default指定的模块作为入口
default应该总是添加到exports对象字段的结尾,避免当仅定义require或者import时找不到指定模块而报[ERR_PACKAGE_PATH_NOT_EXPORTED]的错误。
例如兼容 ESM 和 CJS 语法,需要在exports内部定义import和require来分别指定 ESM 和 CJS 模块入口文件路径:
{
"type": "module",
"exports": {
"import": "./lib/index.js",
"require": "./lib/index.cjs"
}
}
exports关键字之间支持嵌套,例如指定node环境不同的模块入口:
{
"exports": {
"node": {
"import": "./feature-node.mjs",
"require": "./feature-node.cjs"
},
"default": "./feature.mjs"
}
}
除了上面提供的关键字,exports还支持定义子模块路径,将子模块单独指定为 ESM 或者 CJS 模块,此时,.就相当于主入口程序,可以直接使用pkg的name加载,而子模块则需要带上子路径。这种情况主要用于一个package使用 CJS 语法开发并且不希望使用 ESM 重构,则需要定义子模块来兼容 ESM 使用。
例如下面的定义,使用require('module')加载 CJS 模块,而require('module/wrapper')则加载 ESM 模块。
{
"type": "module",
"exports": {
".": "./index.cjs",
"./module": "./wrapper.mjs"
}
}
对于有多个子模块的package,例如lodash等,可以使用文件夹匹配模式来导出所有子模块路径,例如下面的定义可以使用features下的所有子模块,也包括features内子目录下的模块:
// package.json 定义
{
"exports": {
"./features/*.js": "./src/features/*.js"
},
}
// 加载
import featureX from 'es-module-package/features/x.js';
// Loads ./node_modules/es-module-package/src/features/x.js
import featureY from 'es-module-package/features/y/y.js';
// Loads ./node_modules/es-module-package/src/features/y/y.js
Node 从12.20.0版本正式支持所有exports定义的语法,所以推荐基于12.20.0+开发时在 package.json内部使用exports定义程序入口,如果同时定义了main字段,则exports优先级高于main。
从 CJS 迁移到 ESM
参见 blog - pureESM