浅谈 ES 模块和 Webpack Tree-shaking

浅谈 ES 模块和 Webpack Tree-shaking

 

这是我在参加 GSoC(Google Summer of Code) 2018 里的项目:为 webpack 改进 tree-shaking。本人是 19 届应届生,目前在今日头条效率工程部(深圳研发)实习。

Tree-shaking ???? 是前端比较重要的技术之一,因为减少代码包的体积意味着减少每一次网络传输的耗时,对用户体验有比较大的提升。对于一个包管理工具来说,DCE 是必不可少的 feature 之一了。

Tree-shaking 最早由打包工具 Rollup 提出,而作者也在一篇 Medium 文章解释了 Tree-shaking 和 DCE 的区别:

Rather than excluding dead code, we’re including live code.

Webpack 的 maintainer 之一 Tobias ????也跟我解释了他的看法,DCE 作用于模块内(webpack 的 DCE 通过 UglifyJS 完成),而 Tree-shaking 则是在打包的时候通过模块之间的信息打包必须的代码。

Webpack 与 Tree-shaking

Webpack 从 2 开始也支持 Tree-shaking,对于一个模块,没有被使用过的引入代码并不会被打包 ????:

浅谈 ES 模块和 Webpack Tree-shaking

上图代码中,变量 isString 并没有被使用,所以 webpack 的 tree-shaking 功能不会把 isString打包进来。但是这样做的作用不大,???? 上图中 VSCode 已经用透明度提醒了写代码的人这个变量没有用到,加上 eslint 等工具的提示,一般人不会在代码里面引入用不到的变量。所以 webpack 需要更加强大的 tree-shaking 机制。

我们看如下代码,VSCode 对引入变量是不会有透明度提示的,因为对于这个模块所有引入都被用到了:

file1.js:

浅谈 ES 模块和 Webpack Tree-shaking

index.js:

浅谈 ES 模块和 Webpack Tree-shaking

file1 中导出两个变量,分别使用了两个导入变量,但是在 entry(index.js) 中我们只使用了 one ,那么,webpack 是否会把 two 和 isString 打包进来呢?答案是是的 ☑️,因为目前来说 webpack 并不支持这种级别的 tree-shaking。这也是今年年初 webpack 一个 issue 指出的问题。

解决办法

解决办法就是使用我的作用域分析插件 ????:Github 地址

对于上文中提到的代码,使用插件使用前:

浅谈 ES 模块和 Webpack Tree-shaking

使用后 ????:

浅谈 ES 模块和 Webpack Tree-shaking

使用方法:

浅谈 ES 模块和 Webpack Tree-shaking

这里看到减少的体积不多,是因为引入的代码本来就不多,所以效果不明显,但是插件消去的代码就是那个没有用到的 ???? isString ????。

插件的原理

这个之所以能够实现,靠的是 ES6 优秀的模块设计 ????。CommonJS 的设计过于灵活,对静态分析不友好。ES6 module 则有诸多限制:比如说只能在文件的顶部 import(CommonJS 的 require 语法允许在文件的任意位置调用),export { ... } 语法保证了导出的变量不会是 getter/setter 之类奇怪的东西(这个 block 不是一个 Object),变量也不能被重新绑定。以上种种设计可以让分析器一定程度上判断出导入和导出变量的关系,让这个插件的实现成为了可能。

而插件本身的原理则是作用域分析。在编译器领域,还有超级多各种高大上的静态分析方法(比如说数据流分析),但是对于 ES 来说,他们实现的难度太大。据我所知,现在还没有针对 JS 的,能在生产环境能用的,基于数据流分析的优化器。这也是为啥现在这些打包器还不能去除没有用到的类成员方法(class method)。

所谓作用域分析,就是可以分析出代码里面变量所属的作用域以及他们之间的引用关系。有了这些信息,就可以推导出导出变量导入变量之间的引用关系。我在 Medium 里面贴的一张我自己画的图片可能能说明插件的原理:

浅谈 ES 模块和 Webpack Tree-shaking

而对于 webpack 来说,webpack 可以通过 entry 和 module 之间的调用得知对于一个 module 来说,哪个变量是会被使用到的。就如同上文的例子 ????:我的插件可以从 webpack 得知 file1.js的导出变量 one 被使用了。我的插件通过分析出模块中的作用域,遍历引用到的作用域,找到真正需要 import 的变量,比如说 isNumber,然后再把结果返回 webpack。

当然这一切也得益于 webpack 优秀的插件机制,让这一个 feature 可以通过插件来解决。

合理模块设计才是减少代码体积的关键

Tree-shaing 其实只是一个打包器的特性,但是工具始终只是工具,良好的模块拆分才是减少代码体积的关键 ????‍????。

对于 ES6 模块来说,会有 defaut export 和 named export 的区别。有些开发者喜欢把所有东西都弄成一个对象塞到 default 里面。Default export 在概念上来说并不仅仅一个名字叫做 default 的 export,虽然它会被这样转译。把一切东西都塞到 default 里面是一个错误的选择,会让 tree-shaking 无效。从语意上来说,default export 用来说明这个模块是什么,named export 用来说明这个模块有什么。合理的模块拆分是一定可以让编译器只打包到所需的代码的。

另一方面 ????,插件本身也有许多注意事项:

  • 使用 ES6 Module:不仅是项目本身,引入的库最好也是 es 版本,比如用 lodash-es 代替 lodash。另外注意 TypeScript 和 Babel 的配置是否会把代码编译成非 es module 版本。
  • 最纯函数调用使用 PURE 注释:由于无法判断副作用,所以对于导出的函数调用最好使用 PURE 注释,不过一般来说有相关的 babel 插件自动添加。

总结

插件发布之后,有人跟我反馈说这个插件帮助他们项目打包减少了 1m 多的体积 ????,也有人跟我反馈说一点体积都没有减少 ????。对于后者,一方面是可能是有些项目本身设计得很好,引入的代码都是有用的,这个插件已经没啥好优化了,也有可能是代码本身被编译成其他 module 导致分析不生效。另一方面,对于 webpack 来说,这可以算是一个在 production mode 下很重要的 feature 了。我和 webpack 的 maintainer 沟通过,有望在不久之后会在 webpack 的 production mode 默认开启这个插件 ????????????。