模块:支持 require() 同步 ESM 图
Related Material
- 2025-03-13, Version 20.19.0 'Iron' (LTS), @marco-ippolito
- [v20.x] backport unflagging of require(esm) to v20 - Feb 6, 2025
- module: support require()ing synchronous ESM graphs - Mar 6, 2024
- module: implement the "module-sync" exports condition - Aug 30, 2024
- [WIP] Support requiring .mjs files - Dec 11, 2019
- Proposal for single-mode packages with optional fallbacks for older versions of node - Mar 26, 2019
- Node.js Conditional exports
版本状态
在命令行标志 --experimental-require-module
下,v20.x
上已支持使用 require()
加载原生 ESM
模块。在 v20.19
、v22.x
、v23.x
上默认启用这一新特性,可以通过 process.features.require_module
来检查当前 node
版本是否支持这一特性。
存在的问题
双模式包(dual-package
)
在 node.js
中,长期以来存在两种模块系统并行的情况:
CommonJS (CJS)
- 使用require()
和module.exports
ECMAScript Modules (ESM)
- 使用import
和export
这种双系统带来了所谓的"双包危害"(dual-package hazard
)问题:同一个包可能在不同环境下加载不同版本的代码,导致难以预测的行为。
考虑以下用例:
demo
包:
export default {
version: 'ESM'
};
module.exports = {
version: 'CJS'
};
{
"name": "demo",
"exports": {
".": {
"import": "./es/index.mjs",
"require": "./index.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);
});
const data = require('demo');
module.exports = data;
对于上述场景下,main.mjs
的执行结果在任何一个 node
版本下都是如下:
ESM Dynamic Import: [Module: null prototype] { default: { version: 'ESM' } }
CJS Static Import: { version: 'CJS' }
Equal? false
那么可以得出一个结论,在混合使用 esm
和 cjs
的时候,当两种模块系统 均依赖 同一个包时,同时 依赖包 提供了 双模式
的包(dual-package
)。那么 esm
会使用包的 import
入口,而 cjs
会使用包的 require
入口。这会 破坏 部分场景下的 单例模式 和 增加最终产物的体积。
解决方案
支持 require(esm)
的方案可以解决上述问题,以下是 node
新的 解析逻辑:
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-sync
是 node
新增的一个条件,无论包是通过 import
、import()
还是 require()
加载都会匹配。预期格式为 es
模块,其模块图中不包含 TLA
- 如果包含,当模块被 require()
时将抛出 ERR_REQUIRE_ASYNC_MODULE
。
module-sync
是一个辅助工具,帮助生态系统在向ESM
统一的道路上更平稳地过渡,但他本身并不是长期解决方案,而是一个临时的兼容层。
按照上述约定 修改 demo
包的 package.json
文件:
{
"name": "demo",
"exports": {
".": {
"import": "./es/index.mjs",
"require": "./index.js",
"module-sync": "./es/index.mjs",
"default": "./index.js"
}
}
}
在 v20.19.0
版本中,执行 main.mjs
结果如下:
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
版本中执行结果:
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
入口。
{
"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
(rollup
、esbuild
、rolldown
等) 来说并不友好,大部分的 ESM Bundlers
并不会将完全实现 TLA
的特性,而仅仅是扁平化 TLA
模块,以 串型 的方式加载模块,而 TLA
规范是 并行 加载模块的,语义上会有很大的歧义。依赖运行时特性 是现阶段 TLA
的主要使用场景,例如 初始化配置项、初始化服务等,借助 node
原生支持 TLA
的能力。
Link
更多细节可参考 Top Level Await 一章的讲解。
对包作者的建议
module-sync
仅作为一种功能检测机制,适用于希望在过渡期间同时支持 cjs
和 esm
的包作者,此时部分活跃的 node.js LTS
版本支持 require(esm)
,而一些旧版本不支持。当所有活跃的 node.js LTS
版本都支持 require(esm)
时,包作者可以通过升级包的 主版本号、删除 exports
的 cjs
入口并移除 exports
的 module-sync
标识符(仅保留指向 main
或 default
的 esm
入口)。
如果包需要在过渡期同时支持 捆绑工具 和在 node.js
上不经打包处理直接运行,请同时使用 module-sync
和 module
并将其指向相同的 esm
文件。如果包已经不打算支持不支持 require(esm)
的旧版 node.js
,则无需使用此 export
条件,直接使用 esm
格式发布包即可。