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