每日一报
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 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
模块在语法上的限制。
出于上述两个原因,@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 ----> 8
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
在判断 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
优化和产物的执行 性能 上会产生一定的影响。当然代码若执行 压缩 处理,那么则影响较小。