Tree Shaking 原理
使用
在 Webpack 中,启动 Tree Shaking 功能必须同时满足三个条件:
- 使用 ESM 规范编写模块代码。
- 配置 optimization.usedExports 为 true,启动标记功能。(默认为true)
- 启动代码优化功能,可以通过如下方式实现:
- 配置 mode = production
- 配置 optimization.minimize = true(默认为true)
- 提供 optimization.minimizer 数组
初始化
applyWebpackOptionsDefaults中调用applyOptimizationDefaults方法,设置两个变量:
D(optimization, "providedExports", true);
D(optimization, "usedExports", production);紧接着WebpackOptionsApply中根据这两个变量应用两个插件:
if (options.optimization.providedExports) {
const FlagDependencyExportsPlugin = require("./FlagDependencyExportsPlugin");
new FlagDependencyExportsPlugin().apply(compiler);
}
if (options.optimization.usedExports) {
const FlagDependencyUsagePlugin = require("./FlagDependencyUsagePlugin");
new FlagDependencyUsagePlugin(
options.optimization.usedExports === "global"
).apply(compiler);
}export解析为Dependency
通常来讲导出分为三种情况:
- 具名导出
export const a = 1 - 默认导出
export default const b = 2 - 全部导出
export * from './xxx.js'
在make阶段对源码进行编译得到ast时,会对以上三种情况进行分析:
blockPreWalkStatement(statement) {
// ...
switch (statement.type) {
case "ExportAllDeclaration":
this.blockPreWalkExportAllDeclaration(statement);
break;
case "ExportDefaultDeclaration":
this.blockPreWalkExportDefaultDeclaration(statement);
break;
case "ExportNamedDeclaration":
this.blockPreWalkExportNamedDeclaration(statement);
break;
}
}最终触发HarmonyExportDependencyParserPlugin插件对应的钩子形成三种不同的dependency,精简代码如下:
parser.hooks.exportExpression.tap(
"HarmonyExportDependencyParserPlugin",
(statement, expr) => {
const dep = new HarmonyExportExpressionDependency()
parser.state.current.addDependency(dep);
return true;
}
);
parser.hooks.exportSpecifier.tap(
"HarmonyExportDependencyParserPlugin",
(statement, id, name, idx) => {
if (settings) {
dep = new HarmonyExportImportedSpecifierDependency();
} else {
dep = new HarmonyExportSpecifierDependency(id, name);
}
parser.state.current.addDependency(dep);
return true;
}
);
parser.hooks.exportImportSpecifier.tap(
"HarmonyExportDependencyParserPlugin",
(statement, source, id, name, idx) => {
const dep = new HarmonyExportImportedSpecifierDependency();
parser.state.current.addDependency(dep);
return true;
}
);FlagDependencyExportsPlugin分析exportsInfo
FlagDependencyExportsPlugin插件在compilation实例化时监听了hooks.finishModules钩子:
compiler.hooks.compilation.tap(
"FlagDependencyExportsPlugin",
compilation => {
compilation.hooks.finishModules.tapAsync(
"FlagDependencyExportsPlugin",
(modules, callback) => {}
)
}
)也就是说在make阶段当所有modules都build完毕后会触发该插件的回调函数。此时会遍历所有module,并分析他们有哪些导出项,其核心代码如下:
while (queue.length > 0) {
module = queue.dequeue();
exportsInfo = moduleGraph.getExportsInfo(module);
processDependenciesBlock(module);
for (const [
dep,
exportsSpec
] of exportsSpecsFromDependencies) {
processExportsSpec(dep, exportsSpec);
}
}首先会获取module的exportsInfo,该变量会记录该模块所有的导出信息。
其次会调用processDependenciesBlock,该方法会遍历dependencies并调用processDependency方法:
const processDependency = dep => {
const exportDesc = dep.getExports(moduleGraph);
if (!exportDesc) return;
exportsSpecsFromDependencies.set(dep, exportDesc);
};通常来讲export相关的dependency,它们的exportDesc是存在的,如:
getExports(moduleGraph) {
return {
exports: [this.name],
priority: 1,
terminalBinding: true,
dependencies: undefined
};
}
getExports(moduleGraph) {
return {
exports: ["default"],
priority: 1,
terminalBinding: true,
dependencies: undefined
};
}最终会遍历这些具有export内容的dependency,并执行processExportsSpec,精简后的代码如下:
const processExportsSpec = (dep, exportDesc) => {
const exports = exportDesc.exports;
const mergeExports = (exportsInfo, exports) => {
for (const exportNameOrSpec of exports) {
name = exportNameOrSpec;
const exportInfo = exportsInfo.getExportInfo(name);
}
};
mergeExports(exportsInfo, exports);
}首先会获取所有导出的变量exports,然后遍历exports,对于每个export变量的name都会调用exportsInfo.getExportInfo(name)建立一个exportInfo:
getExportInfo(name) {
const newInfo = new ExportInfo(name, this._otherExportsInfo);
this._exports.set(name, newInfo);
return newInfo;
}这样就在exportsInfo里可以通过_exports属性访问所有的导出变量信息。这里省略了exportInfo信息的一些要素。
FlagDependencyUsagePlugin标记使用
在seal阶段时,已经创建好了所有module,在生成chunk之前会先触发hooks.optimizeDependencies钩子:
while (this.hooks.optimizeDependencies.call(this.modules)) {
/* empty */
}此时会调用FlagDependencyUsagePlugin插件的回调,核心代码如下:
// 遍历 entry dependency
for (const dep of deps) {
processEntryDependency(dep, runtime);
}
// 处理完入口后,将遇到的 module 又加入到 queue 中进行处理
while (queue.length) {
const [module, runtime] = queue.dequeue();
processModule(module, runtime, false);
}从入口开始遍历module,真正的执行者为processModule方法:
const processModule = (module, runtime, forceSideEffects) => {
const map = new Map();
const queue = new ArrayQueue();
queue.enqueue(module);
for (;;) {
const block = queue.dequeue();
for (const dep of block.dependencies) {
const connection = moduleGraph.getConnection(dep);
// 1. 获取 dependency 对应的 module
const { module } = connection;
// 2. 根据当前 dependency 分析引用了哪些变量
const referencedExports =
compilation.getDependencyReferencedExports(dep, runtime);
if (
oldReferencedExports === undefined ||
oldReferencedExports === NO_EXPORTS_REFERENCED ||
referencedExports === EXPORTS_OBJECT_REFERENCED
) {
// 3. 形成 map 结构
map.set(module, referencedExports);
}
}
}
// ...
};processModule前半部分代码是遍历module.dependencies。在遇到import相关的dependency时,通过getDependencyReferencedExports方法解析该dependency,获取所有引用到的变量,记做referencedExports,并添加到map中。
后半段代码则是遍历map,处理import与export之间的联系:
for (const [module, referencedExports] of map) {
if (Array.isArray(referencedExports)) {
processReferencedModule(
module,
referencedExports,
runtime,
forceSideEffects
);
} else {
processReferencedModule(
module,
Array.from(referencedExports.values()),
runtime,
forceSideEffects
);
}
}processReferencedModule传入参数中,module表示dep对应的模块(这里其实指被导入的模块),referencedExports表示当前模块import了哪些变量,为数组形式。processReferencedModule精简代码如下:
const processReferencedModule = (
module,
usedExports,
runtime,
forceSideEffects
) => {
// 1. 获取导入模块 的导出信息
const exportsInfo = moduleGraph.getExportsInfo(module);
if (usedExports.length > 0) {
// 2. 遍历这个模块被使用到的变量
for (const usedExportInfo of usedExports) {
usedExport = usedExportInfo;
let currentExportsInfo = exportsInfo;
// 3. 遍历这个模块被使用到的变量
for (let i = 0; i < usedExport.length; i++) {
// 4. 根据引用到的变量,获取到该变量在模块中导出的信息
const exportInfo = currentExportsInfo.getExportInfo(
usedExport[i]
);
// 5.在该变量的exportInfo里标记被使用
if (
exportInfo.setUsedConditionally(
v => v !== UsageState.Used,
UsageState.Used,
runtime
)
) {
const currentModule =
currentExportsInfo === exportsInfo
? module
: exportInfoToModuleMap.get(currentExportsInfo);
if (currentModule) {
// 6. 递归处理遇到的 module
queue.enqueue(currentModule, runtime);
}
}
}
}
}
};其中标记发生在setUsedConditionally函数中:
setUsedConditionally(condition, newValue, runtime) {
// ...
if (newValue !== UsageState.Unused && condition(UsageState.Unused)) {
this._usedInRuntime = new Map();
forEachRuntime(
runtime,
runtime => this._usedInRuntime.set(runtime, newValue)
);
return true;
}
}此时会在_usedInRuntime属性中标记UsageState.Used,为已被使用。
导入了但是未被使用怎么办?
举一个简单例子如下:
import a from './a.js'
// 如果不调用 a
// a()正常情况下,walkStatement处理解析后的ast,会为import语法创建HarmonyImportSideEffectDependency,而当使用a()时,会创建HarmonyImportSpecifierDependency。前面提到回编译dependencies进行标记,但是在遍历之前会做一层处理:
const connection = moduleGraph.getConnection(dep);
if (!connection || !connection.module) {
continue;
}
const activeState = connection.getActiveState(runtime);
if (activeState === false) continue;获取dep的connection,然后调用getActiveState方法:
// ModuleGraphConnection 类方法
getActiveState(runtime) {
if (!this.conditional) return this._active;
return this.condition(this, runtime);
}这个conditional实际上是在建立connection时被赋值的:
// ModuleGraph 类方法
setResolvedModule(originModule, dependency, module) {
const connection = new ModuleGraphConnection(
originModule,
dependency,
module,
undefined,
dependency.weak,
dependency.getCondition(this)
);
}这里又通过dependency.getCondition来确认最终conditional是否为为true。对于HarmonyImportSideEffectDependency,getCondition最终返回的是一个函数,因此为true。但是HarmonyImportSpecifierDependency不一样,最终会返回false。因此,实际上标记时是根据HarmonyImportSpecifierDependency来进行标记的。所以,当只导入了变量,但是没有使用时,标记同样为未使用。
(但是在递归创建module的时候,使用的是HarmonyImportSideEffectDependency,而不是HarmonyImportSpecifierDependency)。
去除没有使用的export
在seal阶段进行code generate的时候,此时需要遍历模块的dependencies,然后将其生成最终的代码。而export的处理也正是在这个时候,这里以export const a = 1为例,对应于HarmonyExportSpecifierDependency。当生成代码时,需要执行HarmonyExportSpecifierDependency.Template.apply方法:
HarmonyExportSpecifierDependency.Template = class HarmonyExportSpecifierDependencyTemplate extends (
NullDependency.Template
) {
apply(
dependency,
source,
{ module, moduleGraph, initFragments, runtime, concatenationScope }
) {
const dep = /** @type {HarmonyExportSpecifierDependency} */ (dependency);
const used = moduleGraph
.getExportsInfo(module)
.getUsedName(dep.name, runtime);
if (!used) {
const set = new Set();
set.add(dep.name || "namespace");
initFragments.push(
new HarmonyExportInitFragment(module.exportsArgument, undefined, set)
);
return;
}
const map = new Map();
map.set(used, `/* binding */ ${dep.id}`);
initFragments.push(
new HarmonyExportInitFragment(module.exportsArgument, map, undefined)
);
}
};它会根据getExportsInfo和dep.name获取导出的变量的使用情况,如果没有使用,那么传入的map为undefined,这样在HarmonyExportInitFragment获取内容时就不会生成相应的export代码了:
// HarmonyExportInitFragment 类 getContent 方法
getContent({ runtimeTemplate, runtimeRequirements }) {
const definitions = [];
for (const [key, value] of this.exportMap) {
definitions.push(
`\n/* harmony export */ ${JSON.stringify(
key
)}: ${runtimeTemplate.returningFunction(value)}`
);
}
const definePart =
this.exportMap.size > 0
? `/* harmony export */ ${RuntimeGlobals.definePropertyGetters}(${
this.exportsArgument
}, {${definitions.join(",")}\n/* harmony export */ });\n`
: "";
return `${definePart}${unusedPart}`;
}从而达到去除那些没有使用到的export等变量的目的。
terser-webpack-plugin
最后会通过terser-webpack-plugin删除无用代码,实现完整的tree shaking效果。
总结
tree shaking的原理大致包括以下几个步骤:
buildModule阶段:当源码解析成ast树后,分析export语法,转变为对应的dependency。hooks.finishModules钩子:通过FlagDependencyExportsPlugin插件遍历所有module的dependencies,找到export相关的dependencies,然后分析出导出的变量名称。最后根据每个导出的变量名称创建一个exportInfo,最后将exportInfo与当前的module建立联系:通过getExportsInfo可以访问当前module所有的exportInfo。hooks.optimizeDependencies钩子:通过FlagDependencyUsagePlugin插件,也是遍历dependencies。- 查找使用了模块变量的
dependency,解析出具体使用了哪些变量。 - 根据该
dependency和moduleGraph,获取该dependency对应的module。 - 获取
module的exportsInfo,根据name获取对应的exportInfo。 - 将
exportInfo,标记为已使用。(可通过_usedInRuntime属性访问是否使用过)。
- 查找使用了模块变量的
seal阶段:在生成代码时,分析export相关的dependency,如果没有被使用,那么不生成相应的导出代码。assets阶段:生成的代码通过terser-webpack-plugin插件删除无用代码。
