webpack 如何通过作用域分析消除无用代码

GSoC 2018 中,我的项目就在于给 webpack 实现深作用域分析(Deep Scope Analysis),主要还是为了改进 webpack 的 tree-shaking 工作。

前言

JS 的 tree-shaking 一直是前端开发中的痛点,大家都在想尽办法减少打包的代码体积。Tree shaking 是一个帮助在不同模块之间消除无用代码的 feature。在编译原理中,我们把这项技术叫做 DCE(dead code elimination)。但是 DCE 和 tree shaking 有些许不同,按照 Tobias 的说法,tree shaking 主要应用于于模块(module)之间,用于帮助进行 DCE(webpack 的 DEC 通过 uglify 完成),rollup 的作者也曾经提到, tree shaking 是打包的过程中抽取有用的部分,别的部分像树叶一样落下,所以叫 tree shaking。

项目地址

从前

webpack 本身的 tree shaking 比较简单,主要是找一个 import 进来的变量是否在这个模块内出现过,非常简单粗暴。但是这种方式往往作用不大,因为一般人不会去 import 一个没有用到的变量。比较多的情况是可能曾经引用过,但是忘了删掉。现在的编辑器和 lint 工具都会提示你去删掉无用的变量,所以 webpack 本身的 tree shaking 功能是不够强大的。

在上面的例子中,变量 isNumber 并没有被引用到,所以会被消去。

开端

在今年年初,webpack 项目下面有一个 issue 提到了 webpack 打包了多余的代码和模块。但是这也为优化 tree-shaking 提供了一个思路,就是找到作用域之间的关系,来进行优化。

在上面的例子中,其实 function2 和整个 external2 都可以被消去,因为 function2 并没有被 entry 引用到。但是目前 webpack 的机制不能做到这一点。借助于 webpack 强大的插件极致,我的插件就可以帮助 webpack 做到。

我的插件做了什么

插件包括了一个作用域分析器,可以分析一个模块里面的作用域,从此我们可以得到不同作用域之间变量的引用关系。当我们知道一个作用域是否会被使用,就可以因此而推断出这个作用域做引用的其他作用域是否也会被使用。这就是作用域分析器帮助消除无用代码的原理。

什么是作用域

下面的代码列举了 JS 中会新建一个作用域的代码:

对于 ES6 模块来说,module scope 是最底层的作用域。而对于一个模块来说,只有 class 和 function 的作用域是可以导出到其他模块的。所以在这张需要遍历的图里面,并不是所有的作用域都可以被当作一个独立的遍历结点,像 if-else 作用域其实是归属于父作用域的。

插件的工作原理

在我们去分析作用域之间的引用关系之前,我们先需要去分析代码的作用域。代码的作用域分析建立在 AST(Abstract Syntax Tree) 之上。在这里,我借助了一个叫 escope 的工具。

解析完之后,其实就是图的深度遍历,找到那些作用域是会被使用到了,哪些是可以消去的。

因为这个插件可以从导出的作用域之间分析出这些导出的作用域和导入变量之间的关系,也就是说。只要知道哪些导出作用域被使用的到,那么就知道哪些导入变量被引用,那些没有被引用。

另一方面,webpack 本身是可以分析出模块之间的变量引用关系的,从 webpack 我可以得知一个模块哪些导出变量是被用到的,这是 webpack 4 的新 feature。所以我的插件 tap 上了 webpack 相应的 hook,获取这个模块中会被其他模块引用的导出变量,返回给 webpack 哪些引入的变量被用到,这样 webpack 就可以根据我的插件的信息进行更完善的 tree-shaking。

Edge cases

实际上,JavaScript 的分析有很多 Edge cases 会导致代码不会被消去,这里列举一些比较常见的:

同时提供一个 Demo 来给大家尝试。

根作用域的引用

在根作用域引用到的作用域不会被消除。

给变量重新赋值 👎

因为缺少数据流分析,对变量重新赋值的作用域不会被消去。在上面的例子中,因为对 fun 变量进行了重新赋值,所以 isNull 无论如何都会被引入。

纯函数调用 👍

如果一个匿名函数被包在一个函数调用中,那么其实这个插件是无法分析的,像上面的例子。但是如果加上了 PURE 注释的话,这个插件会把这个函数调用当作一个独立的域,所以在上述的例子中,tree-shaking 是会生效的。

实际使用的过程中应该注意什么

深作用域分析原理很简单,实现起来也不复杂,但是真的要使用再实际项目的过程中,却有很多要注意的地方:

一、必须使用 ES6 的 import/export 模块机制。

其实整个深作用域分析都是基于 ES6 模块完成的,也就是说深作用域分析无法分析 CommonJS 和 AMD 等等模块规范。这个时候,就要求项目中引用的模块都遵循 ES6 的规范,比如使用 lodash-es 代替 lodash。另外就是要注意 babel-loader 和 TypeScript 的设置,是否会把代码转换到 ES5 语法,导致深作用域分析失效。

二、学会使用 PURE 注释。

由于 JS 语法的复杂程度,webpack 没有打算给 JS 实现数据流分析,所以插件是无法知道一个函数调用是否具有副作用的。所以对于一些导出模块,如果是纯的函数调用,则需要加上 /*@__PURE__*/ 注释来表明这个函数是 pure 的,这是 Uglify 使用的方法。当然也可以使用相关的 babel 插件进行批量添加。

总结

其实我这插件的实现是归根于 ES6 中良好的 import/export 语法的设计的。相信很多前端大佬都提到,就是模块的设计一定要合理。tree shaking 再强大它也只是一个编译器的工具,如果模块设计不合理,它一样会在打包的时候引入很多无用的代码。一个合理设计的模块一定能借助 tree shaking 机制只引入它需要的代码。