Skip to content

打包 CJS ​

Rolldown 为 CommonJS 模块提供一等支持。本文档解释了 Rolldown 如何处理 CJS 模块,以及它们与 ES 模块之间的互操作性。

关键特性 ​

原生 CJS 支持 ​

Rolldown 会自动识别并处理 CommonJS 模块,无需任何额外的插件或包。这种原生支持意味着:

  • 无需安装额外依赖
  • 与基于插件的方案相比性能更好

按需执行 ​

Rolldown 保留了 CommonJS 模块的按需执行语义,这是 CommonJS 模块系统的一项关键特性。这意味着模块只有在真正被 require 时才会执行。

下面是一个示例:

js
// index.js
import { value } from './foo.js';

const getFooExports = () => require('./foo.js');

// foo.js
module.exports = { value: 'foo' };

打包后会生成:

js
// #region \0rolldown/runtime.js
// ...运行时代码
// #endregion

// #region foo.js
var require_foo = __commonJS({
  'foo.js'(exports, module) {
    module.exports = { value: 'foo' };
  },
});

// #endregion
// #region index.js
const getFooExports = () => require_foo();
// #endregion

在这个示例中,foo.js 模块在调用 getFooExports() 之前不会执行,从而保持了 CommonJS 的懒加载行为。

ESM/CJS 互操作性 ​

Rolldown 提供了 ES 模块与 CommonJS 模块之间的无缝互操作。

ESM 从 CJS 导入的示例:

js
// index.js
import { value } from './foo.js';

console.log(value);

// foo.js
module.exports = { value: 'foo' };

打包输出:

js
// #region \0rolldown/runtime.js
// ...运行时代码
// #endregion

// #region foo.js
var require_foo = __commonJS({
  'foo.js'(exports, module) {
    module.exports = { value: 'foo' };
  },
});

// #endregion
// #region index.js
var import_foo = __toESM(require_foo());
console.log(import_foo.value);

// #endregion

__toESM 辅助函数确保 CommonJS 导出会被正确转换为 ES 模块格式,从而可以无缝访问导出的值。

注意事项 ​

require 外部模块 ​

默认情况下,Rolldown 会尽量保持 require 的语义,不会把针对外部模块的 require 转换为 import。这是因为 require 的语义与 ES 模块中的 import 不同。例如,require 是延迟求值的,而 import 会在代码执行之前求值。

仍然想把 require 转换为 import?

如果你想把 require 调用转换为 import 语句,可以使用内置的 esmExternalRequirePlugin。请注意,该插件必须自行负责它转换的外部依赖:把它们列在插件的 external 选项中,而不是顶层的 external 选项中。

对于 platform: 'node',Rolldown 会基于 module.createRequire 生成一个 require 函数。这会完全保留 require 的语义。需要注意的是,与转换为 import 相比,这种方式有两个缺点:

  1. 运行时需要支持 module.createRequire,在某些部分兼容 Node 的环境中可能不可用
  2. 不适合期望以被打包形式发布的库,因为 require 函数会成为一个局部变量,这会让打包器更难静态分析代码

对于其他平台,Rolldown 会保持原样不变,允许运行环境提供一个 require 函数,或者手动注入一个。例如,你可以使用 inject 功能 注入一个返回通过 import 获取的值的 require 函数。

js
import path from 'node:path';
export default {
  inject: {
    require: path.resolve('./require.js'),
  },
};
js
import fs from 'node:fs';

export default (id) => {
  if (id === 'node:fs') {
    return fs;
  }
  throw new Error(`不允许 require ${JSON.stringify(id)}。`);
};

来自 CJS 模块的歧义 default 导入 ​

在生态系统中,处理来自 CJS 模块的导入有两种常见方式。虽然 Rolldown 会尝试自动支持这两种解释,但它们对于 default 导入来说是不兼容的。在这种情况下,Rolldown 会使用类似 Webpack 和 esbuild 的启发式规则来确定 default 导入的值。

如果满足下面任一条件,那么 default 导入就是被导入的 CJS 模块的 module.exports 值。否则,default 导入就是被导入的 CJS 模块的 module.exports.default 值。

  • 导入者是 .mjs 或 .mts
  • (当它是动态导入时)导入者是 .cjs 或 .cts
  • 导入者最近的 package.json 的 type 字段设置为 module
  • (当它是动态导入时)导入者最近的 package.json 的 type 字段设置为 commonjs
  • 被导入的 CJS 模块的 module.exports.__esModule 值未设置为 true
  • 被导入的 CJS 模块的 module.exports 值没有自有的 default 属性

最后一个条件用于处理那些设置了 __esModule 但实际上并未提供 default 导出的 CJS 模块(例如 tslib 的 UMD 构建)。如果没有这个条件,default 导入将会是 undefined。@rollup/plugin-commonjs 也使用相同的回退方式处理这种情况。

详细行为

假设有如下 ESM 导入方模块和 CJS 被导入方模块:

js
import foo from './importee.cjs';
console.log(foo);
js
Object.defineProperty(module.exports, '__esModule', {
  value: true,
});
module.exports.default = 'foo';

在第一种解释中,也就是 Babel 的解释方式,这段代码会打印 foo。在这种解释下,行为会根据 __esModule 标志而改变。__esModule 通常由转换器设置,用于表示该模块原本是用 ESM 语法编写的(例如这里的 export default 'foo'),并已被转换为 CJS 语法。这样处理的原因是,转换后的模块应当与未经转换时表现一致。@rollup/plugin-commonjs 默认使用这种解释方式。

在第二种解释中,也就是 Node.js 的解释方式,这段代码会打印 { default: 'foo' }。这样处理的原因是,CJS 模块可以动态设置导出键,而 ESM 要求导出键在静态上已知,因此为了允许访问所有导出,整个 module.exports 会作为默认导出暴露出来。@rollup/plugin-commonjs 在设置了 defaultIsModuleExports: false 时使用这种解释方式。

这两种解释对于 default 导入期望不同的值,而 Rolldown 必须决定使用哪一种。

这种启发式规则的依据是什么?

Rolldown 的启发式规则基于这样一个假设:受 Node.js 模块判定概念影响的文件,应当能够在 Node.js 中运行。对于 ESM 文件而言,要能在 Node.js 中运行,它们需要使用 .mjs,或者其最近的 package.json 中 type 字段被设置为 module(这样才会使用 ESM 加载器),并且代码应当以符合 Node.js 解释方式的形式编写。另一方面,对于使用 ESM 语法编写、但在 Node.js 的模块判定概念中未被标记为 ESM 的文件,这些代码很可能会被其他工具转换,而这些工具通常遵循 Babel 的解释方式。

给库作者的建议 ​

如果你正在编写新代码,我们强烈建议你以 ESM 语法发布你的代码。随着 Node.js 中已提供 require(ESM) 功能,这样做不会有太大的障碍。 如果你仍然需要以 CJS 语法发布代码,我们强烈建议你避免使用 default 导出。

当从 CJS 模块导入默认导出时,我们建议编写能够同时处理这两种解释的代码。例如,你可以使用下面的代码同时兼容两种解释:

js
import rawFoo from './importee.cjs';
const foo =
  typeof rawFoo === 'object' && rawFoo !== null && rawFoo.__esModule ? rawFoo.default : rawFoo;
console.log(foo);

这段代码在两种解释下都会打印 foo。需要注意的是,TypeScript 在使用这段代码时可能会报类型错误;这是因为 TypeScript 不支持这种行为,但可以安全地忽略该错误。

给库使用者的建议 ​

如果你发现的问题似乎是由这种不兼容引起的,可以先使用 publint 检查该包。它有 一条可检测这种不兼容的规则(注意,它只检查包中的部分文件,而不是全部文件)。

如果这种启发式规则对你不起作用,你可以使用上面那段同时处理两种解释的代码。如果导入来自某个依赖,我们建议向该依赖提 issue。与此同时,你也可以使用 patch-package、pnpm patch 或其他替代方案作为临时解决办法。

.js 文件应用严格模式 ​

对于以 .js 结尾的文件,Rolldown 会将其作为 ESM 解析(#7009),而不会回退到 CJS。这意味着只有非严格模式(sloppy mode)下才允许的语法会被拒绝。

目前,你可以临时将文件扩展名改为 .cjs 作为变通方法。

未来计划 ​

Rolldown 对 CommonJS 模块的一等支持带来了若干潜在优化:

  • 针对 CommonJS 模块的高级 tree-shaking 能力
  • 更好的死代码消除

Was this page helpful?