Skip to content

default strictRequires to true

strictRequires 属性是 @rollup/plugin-commonjs 插件的一个配置属性,用于控制 commonjs 模块转译为 esm 模块时的行为。

strictRequires 属性介绍

类型:"auto" | boolean | "debug" | string[] 默认值:true

  • "auto": 仅在 commonjs 文件作为 commonjs 依赖循环的一部分时进行包装,例如一个索引文件被其某些依赖项所要求,或者它们只是以潜在的“条件”方式被要求,比如从 if 语句或函数中。所有其他 commonjs 文件都会被提升。这是大多数代码库推荐的设置。请注意,检测条件性 require 可能会存在竞态条件问题,如果同一文件既有条件性 require 又有无条件 require,则在极端情况下可能导致构建之间不一致。如果您认为这对您造成了问题,可以通过使用除 "auto""debug" 之外的任何值来避免这种情况。
  • true: 默认值。会将所有 commonjs 文件包装在函数中,这些函数在首次 require 时会执行,保留 nodejs 语义。这是最安全的设置,如果生成的代码与 "auto" 不兼容,则应使用此设置。请注意,strictRequires: true 可能会对 生成的代码大小性能 产生一定影响,但如果代码经过 压缩,则影响较小。
  • false: 不进行任何 commonjs 文件进行包裹并执行 require 语句提升。这可能仍然有效,取决于循环依赖的性质,但通常会引起问题。
  • "debug": "debug" 的工作方式类似于 "auto",但在捆绑后,它将显示一个警告,其中包含已被包装的 commonjs 文件的 id 列表,可用作 picomatch 模式 进行微调或避免所提到的 "auto" 可能存在的竞争条件。
  • string[]: 提供一个 picomatch 模式模式数组,来指定具体需要包装在函数中的 commonjs 模块。

旧版本 commonjs 转译为 esm 模块的缺陷

从历史上看,@rollup/plugin-commonjs 插件会试图将 require 语句提升到每个文件的顶部,作为 esm 模块的 import 导出方式进行加载。虽然这对许多代码库效果表现不错,由 commonjs 转译后的 esm 模块也具备了如原生 esm 所具有的特性。但具备一定的缺陷,由于 commonjs 与生俱有的 动态性,因此会存在边界样例导致转译后的 esm 与原先的 commonjs 在语义上存在不一致问题。

  1. 执行顺序不一致

    以如下例子来进行说明,其中 index.mjs 作为入口模块。

    js
    export * from './a.cjs';
    js
    console.log('Module A is being loaded');
    const b = require('./b.cjs');
    console.log('Module A has loaded B');
    module.exports = 'This is A';
    js
    console.log('Module B is being loaded');
    const a = require('./a.cjs');
    console.log('Module B has loaded A');
    module.exports = 'This is B';

    通过 noderollup 打包后的产物的执行行为可以发现,node 执行的顺序是符合预期的,而 rollup 执行的顺序是不符合预期的。

    bash
    Module A is being loaded
    Module B is being loaded
    Module A has loaded B
    Module B has loaded A
    bash
    Module B is being loaded
    Module B has loaded A
    Module A is being loaded
    Module A has loaded B

    原因就在于 rollup 在转译 commonjs 模块为 esm 模块时(strictRequires = false),会将 require 语句自动提升到最外层作用域的顶部。

    js
    console.log('Module A is being loaded');
    const b = require('./b.cjs');
    console.log('Module A has loaded B');
    module.exports = 'This is A';
    js
    import * as commonjsHelpers from 'commonjsHelpers.js';
    import require$$0 from '\u0000/Users/Project/vrite/examples/commonjs/src/strict-requires/b.cjs?commonjs-proxy';
    
    console.log('Module A is being loaded');
    const b = require$$0;
    console.log('Module A has loaded B');
    var a = 'This is A';
    
    export default /*@__PURE__*/ commonjsHelpers.getDefaultExportFromCjs(a);
    export { a as __moduleExports };

    很明显,转译后的 ESM 与原生 commonjs 模块执行行为完全不一致。前者会先执行 b.cjs 模块,再执行 a.cjs 模块,而后者会先执行 a.cjs 模块,再执行 b.cjs 模块。

    CommonJS 中,require 是惰性执行的,这意味着模块在第一次被 require 时,代码会开始执行。如果模块 A 在其加载过程中(模块 A 未解析完成)再次 require 模块 A(直接或间接),会返回一个未完全初始化的模块 A 对象(module.export = {})。

    ESM 中,import 语句在模块解析时会立即提升并预加载模块,这意味着模块的执行顺序是静态确定的。如果我们试图将 require 替换为 import,初始执行顺序将被改变,无法如同 CommonJS 一样处理循环导入,因为 import 是在加载时解析的,而不是在运行时 惰性加载 的。

    由于 ESM 所需的静态特性,预加载 模块的行为对于高度依赖 commonjs 模块的 惰性加载 的场景就会导致一定的副作用。比如类似上述的日志语句以不同顺序发出、一些依赖于 require 语句中 polyfills 初始化顺序的代码。

  2. 循环依赖

    commonjs 模块之间存在循环 require 调用时,由于这些通常依赖于嵌套 require 调用的 延迟执行,因此会存在问题。

    以下述例子为例,其中 index.mjs 作为入口模块。

    js
    export * from './a.cjs';
    js
    console.log('Module A is being loaded');
    exports.value = 1;
    const b = require('./b.cjs');
    console.log('In Module A, b.value =', b.value);
    const sum = exports.value + b.value;
    console.log('In Module A, sum =', sum);
    module.exports = { sum };
    js
    console.log('Module B is being loaded');
    const a = require('./a.cjs');
    console.log('In Module B, a.value =', a.value);
    
    module.exports = {
      value: 2
    };

    上述的例子是在 commonjs 语法中十分典型的循环依赖使用场景的例子,在 commonjs 语法中是十分常用的做法。在上述例子中,a.cjsb.cjs 两模块是具有 运行时强相关性的循环依赖关系。在这种场景下对于 @rollup/plugin-commonjs 插件而言(strictRequires = false),由于两个模块是具有 运行时强相关性的循环依赖关系,因此转译后的 ESM 代码通常都会存在问题。

    下面对上述 a.cjsb.cjs 模块进行转译后的代码进行分析。

    js
    console.log('Module A is being loaded');
    exports.value = 1;
    const b = require('./b.cjs');
    console.log('In Module A, b.value =', b.value);
    const sum = exports.value + b.value;
    console.log('In Module A, sum =', sum);
    module.exports = { sum };
    js
    import * as commonjsHelpers from 'commonjsHelpers.js';
    import { __module as aModule } from '\u0000/Users/Project/vrite/examples/commonjs/src/strict-requires/case2/a.cjs?commonjs-module';
    var a = aModule.exports;
    import require$$0 from '\u0000/Users/Project/vrite/examples/commonjs/src/strict-requires/case2/b.cjs?commonjs-proxy';
    
    (function (module, exports) {
      console.log('Module A is being loaded');
      exports.value = 1;
      const b = require$$0;
      console.log('In Module A, b.value =', b.value);
      const sum = exports.value + b.value;
      console.log('In Module A, sum =', sum);
      module.exports = { sum };
    })(aModule, aModule.exports);
    
    var aExports = aModule.exports;
    export default /*@__PURE__*/ commonjsHelpers.getDefaultExportFromCjs(
      aExports
    );
    export { aExports as __moduleExports };
    js
    console.log('Module B is being loaded');
    const a = require('./a.cjs');
    console.log('In Module B, a.value =', a.value);
    
    module.exports = {
      value: 2
    };
    js
    import * as commonjsHelpers from 'commonjsHelpers.js';
    import require$$0 from '\u0000/Users/Project/vrite/examples/commonjs/src/strict-requires/case2/a.cjs?commonjs-proxy';
    
    console.log('Module B is being loaded');
    const a = require$$0;
    console.log('In Module B, a.value =', a.value);
    
    var b = {
      value: 2
    };
    
    export default /*@__PURE__*/ commonjsHelpers.getDefaultExportFromCjs(b);
    export { b as __moduleExports };

    由于 commonjs 语法具有动态性,因此可以通过先 exports.value = 1 执行部分导出给 b.cjs 模块来进行消费,在 b.cjs 模块中可以正常消费到 a.cjs 模块部分导出的值(即 exports.value = 1)。这在 commonjs 语法中是十分常用的做法。

    但不幸的是,ESM 语法具有静态性,无法像 commonjs 语法可以部分导出属性供其他模块消费。如果 esm 中也像上述那样使用,即在未解析完成 a.cjs 模块的时候在 b.cjs 模块中引用 a.cjs 模块,执行时会显示:

    ReferenceError: Cannot access 'XXX' before initialization

    也就是说,在 esm 中,若直接访问未解析完成的模块引用时会提示访问未初始化,从而引发错误(类似 const 声明)。

    js
    console.log(a);
    
    const a = 1;

    当然在 esm 中也是有办法解决上述的问题,那就是通过 namespace object 的方式来暂存未解析的引用,等到 a.cjs 模块解析完成后在 b.cjs 模块中就可以正常访问。

    js
    // index.mjs
    import a from './a.mjs';
    a();
    js
    // a.mjs
    import b from './b.mjs';
    export const a = 'a';
    export default b;
    js
    // b.mjs
    import * as temporaryNamespace from './a.mjs';
    export default function () {
      return temporaryNamespace;
    }

    本质上就是延迟访问未解析完成模块提供的引用,等到模块解析完成再访问,这就是 esm 模块在静态加载时的特性(需要完全确定每一个模块要导出哪些内容)。而不能如同 commonjs 语法一样在未解析完成模块时可以访问未解析模块的引用。

    不过需要注意的是,esm 模块的 namespace object 方式依旧无法像 commonjs 语法那样可以部分导出属性供其他模块消费。这是 esm 模块在语法上的限制。

strictRequires 默认设置为 auto

出于上述两个原因,@rollup/plugin-commonjs 插件默认将 strictRequires 设置为 auto,以解决上述的边界问题。将 strictRequires 设置为 "auto" 时,仅在 commonjs 文件作为 commonjs 依赖循环的一部分时才进行包装,类似于 nodejs 处理 commonjs 语法的行为,在首次 require 时执行模块,保留 nodejs 的语义。其他情况下仍然会提升,这是大多数代码库推荐的设置。

条件性 require 的检测可能存在竞争加载模块的问题

但是这里又产生了一个问题,条件性 require 的检测可能存在竞争加载模块的问题,如果同一文件既有条件性 require 又有无条件 require,则在极端情况下可能导致构建之间不一致。

@rollup/plugin-commonjs sometimes fails to detect circular dependency 这个 issue 中可以发现。

前提要知

@rollup/plugin-commonjs 是通过 await loadModule(resolved) 方式来加载子依赖模块,loadModule 本质上就是调用了 rollup 暴露的插件上下文中的 load 方法,在 rollup 插件上下文 小结中有对其进行说明。

this.load:

rollup 为每一个插件提供插件执行上下文,也就是在插件中可以通过 this 来访问到插件上下文中的方法。this.load 方法就是插件上下文中的方法之一,当在插件中调用 this.load 方法时,会加载指定路径(resolvedId)的模块实例,不过需要注意的是:

  1. resolvedId.resolveDependencies = true 时,意味着完成当前模块的依赖项模块的路径解析。this.load 方法会返回已经解析完成的当前模块,同时该模块的所有依赖项模块的路径(resolvedId)也已经解析完成(即 moduleParsed 钩子已经触发)。
  2. 未指定 resolvedId.resolveDependencies = false 时(默认),意味着还未开始解析当前模块的依赖项模块的路径。this.load 方法会返回解析完成的当前模块实例,但其所有依赖项模块的路径(resolvedId)均还未解析完成。

这有助于避免在 resolveId 钩子中等待 this.load 时出现死锁情况。预先加载指定模块,如果指定模块稍后成为图的一部分,则使用此上下文函数并不会带来额外的开销,因为由于缓存,指定模块不会再次解析。

那么默认情况下(resolvedId.resolveDependencies = false)执行 await this.load(resolvedId) 时,会返回解析完成的当前模块实例,但其所有依赖项模块的路径(resolvedId)均还未解析完成。

了解了上述前提要知的内容,那么就以 Issue 1425 中的例子进行分析。

以下是模块的依赖图,main 模块为入口模块。

bash
5 <-- 4 <-- 11 <-- 10
|     ^             ^
V     |             |
6     1 <-- main    9
|     |             ^
V     V             |
7 --> 2 <=> 3 ----> 8

各个模块执行 await this.load(resolvedId) 的时机如下:

md
main -> 1 -> 2 -> 4 -> 3 -> 5 -> 8 -> 6 -> 9 -> 10 -> 7 -> 11

在回溯阶段,@rollup/plugin-commonjs 插件会通过 IS_WRAPPED_COMMONJS 来标记模块是否需要包装在函数中。

js
const isCyclic = id => {
  const dependenciesToCheck = new Set(getDependencies(id));
  for (const dependency of dependenciesToCheck) {
    if (dependency === id) {
      return true;
    }
    for (const childDependency of getDependencies(dependency)) {
      dependenciesToCheck.add(childDependency);
    }
  }
  return false;
};
const getTypeForFullyAnalyzedModule = id => {
  const knownType = knownCjsModuleTypes[id];
  if (
    knownType !== true ||
    !detectCyclesAndConditional ||
    fullyAnalyzedModules[id]
  ) {
    return knownType;
  }
  if (isCyclic(id)) {
    return (knownCjsModuleTypes[id] = IS_WRAPPED_COMMONJS);
  }
  return knownType;
};
const isCommonJS = (parentMeta.isRequiredCommonJS[dependencyId] =
  getTypeForFullyAnalyzedModule(dependencyId));

判断逻辑中有个重点函数 isCyclic,如果当前模块在循环依赖中,那么就会通过 IS_WRAPPED_COMMONJS 来标记模块是否需要包装在函数中。

下面是各个模块的回溯时机:

md
`7` -> 6 -> 5 -> 11 -> 4 -> 10 -> 9 -> 8 -> 3 -> 2 -> 11 -> main

对比各个模块的执行时机:

md
main -> 1 -> 2 -> 4 -> 3 -> 5 -> 8 -> 6 -> 9 -> 10 -> `7` -> 11

可以发现当 7 模块执行完成需要进行回溯时,由于 11 模块还未开始解析,此时执行 getDependencies(10) 并不会获得任何依赖项。那么对于 7 模块来说,isCyclic 函数的决策路径如下:

md
`7` -> 2 -> 3 -> 8 -> 9 -> 10 -> X(getDependencies(10)值为空)

在判断 10 模块时发现没有依赖项,意味着 7 模块不是在循环依赖中,那么 7 模块就不会被包装在函数中。

这是错误的决策,事实并不是这样的。7 模块实际上是在循环依赖中,需要进行包装。await this.load(X) 方法仅意味着 X 模块已经解析完成,但并不意味着 X 模块的依赖项模块均完成解析,这就是出现循环导入检测失败的问题的原因。

strictRequires 默认设置为 true

鉴于上述的原因,@rollup/plugin-commonjs 插件默认将 strictRequires 设置为 true。也就是 default strictRequires to true PR 中提出的修改,默认将 strictRequires 设置为 true,来解决 @rollup/plugin-commonjs 插件在处理 commonjs 模块转译为 esm 模块时的边界问题,即有助于解决导致循环导入检测失败的竞态条件,并有助于确保构建哈希的一致性。

strictRequires 默认值为 true 时,@rollup/plugin-commonjs 插件会将 所有commonjs 文件包装在函数中,执行行为类似 nodejs,这是最安全的设置。这有助于解决设置 "auto" 时导致循环导入检测失败的竞态条件,并确保构建哈希是一致的。

对产物的影响

不足之处在于,strictRequires 设置为 true 时,意味着 @rollup/plugin-commonjs 插件确保 commonjs 语义的准确性,保留了 commonjs 语法的动态性的同时,从而放弃原先(strictRequires = "auto" | false)尽可能将 commonjs 语法转译为 esm 语法的静态性质。这对后续的模块的 tree-shaking 优化和产物的执行 性能 上会产生一定的影响。当然代码若执行 压缩 处理,那么则影响较小。

特殊例子

commonjs 模块处于未封装状态(即 strictRequires = false 或者 strictRequires = 'auto' && commonjs 模块不处于循环依赖中)

js
import { a } from './commonjs.cjs';
console.log(a);
js
exports.a = 1234;
exports.b = 4567;
js
import commonjs from '@rollup/plugin-commonjs';
export default {
  input: './main.js',
  output: {
    dir: 'dist',
    format: 'esm'
  },
  plugins: [
    commonjs({
      // or (auto && the target module is not in a circular dependency).
      strictRequires: false
    })
  ]
};

按照如上的方式进行打包时,由于 commonjs.cjs 模块 符合无包装 的场景,同时 commonjs 中使用 exports 来导出属性,那么 rollup 就会将上述的 commonjs 模块进行 tree-shaking 处理,也就是未使用的 b 属性不会在最终产物中。

这种做法通常是符合预期的,但是由于 commonjs 语言的动态性质,也就是说在运行后 exports.a 属性也不一定会存在

js
exports.a = 1234;
exports.b = 4567;

delete exports.a;

那么就不适用于 tree-shaking。所幸 @rollup/plugin-commonjs 插件是聪慧的,即使设置了 strictRequires: false 的标识位,但若也如上述例子,@rollup/plugin-commonjs 插件会将 commonjs 进行封装,这避免了 rollup 对其执行 tree-shaking 操作。

进一步优化(object tree-shaking)

由于 @rollup/plugin-commonjs 插件默认将 strictRequires 设置为 truerollup 目前支持对于静态 esm 模块的 tree-shaking 优化,那么对于 commonjs 模块的 tree-shaking 优化则需要进一步优化。详细内容可以参考 object tree-shaking 小节。

Contributors

Changelog

Discuss

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