背景
这个研究背景其实是出于一个小伙伴在项目中遇到的问题在:项目ts相关的tree-shaking没有生效。由此延伸为解决这个问题,深入探究webpack到底是如何对文件模块化引入做到Tree-Shaking。
文章围绕的核心话题
- webpack 核心模块化是如何处理tree shaking
- Tree Shaking如何应用到实际业务场景
本文整体架构:
一、什么是Tree Shaking
Tree-Shaking 翻译过来叫“摇树”, 非常生动的描述了其作用,就是通过摇树,把树上枯萎的叶子摇落,保持树干的整洁。摇树就是Tree-Shaking的核心,也是我们要探讨的实际原理。
Tree-shaking 较早由 Rich Harris 的 rollup 实现,后来,webpack2 也增加了tree-shaking 的功能,逐步发展到今天,其核心实现总结下来为以下两点:
-
- 利用 ES Module 可以进行静态分析的特点来检测模块内容的导出、导入以及被使用的情况,保留 Live Code
- 消除不会被执行和没有副作用(Side Effect) 的 Dead Code,即 Dead Code Elimination(以下统称 DCE) 过程
基于上述两点,想必很多同学都会发出一些疑问,比如:
-
- Tree-Shaking是如何利用ES Module静态分析来进行识别的?
- 不被执行的代码很好理解,但何为没有副作用的Dead Code呢?
下面我们带着问题,逐一解答🙇
ES Module 静态分析
所谓ES Module 即基于ECMA Script组织推出的一种代码模块化的规范,JavaScript 的模块化经历一个漫长的发展历程,我们知道刚开始 JavaScript 是没有模块的概念的,最初我们只能借助 IIFE 尽量减少对全局环境的污染,后来社区出现了用于浏览器端的以 RequireJS 为代表的 AMD 规范和以 Sea.js 为代表的 CMD 规范,服务器端也出现了 CommonJS 规范,再后来 JavaScript 原生引入了 ES Module,取代社区方案成为浏览器端一致的模块解决方案
通过对比CommonJS来理解
- ES Module 输出的是值的引用,而 CommonJS 输出的是值的拷贝
- ES Module 是编译时执行,而 CommonJS 模块是在运行时加载
ESM最大的特点在于静态化,即编译时通过AST语法树解析后,分析可确定模块的依赖关系,以及输入输出。而Tree-Shaking就是基于这点来做分析的。
借助在线AST解析工具对以下代码进行解析
import a from './a.js'
export const b = 'b';
export default 'foo-bar'
可以看到,通过AST语法树解析后,可看出以下明显关系
- import 语句关键type为ImportDeclaration,依赖关系source指出依赖源为'./a.js'
- export 语句关键type为ExportNameDeclaration,以及default导出为ExportDefaultDelcaration
静态分析,也就是通过把js转换为具备识别属性type、source、declaration的对象,其中key分别指代了代码类型、依赖关系、声明与输出,通过以上确定类型的分析,Tree-shaking 就知道其模块的依赖关系和输入输出,便于后续的DCE处理。
副作用(Side Effect)
Wiki 上对副作用(Side Effect)做出的介绍:
在计算机科学中,如果操作、函数或表达式在其本地环境之外修改某些状态变量值,则称其具有副作用。
把这段话转换成我们熟悉的,它指的是当你修改了不包含在当前作用域的某些变量值的时候,则会产生副作用。
举个简单例子:
// bar.js
export const bar = 'bar';
export function foo() {
return `foo ${bar}`
};
// main.js entry
import {foo} from './bar';
console.log(foo());
通过Rollup的repl工具可以看到,这里我们并没有直接导入 bar.js 文件中的 bar 变量,但是由于在 foo() 函数中访问了它作用域之外的变量 bar,产生了副作用,所以最后输出的结果也会有 bar 变量。当然这只是一个简单的场景,同理改全局变量、或Array、Object等构造对象的原型属性也是被认为具有副作用的。
此外,如果是基于UglifyJS作为Tree-Shaking的压缩,在转换class或者构造函数时也会产生副作用,其原因是UglifyJS不支持程序流分析,具体可参考一下其仓库ISSUE。
也正是因为这个原因,在使用webpack4以下作为构建框架(压缩基于UglifyJS),则会导致Tree-Shaking识别不了class的副作用而默认保留
上面提到的副作用,在实测webpack4以上已经被优化,经过Terser处理相关副作用问题
比如以下面构造函数代码引用为例
// menu.js
function Menu() {
}
Menu.prototype.show = function() {
}
//或class 配合babel转译后实际为构造函数
/*
class Menu {
show() {
}
}
*/
Array.prototype.unique = function() {
// 将 array 中的重复元素去除
}
export default Menu;
// main.js
import Menu from './menu';
实测代码可见,Terser标记了未被使用的Menu函数#PURE, 通过解读官方文档可知,/#PURE/ 标记后会告诉terser该语句没有副作用,可以被tree-shake。其中Array的拓展unique方法也被保留了。
这里也给自己挖了个坑,webpack是如何识别语句有无副作用的,rullup的Tree-shaking分析副作用是通过程序流分析判断有无副作用,而webpack是否做了类似机制,得待进一步研究源码分析💆
小结
- Tree-Shaking 基于ES Module通过AST语法树的静态分析,得出模块的依赖关系与产出,最终把无副作用的Dead Code删除,达到摇树效果
- 副作用,是引用模块对作用域之外的变量进行了修改,那么就会被识别可能存在副作用。而随着框架的不断演进,以往无法识别有无副作用的语句,不断被优化识别,比如webpack4、rollup的程序流分析优化。所以建议对项目框架进行迭代升级,以获取更好体验。
二、Webpack Tree-Shaking原理
本节主要讲解Webpack的Tree-Shaking是如何运作的,包括贯穿整个webpack的运行周期,从make阶段的AST语法模块解析、Finish Modules阶段的收集、OptimizeDependencier阶段的标记引用、最终Seal 阶段的组成和删除Dead Code。
下面将以围绕Webpack@5 的Tree-Shaking 实现,深入源码解析。正式展开之前,有必要先对几个webpack的重要概念进行了解。
-
- Compilation:单次编辑过程的管理器,比如 watch = true 时,运行过程中只有一个 compiler 但每次文件变更触发重新编译时,都会创建一个新的 compilation 对象
- Module:资源在 webpack 内部的映射对象,包含了资源的路径、上下文、依赖、内容等信息。
- Dependency :在模块中引用其它模块,例如 import "a.js" 语句,webpack 会先将引用关系表述为 Dependency 子类并关联 module 对象,等到当前 module 内容都解析完毕之后,启动下次循环开始将 Dependency 对象转换为适当的 Module 子类。
- Module Graph:记录 Dependency Graph 信息的容器,一方面保存了构建过程中涉及到的所有 module 、dependency 对象,以及这些对象互相之间的引用;另一方面提供了各种工具方法,方便使用者迅速读取出 module 或 dependency 附加的信息
模块解析阶段
入口文件开始:
- 首先经过compilation addEntry开启入口模块编译,经过路径解析库(enhanced-resolve) Resolver的finishResolved回调,整理文件信息result给到下一步调用生成Module
源码:node_modules/enhanced-resolve/lib/Resolver.js 293行
- 经NormalModuleFactory 创建调用hooks.createModule,最终在Compilation的handleModuleCreation回调,根据文件类型构建 module 子类
源码:node_modules/webpack/lib/Compilation.js 1811行
- 调用 loader-runner 的 runLoaders 转译 module 内容,通常是从各类资源类型转译为 JavaScript 文本
源码: node_modules/webpack/lib/NormalModule.js 820行
- 调用 acorn 将 JS 文本解析为AST
源码: node_modules/webpack/lib/NormalModule.js 1090行
- 遍历 AST,触发各种钩子
源码: node_modules/webpack/lib/javascript/JavascriptParser.js 1472行
- 在 HarmonyExportDependencyParserPlugin 插件监听 export 钩子,解读 JS 文本对应的资源依赖。
首次入口文件如没有export不会进入该钩子,有import走HarmonyImportDependencyParserPlugin,原理与该阶段类似一样生成依赖收集
源码: node_modules/webpack/lib/dependencies/HarmonyExportDependencyParserPlugin.js 37行
- 调用 module 对象的 addDependency 将依赖对象加入到 module 依赖列表中
源码: node_modules/webpack/lib/dependencies/HarmonyExportDependencyParserPlugin.js 49行
- AST 遍历完毕后,调用 module.handleParseResult 处理模块依赖
源码: node_modules/webpack/lib/NormalModule.js 983行
- 对于 module 新增的依赖,递归调用 handleModuleCreation ,控制流回到第一步
源码: node_modules/webpack/lib/Compilation.js 1478行
- 当所有module执行完后,modules对象就有了所有模块的依赖关系,则触发finishModules钩子,触发后续流程执行
源码: node_modules/webpack/lib/Compilation.js 2531行
该阶段主要目的: entry文件 => Module对象
从入口文件处理,通过AST解析文件生成模块Module,并递归调用确定依赖dependency关系,以便后续环节使用。各个阶段附上了源码位置,便于debugger理解整个阶段是如何流转的。
收集导出模块阶段
通过上一阶段的模块解析,我们知道了Modules记录了我们所需要的所有依赖关系,那么接下来就是收集标记导出,大体流程:
- 解析流程将模块的所有 ESM 导出语句转换为 Dependency 对象,并记录到 module 对象的 dependencies 集合,转换规则:
-
- 具名导出转换为 HarmonyExportSpecifierDependency 对象
- default 导出转换为 HarmonyExportExpressionDependency 对象
例如对于下面的模块:
export const bar = 'bar';
export const foo = 'foo';
export default 'foo-bar'
对应的dependencies 值为:
- 触发 compilation.hooks.finishModules 钩子,开始执行FlagDependencyExportsPlugin 插件回调
- FlagDependencyExportsPlugin 插件从 entry 开始读取 ModuleGraph 中存储的模块信息,通过asyncLib遍历所有 module 对象
源码: node_modules/webpack/lib/FlagDependencyExportsPlugin.js 56行
- 遍历 module 对象的 dependencies 数组,找到所有 HarmonyExportXXXDependency 类型的依赖对象,将其转换为 ExportInfo 对象并记录到 ModuleGraph 体系中
源码: node_modules/webpack/lib/FlagDependencyExportsPlugin.js
该阶段主要目的: Dependency => ExportsInfo
这一阶段,实际意义就是收集所有Export语句相关的Dependency, 经过 FlagDependencyExportsPlugin 插件处理后,收集所有依赖导出转换为ExportInfo对象,以便后续使用。
标记导出模块阶段
模块导出信息收集完毕后,Webpack 需要标记出各个模块的导出列表中,哪些导出值有被其它模块用到,哪些没有,这一过程发生在 Seal 阶段,主流程:
- 触发 compilation.hooks.optimizeDependencies 钩子,开始执行 FlagDependencyUsagePlugin 插件逻辑
- 在 FlagDependencyUsagePlugin 插件中,从 entry 开始逐步遍历 ModuleGraph 存储的所有 module 对象
源码: node_modules/webpack/lib/FlagDependencyUsagePlugin.js
- 遍历 module 对象对应的 exportInfo 数组
- 为每一个 exportInfo 对象执行 compilation.getDependencyReferencedExports 方法,确定其对应的 dependency 对象有否被其它模块使用
源码: node_modules/webpack/lib/FlagDependencyUsagePlugin.js 216行
- 被任意模块使用到的导出值,调用 exportInfo.setUsedConditionally 方法将其标记为已被使用
源码: node_modules/webpack/lib/FlagDependencyUsagePlugin.js 133行
- exportInfo.setUsedConditionally 内部修改 exportInfo._usedInRuntime 属性,记录该导出被如何使用
源码: node_modules/webpack/lib/ExportsInfo.js 955行
该阶段主要目的: ExportsInfo => 标记_usedInRuntime
上面是极度简化过的版本,中间还存在非常多的分支逻辑与复杂的集合操作,我们抓住重点:标记模块导出这一操作集中在 FlagDependencyUsagePlugin 插件中,执行结果最终会记录在模块导出语句对应的 exportInfo._usedInRuntime 字典中。
生成阶段
经过前面的收集与标记步骤后,Webpack 已经在 ModuleGraph 体系中清楚地记录了每个模块都导出了哪些值,每个导出值又没那块模块所使用。接下来,Webpack 会根据导出值的使用情况生成不同的代码,这一段生成逻辑均由导出语句对应的 HarmonyExportXXXDependency 类实现,大体的流程:
- 打包阶段,调用 HarmonyExportXXXDependency.Template.apply 方法生成代码
- 在 apply 方法内,读取 ModuleGraph 中存储的 exportsInfo 信息,判断哪些导出值被使用,哪些未被使用
源码:node_modules/webpack/lib/dependencies/HarmonyExportExpressionDependency.js 154 行
- 对已经被使用及未被使用的导出值,分别创建对应的 HarmonyExportInitFragment 对象,保存到 initFragments 数组
- 遍历 initFragments 数组,生成最终结果
- 调用Terser-webpack-plugin 对unused haramoy和标记/#PURE/的未调用的函数进行删除
该阶段主要目的:exportsInfo => initFragments => Delete Dead Code
基本上,这一步的逻辑就是用前面收集好的 exportsInfo 对象未模块的导出值分别生成导出语句。经过前面几步操作之后,模块导出列表中未被使用的值都不会定义在 webpack_exports 对象中,形成一段不可能被执行的 Dead Code 效果,在此之后,将由 Terser、UglifyJS 等 DCE 工具“摇”掉这部分无效代码,构成完整的 Tree Shaking 操作。
📌
一句话总结:Tree-Shaking 的过程类似于JS的GC标记清除算法:分析 -> 标记 -> 清除
三、最佳实践
替换压缩工具,减少使用UglifyJS
从副作用中我们了解到,UglifyJS 缺少程序流分析。对构造函数识别Tree-Shaking的副作用无法处理。Webpack4以上已经替换为Terser内置的压缩工具,可通过升级版本使用。
使用支持 Tree Shaking 的包
如果可以的话,应尽量使用支持 Tree Shaking 的 npm 包,例如:
- 使用 lodash-es 替代 lodash ,或者使用 babel-plugin-lodash 实现类似效果
不过,并不是所有 npm 包都存在 Tree Shaking 的空间,诸如 React、Vue2 一类的框架原本已经对生产版本做了足够极致的优化,此时业务代码需要整个代码包提供的完整功能,基本上不太需要进行 Tree Shaking。
优化导出值的粒度
Tree Shaking 逻辑作用在 ESM 的 export 语句上,因此对于下面这种导出场景:
// bar.js
export default {
bar: 'bar',
foo: 'foo'
}
import * as bar from './bar'
尽量避免这种单一default导出的方式,这种引入将默认把default所有内容导出。当仅使用某个参数或方法时,Tree-Shaking无法起到作用。
四、项目优化
Typescript项目支持Tree-Shaking
回到最初的问题,为何weboffice项目tree-shaking无法生效,从生效条件开始检查,不生效的原因有两个:一、typescript是否转换为ES Modlue。 二、是否副作用导致无法生效。从第一个点就发现了项目的原因:
tsconfig配置中,生成模块方式为commonjs 所以无法识别Tree-Shaking。真相大白!
其次,另一种情况也会导致Tree-Shaking无法生效: babel-loader 配置option中preset-env 配置module
详见Babel文档,经过babel转译后如果非ES模块,则无法识别Tree-Shaking。故建议设置为false。
Q&A
- 谈谈如何阅读源码
webpack 源码浩如烟海,实际提供的功能并不只是Tree-Shaking,而我们要从中找到自己想要的东西其实并不容易。往往架构设计、兼容性等代码就容易导致我们理解成本叠加。个人认为阅读源码可以从以下几点展开:
-
- 关键词索引。
通过查找核心问题所需要的代码关键词,往往更容易找到核心代码,比如通过官方文档我们知道Tree-shaking通过useExport开启,那么useExport就成为了我们查找核心代码的关键词。从开启功能查找处入手,查找如何一步步实现。
-
- 多利用Debugger。
通过debugger 某项关键函数执行,我们很容易就能从堆栈中找到其触发流程。结合关键索引找到代码,一步步验证每个阶段是如何处理,就能相对容易分析出其执行原理。
-
- 折叠代码,找出核心,排除干扰项
- 理解命名意义,查注释,找出核心,排除干扰项
上面两点都是为了减少干扰项,让我们只关注核心。那么如何排除干扰项,往往就需要靠梳理代码结构,理解作者命名意义入手。即折叠代码块,理解函数、变量名意义,这也就能加快我们理解其源码意义,进一步加快找到核心。
- 何为程序流分析?
程序流分析一词来源于ISSUE -- IIFE 中的类声明被视为副作用
UglifyJS作者kzc表明在类通过IIFE执行,没有程序流分析,无法保证二次执行是否存在副作用,所以做最坏打算处理,按存在副作用处理。
从上述我们可以知道程序流分析类似一个简单的程序执行解析。相当于引用模块做一次简单执行,判断模块执行内容有无对作用域外的变量进行变更,排除是否有副作用。
那么webpack调用 terser来解决了这一问题,到底是如何实现的呢?暂未研究,还待进一步分析。