像素

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 阶段,大体流程:

  1. 将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则:
    • 具名导出转换为 HarmonyExportSpecifierDependency 对象
    • default 导出转换为 HarmonyExportExpressionDependency 对象
  2. 所有模块都编译完毕后,FlagDependencyExportsPlugin 插件回调
  3. FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,遍历所有 module 对象
  4. 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象并记录到 ModuleGraph 体系中

经过 FlagDependencyExportsPlugin 插件处理后,所有 ESM 风格的 export 语句都会记录在 ModuleGraph 体系内

标记模块导出

模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段,主流程:

  1. 开始执行 FlagDependencyUsagePlugin 插件逻辑
  2. 从 entry 开始逐步遍历 ModuleGraph 存储的所有 module 对象
  3. 遍历 module 对象对应的 exportInfo 数组
  4. 为每一个 exportInfo 对象执行方法,确定其对应的 dependency 对象有否被其它模块使用
  5. 被任意模块使用到的导出值,调用 exportInfo.setUsedConditionally 方法将其标记为已被使用。
  6. 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 代码,提升应用的性能和加载速度。