Skip to content

Named exports behavior of CommonJS

Phenomenon

如下创建两个几乎相同的 commonjs 模块做测试,唯一的区别是一个包含 exports.namedExport_A = Symbol('placeholder') 语句,而另一个不包含。然后我们将尝试从 esm 模块中具名导入它们。

测试文件结构如下:

bash
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 模块

相对应的模块代码如下:

js
import defaultExport, { namedExport_A } from './with-exports.cjs';

console.log('From with-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
js
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');
js
import defaultExport, { namedExport_A } from './without-exports.cjs';

console.log('From without-exports.cjs:');
console.log('- defaultExport:', defaultExport);
console.log('- namedExport_A:', namedExport_A);
js
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 的模块可以被具名导入,而没有它的模块则报错。

  1. 执行 node import-with.mjs:

    bash
    with-exports module loaded
    From with-exports.cjs:
    - defaultExport: { mainExport: 'main value', namedExport_A: 'namedExport_A', namedExport_B: 'namedExport_B' }
    - namedExport_A: namedExport_A
  2. 执行 node import-without.mjs:

    bash
    SyntaxError: 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

  1. 直接属性赋值

    javascript
    exports.name = 'value';
    module.exports.name = 'value';
  2. 计算属性赋值(使用字符串字面量)

    javascript
    exports['name'] = 'value';
    module.exports['name'] = 'value';
  3. 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

javascript
module.exports = {
  a, // 简写属性
  b: value, // 常规属性
  stringKey: value, // 字符串键属性
  ...spread // 展开运算符(会被忽略,但不会中断解析)
};

Reexport Detection

  1. 直接模块重新赋值(只检测最后一个)

    javascript
    module.exports = require('./dep.js');
  2. 对象字面量中的展开重导出

    javascript
    module.exports = {
      ...require('./dep1.js'),
      ...require('./dep2.js')
    };
  3. 转译器特定模式(仅顶层检测)

    Babel 风格:

    javascript
    var _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

  1. 无作用域分析:可能过度检测(将非实际导出识别为导出)

    javascript
    // 会错误检测出 'c' 作为导出,尽管条件永不执行
    if (false) exports.c = 'value';
  2. 标识符敏感:如果重命名标准模式中的标识符,可能导致检测不足

    javascript
    // 不会检测任何导出,因为 'e' 替代了 'exports'
    (function (e) {
      e.a = 'value';
    })(exports);
  3. 对象解析有限:在对象字面量中,遇到复杂表达式会中断解析

    javascript
    module.exports = {
      a: 'detected',
      b: require('c'), // 复杂表达式
      d: 'not-detected' // 在复杂表达式后被忽略
    };
  4. getter 限制:仅支持特定模式的 getter 返回值,如标识符或成员表达式

这些规则是经过精心设计的,旨在在不进行完整语法分析的情况下,高效地识别常见的 CommonJS 导出模式,特别是流行转译器生成的代码模式。

Ecosystem Application

esbuildCHANGELOG 中也利用了这一特性:

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:

js
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):

js
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 现在将为已转换为 CommonJSESM 模块添加额外的"注解"代码。这个注解是死代码,幸运的是,能够被 cjs-module-lexer 库检测到。如果原始 ESM 模块有导出 foobar,则转译为 CommonJS 后,额外的注解代码将如下所示:

js
0 && (module.exports = { foo, bar });

这样就可以在 node 中使用 ESM 导入语句时使用 命名导入(以前你只能使用默认导入):

js
import { bar, foo } from './file-built-by-esbuild.cjs';

esbuild 通过在末尾添加 0 && (module.exports = { foo, bar }) 来实现这一点。有效引导 cjs-module-lexer 正确识别 CommonJS 模块的命名导出的同时,又不影响 esbuild 的正常解析和构建行为。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)