跳到主要内容

node-esm

Node-ESM发展

版本特性
v18.6.0Add support for chaining loaders.
v17.1.0, v16.14.0支持import assert语法
v17.0.0, v16.12.0Consolidate 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.0ESM 正式稳定支持
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仅支持importexport语法,和通过.mjs标识 ESM 文件;在命令行执行mjs文件需要额外指定node --experimental-modules参数

CJS 和 ESM 的区别

CJS 语法特点

  1. 使用require语句加载模块,同步解析代码;
  2. 支持加载文件夹,Node 会尝试查找文件夹内部的package.json指定的main文件,或者index.js文件;
  3. 支持忽略模块后缀名,按照.js.json.node顺序匹配,如果没找到则尝试按照文件夹解析;
  4. 支持加载json文件;
  5. 不支持加载 ESM 模块文件,包括使用 ESM 语法的模块或者.mjs后缀的模块,会报ERR_REQUIRE_ESM的错误。

ESM 语法特点

  1. 使用import或者import()语句加载模块,异步解析代码;
  2. 不支持加载文件夹;
  3. 不支持忽略模块名后缀,支持.js.mjs.cjs后缀;
  4. 不支持加载json
  5. 支持使用import加载 CJS 模块,但是 ESM 模块内部不支持使用 CJS 语法

如何判定模块

简单来说,Node 主要根据package.json内部的type判断模块标准,如果定义了type:module,则.js后缀的文件会被判定为 ESM,如果指定type:commonjs或没有定义type,则.js文件则会按照 CJS 模块语法解析。

ESM 模块判定

  1. .mjs后缀文件判定为 ESM 模块;
  2. .js后缀的文件,如果最近的package.json内部定义的有type:module,则判定为 ESM 模块;
  3. 通过node --input-type=module执行的命令行代码。

CJS 模块判定

  1. .cjs后缀判定为 CJS 模块
  2. .js后缀的文件,如果最近的package.json内部定义的有type:commonjs或者没有定义type字段,则判定为 CJS 模块;
  3. 通过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:modulepackage无法在 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:modulepackage,显然无法通过main兼容到使用 CJS 语法开发的package.

于是,Node 从12.7.0版本后为package.json拓展了exports字段,其支持指定一个对象、字符串或者字符串数组作为值,来标识不同环境下的程序入口模块,这样我们就可以使用条件导出来为package兼容 CJS 语法。下面来看下exports的使用语法。

exports语法

exports内部支持指定以下关键字字段,会按顺序进行匹配:

  1. node-addons:此条件可用于提供使用原生 C++ 插件的入口点;
  2. node:普通 Node 模块路径;
  3. import:ESM 模块入口路径;
  4. require:CJS 模块入口路径;
  5. default:备选入口模块路径,当按照上述顺序无法匹配时,选择default指定的模块作为入口
警告

default应该总是添加到exports对象字段的结尾,避免当仅定义require或者import时找不到指定模块而报[ERR_PACKAGE_PATH_NOT_EXPORTED]的错误。

例如兼容 ESM 和 CJS 语法,需要在exports内部定义importrequire来分别指定 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 模块,此时,.就相当于主入口程序,可以直接使用pkgname加载,而子模块则需要带上子路径。这种情况主要用于一个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