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
在语义上存在不一致问题。
执行顺序不一致
以如下例子来进行说明,其中
index.mjs
作为入口模块。jsexport * from './a.cjs';
jsconsole.log('Module A is being loaded'); const b = require('./b.cjs'); console.log('Module A has loaded B'); module.exports = 'This is A';
jsconsole.log('Module B is being loaded'); const a = require('./a.cjs'); console.log('Module B has loaded A'); module.exports = 'This is B';
通过
node
和rollup
打包后的产物的执行行为可以发现,node
执行的顺序是符合预期的,而rollup
执行的顺序是不符合预期的。bashModule A is being loaded Module B is being loaded Module A has loaded B Module B has loaded A
bashModule 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
语句自动提升到最外层作用域的顶部。jsconsole.log('Module A is being loaded'); const b = require('./b.cjs'); console.log('Module A has loaded B'); module.exports = 'This is A';
jsimport * 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
初始化顺序的代码。循环依赖
当
commonjs
模块之间存在循环require
调用时,由于这些通常依赖于嵌套require
调用的 延迟执行,因此会存在问题。以下述例子为例,其中
index.mjs
作为入口模块。jsexport * from './a.cjs';
jsconsole.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 };
jsconsole.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.cjs
和b.cjs
两模块是具有 运行时强相关性的循环依赖关系。在这种场景下对于@rollup/plugin-commonjs
插件而言(strictRequires
=false
),由于两个模块是具有 运行时强相关性的循环依赖关系,因此转译后的ESM
代码通常都会存在问题。下面对上述
a.cjs
、b.cjs
模块进行转译后的代码进行分析。jsconsole.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 };
jsimport * 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 };
jsconsole.log('Module B is being loaded'); const a = require('./a.cjs'); console.log('In Module B, a.value =', a.value); module.exports = { value: 2 };
jsimport * 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
声明)。jsconsole.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
)的模块实例,不过需要注意的是:
- 当
resolvedId.resolveDependencies = true
时,意味着完成当前模块的依赖项模块的路径解析。this.load
方法会返回已经解析完成的当前模块,同时该模块的所有依赖项模块的路径(resolvedId)也已经解析完成(即moduleParsed
钩子已经触发)。 - 当
未指定 resolvedId.resolveDependencies = false
时(默认),意味着还未开始解析当前模块的依赖项模块的路径。this.load
方法会返回解析完成的当前模块实例,但其所有依赖项模块的路径(resolvedId
)均还未解析完成。
这有助于避免在 resolveId
钩子中等待 this.load
时出现死锁情况。预先加载指定模块,如果指定模块稍后成为图的一部分,则使用此上下文函数并不会带来额外的开销,因为由于缓存,指定模块不会再次解析。
那么默认情况下(resolvedId.resolveDependencies = false
)执行 await this.load(resolvedId)
时,会返回解析完成的当前模块实例,但其所有依赖项模块的路径(resolvedId
)均还未解析完成。
了解了上述前提要知的内容,那么就以 Issue 1425 中的例子进行分析。
以下是模块的依赖图,main
模块为入口模块。
5 <-- 4 <-- 11 <-- 10
| ^ ^
V | |
6 1 <-- main 9
| | ^
V V |
7 --> 2 <=> 3 ----> 8
各个模块执行 await this.load(resolvedId)
的时机如下:
main -> 1 -> 2 -> 4 -> 3 -> 5 -> 8 -> 6 -> 9 -> 10 -> 7 -> 11
在回溯阶段,@rollup/plugin-commonjs
插件会通过 IS_WRAPPED_COMMONJS
来标记模块是否需要包装在函数中。
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
来标记模块是否需要包装在函数中。
下面是各个模块的回溯时机:
`7` -> 6 -> 5 -> 11 -> 4 -> 10 -> 9 -> 8 -> 3 -> 2 -> 11 -> main
对比各个模块的执行时机:
main -> 1 -> 2 -> 4 -> 3 -> 5 -> 8 -> 6 -> 9 -> 10 -> `7` -> 11
可以发现当 7
模块执行完成需要进行回溯时,由于 11
模块还未开始解析,此时执行 getDependencies(10)
并不会获得任何依赖项。那么对于 7
模块来说,isCyclic
函数的决策路径如下:
`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 模块不处于循环依赖中
)
import { a } from './commonjs.cjs';
console.log(a);
exports.a = 1234;
exports.b = 4567;
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
属性也不一定会存在
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
设置为 true
,rollup
目前支持对于静态 esm
模块的 tree-shaking
优化,那么对于 commonjs
模块的 tree-shaking
优化则需要进一步优化。详细内容可以参考 object tree-shaking 小节。