什么是 tree shaking
想必前端的同学或多或少对于 Tree Shaking 都会了解一点。随着项目的扩大,引用第三方库或者是不同入口文件引入项目中的公用方法,一定会遇到只引用其中一部分方法的 case。而 Tree Shaking 就是在打包的时候,将这些第三方库或者是公用模块里没有被引用或者使用的代码删除的一种技术。通过删除无用代码,减少最后包的体积,从而减少用户的等待时间,所以 Tree Shaking 是前端一种必要的优化手段。
其实 Tree Shaking 是 Dead Code Elimination(DCE) 的一种表现。DCE 在上世纪90年代的时候就提出了,然而在前端届,在 ES6 出现后,才被 Rollup 在 webpack2 中引入。那么为什么直到 ES6 之后,前端才开始有 Tree Shaking 呢?是因为 Tree Shaking 依赖于 ES6 中的 import / export。
为什么依赖于 import / export
首先看一下前端模块化这个概念。
在 ES6 出 ESM 之前,前端主要有三个模块化规范:
- AMD
- CMD
- CommonJs
其中 AMD 和 CMD 都是异步加载(这里异步指的是不堵塞浏览器其他任务:dom构建,css渲染等),这里就不多说了。主要看的是同步加载的 CommonJs 为什么不能被 Tree Shaking,而 ESM 却可以。
原因是因为CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
CommonJs 输出的是对象,输入时必须查找对象属性,只有运行时才能得到这个对象,知道引入的是什么模块,所以完全没有办法在编译时“静态优化”
const { a, b } = require(‘module’);
// 等同于
const _module = require(‘module’);
const a = _module.a;
const b = _module.b;
这段代码的实质是整体加载 module 模块(所有方法),生成一个对象 _module,再从这个对象里读取 a 和 b。
而 ES6 Modules 模块依赖关系是确定的,和运行时的状态无关。import 命令会被 Javascript 引擎静态分析,静态分析时,遇到 import,会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
tree shaking 原理是什么
- tree shaking 只能在静态 modules 下工作,ES6 Module 引入静态分析,所以编译的时候整个依赖树可以被静态地推导出解析语法树,可以正确判断到底加载了哪些模块
- 静态分析程序流,判断哪些模块和变量未被使用或者引用,进而删除对应代码
接入 tree shaking
1、sideEffects
package.json 的 "sideEffects" 标记出有副作用的代码,告知 webpack 可以安全地删除未用到的 export。
{
// 所有代码都不包含副作用
"sideEffects": false
// 某些代码有副作用
"sideEffects": [
"*.scss",
"./src/some-side-effectful-file.js"
]
}
那么什么是副作用呢。
side effect(副作用) 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。只有当函数给定输入后,产生相应的输出,而不修改任何外部的东西,才可以认为是安全的。像 polyfill,它影响全局作用域,并且通常不提供 export,所以是有副作用的。
如果被标记为无副作用的模块没有被直接导出使用,打包工具会跳过进行模块的副作用分析评估。
来看一下下面 webpack 给的官方例子。
// index.js
import './configure';
export * from './types';
export * from './components';
// components/index.js
export { default as Breadcrumbs } from './Breadcrumbs';
export { default as Button, buttonFrom, buttonsFrom } from './Button';
export { default as ButtonGroup } from './ButtonGroup';
// package.json
{
"sideEffects": [
"**/*.css",
"**/*.scss",
"./index.js",
"./configure.js"
],
}
对于 import { Button } from "./index" 来说:
- index.js: 没有直接导出被使用,但被标记为有副作用 -> 导入
- configure.js: 没有导出被使用,但被标记为有副作用 -> 导入
- types/index.js: 没有导出被使用,没有被标记为有副作用 -> 不导入
- components/index.js: 没有导出被使用,没有被标记为有副作用,但重新导出的导出内容被使用了 -> 跳过
- components/Breadcrumbs.js: 没有导出被使用,没有被标记为有副作用 -> 不导入。
(所有 components/Breadcrumbs.css 的依赖也不会被倒导入,尽管 css 文件都被标记为有副作用。) - components/Button.js: 直接的导出被使用,没有被标记为有副作用 -> 导入
- components/Button.css: 没有导出被使用,但被标记为有副作用 -> 导入
这里要注意的是 sideEffects: false 的意思并不是这个模块真的没有副作用,而只是为了在 tree shaking 的时候告诉 webpack:这个包在设计的时候就是期望没有副作用的,即使打完包后是有副作用的,也可以放心当成无副作用 shaking 就行。
2、ModuleConcatenationPlugin
在使用 tree shaking 时必须有 ModuleConcatenationPlugin 的支持,可以通过设置配置项 mode: "production" 以启用它;也可以直接手动引入
3、禁止 @babel/preset-env 把 ES6 模块语法转为 CommonJS
@babel/preset-env 现在的默认行为,会把 ES6 编译到可兼容性更好的 CommonJS。前面说过了,Tree Shaking 对 CommonJs 并不能支持。
presets: [
[
'@babel/preset-env',
{
modules: false
}
]
]
webpack4 VS webpack5
最后再讲一下 Tree Shaking 在 webpack4 和 webpack5 中的不同。
webpack4 本身的 Tree Shaking 比较简单,主要是找一个 import 进来的变量是否在这个模块内出现过,这种方法非常简单粗暴。但这种方式往往作用不大,因为代码里一般不会去 import 一个没有用到的变量,而且现在都有 lint 工具提示这种无用变量。所以其实真正遇到的场景不会很多。
- webpack 4 没有分析模块的导出和引用之间的依赖关系。webpack 5 有一个新的选项 optimization.innerGraph,在生产模式下是默认启用的,它可以对模块中的标志进行分析,找出导出和引用之间的依赖关系。
- webpack5 能够跟踪对导出的嵌套属性的访问。这可以改善重新导出命名空间对象时的 Tree Shaking(清除未使用的导出和混淆导出)。
- webpack5 增加了对一些 CommonJs 构造的支持,允许消除未使用的 CommonJs 导出,并从 require() 调用中跟踪引用的导出名称。
项目结合
那么平时在项目中时要注意什么?
- 尽量不写带有副作用的代码,比如编写了立即执行函数,在函数里又使用了外部变量等
- 难以避免的产生各种副作用代码,可以将功能函数或者组件,打包成单独的文件或目录,以便于用户可以通过目录去加载
- 如果对ES6语义特性要求不是特别严格,可以开启babel的loose模式,如:是否真的要不可枚举class的属性。
- 区分 export default / export 的导出方式
可以看出,用 export 和 export default 导出同一个对象,最后编译成的文件里,export 会直接打出用到的属性,其他没有用到的属性都被 Tree Shaking 掉了;而 export default 会直接导出这个对象所有的属性和方法,即使没有被调用,也无法被 Tree Shaking。
参考文档
Tree Shaking | webpack 中文文档
前端模块化:CommonJS,AMD,CMD,ES6
Tree-Shaking性能优化实践 - 原理篇
Tree shaking & 初识 Webpack 5 - SegmentFault 思否
你的Tree-Shaking并没什么卵用 - SegmentFault 思否