每日一报
rollup 的那些事
object tree-shaking
对于导入对象的 tree-shaking 效果有限,以下实例中通过结构获取对象中的属性可以正常执行 tree-shaking 优化。
correct input:
// main.js
// ✅ good case 1
import * as maths from './maths.js';
// ✅ good case 2
import { square } from './maths.js';
// ✅ good case 3
const { square: square2 } = await import('./maths.js');
console.log(maths.square(10), square, square2);// maths.js
export const square = (x) => x * x;
export const double = (x) => x * 2;correct output:
const square = (x) => x * x;
var maths = /*#__PURE__*/ Object.freeze({
__proto__: null,
square,
});
console.log(square(10));
await Promise.resolve().then(function () {
return maths;
});但若直接通过对象引用的属性引用方式,可以发现 tree-shaking 的效果似乎就失效了。
incorrect input:
// main.js
// ❌ bad case 1
import('./maths.js').then((tools) => {
console.log(tools.square);
});
// ❌ bad case 2
const obj = await import('./maths.js');
console.log(obj.square);incorrect output:
// main.js
import('./maths-sOWWGe1X.js').then((tools) => {
console.log(tools.square);
});
const obj = await import('./maths-sOWWGe1X.js');
console.log(obj.square);// maths-sOWWGe1X.js
const square = (x) => x * x;
const double = (x) => x * 2;
export { double, square };feat: implement object tree-shaking PR 的提出就是为了解决上述直接使用 namespace object 引用时候的 tree-shaking 问题。
这本质上是针对 object 的深度 tree-sharking 优化,针对对象的结构属性获取做更深层次的 tree-shaking 优化。这个 PR 合并也意味对于 commonjs 模块的 tree-shaking 的更深度优化。
@rollup/plugin-commonjs
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模块。
从历史上看,@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 AbashModule 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模块在语法上的限制。
出于上述两个原因,@rollup/plugin-commonjs 插件默认将 strictRequires 设置为 auto,以解决上述的边界问题。将 strictRequires 设置为 "auto" 时,仅在 commonjs 文件作为 commonjs 依赖循环的一部分时才进行包装,类似于 nodejs 处理 commonjs 语法的行为,在首次 require 时执行模块,保留 nodejs 的语义。其他情况下仍然会提升,这是大多数代码库推荐的设置。
但是这里又产生了一个问题,条件性 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 中的例子进行分析。
5 <-- 4 <-- 11 <-- 10
| ^ ^
V | |
6 1 <-- main 9
| | ^
V V |
7 --> 2 <=> 3 ----> 8main -> 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在判断 10 模块时已经结束了,意味着 7 模块不是在循环依赖中,那么 7 模块就不会被包装在函数中。
这是错误的决策,事实并不是这样的。7 模块实际上是在循环依赖中,需要进行包装。await this.load(X) 方法仅意味着 X 模块已经解析完成,但并不意味着 X 模块的依赖项模块均完成解析,这就是出现循环导入检测失败的问题的原因。
鉴于上述的原因,@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 优化和产物的执行 性能 上会产生一定的影响。当然代码若执行 压缩 处理,那么则影响较小。