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
核心的所有更改,但如果能得到支持来改进插件,我会非常感谢。
这篇篇幅 非常 长,我会根据建议尝试更新。我也会尽快在文末添加一个汇总摘要。
由于我坚信测试胜于想当然,我几乎为所有内容都添加了实际的输出代码示例。要生成预格式化的代码示例,我使用了以下配置:
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
的部分)。
const dep = require('./dep.js');
console.log(dep);
module.exports = 'foo';
// "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;
var dep = 'foo';
console.log(dep);
给 exports
或 module.exports
的属性赋值
在这种情况下,rollup
会创建一个带有所有属性内联的 module.exports
对象。这非常高效,不同于一个一个地给对象赋值属性,因为运行时引擎可以立即优化这样的对象来实现快速访问。这个对象再次被导出为 default
和 __moduleExports
。此外,所有赋值的属性也作为具名导出。
const dep = require('./dep.js');
console.log(dep);
module.exports.foo = 'foo';
exports.bar = 'bar';
exports.default = 'baz';
// "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;
// "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.exports
或 exports
用法
有很多情况下插件会实施去优化操作(deoptimize
),例如运行时才能确定模块导出的具名属性、读取而不是赋值属性等场景。
在这些情况下,使用 createCommonjsModule
辅助函数来创建一个包装器,以更接近 node
执行模块的方式执行它,而不检测模块的任何具名导出。
关于 deoptimize
的重要说明
deoptimize
(去优化) 是一个重要的概念。让我们通过示例来理解:
// 简单的属性赋值
exports.foo = 'hello'; // 这种情况插件可以静态分析并优化
deoptimize
(去优化) 的场景:
// 运行时动态生成属性
for (let i = 0; i < 10; ++i) {
exports[Math.random()] = i;
}
为什么会发生 deoptimize
(去优化)呢?
当插件看到简单的属性赋值时(如
exports.foo = value
),插件可以清楚地知道这个模块具体导出了哪些属性名,后续可以通过静态分析做tree-shaking
优化。当插件看到属性是通过运行时动态生成时,情况变得复杂,插件无法确切知道模块具体导出了哪些属性名。在这种情况下,虽然部分用例可以通过进一步的静态分析是可以确定的,但由于
CJS
的动态特性,大部分用例是无法确定具体导出哪些属性。此时,插件会采取 保守 的方式,使用createCommonjsModule
辅助函数来包装整个模块,以确保行为与node.js
中的CommonJS
模块导出行为完全一致。
deoptimize
(去优化)的代价:
- 无法做更进一步的静态分析,
tree-shaking
不完整甚至是不生效,生成的代码体积更大。 - 依赖运行时确定模块的导出,性能可能降低。
const dep = require('./dep.js');
console.log(dep);
if (true) {
exports.foo = 'foo';
}
// "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;
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
。
这里有一个示例来说明:
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');
}
console.log('second');
console.log('not executed');
// "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;
console.log('second');
console.log('not executed');
console.log('first');
console.log('third');
接下来让我们来看看实际的互操作问题。
从 ESM
导入 CJS
NodeJS 风格
在 node
中,CJS
模块通过 module.exports
只暴露一个 默认导出。这个重要的模式应该始终有效。
对于插件来说,相比于处理 CJS
导入 CJS
的情况,主要的区别在于,在 ESM
模块中是直接导入 实际的模块 而不是 代理模块。这里只举一个例子来说明,总的来说一切工作方式都类似于 CJS
到 CJS
的情况。
import foo from './dep.js';
console.log(foo);
module.exports = 'foo';
// "main.js"
import foo from './dep.js';
console.log(foo);
// "dep.js"
var dep = 'foo';
export default dep;
export { dep as __moduleExports };
var dep = 'foo';
console.log(dep);
带具名导入的 NodeJS 风格
这在 webpack
中得到支持,现在也(之前部分支持但现在完全)得到了这个插件的支持。
除了默认导入解析为 module.exports
外,具名导入将解析为 module.exports
上的属性。之前,这只对插件可以自动检测的具名导出有效(而且仅当模块没有实施去优化(deoptimize
) 来使用 createCommonjsModule
做模块外层包装时)或者由用户列出的导出。使用 rollup
的 syntheticNamedExports
属性的当前形式,现在可以在不需要额外配置的情况下解析任意具名导入,甚至具名导入的引用保持 实时绑定。
关于 Webpack.mjs
语义和更好的 NodeJS
互操作性的说明
注意,当从带有 .mjs
扩展名的模块使用这种模式时,webpack
目前要么不允许要么会发出警告。
这里的意图是这个扩展名表示我们想要进入某种严格的 NodeJS
互操作模式。我们也可以做类似的事情,但我希望在 @rollup/plugin-node-resolve
中进行 ESM/CJS
检测,并建立一个通信通道从该插件获取这些信息。然后我们可能会给 @rollup/plugin-commonjs
添加一个开关来使用"严格的 NodeJS
互操作",它将:
- 不自动检测模块类型而使用
NodeJS
语义(扩展名 + package.type),这甚至可能带来一点性能提升 - 不允许从 CJS 文件进行非默认导入
这可以成为我们在解决当前更紧迫的问题后添加的高级功能。
带静态检测的具名导出示例
import { foo } from './dep.js';
console.log(foo);
exports.foo = 'foo';
// "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 };
var foo = 'foo';
console.log(foo);
依赖合成具名导出的示例
这里我们只是将一个对象赋值给 module.exports
。
import { foo } from './dep.js';
console.log(foo);
module.exports = { foo: 'foo' };
// "main.js"
import { foo } from './dep.js';
console.log(foo);
// "dep.js"
var dep = { foo: 'foo' };
export default dep;
export { dep as __moduleExports };
var dep = { foo: 'foo' };
console.log(dep.foo);
注意我们通过使用属性访问保持了实时绑定
// 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
属性,则使用CJS
的module.exports.default
属性值作为默认导出。 - 如果不存在
__esModule
属性,则使用CJS
的module.exports
属性值作为默认导出。
Standardize Interface Specification from ESM
to CJS
在 node
的 v14.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
的具名导出。
// cjs.cjs
exports.name = 'exported';
// main.js
import { name } from './cjs.cjs';
// Prints: 'exported'
console.log(name);
但对于默认导出形式,node
会直接将 module.exports
作为 默认导出。
// 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
规范开发的,包出口的导出方式如下:
export const a = 1;
export default {
b: 2
};
通过构建工具打包成 npm
包,其中供消费者使用的 CJS
产物如下:
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
const a = 1;
var main = {
b: 2
};
exports.a = a;
exports.default = main;
此时若消费者使用 node
运行时解析 demo
包,获取到的引用信息如下:
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
规范的原意不符,demo
的 ESM
规范的意图是:
// demo
export const a = 1;
export default {
b: 2
};
// 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
声明的接口规范不一致。
因此现阶段的工具链(typescript
、babel
)和构建工具(webpack
、rollup
、esbuild
)都支持对带有 __esModule
属性的 CJS
模块做特殊的 interop
支持,以兼容 ESM
的默认导出接口规范。
存在默认导出时导入命名导出的示例
🐞 缺陷 2
: 这个场景下工作是不正确,因为它不是返回具名导出,而是试图返回默认导出上的属性。原因是这种情况下,unwrapExports
中的互操作模式在某种程度上正确地提取了 默认导出 并将其作为 默认导出,但 syntheticNamedExports
不应该使用它来提取命名导出。
import { foo } from './dep.js';
console.log(foo);
Object.defineProperty(exports, '__esModule', { value: true });
exports.foo = 'foo';
exports.default = 'default';
// "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 };
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
情况下的所有 实时绑定,因为我们需要构建一个新对象,即:
export default moduleExports && moduleExports.__esModule
? moduleExports
: { ...moduleExports, default: moduleExports };
📈 改进 1
: 我有一个更好的想法,那就是允许将任意字符串作为 syntheticNamedExports
的值,例如 syntheticNamedExports: "__moduleExports"
。其代表的意义是缺失的 具名导出(甚至 默认导出)不是从 默认导出 中获取,而是从给定名称的 具名导出 中获取。
那么互操作就只需要:
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
,而这里它实际上是命名空间。
import foo from './dep.js';
console.log(foo);
Object.defineProperty(exports, '__esModule', { value: true });
exports.foo = 'foo';
// "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 };
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
nodejs
的 v20.0.0
版本中,在 CJS
模块中实验性支持 require
一个 纯的 ESM
模块,预计在 v22.12.0
版本中默认启用,但依旧是一个实验性的特性。
// 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;
指令执行如下:
方式一:
// package.json
{
"engines": {
"node": ">=20.0.0 <=22.11.0"
}
}
node --experimental-require-module ./a.cjs
方式二:
{
"engines": {
"node": ">=22.12.0"
}
}
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
它们时,CJS
和 ESM
版本应该是可以互换的。所以对于内部导入,按照 rollup
的 auto
模式反向工作可能是有意义的,当没有 具名导出 时给你 默认导出,否则给你 命名空间。
但我理解从长远来看,我们可能应该与工具世界的其他部分保持一致,即使它生成的代码效率较低。因此,在这方面我的建议是:
📈 改进 3
: 我们默认情况下总是在 require
时返回 命名空间。
📈 改进 4a
: 我们添加一个标志来切换所有模块或某些模块(通过 glob/include/exclude
模式,或者可能类似于 rollup
中 external
选项的工作方式)以按照上述自动模式工作,以使现有的混合 ESM
CJS
代码库能够工作。我认为我们需要这个的原因是,导入模块本身可能是第三方依赖,因此不在你的直接控制之下。
📈 改进 5
: rollup
本身将被调整为在未明确指定的情况下使用 auto
模式时显示警告,以解释当输出旨在与 ESM
模块互换时可能遇到的问题,并解释如何更改接口。
仅带具名导出的 ESM
这按预期工作。
const foo = require('./dep.js');
console.log(foo);
export const foo = 'foo';
// "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;
const foo = 'foo';
var dep = /*#__PURE__*/ Object.freeze({
__proto__: null,
foo
});
console.log(dep);
仅带默认导出的 ESM
这一个对于 auto
模式工作正确,但根据上面的论述,行为应该被更改,参见 改进 3
和 改进 4
。
const foo = require('./dep.js');
console.log(foo);
export default 'default';
// "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";
var foo = 'default';
console.log(foo);
带混合导出的 ESM
🐞 缺陷 4
: 这个是有问题的 - 它应该要返回 命名空间,却返回了 默认导出。
const foo = require('./dep.js');
console.log(foo);
export const foo = 'foo';
export default 'default';
// "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";
var foo = 'default';
console.log(foo);
外部导入
要完整地了解这里的互操作情况,需要同时查看这个插件生成的内容和 rollup
生成的 CJS
输出。
CommonJS 插件中的外部导入
🐞 缺陷 5
: 对于外部导入,这个插件将始终导入 默认导出。
📈 改进 6
: 根据上面给出的论述,这实际上默认应该是命名空间(import * as external from 'external'
),因为这在技术上等同于导入一个 ESM
模块。
📈 改进 4b
: 同样,我们应该添加一个选项来指定何时外部导入应该只返回默认导出。它甚至可以是相同的选项。所以这里有一些讨论的空间。
const foo = require('external');
console.log(foo);
// "main.js"
import 'external';
import foo from 'external?commonjs-proxy';
console.log(foo);
// "\u0000external?commonjs-external"
import external from 'external';
export default external;
import external from 'external';
console.log(external);
Rollup CJS 输出中的外部导入
导入命名空间
理想情况下,这应该被转换为一个简单的 require
语句,因为:
- 当被导入时,转译的
ESM
模块将返回 命名空间。 - 这意味着根据前一节中的论述,一个简单的
require
将再次变成一个简单的require
。
实际上确实如此:
import * as foo from 'external';
console.log(foo);
'use strict';
var foo = require('external');
console.log(foo);
导入具名绑定
这些应该只是转换为 require
返回的任何内容上的属性,因为这应该等同于 命名空间。而这确实再次如此:
import { bar, foo } from 'external';
console.log(foo, bar);
'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
模块中并没有使用 默认导出。jsimport foo from 'external'; console.log(foo);
jsexports.foo = 'foo'; exports.__esModule = true;
结果将错误地返回 命名空间,但实际上并不是这样,因为
CJS
模块是由ESM
转译而来的,根据ESM
的语义,由于没有使用 默认导出,应该需要提示报错。默认导入 的实时绑定将不会被保留。如果与外部模块存在循环依赖,这可能会导致问题。
import foo from 'external';
console.log(foo);
'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
的做法。
'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
function getDefaultExportFromCjs(x) {
return x &&
x.__esModule &&
Object.prototype.hasOwnProperty.call(x, 'default')
? x.default
: x;
}
这与 babel
的 interop
行为有些不一致。
入口点
给 module.exports
赋值的入口点
这将被转换为 默认导出,这可能是我们能期望的最好结果。
module.exports = 'foo';
// "main.js"
var main = 'foo';
export default main;
var main = 'foo';
export default main;
给 exports 添加属性的入口点
这似乎工作得很合理。
module.exports.foo = 'foo';
// "main.js"
var foo = 'foo';
var main = {
foo
};
export default main;
export { foo };
// "bundle.js"
var foo = 'foo';
var main = {
foo
};
export default main;
export { foo };
给 module.exports
赋值但导入自身的入口点
🐞 缺陷 7
: 基本上它寻找一个不存在的 __moduleExports
导出,而不是给出 命名空间。我上面关于如何重新设计 syntheticNamedExports
的建议在 改进 1
中也应该从 rollup
内部修复这个问题。当另一个模块导入我们的入口点或当模块只是给 exports
添加属性时,也会出现类似的问题。
const x = require('./main.js');
console.log(x);
module.exports = 'foo';
// "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;
console.log(main.__moduleExports);
var main = 'foo';
export default main;
给 exports.default
赋值的入口点
🐞 缺陷 8
: 这里没有生成输出,而它应该导出 命名空间 作为 默认导出,除非存在 __esModule
属性。
module.exports.default = 'foo';
// "main.js"
var _default = 'foo';
var main = {
default: _default
};
// "bundle.js"
作为转译的 ES 模块的入口点
理想情况下,输出应该具有与原始入口相同的导出。目前,这种情况并不会出现,因为 Object.defineProperty
调用将始终导致使用 createCommonjsModule
包装器,并且不会检测到任何 命名导出。有几种方法可以改进这一点:
- 移除
__esModule
属性定义,并且不将其视为退优化的原因,参见改进 2
- 📈
改进 8
: 添加一个类似于现已移除的namedExports
的新选项,专门列出入口点的暴露导出。我们也可以使用它来激活或禁用默认导出,并决定默认导出应该是赋值给exports.default
的内容还是module.exports
。这可以通过简单地创建一个重新导出这些导出的包装文件来处理。如果我们这样做,rollup
的tree-shaking
甚至可能会移除一些未使用的导出代码。
Object.defineProperty(exports, '__esModule', { value: true });
exports.default = 'foo';
exports.bar = 'bar';
// "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);
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
中
我很乐意负责这里所需的改进:
实现
改进 5
: 当在未明确指定的情况下使用auto
模式,且有一个只有 默认导出 的包时,添加一个描述性警告。解释这个包在许多工具中将无法与其ESM
版本互换,并建议使用带有所有后果的 命名导出 模式,或者更好的是,不要使用 默认导出。► Warn when implicitly using default export mode rollup#3659 ✅
实现
rollup
部分的改进 1
:syntheticNamedExports
应该支持接收一个对应于从中选取缺失导出的导出名称的字符串值。值true
将对应于"default"
,但使用字符串时,对于入口点,列出的属性名称不会成为公共接口的一部分。这将修复突然将__moduleExports
引入接口的问题。► Add basic support for using a non-default export for syntheticNamedExports rollup#3657 ✅
在
rollup 3
中(或在rollup 2
中作为标志,因为这是一个破坏性变更): 实现改进 7
: 改变在CJS
输出中导入默认值的方式,使其像babel
那样工作。
在这个插件中
实现
改进 3
、改进 4a
、改进 4b
和改进 6
: 在导入ES
模块时始终返回命名空间,除非设置了特定选项。返回的内容在代理模块中配置。目前,我们只在模块不使用默认导出时返回命名空间。这样的选项有很多种可能的实现方式,这里是一个建议:- name:
requireReturnsDefault
支持的值:
false (默认)
: 所有ES
模块在被导入时返回其命名空间。外部导入渲染为import * as external from 'external'
而不进行任何互操作。true
: 所有具有默认导出的ES
模块在被导入时应该返回其默认值。只有在没有默认导出时,才使用命名空间。这将与当前行为相同。对于外部导入,使用以下互操作模式:jsimport * 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 模块调用。该函数将返回true
或false
|undefined
。应该确保对每个解析后的模块ID
只调用一次此函数。这等同于指定一个包含所有返回true
的模块ID
的数组。
- 一个模块
- name:
一旦在
rollup
核心端完成,实现插件部分的改进 1
: 使用syntheticNamedExports
的新值,并按照建议的简化互操作模式添加默认导出。实现
改进 2
: 如果遇到__esModule
属性的定义,则移除它,不将其视为使用createCommonjsModule
包装器的原因,也不向模块添加互操作默认导出。理想情况下,在这种情况下,对exports.default
的赋值应该像对exports.foo
的赋值一样处理,以生成显式导出。所以这样的代码:jsObject.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 ✅
实现
改进 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
- 外部模块导入
- 入口点处理
对每个场景,作者都遵循一个统一的分析框架:
- 描述预期行为
- 展示实际行为
- 分析差异原因
- 提出改进方案
基于代码的实证分析
作者不是基于理论推测,而是通过实际的代码示例来论证问题:
// 输入代码
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);
通过完整展示代码转换的整个过程,使问题和解决方案更加具体和可验证。
渐进式解决策略
作者采用了一种渐进式的问题解决策略:
识别核心问题
首先识别最基础的功能性问题,如:
- 模块导出的正确性
- 绑定关系的保持
- 运行时行为的一致性
分层改进
按照不同的层次提出改进方案:
rollup
核心层面的改进- 插件层面的改进
- 配置选项的优化
权衡取舍在每个改进建议中,都详细讨论了:
- 收益与成本
- 向后兼容性影响
- 性能影响
- 使用复杂度
生态系统思维
作者展现了深刻的生态系统思维:
工具链协同
考虑了与其他工具的互操作性:
node.js
的模块解析机制webpack
的互操作约定babel
的转换策略
最佳实践参考
借鉴其他工具的成功经验:
- 最佳实践参考
- 借鉴其他工具的成功经验:
js// Babel 的互操作方案 function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
标准化考虑,关注模块规范的演进:
ESM
规范的发展node.js
的模块互操作策略- 社区常见的互操作约定
实现路径设计
作者为每个改进建议都设计了清晰的实现路径:
循序渐进
- 优先解决核心功能问题
- 逐步添加配置能力
- 最后优化性能
特性开关,通过配置项来控制新特性:
json{ "requireReturnsDefault": false, // 控制默认导入行为 "exposedExports": {} // 控制暴露的导出 // ...其他配置项 }
向后兼容,保持与现有代码的兼容性:
- 保留旧的行为模式
- 提供迁移选项
- 添加警告提示
方法论的普适性启示
这种处理复杂工程问题的方法论具有普适性:
分析阶段
- 系统化分类问题
- 通过代码验证
- 建立评估标准
解决阶段
- 渐进式改进
- 分层次处理
- 保持兼容性
演进阶段
- 持续优化
- 收集反馈
- 迭代改进