Skip to content

模块:支持 require() 同步 ESM 图

版本状态

在命令行标志 --experimental-require-module 下,v20.x 上已支持使用 require() 加载原生 ESM 模块。在 v20.19v22.xv23.x 上默认启用这一新特性,可以通过 process.features.require_module 来检查当前 node 版本是否支持这一特性。

存在的问题

双模式包(dual-package)

node.js 中,长期以来存在两种模块系统并行的情况:

  • CommonJS (CJS) - 使用 require()module.exports
  • ECMAScript Modules (ESM) - 使用 importexport

这种双系统带来了所谓的"双包危害"(dual-package hazard)问题:同一个包可能在不同环境下加载不同版本的代码,导致难以预测的行为。

考虑以下用例:

demo

js
export default {
  version: 'ESM'
};
js
module.exports = {
  version: 'CJS'
};
json
{
  "name": "demo",
  "exports": {
    ".": {
      "import": "./es/index.mjs",
      "require": "./index.js"
    }
  }
}

消费应用

js
import data from './commonjs.cjs';

import('demo').then(module => {
  console.log('ESM Dynamic Import:', module);
  console.log('CJS Static Import:', data);
  console.log('Equal? ', module.default === data.default);
});
js
const data = require('demo');
module.exports = data;

对于上述场景下,main.mjs 的执行结果在任何一个 node 版本下都是如下:

bash
ESM Dynamic Import: [Module: null prototype] { default: { version: 'ESM' } }
CJS Static Import: { version: 'CJS' }
Equal?  false

那么可以得出一个结论,在混合使用 esmcjs 的时候,当两种模块系统 均依赖 同一个包时,同时 依赖包 提供了 双模式 的包(dual-package)。那么 esm 会使用包的 import 入口,而 cjs 会使用包的 require 入口。这会 破坏 部分场景下的 单例模式增加最终产物的体积

解决方案

支持 require(esm) 的方案可以解决上述问题,以下是 node 新的 解析逻辑

md
require(X) from module at path Y

1. If X is a core module, a. return the core module b. STOP
2. If X begins with '/' a. set Y to be the file system root
3. If X begins with './' or '/' or '../' a. LOAD_AS_FILE(Y + X) b. LOAD_AS_DIRECTORY(Y + X) c. THROW "not found"
4. If X begins with '#' a. LOAD_PACKAGE_IMPORTS(X, dirname(Y))
5. LOAD_PACKAGE_SELF(X, dirname(Y))
6. LOAD_NODE_MODULES(X, dirname(Y))
7. THROW "not found"

MAYBE_DETECT_AND_LOAD(X)

1. If X parses as a CommonJS module, load X as a CommonJS module. STOP.
2. Else, if the source code of X can be parsed as ECMAScript module using <a href="esm.md#resolver-algorithm-specification">DETECT_MODULE_SYNTAX defined in the ESM resolver</a>, a. Load X as an ECMAScript module. STOP.
3. THROW the SyntaxError from attempting to parse X as CommonJS in 1. STOP.

LOAD_AS_FILE(X)

1. If X is a file, load X as its file extension format. STOP
2. If X.js is a file, a. Find the closest package scope SCOPE to X. b. If no scope was found
   1. MAYBE_DETECT_AND_LOAD(X.js) c. If the SCOPE/package.json contains "type" field,
   1. If the "type" field is "module", load X.js as an ECMAScript module. STOP.
   1. If the "type" field is "commonjs", load X.js as an CommonJS module. STOP. d. MAYBE_DETECT_AND_LOAD(X.js)
3. If X.json is a file, load X.json to a JavaScript Object. STOP
4. If X.node is a file, load X.node as binary addon. STOP

LOAD_INDEX(X)

1. If X/index.js is a file a. Find the closest package scope SCOPE to X. b. If no scope was found, load X/index.js as a CommonJS module. STOP. c. If the SCOPE/package.json contains "type" field,
   1. If the "type" field is "module", load X/index.js as an ECMAScript module. STOP.
   2. Else, load X/index.js as an CommonJS module. STOP.
2. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP
3. If X/index.node is a file, load X/index.node as binary addon. STOP

LOAD_AS_DIRECTORY(X)

1. If X/package.json is a file, a. Parse X/package.json, and look for "main" field. b. If "main" is a falsy value, GOTO 2. c. let M = X + (json main field) d. LOAD_AS_FILE(M) e. LOAD_INDEX(M) f. LOAD_INDEX(X) DEPRECATED g. THROW "not found"
2. LOAD_INDEX(X)

LOAD_NODE_MODULES(X, START)

1. let DIRS = NODE_MODULES_PATHS(START)
2. for each DIR in DIRS: a. LOAD_PACKAGE_EXPORTS(X, DIR) b. LOAD_AS_FILE(DIR/X) c. LOAD_AS_DIRECTORY(DIR/X)

NODE_MODULES_PATHS(START)

1. let PARTS = path split(START)
2. let I = count of PARTS - 1
3. let DIRS = []
4. while I >= 0, a. if PARTS[I] = "node_modules", GOTO d. b. DIR = path join(PARTS[0 .. I] + "node_modules") c. DIRS = DIR + DIRS d. let I = I - 1
5. return DIRS + GLOBAL_FOLDERS

LOAD_PACKAGE_IMPORTS(X, DIR)

1. Find the closest package scope SCOPE to DIR.
2. If no scope was found, return.
3. If the SCOPE/package.json "imports" is null or undefined, return.
4. If `--experimental-require-module` is enabled a. let CONDITIONS = ["node", "require", "module-sync"] b. Else, let CONDITIONS = ["node", "require"]
5. let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE), CONDITIONS) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
6. RESOLVE_ESM_MATCH(MATCH).

LOAD_PACKAGE_EXPORTS(X, DIR)

1. Try to interpret X as a combination of NAME and SUBPATH where the name may have a @scope/ prefix and the subpath begins with a slash (`/`).
2. If X does not match this pattern or DIR/NAME/package.json is not a file, return.
3. Parse DIR/NAME/package.json, and look for "exports" field.
4. If "exports" is null or undefined, return.
5. If `--experimental-require-module` is enabled a. let CONDITIONS = ["node", "require", "module-sync"] b. Else, let CONDITIONS = ["node", "require"]
6. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH, `package.json` "exports", CONDITIONS) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
7. RESOLVE_ESM_MATCH(MATCH)

LOAD_PACKAGE_SELF(X, DIR)

1. Find the closest package scope SCOPE to DIR.
2. If no scope was found, return.
3. If the SCOPE/package.json "exports" is null or undefined, return.
4. If the SCOPE/package.json "name" is not the first segment of X, return.
5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE), "." + X.slice("name".length), `package.json` "exports", ["node", "require"]) <a href="esm.md#resolver-algorithm-specification">defined in the ESM resolver</a>.
6. RESOLVE_ESM_MATCH(MATCH)

RESOLVE_ESM_MATCH(MATCH)

1. let RESOLVED_PATH = fileURLToPath(MATCH)
2. If the file at RESOLVED_PATH exists, load RESOLVED_PATH as its extension format. STOP
3. THROW "not found"

当启用 --experimental-require-module 选项时,node 会使用新的解析逻辑。会通过 ["node", "require", "module-sync"] 来确定 require(esm) 的入口。module-syncnode 新增的一个条件,无论包是通过 importimport() 还是 require() 加载都会匹配。预期格式为 es 模块,其模块图中不包含 TLA - 如果包含,当模块被 require() 时将抛出 ERR_REQUIRE_ASYNC_MODULE

module-sync 是一个辅助工具,帮助生态系统在向 ESM 统一的道路上更平稳地过渡,但他本身并不是长期解决方案,而是一个临时的兼容层。

按照上述约定 修改 demo 包的 package.json 文件:

json
{
  "name": "demo",
  "exports": {
    ".": {
      "import": "./es/index.mjs",
      "require": "./index.js", 
      "module-sync": "./es/index.mjs", 
      "default": "./index.js"
    }
  }
}

v20.19.0 版本中,执行 main.mjs 结果如下:

bash
ESM Dynamic Import: [Module: null prototype] { default: { version: 'ESM' } }
CJS Static Import: [Module: null prototype] {
  __esModule: true,
  default: { version: 'ESM' }
}
Equal?  true

同时还兼容先前版本,例如在 v18.20.1 版本中执行结果:

bash
ESM Dynamic Import: [Module: null prototype] { default: { version: 'ESM' } }
CJS Static Import: { version: 'CJS' }
Equal?  false

Caution for module-sync flag

exports 中的 require 标识符 可以 放置在 module-sync 标识符 之后,但 不要exports 中的 require 标识符放置在 module-sync 标识符 之前,否则不管是 node新版本 还是 旧版本 通过 require() 加载的包均会解析为 require 对应的 cjs 入口。

json
{
  "name": "demo",
  "exports": {
    ".": {
      "import": "./es/index.mjs",
      "require": "./index.js", 
      "module-sync": "./es/index.mjs"
    }
  }
}

建议按照上述用例一样,在 exports 中使用 default 标识符来做 cjs 入口的旧版本兼容性处理。这样包在支持 require(esm)node.js 版本上提供 esm 版本,在不支持的旧版本上回退到 cjs 的版本,使包能够逐步向纯 esm 过渡,而不必立即放弃对 旧版本 的支持。

新特性对于现阶段的包是有价值的,TLA 特性对于 ESM Bundlers(rollupesbuildrolldown 等) 来说并不友好,大部分的 ESM Bundlers 并不会将完全实现 TLA 的特性,而仅仅是扁平化 TLA 模块,以 串型 的方式加载模块,而 TLA 规范是 并行 加载模块的,语义上会有很大的歧义。依赖运行时特性 是现阶段 TLA 的主要使用场景,例如 初始化配置项初始化服务等,借助 node 原生支持 TLA 的能力。

Link

更多细节可参考 Top Level Await 一章的讲解。

对包作者的建议

module-sync 仅作为一种功能检测机制,适用于希望在过渡期间同时支持 cjsesm 的包作者,此时部分活跃的 node.js LTS 版本支持 require(esm),而一些旧版本不支持。当所有活跃的 node.js LTS 版本都支持 require(esm) 时,包作者可以通过升级包的 主版本号、删除 exportscjs 入口并移除 exportsmodule-sync 标识符(仅保留指向 maindefaultesm 入口)。

如果包需要在过渡期同时支持 捆绑工具 和在 node.js 上不经打包处理直接运行,请同时使用 module-syncmodule 并将其指向相同的 esm 文件。如果包已经不打算支持不支持 require(esm) 的旧版 node.js,则无需使用此 export 条件,直接使用 esm 格式发布包即可。

Contributors

No contributors

Changelog

No recent changes

Discuss

Released under the CC BY-SA 4.0 License. (7902bca)