Tree Shaking 与 Scope hoisting
Tree Shaking
tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES6 模块语法的 静态结构 特性,例如 import 和 export。这个术语和概念实际上是由 ES6 模块打包工具 rollup 普及起来的。
理论支持
在 esModule 之前 CommonJs、AMD、CMD 等旧版本的 JavaScript 模块化方案中,导入导出行为是高度动态,难以预测的:
let dynamicModule;
// 动态导入
if (condition) {
myDynamicModule = require("foo");
} else {
myDynamicModule = require("bar");
}而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句只能出现在模块顶层,且导入导出的模块名必须为字符串常量, 所以,ESM 下模块之间的依赖关系是高度确定的,与运行状态无关,编译工具只需要对 ESM 模块做静态分析,就可以从代码字面量中推断出哪些模块值未曾被其它模块使用。
// index.js
import { bar } from './bar';
console.log(bar);
// bar.js
export const bar = 'bar';
export const foo = 'foo';经过 tree shaking 处理后 foo 会被删除,仅保留被引用的 bar。
实现原理
Webpack 中,Tree-shaking 的实现一是先标记出模块导出值中哪些没有被用过,二是使用 Terser 删掉这些没被用到的导出语句。标记过程大致可划分为三个步骤:
- Make 阶段,收集模块导出变量并记录到模块依赖关系图 ModuleGraph 变量中
- Seal 阶段,遍历 ModuleGraph 标记模块导出变量有没有被使用
- 生成产物时,若变量没有被其它模块使用则删除对应的导出语句
收集模块导出
首先,Webpack 需要弄清楚每个模块分别有什么导出值,这一过程发生在 make 阶段,大体流程:
- 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则:
- 具名导出转换为 HarmonyExportSpecifierDependency 对象
- default 导出转换为 HarmonyExportExpressionDependency 对象
- 所有模块都编译完毕后,FlagDependencyExportsPlugin 插件回调
- FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
- 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象并记录到 ModuleGraph 体系中
经过 FlagDependencyExportsPlugin 插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内
标记模块导出
模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段,主流程:
- 开始执行 FlagDependencyUsagePlugin 插件逻辑
- 从 entry 开始逐步遍历 ModuleGraph 存储的所有 module 对象
- 遍历 module 对象对应的 exportInfo 数组
- 为每一个 exportInfo 对象执行方法,确定其对应的 dependency 对象有否被其它模块使用
- 被任意模块使用到的导出值,调用 exportInfo.setUsedConditionally 方法将其标记为已被使用。
- exportInfo.setUsedConditionally 内部修改 exportInfo._usedInRuntime 属性,记录该导出被如何使用
重点:标记模块导出这一操作集中在 FlagDependencyUsagePlugin 插件中,执行结果最终会记录在模块导出语句对应的 exportInfo._usedInRuntime 字典中。
生成代码
经过前面的收集与标记步骤后,Webpack 已经在 ModuleGraph 体系中清楚地记录了每个模块都导出了哪些值,每个导出值又没那块模块所使用。接下来,Webpack 会根据导出值的使用情况生成不同的代码,例如:
- 打包阶段,调用 HarmonyExportXXXDependency.Template.apply 方法生成代码
- 在 apply 方法内,读取 ModuleGraph 中存储的 exportsInfo 信息,判断哪些导出值被使用,哪些未被使用
- 对已经被使用及未被使用的导出值,分别创建对应的 HarmonyExportInitFragment 对象,保存到 initFragments 数组
- 遍历 initFragments 数组,生成最终结果
删除 Dead Code
之后,由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。
实践
避免无意义的赋值
import { foo, bar } from './module';
console.log(foo);
const b = bar; // 无意义赋值bar 不会被 treeshaking 清理,因为Tree Shaking 逻辑停留在代码静态分析层面,只是浅显地判断:
- 模块导出变量是否被其它模块引用
- 引用模块的主体代码中有没有出现这个变量
细化导出和导入的粒度
const Button = 'burtton';
const Header = 'header';
// 即使只用到 default 导出值的其中一个属性,整个 default 对象依然会被完整保留。
export default {
Button,
Header
}
// 修改
export {
Button,
Header
}import * as Component from './component';
// 这样使用 Component.navbar 访问组件,Treeshaking无法确定是否所有其它组件都未使用
Component.Button
//改用
import { Button } from './component'Scope Hoisting
作用域提升(Scope Hoisting)是一种 JavaScript 构建技术,它旨在优化模块打包的性能。通过作用域提升,可以显著减少打包后的代码体积,并提高运行时的执行性能。
原理
在传统的模块打包中,每个模块都被封装在一个函数作用域内,这样可以避免变量之间的冲突。但这也导致了一些额外的开销,在浏览器加载时需要创建和维护大量的函数作用域,增加了请求的数量和执行时间。 作用域提升通过静态分析模块之间的依赖关系,在打包过程中将多个模块的代码合并到一个函数作用域中。这样做的好处是减少了函数声明和闭包的数量,从而减小了打包后的代码体积,并且在运行时也减少了创建和初始化函数作用域的开销。
示例
// moduleA.js
export const value = 'Hello';// moduleB.js
import { value } from './moduleA';
export function sayHello() {
console.log(value);
}未使用 Scope hoisting 每个模块都被包裹在一个函数作用域中 使用 Scope hoisting 后
// bundle.js
const value = 'Hello';
export function sayHello() {
console.log(value);
}这样的优化带来了以下好处:
- 减少了函数声明和闭包数量,减小了代码体积。
- 减少了浏览器加载时请求的数量,加快了页面加载速度。
- 在运行时减少了函数作用域的创建和初始化开销,提高了性能。
需要注意的是 Scope hoisting 依赖于 ES6 模块系统的静态结构,以及打包工具的支持。
总结
Tree shaking (摇树优化) 和 Scope Hoisting (作用域提升) 是两种不同的 JavaScript 代码优化技术。
Tree shaking (摇树优化):
- 目标:通过静态分析代码,去除未使用的代码,以减少最终打包文件的大小。
- 原理:利用模块系统中的静态特性,识别和删除未被引用的模块、函数、变量等,从而消除无用代码。
- 优势:有效降低了打包后的文件体积,提高了网页加载速度。
- 实现方式:通常与模块打包工具(如 webpack)结合使用,通过配置来启用 tree shaking。
Scope Hoisting (作用域提升):
- 目标:减少 JavaScript 模块之间的函数调用开销和模块加载时间,提高执行性能。
- 原理:将多个模块中的函数合并到一个函数中,并消除模块间的闭包,从而减少函数调用的数量。
- 优势:减少了函数调用和模块加载的开销,提高了代码执行效率。
- 实现方式:需要打包工具对 JavaScript 代码进行优化,常见的打包工具如 webpack、Rollup 等都支持 Scope Hoisting。
Tree shaking 主要关注于剔除未使用的代码,以减少打包体积;而 Scope Hoisting 主要关注于优化模块间的函数调用和加载,以提高代码执行效率。两者可以一起使用,共同优化 JavaScript 代码,提升应用的性能和加载速度。