Skip to content

Interop

A Note to Our Readers

While maintaining fidelity to the source material, this translation incorporates explanatory content and localized expressions, informed by the translator's domain expertise. These thoughtful additions aim to enhance readers' comprehension of the original text's core messages. For any inquiries about the content, we welcome you to engage in the discussion section or consult the source text for reference.

The content of the following article is based on the following plugin versions.

@rollup/plugin-commonjs: 13.0.0

预期行为/情况

该插件(以及 rollup)应该能够与其自身和生态系统中的工具生成的输出尽可能无缝地工作。

实际行为/情况

存在大量互操作性问题,详见下文。本文旨在概述该插件的实际工作原理,以及所有互操作场景和当前行为的较为完整的图景。目的是将此作为讨论的基础,并识别出可以通过合理努力解决的子问题。我很乐意负责处理对 rollup 核心的所有更改,但如果能得到支持来改进插件,我会非常感谢。

这篇篇幅 非常 长,我会根据建议尝试更新。我也会尽快在文末添加一个汇总摘要。

由于我坚信测试胜于想当然,我几乎为所有内容都添加了实际的输出代码示例。要生成预格式化的代码示例,我使用了以下配置:

js
import path from 'path';
import cjs from '@rollup/plugin-commonjs';

const inputFiles = Object.create(null);
const transformedFiles = Object.create(null);

const formatFiles = files =>
  Object.keys(files)
    .map(id => `// ${JSON.stringify(id)}\n${files[id].code}\n`)
    .join('\n');

const formatId = id => {
  const [, prefix, modulePath] = /(\0)?(.*)/.exec(id);
  return `${prefix || ''}${path.relative('.', modulePath)}`;
};

export default {
  input: 'main',
  plugins: [
    {
      name: 'collect-input-files',
      transform(code, id) {
        if (id[0] !== '\0') {
          inputFiles[formatId(id)] = { code: code.trim() };
        }
      }
    },
    cjs(),
    {
      name: 'collect-output',
      transform(code, id) {
        // Never display the helpers file
        if (id !== '\0commonjsHelpers.js') {
          transformedFiles[formatId(id)] = { code: code.trim() };
        }
      },
      generateBundle(options, bundle) {
        console.log(`<details>
<summary>Input</summary>

\`\`\`js
${formatFiles(inputFiles)}
\`\`\`

</details>
<details>
<summary>Transformed</summary>

\`\`\`js
${formatFiles(transformedFiles)}
\`\`\`

</details>
<details>
<summary>Output</summary>

\`\`\`js
${formatFiles(bundle)}
\`\`\`

</details>`);
      }
    }
  ],
  output: {
    format: 'es',
    file: 'bundle.js'
  }
};

CJS 导入 CJS

插件需要确保这种情况能够无缝工作,将依赖方模块中的 require 语句与依赖模块中的 module.exports 关联起来。

这实际上并不是一个真正的互操作问题,本节的目标更多是突出插件实际的工作方式以及插件是如何处理某些场景,以及在进入实际的互操作问题之前可以改进的地方。

module.exports 赋值

在依赖方模块中,插件会生成两种导入方式:对空导入原始模块 和导入 代理模块

其中实际的绑定是从 代理模块 导入的,对原始模块进行 空导入原因 是为了触发原始模块的 加载和转换,这样在构建代理模块时插件就知道原始模块是 CJS 还是 ESM

原始模块和代理模块的转换逻辑:

  • 原始模块(CJS)转换为 ESM 后会形成两种导出方式,原始模块导出的 module.exports 内容在 ESM 中既以 default 属性做默认导出,同时又以 __moduleExports 属性名做具名导出。
  • 代理模块再以原始模块的 __moduleExports 具名导入后再做 default 的默认导出(对于代理执行略有不同的情况,可以看看从 CJS 导入 ESM 的部分)。
js
const dep = require('./dep.js');
console.log(dep);
js
module.exports = 'foo';
js
// "main.js"
import "./dep.js";
import dep from "./dep.js?commonjs-proxy";

console.log(dep);

// "dep.js"
var dep = "foo";

export default dep;
export { dep as __moduleExports };

// "\u0000dep.js?commonjs-proxy"
import { __moduleExports } from "/Users/lukastaegert/Github/rollup-playground/dep.js";
export default __moduleExports;
js
var dep = 'foo';

console.log(dep);

exportsmodule.exports 的属性赋值

在这种情况下,rollup 会创建一个带有所有属性内联的 module.exports 对象。这非常高效,不同于一个一个地给对象赋值属性,因为运行时引擎可以立即优化这样的对象来实现快速访问。这个对象再次被导出为 default__moduleExports。此外,所有赋值的属性也作为具名导出。

js
const dep = require('./dep.js');
console.log(dep);
js
module.exports.foo = 'foo';
exports.bar = 'bar';
exports.default = 'baz';
js
// "main.js"
import "./dep.js";
import dep from "./dep.js?commonjs-proxy";

console.log(dep);

// "dep.js"
var foo = "foo";
var bar = "bar";
var _default = "baz";

var dep = {
  foo,
  bar,
  default: _default,
};

export default dep;
export { dep as __moduleExports };
export { foo };
export { bar };

// "\u0000dep.js?commonjs-proxy"
import { __moduleExports } from "/Users/lukastaegert/Github/rollup-playground/dep.js";
export default __moduleExports;
js
// "bundle.js"
var foo = 'foo';
var bar = 'bar';
var _default = 'baz';

var dep = {
  foo,
  bar,
  default: _default
};

console.log(dep);

🐞 缺陷 1: 给同一个属性赋值两次会生成两个同名导出,导致 rollup 抛出 Duplicate export 错误。

处理不支持的 module.exportsexports 用法

有很多情况下插件会实施去优化操作(deoptimize),例如运行时才能确定模块导出的具名属性、读取而不是赋值属性等场景。

在这些情况下,使用 createCommonjsModule 辅助函数来创建一个包装器,以更接近 node 执行模块的方式执行它,而不检测模块的任何具名导出。

关于 deoptimize 的重要说明

deoptimize(去优化) 是一个重要的概念。让我们通过示例来理解:

js
// 简单的属性赋值
exports.foo = 'hello'; // 这种情况插件可以静态分析并优化

deoptimize(去优化) 的场景:

js
// 运行时动态生成属性
for (let i = 0; i < 10; ++i) {
  exports[Math.random()] = i;
}

为什么会发生 deoptimize(去优化)呢?

  1. 当插件看到简单的属性赋值时(如 exports.foo = value),插件可以清楚地知道这个模块具体导出了哪些属性名,后续可以通过静态分析做 tree-shaking 优化。

  2. 当插件看到属性是通过运行时动态生成时,情况变得复杂,插件无法确切知道模块具体导出了哪些属性名。在这种情况下,虽然部分用例可以通过进一步的静态分析是可以确定的,但由于 CJS 的动态特性,大部分用例是无法确定具体导出哪些属性。此时,插件会采取 保守 的方式,使用 createCommonjsModule 辅助函数来包装整个模块,以确保行为与 node.js 中的 CommonJS 模块导出行为完全一致。

deoptimize(去优化)的代价:

  • 无法做更进一步的静态分析,tree-shaking 不完整甚至是不生效,生成的代码体积更大。
  • 依赖运行时确定模块的导出,性能可能降低。
js
const dep = require('./dep.js');
console.log(dep);
js
if (true) {
  exports.foo = 'foo';
}
js
// "main.js"
import "./dep.js";
import dep from "./dep.js?commonjs-proxy";

console.log(dep);

// "dep.js"
import * as commonjsHelpers from "commonjsHelpers.js";

var dep = commonjsHelpers.createCommonjsModule(function (module, exports) {
  if (true) {
    exports.foo = "foo";
  }
});

export default dep;
export { dep as __moduleExports };

// "\u0000dep.js?commonjs-proxy"
import { __moduleExports } from "/Users/lukastaegert/Github/rollup-playground/dep.js";
export default __moduleExports;
js
function createCommonjsModule(fn, basedir, module) {
  return (
    (module = {
      path: basedir,
      exports: {},
      require(path, base) {
        return commonjsRequire(
          path,
          base === undefined || base === null ? module.path : base
        );
      }
    }),
    fn(module, module.exports),
    module.exports
  );
}

function commonjsRequire() {
  throw new Error(
    'Dynamic requires are not currently supported by @rollup/plugin-commonjs'
  );
}

var dep = createCommonjsModule(function (module, exports) {
  {
    exports.foo = 'foo';
  }
});

console.log(dep);

内联 require 调用

目前,该插件无法保证精确的执行顺序。相反,即使是嵌套和条件执行的 require 语句(除非它们是以特定方式通过 if 语句编写的)也总是被提升到顶部。

这是一个可以通过重大更改来改进的独立情况,即将模块包装在函数闭包中,并在首次使用时调用它们。然而,这会对生成代码的效率产生负面影响,相比现状而言,所以这只在真正必要时才会实施的 保守 行为。

不幸的是,在构建整个模块依赖图之前,无法判断一个模块是否以非标准方式被 require,因此在最坏的情况下,可能需要使用函数来包装所有的模块。

rollup 在这里可以稍微提供一些帮助,通过实现一些内联算法,但这是非常遥远的未来话题(比如说,最多一年后)且很可能只适用于模块恰好在一个地方使用的情况。其他方法可能是让插件分析实际的执行顺序,看看能否确保第一次使用不需要包装,这样后面有动态 require 也无关紧要,但这感觉复杂且容易出错。

无论如何,这个问题主要是为了完整性而提出的,因为它并不真正涉及互操作的问题,而是值得单独开一个 issue

这里有一个示例来说明:

js
console.log('first');
require('./dep.js');
console.log('third');
false && require('./broken-conditional.js');
// There is special logic to handle this exact case, which is why
// "working-conditional.js" is not in the module graph, but it is not easily
// generalized.
if (false) {
  require('./working-conditional.js');
}
js
console.log('second');
js
console.log('not executed');
js
// "main.js"
import "./dep.js";
import "./broken-conditional.js";
import "./dep.js?commonjs-proxy";
import require$$1 from "./broken-conditional.js?commonjs-proxy";

console.log("first");

console.log("third");
false && require$$1;
// There is special logic to handle this exact case, which is why
// "working-conditional.js" is not in the module graph, but it is not easily
// generalized.
if (false) {
  require("./working-conditional.js");
}

// "dep.js"
console.log("second");

// "\u0000dep.js?commonjs-proxy"
import * as dep from "/Users/lukastaegert/Github/rollup-playground/dep.js";
export default dep;

// "broken-conditional.js"
console.log("not executed");

// "\u0000broken-conditional.js?commonjs-proxy"
import * as brokenConditional from "/Users/lukastaegert/Github/rollup-playground/broken-conditional.js";
export default brokenConditional;
js
console.log('second');

console.log('not executed');

console.log('first');

console.log('third');

接下来让我们来看看实际的互操作问题。

ESM 导入 CJS

NodeJS 风格

node 中,CJS 模块通过 module.exports 只暴露一个 默认导出。这个重要的模式应该始终有效。

对于插件来说,相比于处理 CJS 导入 CJS 的情况,主要的区别在于,在 ESM 模块中是直接导入 实际的模块 而不是 代理模块。这里只举一个例子来说明,总的来说一切工作方式都类似于 CJSCJS 的情况。

js
import foo from './dep.js';
console.log(foo);
js
module.exports = 'foo';
js
// "main.js"
import foo from './dep.js';
console.log(foo);

// "dep.js"
var dep = 'foo';

export default dep;
export { dep as __moduleExports };
js
var dep = 'foo';

console.log(dep);

带具名导入的 NodeJS 风格

这在 webpack 中得到支持,现在也(之前部分支持但现在完全)得到了这个插件的支持。

除了默认导入解析为 module.exports 外,具名导入将解析为 module.exports 上的属性。之前,这只对插件可以自动检测的具名导出有效(而且仅当模块没有实施去优化(deoptimize) 来使用 createCommonjsModule 做模块外层包装时)或者由用户列出的导出。使用 rollupsyntheticNamedExports 属性的当前形式,现在可以在不需要额外配置的情况下解析任意具名导入,甚至具名导入的引用保持 实时绑定

关于 Webpack.mjs 语义和更好的 NodeJS 互操作性的说明

注意,当从带有 .mjs 扩展名的模块使用这种模式时,webpack 目前要么不允许要么会发出警告。

这里的意图是这个扩展名表示我们想要进入某种严格的 NodeJS 互操作模式。我们也可以做类似的事情,但我希望在 @rollup/plugin-node-resolve 中进行 ESM/CJS 检测,并建立一个通信通道从该插件获取这些信息。然后我们可能会给 @rollup/plugin-commonjs 添加一个开关来使用"严格的 NodeJS 互操作",它将:

  • 不自动检测模块类型而使用 NodeJS 语义(扩展名 + package.type),这甚至可能带来一点性能提升
  • 不允许从 CJS 文件进行非默认导入

这可以成为我们在解决当前更紧迫的问题后添加的高级功能。

带静态检测的具名导出示例

js
import { foo } from './dep.js';
console.log(foo);
js
exports.foo = 'foo';
js
// "main.js"
import { foo } from './dep.js';
console.log(foo);

// "dep.js"
var foo = 'foo';

var dep = {
  foo
};

export default dep;
export { dep as __moduleExports };
export { foo };
js
var foo = 'foo';

console.log(foo);

依赖合成具名导出的示例

这里我们只是将一个对象赋值给 module.exports

js
import { foo } from './dep.js';
console.log(foo);
js
module.exports = { foo: 'foo' };
js
// "main.js"
import { foo } from './dep.js';
console.log(foo);

// "dep.js"
var dep = { foo: 'foo' };

export default dep;
export { dep as __moduleExports };
js
var dep = { foo: 'foo' };

console.log(dep.foo);

注意我们通过使用属性访问保持了实时绑定

js
// main.js
import { foo } from './dep.js';

// same as
import dep from './dep.js';

console.log(dep.foo);

如果 module.exports 上的对象后来被修改,访问我们的命名变量将始终提供最新的值(修改后的值)。

ESM 被转译为 CJS

这是一个棘手的问题。这里的难点在于要在原始的 ESM 模块和 CJS 模块之间保持同构行为。当使用"带命名导入的 NodeJS"模式时,具名导出 大部分都能正确处理(除了我们不会对缺失的导出抛出错误),但 默认导出 的行为是存在歧义的,ESM 中的 export default 不应该视作 CJS 中的 module.exports,而应该视作 module.exports.default。这与之前列出的互操作模式不兼容。

目前大多数工具通过添加 __esModule 属性到 module.exports 来用作运行时检测,用来告知其他工具当前的 CJS 模块是由 ESM 模块转译而来的。然后其他工具约定通过以下算法获取 CJS 模块的默认导入:

  • 如果存在 __esModule 属性,则使用 CJSmodule.exports.default 属性值作为默认导出。
  • 如果不存在 __esModule 属性,则使用 CJSmodule.exports 属性值作为默认导出。

Standardize Interface Specification from ESM to CJS

nodev14.13.0 版本中,出于对现有 javascript 生态的兼容性考虑,新增了对 commonjs 的具名导出支持。

For better compatibility with existing usage in the JS ecosystem, Node.js in addition attempts to determine the CommonJS named exports of every imported CommonJS module to provide them as separate ES module exports using a static analysis process.

但是需要注意的是,需要使用 exports 形式导出才可以被识别为 commonjs 的具名导出。

js
// cjs.cjs
exports.name = 'exported';

js
// main.js
import { name } from './cjs.cjs';
// Prints: 'exported'
console.log(name);

但对于默认导出形式,node 会直接将 module.exports 作为 默认导出

js
// main.js
import cjs from './cjs.cjs';
// Prints: { name: 'exported' }
console.log(cjs);

关于社区使用 __esModule 来对由 ESM 转译的 CJS 模块进行运行时检测的提议,node 的原生 ESM 团队的共识是不采纳这个提议,可参见 Adding an option to interpret __esModule as Babel does-Nov 20, 2021

esbuild 也对此问题做出了相应的处理方案:The default export can be error-prone

可以看出这会导致兼容性问题,因为大部分的 npm 库作者会使用 ESM 来编写代码,最后通过打包工具输出包含 CJS 模块。消费者若在生产中使用这种库产物,那么就会存在问题。

举以下例子进行说明:

假设 demo 包是遵循 ESM 规范开发的,包出口的导出方式如下:

js
export const a = 1;
export default {
  b: 2
};

通过构建工具打包成 npm 包,其中供消费者使用的 CJS 产物如下:

js
'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

const a = 1;
var main = {
  b: 2
};

exports.a = a;
exports.default = main;

此时若消费者使用 node 运行时解析 demo 包,获取到的引用信息如下:

js
import defaultValue, { a, default as namedDefaultValue } from 'demo';
// 1
console.log(a);
// { a: 1, default: { b: 2 } }
console.log(defaultValue);
// { a: 1, default: { b: 2 } }
console.log(namedDefaultValue);

这与 demo 使用 ESM 规范的原意不符,demoESM 规范的意图是:

js
// demo
export const a = 1;
export default {
  b: 2
};

js
// user code
import defaultValue, { a, default as namedDefaultValue } from 'demo';
// 1
console.log(a);
// { b: 2 }
console.log(defaultValue);
// { b: 2 }
console.log(namedDefaultValue);

换句话说,消费者使用 node 加载由 ESM 规范打包的 CJS 包时,处理 默认导入 的接口规范与原先 ESM 声明的接口规范不一致。

因此现阶段的工具链(typescriptbabel)和构建工具(webpackrollupesbuild)都支持对带有 __esModule 属性的 CJS 模块做特殊的 interop 支持,以兼容 ESM 的默认导出接口规范。

存在默认导出时导入命名导出的示例

🐞 缺陷 2: 这个场景下工作是不正确,因为它不是返回具名导出,而是试图返回默认导出上的属性。原因是这种情况下,unwrapExports 中的互操作模式在某种程度上正确地提取了 默认导出 并将其作为 默认导出,但 syntheticNamedExports 不应该使用它来提取命名导出。

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

// "dep.js"
import * as commonjsHelpers from 'commonjsHelpers.js';

var dep = commonjsHelpers.createCommonjsModule(function (module, exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
  exports.foo = 'foo';
  exports.default = 'default';
});

export default /*@__PURE__*/ commonjsHelpers.unwrapExports(dep);
export { dep as __moduleExports };
js
function unwrapExports(x) {
  return x &&
    x.__esModule &&
    Object.prototype.hasOwnProperty.call(x, 'default')
    ? x.default
    : x;
}

function createCommonjsModule(fn, basedir, module) {
  return (
    (module = {
      path: basedir,
      exports: {},
      require(path, base) {
        return commonjsRequire(
          path,
          base === undefined || base === null ? module.path : base
        );
      }
    }),
    fn(module, module.exports),
    module.exports
  );
}

function commonjsRequire() {
  throw new Error(
    'Dynamic requires are not currently supported by @rollup/plugin-commonjs'
  );
}

var dep = createCommonjsModule(function (module, exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
  exports.foo = 'foo';
  exports.default = 'default';
});

var dep$1 = /*@__PURE__*/ unwrapExports(dep);

console.log(dep$1.foo);

修复这个问题很困难,特别是如果我们想要为 具名导出 维持 实时绑定。我的第一个想法是扩展 syntheticNamedExports 以允许一个额外的值,用来表明 默认导出 也是从实际的 默认导出 中作为 default 属性选取的。然而,这意味着自动检测互操作会变得缓慢和困难,并且可能破坏非 ESM 情况下的所有 实时绑定,因为我们需要构建一个新对象,即:

js
export default moduleExports && moduleExports.__esModule
  ? moduleExports
  : { ...moduleExports, default: moduleExports };

📈 改进 1: 我有一个更好的想法,那就是允许将任意字符串作为 syntheticNamedExports 的值,例如 syntheticNamedExports: "__moduleExports"。其代表的意义是缺失的 具名导出(甚至 默认导出)不是从 默认导出 中获取,而是从给定名称的 具名导出 中获取。

那么互操作就只需要:

js
export { __moduleExports };
export default __moduleExports.__esModule
  ? __moduleExports.default
  : __moduleExports;

这相当高效,尽管如果我们想要节省几个字节,它仍然可以放入一个互操作函数 getDefault 中。当然我们在转译的 ESM 情况下仍然无法为 default 的默认导出获得 实时绑定,但如果在第二步中,我们实现对 __esModule 的静态检测,这甚至也是可以修复的:

📈 改进 2: 如果我们在模块的顶层遇到 Object.defineProperty(exports, "__esModule", { value: true }) 行(或者对于压缩的情况使用 !0 而不是 true),那么我们可以直接将该模块标记为被转译过的 CJS 模块,甚至可以在转换器中去掉这一行,让代码更高效并消除任何互操作的需要,即上面我们在这种情况下完全不添加 export default。如果忽略这个属性定义,也不再需要将代码包装在 createCommonjsModule 中。

导入不存在的默认导出示例

🐞 缺陷 3: 这种情况工作不正确并不令人惊讶,因为它应该在构建时或运行时抛出错误。否则,至少默认导出应该是 undefined,而这里它实际上是命名空间。

js
import foo from './dep.js';
console.log(foo);
js
Object.defineProperty(exports, '__esModule', { value: true });
exports.foo = 'foo';
js
// "main.js"
import foo from './dep.js';
console.log(foo);

// "dep.js"
import * as commonjsHelpers from 'commonjsHelpers.js';

var dep = commonjsHelpers.createCommonjsModule(function (module, exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
  exports.foo = 'foo';
});

export default /*@__PURE__*/ commonjsHelpers.unwrapExports(dep);
export { dep as __moduleExports };
js
function unwrapExports(x) {
  return x &&
    x.__esModule &&
    Object.prototype.hasOwnProperty.call(x, 'default')
    ? x.default
    : x;
}

function createCommonjsModule(fn, basedir, module) {
  return (
    (module = {
      path: basedir,
      exports: {},
      require(path, base) {
        return commonjsRequire(
          path,
          base === undefined || base === null ? module.path : base
        );
      }
    }),
    fn(module, module.exports),
    module.exports
  );
}

function commonjsRequire() {
  throw new Error(
    'Dynamic requires are not currently supported by @rollup/plugin-commonjs'
  );
}

var dep = createCommonjsModule(function (module, exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
  exports.foo = 'foo';
});

var foo = /*@__PURE__*/ unwrapExports(dep);

console.log(foo);

要修复这个问题,我想将互操作模式减少到只检查 __esModule 的存在而不检查其他内容。这将由建议的 改进 2 覆盖。

从 CJS 导入 ESM

据我所知,目前没有引擎支持这一点。

Error [ERR_REQUIRE_ESM]: require() of ES Module [PATH] not supported

所以问题在于什么是 正确 的返回值。 nodejs 中,我听说有一些兴趣后期会支持这一个特性,但这里的障碍是技术性的(主要是 ESM 加载器是异步的 + TLA 处理和类似的东西)。然而如果这一点特性得到支持,我觉得在 CJS 模块中 require 一个 ESM 模块很可能会返回 命名空间

Node.js Experimentally Supports require Pure ESM modules

nodejsv20.0.0 版本中,在 CJS 模块中实验性支持 require 一个 纯的 ESM 模块,预计在 v22.12.0 版本中默认启用,但依旧是一个实验性的特性。

js
// a.cjs -> entry module
const b = require('./b.mjs');
// output: [Module: null prototype] { __esModule: true, b: 2, default: 3 }
console.log(b);

// b.mjs
export const b = 2;
export default 3;

指令执行如下:


方式一:

json
// package.json
{
  "engines": {
    "node": ">=20.0.0 <=22.11.0"
  }
}
bash
node --experimental-require-module ./a.cjs

方式二:

json
{
  "engines": {
    "node": ">=22.12.0"
  }
}
bash
node ./a.cjs

module: support require()ing synchronous ESM graphs - Mar 6, 2024 中做了详细介绍。

其中需要注意的一个观点是:

ESM 模块是 异步特征 还是 同步特征,取决于 ESM 模块中是否使用了 TLA 特性或 ESM 模块的依赖模块树中是否存在使用 TLA 特性的模块。(TLA 模块会感染下游依赖链模块,导致下游依赖方模块也具有 TLA 的异步特性)

require 是支持加载 同步特征ESM 模块,若 ESM 模块是 异步特征 的,那么 require 将无法加载该 ESM 模块,此时会提示以下异常信息:

Error [ERR_REQUIRE_ASYNC_MODULE]: require() cannot be used on an ESM graph with top-level await. Use import() instead. To see where the top-level await comes from, use --experimental-print-required-tla.

若需要在 CJS 模块中加载具有 异步特征ESM 模块,可以通过使用 import() 来进行加载。同时若需要检索依赖的 ESM 模块树中 TLA 模块的具体位置,可以通过 node --experimental-print-required-tla 命令来确认。--experimental-print-required-tla 标记是在 v22.0.0 版本中引入的。

当然 webpack 支持了这一特性,据我所知这里总是得到 命名空间

TypeScript 中将 ESM 转译为 CJS 时,它总是会在转译后的 CJS 模块中添加 __esModule 属性并使用 default 作为属性,这反过来意味着如果 require 一个 ESM 模块并使用 CJS 作为输出目标,你总是会得到 命名空间Babel 也是如此

对于 rollup 来说,情况更复杂,部分原因在于 rollup 首先是为库设计的,在这里你希望能够创建 CJS 作为产物目标,其中所有内容(例如一个函数)都直接赋值给 module.exports,以便为“单一功能”库创建一个漂亮的接口。所以当生成 CJS 输出时,rollup 核心默认使用其 auto 模式,这意味着:

  • module.exports 包含命名空间。
  • 除非恰好只有一个默认导出,在这种情况下该导出被赋值给 module.exports

当然这是关于将 ESM 转换为 CJS,但一个想法是在这种情况下,当我 require 它们时,CJSESM 版本应该是可以互换的。所以对于内部导入,按照 rollupauto 模式反向工作可能是有意义的,当没有 具名导出 时给你 默认导出,否则给你 命名空间

但我理解从长远来看,我们可能应该与工具世界的其他部分保持一致,即使它生成的代码效率较低。因此,在这方面我的建议是:

📈 改进 3: 我们默认情况下总是在 require 时返回 命名空间

📈 改进 4a: 我们添加一个标志来切换所有模块或某些模块(通过 glob/include/exclude 模式,或者可能类似于 rollupexternal 选项的工作方式)以按照上述自动模式工作,以使现有的混合 ESM CJS 代码库能够工作。我认为我们需要这个的原因是,导入模块本身可能是第三方依赖,因此不在你的直接控制之下。

📈 改进 5: rollup 本身将被调整为在未明确指定的情况下使用 auto 模式时显示警告,以解释当输出旨在与 ESM 模块互换时可能遇到的问题,并解释如何更改接口。

仅带具名导出的 ESM

这按预期工作。

js
const foo = require('./dep.js');
console.log(foo);
js
export const foo = 'foo';
js
// "main.js"
import './dep.js';
import foo from './dep.js?commonjs-proxy';

console.log(foo);

// "dep.js"
export const foo = 'foo';

// "\u0000dep.js?commonjs-proxy"
import * as dep from '/Users/lukastaegert/Github/rollup-playground/dep.js';
export default dep;
js
const foo = 'foo';

var dep = /*#__PURE__*/ Object.freeze({
  __proto__: null,
  foo
});

console.log(dep);

仅带默认导出的 ESM

这一个对于 auto 模式工作正确,但根据上面的论述,行为应该被更改,参见 改进 3改进 4

js
const foo = require('./dep.js');
console.log(foo);
js
export default 'default';
js
// "main.js"
import "./dep.js";
import foo from "./dep.js?commonjs-proxy";

console.log(foo);

// "dep.js"
export default "default";

// "\u0000dep.js?commonjs-proxy"
export { default } from "/Users/lukastaegert/Github/rollup-playground/dep.js";
js
var foo = 'default';

console.log(foo);

带混合导出的 ESM

🐞 缺陷 4: 这个是有问题的 - 它应该要返回 命名空间,却返回了 默认导出

js
const foo = require('./dep.js');
console.log(foo);
js
export const foo = 'foo';
export default 'default';
js
// "main.js"
import "./dep.js";
import foo from "./dep.js?commonjs-proxy";

console.log(foo);

// "dep.js"
export const foo = "foo";
export default "default";

// "\u0000dep.js?commonjs-proxy"
export { default } from "/Users/lukastaegert/Github/rollup-playground/dep.js";
js
var foo = 'default';

console.log(foo);

外部导入

要完整地了解这里的互操作情况,需要同时查看这个插件生成的内容和 rollup 生成的 CJS 输出。

CommonJS 插件中的外部导入

🐞 缺陷 5: 对于外部导入,这个插件将始终导入 默认导出

📈 改进 6: 根据上面给出的论述,这实际上默认应该是命名空间(import * as external from 'external'),因为这在技术上等同于导入一个 ESM 模块。

📈 改进 4b: 同样,我们应该添加一个选项来指定何时外部导入应该只返回默认导出。它甚至可以是相同的选项。所以这里有一些讨论的空间。

js
const foo = require('external');
console.log(foo);
js
// "main.js"
import 'external';
import foo from 'external?commonjs-proxy';

console.log(foo);

// "\u0000external?commonjs-external"
import external from 'external';
export default external;
js
import external from 'external';

console.log(external);

Rollup CJS 输出中的外部导入

导入命名空间

理想情况下,这应该被转换为一个简单的 require 语句,因为:

  • 当被导入时,转译的 ESM 模块将返回 命名空间
  • 这意味着根据前一节中的论述,一个简单的 require 将再次变成一个简单的 require

实际上确实如此:

js
import * as foo from 'external';
console.log(foo);
js
'use strict';

var foo = require('external');

console.log(foo);

导入具名绑定

这些应该只是转换为 require 返回的任何内容上的属性,因为这应该等同于 命名空间。而这确实再次如此:

js
import { bar, foo } from 'external';
console.log(foo, bar);
js
'use strict';

var external = require('external');

console.log(external.foo, external.bar);

导入默认导出

🐞 缺陷 6: 这里有几个问题:

  • 互操作函数只检查 default 属性的存在,而没有检查 __esModule 属性。我一直以为在某个旧的 issue 中埋藏着原因,但回顾历史似乎它一直都是这样。这应该需要改变,因为存在以下不利的影响:

    • 如果 CJS 模块并不是由 ESM 转译而来的,同时用户在 exports.default 属性上赋值,那么 exports.default 的值将被误认为是 默认导出。但实际上并不是这样,因为 CJS 模块并不是由 ESM 转译而来的,应该被认定为纯 CJS 模块。

    • 如果 CJS 模块是由 ESM 转译而来的,但转译前的 ESM 模块中并没有使用 默认导出

      js
      import foo from 'external';
      console.log(foo);
      js
      exports.foo = 'foo';
      exports.__esModule = true;

    结果将错误地返回 命名空间,但实际上并不是这样,因为 CJS 模块是由 ESM 转译而来的,根据 ESM 的语义,由于没有使用 默认导出,应该需要提示报错。

  • 默认导入 的实时绑定将不会被保留。如果与外部模块存在循环依赖,这可能会导致问题。

js
import foo from 'external';
console.log(foo);
js
'use strict';

function _interopDefault(ex) {
  return ex && typeof ex === 'object' && 'default' in ex ? ex.default : ex;
}

var foo = _interopDefault(require('external'));

console.log(foo);

📈 改进 7: 要修复这个问题,我想我会尝试按照 Babel 的做法

js
'use strict';

var _foo = _interopRequireDefault(require('foo'));

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

console.log(_foo.default);

Attention

fix(commonjs): fix interop when importing CJS that is a transpiled ES module from an actual ES module 中对 interop 做了进一步更新,出于对旧的 interop 的兼容性考虑,在 __esModule 属性存在时同时 default 属性也 存在 时,将返回 default 属性值否则回退返回 命名空间

The "hasOwnProperty" call in "getDefaultExportFromCjs" is technically not needed, but for consumers that use Rollup's old interop pattern, it will fix rollup/rollup-plugin-commonjs#224 We should remove it once Rollup core and this plugin are updated to not use this pattern any more

js
function getDefaultExportFromCjs(x) {
  return x &&
    x.__esModule &&
    Object.prototype.hasOwnProperty.call(x, 'default')
    ? x.default
    : x;
}

这与 babelinterop 行为有些不一致。

入口点

module.exports 赋值的入口点

这将被转换为 默认导出,这可能是我们能期望的最好结果。

js
module.exports = 'foo';
js
// "main.js"
var main = 'foo';

export default main;
js
var main = 'foo';

export default main;

给 exports 添加属性的入口点

这似乎工作得很合理。

js
module.exports.foo = 'foo';
js
// "main.js"
var foo = 'foo';

var main = {
  foo
};

export default main;
export { foo };
js
// "bundle.js"
var foo = 'foo';

var main = {
  foo
};

export default main;
export { foo };

module.exports 赋值但导入自身的入口点

🐞 缺陷 7: 基本上它寻找一个不存在的 __moduleExports 导出,而不是给出 命名空间。我上面关于如何重新设计 syntheticNamedExports 的建议在 改进 1 中也应该从 rollup 内部修复这个问题。当另一个模块导入我们的入口点或当模块只是给 exports 添加属性时,也会出现类似的问题。

js
const x = require('./main.js');
console.log(x);

module.exports = 'foo';
js
// "main.js"
import "./main.js";
import x from "./main.js?commonjs-proxy";

console.log(x);

var main = "foo";

export default main;

// "\u0000main.js?commonjs-proxy"
import { __moduleExports } from "/Users/lukastaegert/Github/rollup-playground/main.js";
export default __moduleExports;
js
console.log(main.__moduleExports);

var main = 'foo';

export default main;

exports.default 赋值的入口点

🐞 缺陷 8: 这里没有生成输出,而它应该导出 命名空间 作为 默认导出,除非存在 __esModule 属性。

js
module.exports.default = 'foo';
js
// "main.js"
var _default = 'foo';

var main = {
  default: _default
};
js
// "bundle.js"

作为转译的 ES 模块的入口点

理想情况下,输出应该具有与原始入口相同的导出。目前,这种情况并不会出现,因为 Object.defineProperty 调用将始终导致使用 createCommonjsModule 包装器,并且不会检测到任何 命名导出。有几种方法可以改进这一点:

  • 移除 __esModule 属性定义,并且不将其视为退优化的原因,参见 改进 2
  • 📈 改进 8: 添加一个类似于现已移除的 namedExports 的新选项,专门列出入口点的暴露导出。我们也可以使用它来激活或禁用默认导出,并决定默认导出应该是赋值给 exports.default 的内容还是 module.exports。这可以通过简单地创建一个重新导出这些导出的包装文件来处理。如果我们这样做,rolluptree-shaking 甚至可能会移除一些未使用的导出代码。
js
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = 'foo';
exports.bar = 'bar';
js
// "main.js"
import * as commonjsHelpers from 'commonjsHelpers.js';

var main = commonjsHelpers.createCommonjsModule(function (module, exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
  exports.default = 'foo';
  exports.bar = 'bar';
});

export default /*@__PURE__*/ commonjsHelpers.unwrapExports(main);
js
function unwrapExports(x) {
  return x &&
    x.__esModule &&
    Object.prototype.hasOwnProperty.call(x, 'default')
    ? x.default
    : x;
}

function createCommonjsModule(fn, basedir, module) {
  return (
    (module = {
      path: basedir,
      exports: {},
      require(path, base) {
        return commonjsRequire(
          path,
          base === undefined || base === null ? module.path : base
        );
      }
    }),
    fn(module, module.exports),
    module.exports
  );
}

function commonjsRequire() {
  throw new Error(
    'Dynamic requires are not currently supported by @rollup/plugin-commonjs'
  );
}

var main = createCommonjsModule(function (module, exports) {
  Object.defineProperty(exports, '__esModule', { value: true });
  exports.default = 'foo';
  exports.bar = 'bar';
});

var main$1 = /*@__PURE__*/ unwrapExports(main);

export default main$1;

总结和可能的行动计划

rollup

我很乐意负责这里所需的改进:

  1. 实现 改进 5: 当在未明确指定的情况下使用 auto 模式,且有一个只有 默认导出 的包时,添加一个描述性警告。解释这个包在许多工具中将无法与其 ESM 版本互换,并建议使用带有所有后果的 命名导出 模式,或者更好的是,不要使用 默认导出

    Warn when implicitly using default export mode rollup#3659

  2. 实现 rollup 部分的 改进 1: syntheticNamedExports 应该支持接收一个对应于从中选取缺失导出的导出名称的字符串值。值 true 将对应于 "default",但使用字符串时,对于入口点,列出的属性名称不会成为公共接口的一部分。这将修复突然将 __moduleExports 引入接口的问题。

    Add basic support for using a non-default export for syntheticNamedExports rollup#3657

  3. rollup 3 中(或在 rollup 2 中作为标志,因为这是一个破坏性变更): 实现 改进 7: 改变在 CJS 输出中导入默认值的方式,使其像 babel 那样工作。

    Rework interop handling rollup#3710

在这个插件中

  1. 实现 改进 3改进 4a改进 4b改进 6: 在导入 ES 模块时始终返回命名空间,除非设置了特定选项。返回的内容在代理模块中配置。目前,我们只在模块不使用默认导出时返回命名空间。这样的选项有很多种可能的实现方式,这里是一个建议:

    • name: requireReturnsDefault

    支持的值:

    • false (默认): 所有 ES 模块在被导入时返回其命名空间。外部导入渲染为 import * as external from 'external' 而不进行任何互操作。

    • true: 所有具有默认导出的 ES 模块在被导入时应该返回其默认值。只有在没有默认导出时,才使用命名空间。这将与当前行为相同。对于外部导入,使用以下互操作模式:

      js
      import * as external_namespace from 'external';
      var external =
        'default' in external_namespace
          ? external_namespace.default
          : external_namespace;

      可能将其提取到一个可重用的辅助函数中是有意义的。

      • 一个模块 ID 数组。为了方便起见,这些 ID 应该通过 this.resolve 处理,这样用户就可以直接使用 node_module 的名称。在这种情况下,如果模块不是 ES 模块或没有默认导出,插件应该抛出错误,并解释错误原因。外部导入只是渲染为 import external from 'external',不进行任何互操作。
      • 一个函数,它对每个从 CJS 导入且具有默认导出的 ES 模块调用。该函数将返回 truefalse|undefined。应该确保对每个解析后的模块 ID 只调用一次此函数。这等同于指定一个包含所有返回 true 的模块 ID 的数组。

    Return the namespace by default when requiring ESM #507

  2. 一旦在 rollup 核心端完成,实现插件部分的 改进 1: 使用 syntheticNamedExports 的新值,并按照建议的简化互操作模式添加默认导出。

    fix(commonjs): fix interop when importing CJS that is a transpiled ES module from an actual ES module #501

  3. 实现 改进 2: 如果遇到 __esModule 属性的定义,则移除它,不将其视为使用 createCommonjsModule 包装器的原因,也不向模块添加互操作默认导出。理想情况下,在这种情况下,对 exports.default 的赋值应该像对 exports.foo 的赋值一样处理,以生成显式导出。所以这样的代码:

    js
    Object.defineProperty(exports, '__esModule', { value: true });
    exports.default = 'default';
    exports.foo = 'foo';

    应该被转换为:

    js
    // Note that we need to escape the variable name as `default` is not allowed
    var _default = 'default';
    var foo = 'foo';
    var dep = {
      default: _default,
      foo
    };
    export { dep as __moduleExports };
    // This is a different way to generate a default export that even allows for live-bindings
    export { foo, _default as default };

    feat(commonjs): reconstruct real es module from __esModule marker #537

  4. 实现 改进 8: 允许指定暴露的导出。我的建议是:

    • name: exposedExports
    • 值: 一个对象,其中键对应模块 ID。为了方便起见,插件应该通过 this.resolve 处理这些键。值是一个命名导出的数组。在内部,这可能只意味着我们不是将模块解析到它自身,而是解析到一个重新导出文件,例如:
    js
    // for exposedExports: {'my-module': ['foo', 'default']}
    export { foo, default } from ' \0my-module?commonjsEntry';

    新的 ' \0my-module?commonjsEntry' 将对应于我们通常在这里渲染的内容。CommonJS 代理也应该从该文件导入。

Methodology Summary

系统化分析方法

问题分类与场景拆解

作者首先将 interop 问题按照模块类型组合进行分类:

  • CJS 导入 CJS
  • ESM 导入 CJS
  • CJS 导入 ESM
  • 外部模块导入
  • 入口点处理

对每个场景,作者都遵循一个统一的分析框架:

  • 描述预期行为
  • 展示实际行为
  • 分析差异原因
  • 提出改进方案

基于代码的实证分析

作者不是基于理论推测,而是通过实际的代码示例来论证问题:

js
// 输入代码
const dep = require('./dep.js');
console.log(dep);

// 转换后代码
import './dep.js';
import dep from './dep.js?commonjs-proxy';
console.log(dep);

// 最终输出
var dep = 'foo';
console.log(dep);

通过完整展示代码转换的整个过程,使问题和解决方案更加具体和可验证。

渐进式解决策略

作者采用了一种渐进式的问题解决策略:

  1. 识别核心问题

    首先识别最基础的功能性问题,如:

    • 模块导出的正确性
    • 绑定关系的保持
    • 运行时行为的一致性
  2. 分层改进

    按照不同的层次提出改进方案:

    • rollup 核心层面的改进
    • 插件层面的改进
    • 配置选项的优化
  3. 权衡取舍在每个改进建议中,都详细讨论了:

    • 收益与成本
    • 向后兼容性影响
    • 性能影响
    • 使用复杂度

生态系统思维

作者展现了深刻的生态系统思维:

  1. 工具链协同

    考虑了与其他工具的互操作性:

    • node.js 的模块解析机制
    • webpack 的互操作约定
    • babel 的转换策略
  2. 最佳实践参考

    借鉴其他工具的成功经验:

    • 最佳实践参考
    • 借鉴其他工具的成功经验:
    js
    // Babel 的互操作方案
    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : { default: obj };
    }
  3. 标准化考虑,关注模块规范的演进:

    • ESM 规范的发展
    • node.js 的模块互操作策略
    • 社区常见的互操作约定

实现路径设计

作者为每个改进建议都设计了清晰的实现路径:

  1. 循序渐进

    • 优先解决核心功能问题
    • 逐步添加配置能力
    • 最后优化性能
  2. 特性开关,通过配置项来控制新特性:

    json
    {
      "requireReturnsDefault": false, // 控制默认导入行为
      "exposedExports": {} // 控制暴露的导出
      // ...其他配置项
    }
  3. 向后兼容,保持与现有代码的兼容性:

    • 保留旧的行为模式
    • 提供迁移选项
    • 添加警告提示

方法论的普适性启示

这种处理复杂工程问题的方法论具有普适性:

  1. 分析阶段

    • 系统化分类问题
    • 通过代码验证
    • 建立评估标准
  2. 解决阶段

    • 渐进式改进
    • 分层次处理
    • 保持兼容性
  3. 演进阶段

    • 持续优化
    • 收集反馈
    • 迭代改进

Contributors

Changelog

Discuss

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