最近对Tree-shaking做了不少的了解和研究,这里做一些个人总结,方便以后遗忘时进行复习。

什么是Tree-shaking

术语解释

Tree-shaking是一种用于消除JavaScript上下文中的未引用代码(dead-code)的优化技术。它是一种基于ES6模块的静态代码分析技术,可以在编译时就进行代码的消除,从而减少代码体积。

通俗解释

从英文字面意思“摇树”上来看,就是把一棵树上的不需要的叶子摇下来,只保留需要的叶子。在编程语言中,Tree-shaking就是指在打包的时候,把不需要的代码摇下来,只保留需要的代码。

Tree-shaking的原理

早期大部分node包都是commonJS格式,也就是通过require来引入。它有一个致命的缺陷:因为它的动态性,无法在编译时就确定依赖关系,所以很难找到不用的代码进行tree-shaking。

到了ESModule时代,Tree-shaking变得容易起来。
tree-shaking概念最早是由Rollup.js提出的,它就是利用ESM的静态优势,通过静态分析找出模块之间的依赖关系然后把没有用到的代码去掉。

那么早期没有ESM,tree-shaking技术的时候,怎么做到按需打包?

在Tree-shaking没有兴起的时候,前端库在打包的时候可以不进行bundle,每个组件都保留成单独的文件,引入的时候也需要按照文件目录引入。也就是通过“文件隔离”来达到静态性。这种方式的缺点是:引入的路径会非常长,增加了学习成本,不够优雅。

import { Button } from 'antd/lib/button'
import { Table } from 'antd/lib/table'

所以后来前端库在按需引入时会同时提供一个插件,它会帮你自动匹配文件目录,然后引入。显而易见这种方式的缺点是:需要在项目中配置额外的插件。

import { Button } from 'antd'
// 通过插件自动转换成👇
var Button = require('antd/lib/button')

不管配置插件有多简单,只要多一个步骤,总会产生新的问题,给开发者增加学习成本。

可以打开 https://github.com/umijs/babel-plugin-import 看看,相信你也无法在10分钟内看懂它的用法并成功跑起来,这就是多余的学习成本。

在ESM时代,也同时进入了tree-shaking的时代

好在有ESM的天然静态优势加持,我们现在可以既bundle,又保留tree-shaking的能力。这一切对于使用库的开发者来说,都是自动实现的,没有额外的学习成本。

Rollup官网示例:tree-shaking

⚠️注意事项

tree-shaking的粒度,以被export的资源为单位。
在开发你的库时,你需要注意以下几点:

1. 注意函数的副作用

// utils.js

export const zero = 0

export const getValue = (a) => {
  return a.value
}

// 这里有副作用
export const five = getValue({value: 5})

就算没有引入five,但是getValue函数会被执行,这就是副作用。为了保证shake后的结果/行为一致,就必须要保留这段代码函数。

为什么?因为这个函数可能会有副作用,比如说修改全局变量,或者是修改DOM,或者是发送网络请求等等。然后其它地方的代码可能会依赖这些被修改的数据。

// main.js
import { zero } from './utils'

console.log(zero)

所以最后的产物输出为:

const zero = 0

const getValue = (a) => {
  return a.value
}

getValue({value: 5})

console.log(zero)

要避免这种情况,就需要告诉bundler,这个函数没有副作用。目前有两种方式实现:

1.1 pacakge.json中的 sideEffects属性

// pacakge.json
{
  "name": "myPkg",
  "sideEffects": false
}

https://webpack.js.org/guides/tree-shaking/#mark-the-file-as-side-effect-free

这种方式最早由webpack提出,但是后来被rollup也支持了。这种方式的优点是简单,缺点是粒度太粗,只能针对整个包进行设置。如果你的包中有一些函数是有副作用的,而另一些函数是没有副作用的,那就要用到第二种方法。

1.2 魔法注释(magic comment)

通过 /*#__PURE__*/ 注释告诉bundler,这个函数没有副作用。

// utils.js

export const zero = 0
export const getValue = (a) => {
  return a.value
}

export const five = /* #__PURE__ */ getValue({value: 5})

2. 不要用「对象」或者「Class」等等任何动态变量来封装模块

将所有你认为需要按需的资源,都通过export暴露出来。一定不要用对象或者Class等等任何动态变量来封装模块。因为前面说过,tree-shaking的粒度是以被export的资源为单位。

查看rollup在线示例


改造后,未用到的bar函数被成功“shake”掉了。

3. 如果产物是单个bundle,那么注意不要封装子模块,否则会失去tree-shaking的能力。

因为在同一个文件下,ESM并没有提供静态的导出子模块的能力。也就是并没有提供大概以下的语法:

const foo = 'foo'
const bar = 'bar'

const a = 1
const b = 2

export { foo, bar } as subModule1
export { a, b } as subModule2

所以打包工具(rollup/webpack)在bundle的时候,会将子模块封装到一个对象中,然后再导出。这样整个子模块就变成了一个被exprot的变量,它的shake粒度就是整个子模块,而不是子模块中的某个函数。那么开发者在使用你这个模块的时候,自然就没有了按需引入的能力。

如图所示,我们将output产物作为一个包引入,最后得到如下结果:
我们只用到了sub1.foo函数,而sub1被整个打包进output产物了。

4. 代码被 bundle / 打包 的时机

在当今的前端工程化中,我们的代码最终会被各种loader、plugin、打包工具等等处理,将一类代码编译转换成另外一类代码,比如ts => js,esm => cjs,或者是通过做babel兼容。

如上图的编译结果,虽然我们通过export静态导出了一个class,但是在最终的代码中,我们发现它被打包成了一个对象,然后再通过module.exports导出。并且Animal被编译成了一个立即执行函数,这里就产生了副作用。不过目前的babel已经足够先进,帮我们添加了之前提到了#__PURE__注释。

所以最好在打包的时候,将bundle阶段(tree-shake阶段),放在当前被转换的代码还是 ESM 的阶段,然后把polyfill等其它转换操作放在之后。

小结

Tree-shaking已经被现代的rollup/webpack等其它bundler所默认支持,其核心就是 ESM 的静态import / export。 在大多数情况下,开发者在编写代码时只需要注意编写规范,就已经能够得到良好的tree-shaking能力了。