少年阿布DX

为什么 WebPack 2 的 Tree Shaking 并不如你想的高效 - Advanced Web Machinery

少年阿布DX · 2017-02-23翻译 · 749阅读 原文链接

随着 WebPack 2 滚桶翻来(译者注:原文作者使用了 barrel forward,这是炉石传说里的一张技能卡,大陆翻译是滚桶翻),Tree Shaking —— 说得学术一点儿,使用静态分析移除未使用的导出对象 —— 正在进入主流。由于它承诺解决包的臃肿这个紧迫的问题。通常我们只需要每个依赖的一小部分代码,但依赖的所有代码都被打包,导致包的大小增大。

Tree Shaking 承诺在构建过程中消除这个麻烦,使得开发者可以无忧地添加依赖而不用担心用户的体验。

但甚至一个快速搜索都表明了它在实践中可能并没有工作地那么好。

是什么造成了这种差异?我们去找出来吧!

Tree Shaking 基础知识

我们先考虑一个关于 Tree Shaking 如何运行的例子。由于它优化了导出对象,那我们就创建 lib.js,导出出两个变量:

export const a = "A_VAL_ES6";
export const b = "B_VAL_ES6";

Then add an entry.js, which imports them but then uses only a:

然后再创建 entry.js 导入它们,但是只使用 a

import {a, b} from "./lib.js";

console.log(a);

由于 bentry.js 中并没有使用,所以它可以被移除,但这没什么新鲜的;UglifyJS 已经做了这个优化。

但导出的 blib.js 中也没被使用,因此也可以移除。观察打的包,发现 _A_VALES6 在,但 _B_VALES6 不在。

Rollup

谈到 Tree Shaking,我就不得不提及 rollup.js。据我所知,这是第一个支持移除导出对象理念的成熟打包器。它已经面世一年多了,但我现在还没听说过节省大量字节的成功故事。

搭建测试

如果你想重现结果的话,这儿有代码提供。

Lodash-es 是 Lodash 兼容 ES6 的版本。它拥有同样的功能,但使用的是 ES6 模块,而非 UMD 模块。它的主文件简单地重新导出每个模块。因为 Lodash 是个“工具腰带”的集合(译者注:最闻名的是蝙蝠侠的工具腰带,utility belt),所以无论是包含独立模块或是整个包都无关紧要。

因此,import {map} from "lodash-es";import map from "lodash-es/map"; 应该是等价的。

为搭建测试环境,我们创建包含 WebPack、Babel 与 lodash-es 依赖的 package.json 文件:

{
  // ...
  "devDependencies": {
    "webpack": "2.2.0",
    "babel-core": "6.16.0",
    "babel-loader": "6.2.7",
    "babel-preset-es2015": "6.22.0",
    "lodash-es": "4.17.4"
  },
  // ...
}

然后再创建 webpack.config.js,提供少量 WebPack 的启动配置:

  // ...
  loader: 'babel-loader',
  options: {
    presets: [['es2015', {modules: false}]]
  }
  // ...
  plugins: [
    new webpack.LoaderOptionsPlugin({
      minimize: true
    }),
    new webpack.optimize.UglifyJsPlugin({
    })
  ]
  //...

注意 {modules: false} 这部分配置。Babel 会把所有模块都默认转成 CommonJS。尽管这是实现最大化兼容的极佳方式,但它阻止了导出对象分析。这个配置关闭了 Babel 这个默认行为,因为 WebPack 自 2.0 以来已经支持原生的 ES6 模块了。

在所有样板代码都就位后,创建带有 importentry.js 文件:

import {map} from "lodash-es";

console.log(map([1, 2], function(i) {return i + 1}));

执行 npm run bundle 然后观察打的包,它的大小为 139.224 字节。

然后改为导入独立的模块

import map from "lodash-es/map";

结果包只有 25.531 字节。

这意味着 Tree Shaking 在真实场景中相对于人工优化更为低效。

模块

这份代码 能用来驱动两种模块格式的测试。

CommonJS

为理解静态分析的限制,我们需要深究 CommonJS 与 ES6 模块之间的差异。

注:大部分库通常为了更好的兼容性都会选择 UMD 格式,它们通常会被解释为 AMD 而不是 CommonJS。但由于它们在这个缺陷(译者:包含独立模块与整个包)上表现一致,而 CommonJS 又更常见,所以我会用它来解释。

在 CommonJS 环境下,exports 对象是至关重要的。在代码运行完成后,exports 对象的内容会被导出。

一个简单的场景,我们很常会设置属性:

exports.a = "A_VAL_COMMONJS";
exports.b = "B_VAL_COMMONJS";

这会导出 ab

但 CommonJS 也可以动态地设置 exports 对象。

for(let i = 0; i < 5; i++) {
  exports["i" + i] = i;
}

这会导出 i0i1i2i3i4

它甚至不要求的 exports 值的是确定的:

if (Math.random() < 0.5) {
  exports.rand = "RANDOM";
}

这完全基于运气来导出 rand

CommonJS 动态的天性很好地适应了 JavaScript 动态的天性,但这完全使静态分析落空。

举例来说,创建 lib_commonjs.js,导出两个值:

exports.a = "A_VAL_COMMONJS";
exports.b = "B_VAL_COMMONJS";

然后修改 entry.js 来导入它们,但只使用其中一个:

import {a as a_commonjs, b as b_commonjs} from "./lib_commonjs.js";

console.log("Hello world:" + a_commonjs);

在打包后观察结果,发现 A_VAL_COMMONJSB_VAL_COMMONJS 都在文件中;没有东西被删掉。

这表明 Tree Shaking 对任何 CommonJS 模块都无效。

由于许多库都已 AMD / CommonJS 格式导出,所以在这个现象改变之前,是不会有什么大的进展的。

ES6

ES6 模块生来就是静态的。它们必须是确定的,不允许任何动态的导出。这才打开了静态分析之门。

举例来看,我们已经有 lib.js 导出了两个值:

export const a = "A_VAL_ES6";
export const b = "B_VAL_ES6";

然后修改 entry.js 来导入两者,但只用一个:

import {a as a_es6, b as b_es6} from "./lib.js";

console.log("Hello world:" + a_es6);

观察打包结果,我们发现只有 A_VAL_ES6b 的值已经被删掉了。

问题

这个问题的症结就是副作用。在很多用例下导入一个库并不一定会造成一段有界代码,与整个应用的其他部分分离。举个例子,使用 css-loaderimport css from 'file.css';,变量的内容并不重要,但其样式已经应用到文档中了。

如果 WebPack 无视副作用而移除了所有未使用的依赖,像这样的导入就会造成崩溃。所以,副作用必须被保留。它们在提供预期的功能的同时也在写控制台、添加样式标签或者用别的方式修改 HTML、赋值给全局变量等等。

但还有另一类代码,不适合被认定为副作用。Object.freeze 并不会修改任何东西,所以所有调用它的函数都是纯的。这些应该被删除。

举例来说,修改 lib.js 导出冷冻版的值:

export const b = Object.freeze("B_VAL_ES6");

这微小的改变导致在包中会包含 B_VAL_ES6

同样,一个简单的函数调用也可以触发这个行为。

Object.freeze(b);

这将导致打的包会包含几乎整个库,即使并没有副作用。

副作用很难合适地辨认。但这儿有一些正在进行工作

结论

打包器选择了安全的路径,选择包括不需要的代码而不是使应用崩溃。这导致打的包更大,代码更低效。

但 Tree Shaking 还是可能可以节省下一些字节的,这仍是一件好事;所有能为 webapp 节省带宽的事物都是在正确道路上的变革。但在实践中,它的效率相比于期待来说大打折扣。

人们正在致力于找到更好的鉴别副作用的解决方案。但他们的动态天性阻碍了对所有代码都能生效的万灵药。不过,这肯定能给人以启发。这儿还有一个提议,说可以标注纯函数,但得到社区范围的支持不太可能很快就发生。

Tree Shaking 可能有一点儿帮助,但小的包还离我们很远呢。

更新

jdalton 指出 babel-plugin-lodashlodash-webpack-plugin 决定了你实际上会从 Lodash 里采摘哪颗樱桃(译者注:作者用到 cherry-pick 这个词,直译就是采樱桃,它还有别的含义——一种非形式谬误,系指刻意挑选支持论点的资料呈现,而将重要但不支持论点的资料忽略不计。可以参考维基百科~)。为了能节省一些字节,他们绝对值得一试。

相关文章