Named exports behavior of CommonJS
Phenomenon
如下创建两个几乎相同的 commonjs
模块做测试,唯一的区别是一个包含 exports.namedExport_A = Symbol('placeholder')
语句,而另一个不包含。然后我们将尝试从 esm
模块中具名导入它们。
测试文件结构如下:
test-folder/
├── import-with.mjs # 从 with-exports.cjs 导入的 ESM 模块
├── with-exports.cjs # 包含 exports.namedExport_A 语句的 CJS 模块
├── import-without.mjs # 从 without-exports.cjs 导入的 ESM 模块
└── without-exports.cjs # 不包含 exports.namedExport_A 语句的 CJS 模块
相对应的模块代码如下:
import defaultExport, { namedExport_A } from './with-exports.cjs';
console.log('From with-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
function getExports() {
return {
mainExport: 'main value',
namedExport_A: 'namedExport_A',
namedExport_B: 'namedExport_B'
};
}
module.exports = getExports();
exports.namedExport_A = Symbol('placeholder');
console.log('with-exports module loaded');
import defaultExport, { namedExport_A } from './without-exports.cjs';
console.log('From without-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
function getExports() {
return {
mainExport: 'main value',
namedExport_A: 'namedExport_A',
namedExport_B: 'namedExport_B'
};
}
module.exports = getExports();
// exports.namedExport_A = Symbol('placeholder'); // 移除此行
console.log('without-exports module loaded');
当执行上述模块时会发现带有 exports.namedExport_A
的模块可以被具名导入,而没有它的模块则报错。
执行
node import-with.mjs
:bashwith-exports module loaded From with-exports.cjs: - defaultExport: { mainExport: 'main value', namedExport_A: 'namedExport_A', namedExport_B: 'namedExport_B' } - namedExport_A: namedExport_A
执行
node import-without.mjs
:bashSyntaxError: Named export 'namedExport_A' not found. The requested module './without-exports.cjs' is a CommonJS module, which may not support all module.exports as named exports. CommonJS modules can always be imported via the default export, for example using: import pkg from './without-exports.cjs'; const { namedExport_A } = pkg;
Reason Explanation
node.js
官方文档在 Interoperability with CommonJS 部分明确说明:
In addition, a heuristic static analysis is performed against the source text of the CommonJS module to get a best-effort static list of exports to provide on the namespace from values on module.exports. This is necessary since these namespaces must be constructed prior to the evaluation of the CJS module.
此外,Node.js 会对 CommonJS 模块的源代码文本执行启发式静态分析,以尽可能获取一个静态的导出列表,用于在命名空间中提供 module.exports 上的值。这是必要的,因为这些命名空间必须在 CJS 模块求值之前构建完成。
这意味着 javascript
运行时(如 node.js
)会在实际执行 commonjs
模块代码前,先通过 静态代码分析 来预测模块可能会导出哪些内容。这种分析并非完美,但它允许 esm
模块在导入 commonjs
模块时能够获得类似原生 esm
模块的导入体验,包括 静态结构分析 和 具名导入 功能。
cjs-module-lexer
项目就是当通过 ESM
导入 CommonJS
模块时,node.js
核心用来检测可用的 命名导出,识别规则如下:
Basic Named Export Detection
直接属性赋值
javascriptexports.name = 'value'; module.exports.name = 'value';
计算属性赋值(使用字符串字面量)
javascriptexports['name'] = 'value'; module.exports['name'] = 'value';
Object.defineProperty 定义
javascript// 值属性 Object.defineProperty(exports, 'name', { value: 'value' }); // getter属性(仅支持特定返回模式) Object.defineProperty(exports, 'name', { enumerable: true, get() { return identifier; } }); // 成员表达式返回也支持 Object.defineProperty(exports, 'name', { enumerable: true, get() { return obj.prop; } }); // 计算成员表达式返回也支持 Object.defineProperty(exports, 'name', { enumerable: true, get() { return obj['prop']; } });
Object Literal Export Detection
module.exports = {
a, // 简写属性
b: value, // 常规属性
stringKey: value, // 字符串键属性
...spread // 展开运算符(会被忽略,但不会中断解析)
};
Reexport Detection
直接模块重新赋值(只检测最后一个)
javascriptmodule.exports = require('./dep.js');
对象字面量中的展开重导出
javascriptmodule.exports = { ...require('./dep1.js'), ...require('./dep2.js') };
转译器特定模式(仅顶层检测)
Babel
风格:javascriptvar _external = require('external'); Object.keys(_external).forEach(function (key) { if (key === 'default' || key === '__esModule') return; exports[key] = _external[key]; });
TypeScript
风格:javascript__export(require('external')); // 或 __exportStar(require('external'));
Detection Limitations
无作用域分析:可能过度检测(将非实际导出识别为导出)
javascript// 会错误检测出 'c' 作为导出,尽管条件永不执行 if (false) exports.c = 'value';
标识符敏感:如果重命名标准模式中的标识符,可能导致检测不足
javascript// 不会检测任何导出,因为 'e' 替代了 'exports' (function (e) { e.a = 'value'; })(exports);
对象解析有限:在对象字面量中,遇到复杂表达式会中断解析
javascriptmodule.exports = { a: 'detected', b: require('c'), // 复杂表达式 d: 'not-detected' // 在复杂表达式后被忽略 };
getter 限制:仅支持特定模式的
getter
返回值,如标识符或成员表达式
这些规则是经过精心设计的,旨在在不进行完整语法分析的情况下,高效地识别常见的 CommonJS
导出模式,特别是流行转译器生成的代码模式。
Ecosystem Application
esbuild
在 CHANGELOG
中也利用了这一特性:
Add export name annotations to CommonJS
output for node
When you import a CommonJS
file using an ESM
import statement in node
, the default import is the value of module.exports
in the CommonJS
file. In addition, node
attempts to generate named exports for properties of the module.exports
object.
Except that node
doesn't actually ever look at the properties of that object to determine the export names. Instead it parses the CommonJS
file and scans the AST
for certain syntax patterns. A full list of supported patterns can be found in the documentation for the cjs-module-lexer
package. This library doesn't currently support the syntax patterns used by esbuild
.
While esbuild
could adapt its syntax to these patterns, the patterns are less compact than the ones used by esbuild
and doing this would lead to code bloat. Supporting two separate ways of generating export getters would also complicate esbuild
's internal implementation, which is undesirable. Another alternative could be to update the implementation of cjs-module-lexer
to support the specific patterns used by esbuild
. This is also undesirable because this pattern detection would break when minification is enabled, this would tightly couple esbuild
's output format with node
and prevent esbuild
from changing it, and it wouldn't work for existing and previous versions of node
that still have the old version of this library.
Instead, esbuild
will now add additional code to "annotate" ESM
files that have been converted to CommonJS
when esbuild
's platform has been set to node
. The annotation is dead code but is still detected by the cjs-module-lexer
library. If the original ESM
file has the exports foo
and bar
, the additional annotation code will look like this:
0 && (module.exports = { foo, bar });
This allows you to use named imports with an ESM
import statement in node
(previously you could only use the default import):
import { bar, foo } from './file-built-by-esbuild.cjs';
为 CommonJS
添加导出命名注解来兼容 node
当在 node
中使用 ESM
导入语句导入 CommonJS
文件时,默认导入的是 CommonJS
文件中 module.exports
的值。此外,node
会尝试为 module.exports
对象的属性生成 命名导出。
然而,node
实际上并不检查 module.exports
对象的属性来确定导出名称。相反,node
通过解析 CommonJS
文件并扫描 AST
(抽象语法树) 来查找特定的语法模式。支持的完整模式列表可以在 cjs-module-lexer 包的文档 中找到。该库目前不支持 esbuild
使用的语法模式。
虽然 esbuild
可以通过调整语法来适应上述的模式,但这些模式比 esbuild
所使用的模式更为冗长,这样做会导致代码膨胀。支持两种不同的 export getters
获取方式也会使 esbuild
的内部实现变得复杂,这是不理想的。另一种选择是更新 cjs-module-lexer
的实现来支持 esbuild
使用特定模式,但这也是不理想,因为启用代码压缩后,这种模式检测会被中断。这会将 esbuild
的输出格式与 node
紧密耦合,阻止 esbuild
对其进行更改,而且它不适用于仍然使用该库旧版本的现有和以前版本的 node
。
相反,当 esbuild
的平台设置为 node
时,esbuild
现在将为已转换为 CommonJS
的 ESM
模块添加额外的"注解"代码。这个注解是死代码,幸运的是,能够被 cjs-module-lexer
库检测到。如果原始 ESM
模块有导出 foo
和 bar
,则转译为 CommonJS
后,额外的注解代码将如下所示:
0 && (module.exports = { foo, bar });
这样就可以在 node
中使用 ESM
导入语句时使用 命名导入(以前你只能使用默认导入):
import { bar, foo } from './file-built-by-esbuild.cjs';
esbuild
通过在末尾添加 0 && (module.exports = { foo, bar })
来实现这一点。有效引导 cjs-module-lexer
正确识别 CommonJS
模块的命名导出的同时,又不影响 esbuild
的正常解析和构建行为。