Day15 X Tree Shaking

在昨天我们学会了 code splitting 与 dynamic import 的技巧,让程序在打包时可以形成好几个 bundle chunks,并在真的需要使用时才载入对应的 chunks。这些方法确实让效能与载入时间优化蛮多的,不过你有没有想过,开发专案免不了会需要下载第三方套件来节省自己重复造轮子的成本,然而也许某些状况我们只会使用一个套件模组之中的特定几个 function,其他的 function 几乎都不会用到。不过如果我们为了这几个 function 而要载入整个模组,就似乎有点得不偿失了,这时候 Tree shaking 就会是我们的救星了。

什麽是 Tree Shaking ?

其实这个技巧跟字面上的意思很像,当用力摇一棵树时可能会把很笨重的果实给摇落,在程序面来说就是把「用不到的程序码给摇落下来」,上面的例子讲到我们可能会为了几个特定函式而需要载入整个套件,运用 Tree Shaking 之後,可以让打包工具在打包阶段就可以分析哪些 code 或哪些 function 是用不到的,而把它们从最终的 bundle 中剔除,换句话说就是确保最後的 bundle 不会包含无用或多余的程序码与资源,减少 bundle size。

Tree Shaking VS Dead Code Elimination

如果你有接触过编译器的原理,应该会听过死码删除(dead code elimination)这个技巧,看起来两者是一样的东西,不过其实本质上是不同的。

死码删除指在完成专案後把不想用到的程序码移除掉。不过仔细想想在完成专案後再去掉没用的程序码似乎是一件没那麽有效率的事,用生活化的例子来举例就好像要做车轮饼时把奶油、红豆、芋头等馅料都混在一起,等到顾客指定要哪种口味时再把其他不要的口味挑掉。(嗯...越想越觉得有点恶心...)

而 Tree Shaking,相较於去掉不必要的 code,思考方向比较像是:「只保留确定会用到的程序。」以车轮饼的例子来说,就是一开始就只加入顾客要的口味。对比 Dead Code Elimination,Tree Shaking 的实作比较像是 Live Code Inclusion。

虽说看起来两种方式最终会达到一样的结果,不过实际上由於 JavaScript 这个语言本身太过动态,在静态分析(Static Analysis)上会有一些限制,所以两种方式的结果并不会相同。

先看看没有 Tree Shaking 的状况

用 ES5 的写法写一个简单的范例

// calculate.js

exports.add_100 = function (x) {
  return x + 100;
};

exports.add_500 = function (x) {
  return x + 500;
};

不过却只使用到 add_100 这个 function

// index.js

import { add_100 } from './calculate';

add_100(10);

看看 webpack 打包後的 bundle 长什麽样子

/***/ "./src/calculate.js":
/*!**************************!*\
  !*** ./src/calculate.js ***!
  \**************************/
/***/ ((__unused_webpack_module, exports) => {

eval("exports.add_100 = function (x) {\n  return x + 100;\n};\n\nexports.add_500 = function (x) {\n  return x + 500;\n};\n\n//# sourceURL=webpack://tree-shaking-demo/./src/calculate.js?");

可以看到没有被使用到的 add_500 还是被加到 bundle 里面了,当面对的是复杂的专案的时候,很多用不到的程序被加到 bundle 里多少还是会对效能产生一些影响。

会产生这种结果的原因是在 CommonJS 规范中,如果要把 module export 出去给其他 module 使用,得透过 exports 这个 object 的 properties 的形式做输出,例如刚刚的

exports.add_100 = function (x) {
  return x + 100;
};

稍微对 JavaScript 熟悉一点的读者应该知道 JS 的 Object 其实坑很多,很多意想不到的存取属性的方法,例如:

let testObj = { ironman: 'kyle mo'};

testObj["iron" + "man"];
// -> "kyle mo"

const a = "ir";
const b = "on";
const c = "man";

testObj[a+b+c];
// -> "kyle mo"

因为这种特性,bundler 无法肯定这些 exports object 上的属性到底会不会被呼叫到,而在不清楚的状况下,直接做 tree shaking 可能会导致 runtime error,所以最保险的方式就是全部都打包到 bundle 里面。

Tree Shaking 是怎麽做到的?

能做到 Tree Shaking,主要得归功於 ES6 import export module system 的帮助。

首先来看看 ES6 module 的一些特性:

  • 只能在 module 顶层的语句出现(dynamic import 不算在内)
  • import 的 module name 不能是动态的
  • import binding 是 immutable 的

这些特性让 ES6 module 间的依赖关系是固定的,可以进行可靠的静态分析,是实现 Tree Shaking 的基础。

所谓静态分析就是不执行代码,单从字面上对程序码进行分析。在 ES6 以前使用 CommonJS 就只有执行後才知道引用了什麽模组,这种方式就不能通过静态分析去做优化。

使用 Tree Shaking 的一些 Tips

使用 bebel 时不要 transpile 成 CommonJS

在开发时通常会利用 bebel 这样的转译工具将语法转换成浏览器看得懂的格式,不过 bebel 预设是会将 import 与 export 转译成 CommonJS 的,这麽做会使 Tree Shaking 失效。需要调整一些 config 让预设的行为被 disabled 掉。

尽量让 exports 的模组保持原子性

以下三种写法是应该避免的:

  • export 一个拥有许多属性与方法的物件
  • 在 export default 一次加入许多东西
  • export 一个有许多属性与方法的类别

透过这几种方式 export 的 code 要不是全部被算进 bundle 中,就是全部一起被 Tree Shaken 掉,所以在使用上要特别注意,尽量保持输出的原子性,不然一不小心可能就在 bundle 里加了一些不会用到的 code。

// 比较不好的写法
export default {
    add_100(x) {
        return x + 100;
    },
    add_500(x) {
        return x + 500;
    }
}

// 应该改成
export function add_100(x) {
    return x + 100;
}

export function add_500(x) {
    return x + 500;
}

避免 Module Level 的 Side Effect

如果不知道 Side Effect 是什麽的读者,可以先参考这篇文章

许多人在撰写模组时会忽略 module scope side effect 带来的影响,什麽意思呢?请看范例:

function add_100(x) {
    return x + 100;
}

// 等同於 window.memorize
export const memorized_add_100 = memorize(add_100);

上面这段 code 的意思是呼叫一个全域的叫做 memorize 的 higher order function(等同於 window.memorize),并把宣告得 add_100 传进去,得到一个 memorized 版本的 add_100。

当这个模组被引入时,window.memorize 就会被呼叫,那打包工具例如 webpack 是怎麽分析这个模组的呢?让我们以打包工具的角度来分析看看:

  1. 发现模组中宣告了一个叫做 add_100 的函式,看起来是一个 pure function (同样输入都可以获得相同输出),如果之後都没人用到它,我应该可以对它做 Tree Shaking,把它从 bundle 中移除掉。
  2. 呼叫 window.memorize,并把 add_100 当作参数传进去
  3. 打包工具没办法分析出 window.memorize 会做什麽事,不排除它有呼叫 add_100 并产生 side effect 的可能性
  4. 为了安全起见,不要让程序坏掉,就算没看到有哪边有用到 memorized_add_100,还是先把 add_100 加到 bundle 里。

这时你生气了:「可是我知道 window.memorize 不会触发任何 side effect 啊!也只有 memorized_add_100 被呼叫的时候才会真的去跑 add_100,因为那可是我写的呢!」

但是,打包工具不是你,它并不知道啊!

所以,我们得利用 ES6 的 import export 特性,给打包工具多一点资讯。

// utils.js

export const memorize = () => {
    // ... memorize implementation
}

// other file
import { memorize } from './utils'

function add_100(x) {
    return x + 100;
}

// 等同於 window.memorize
export const memorized_add_100 = memorize(add_100);

现在打包工具就有足够的资讯可以分析到底会不会产生 side effect 了

  1. 发现模组中宣告了一个叫做 add_100 的函式,看起来是一个 pure function (同样输入都可以获得相同输出),如果之後都没人用到它,我应该可以对它做 Tree Shaking,把它从 bundle 中移除掉。
  2. 呼叫 memorize,并把 add_100 当作参数传进去,不知道他会不会产生 side effect,不过看起来它是从其他档案引入进来的,到那里(utils.js)看看,说不定会有什麽发现。
  3. 看起来这个 memorize function 是个 pure function 呢,应该不用担心会产生 side effect 了。
  4. 如果没看到其他地方有用到 add_100,就放心把它从 bundle 中移除掉吧!

所以说,想要打包工具做到 Tree Shaking,必须给它关於模组足够的资讯,在无法判断的条件下,它会选择最保险的作法,也就是都加到 bundle 里。

为什麽 Dynamic Import 不能 Tree Shaking ?

因为打包工具不知道这个 module 到底会不会被载入,例如说

if (isActive) {
  import('./someModule').then(module => ...);
}

以 bundler 的角度来说,isActive 这个 boolean 很可能会动态切换,所以无法在静态分析时就确定这个模组会不会被载入,为了保险起见,它就不会做 Tree Shaking。

曾经也有人在 webpack 的 github repo 询问过为什麽 Dynamic Import 不能做 Tree Shaking,webpack 的维护者也亲自出来回覆


使用第三方套件时要小心谨慎

在开头时有提过,通常载入第三方模组是为了减少重复造轮子,可以直接使用现成的功能,不过有些时候我们不会需要模组中全部的功能,这时候如果还把整包套件打包进最後的 bundle 就有点得不偿失了。

我们用实际的套件来举例,lodash 是一个非常热门的第三方套件函式库,它提供了超级多关於资料操作的 utility function,假设今天我们要使用它提供的其中一个叫做 flatten 的 util function 来将多层级阵列摊平一个层级深度,效果如下

_.flatten([1, [2, [3, [4]], 5]]);
// => [1, 2, [3, [4]], 5]

我们试着在专案中引入它,再来观察一下 webpack bundle analyzer 的状态

import { flatten } from 'lodash';

const App = () => {
  const flattenArray = flatten([1, [2, [3, [4]], 5]]);
  console.log(flattenArray);
    
  return <div>lodash test</div>
}

骗人的吧...有够肥的,我才用了一个简单的 function 耶!
我合理怀疑这个 flatten 不是普通的 flatten,一定是百年难得一见的绝世函式,拥有钢筋铁骨,才会肥成这样 ?

换成 ES module 版本的 lodash-es

这次换成官方建议的 ES module 版本的 lodash-es 试试看

npm i lodash-es
// import { flatten } from 'lodash';
import { flatten } from 'lodash-es';

const App = () => {
  const flattenArray = flatten([1, [2, [3, [4]], 5]]);
  console.log(flattenArray);
    
  return <div>lodash test</div>
}

bundle size 变小到差点找不到它,不过这样才是合理的嘛!

所以说在使用第三方套件时,可以多留意一下是不是有支援 Tree Shaking 的功能,是不是有不同的引入方式或是提供另一种版本的套件,也许小小的改变却能大大改变应用的 bundle size 喔!

如何实作一个具有 Tree Shaking 功能的 npm module

刚刚看完使用第三方套件的状况,那如果自己要开发一个套件,要怎麽支援 Tree Shaking 功能呢?

最关键的当然还是使用 ES6 的 module system。此外,还需要配合使用压缩工具例如 UglifyJS,再加上一些额外设定才能把用不到的程序从 bundle 中移除。

package.json 的设定

Module Bundler 会优先透过 package.json 来判断这个 module 有没有支援 Tree Shaking。在 package.json 主要有两个部分需要做设定:

side-effect

// package.json

{
  "sideEffects": false
}

// or


{
  "sideEffects": [
    "dist/*",
    "es/components/**/style/*",
    "lib/components/**/style/*",
    "*.less"
  ]
}

这主要是给 bundler 的一些提示,如果给 false 代表告诉 bundler 这个 modules 是没有 side effects 的,如果发现没有用到的模组可以勇敢的做 Tree Shaking。

如果你知道模组中的一些档案会产生 side effects,就可以使用第二种方式把会产生 side effects 的档案放到阵列里。这麽做的话就只有引入 side effects 阵列「之外」的档案时,才会做 Tree Shaking。

不过 side effects 也有些需要注意的 edge case,一个有趣的例子是使用 css loader 载入 CSS,用法可能是这样:

import './index.css'

...
...

不过因为它只有做到引入,但是并没有在其他地方被直接使用,所以会被 bundler Tree Shaken 掉,所以得把它加到 side effects list 里:

{
  "sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}

才不会不小心把 CSS 在 production mode 中移除掉。

module

我们通常会在 main 这个 config 中指定程序的入口档案,例如

// package.json
{
  "main": "src/index.js",
}

现在可以改成多透过一个 module config 指定 ES6 版本的入口

{
  "main": "src/index.js",
  "module": "es/index.js",
}

bundler 会优先透过 module 和 sideEffects 这两个属性指定的路径来引入这个模组的 ES6 版本,并做 Tree Shaking,如果发现 ES6 版本不能用,则会回到预设选项,也就是 main 属性指定的比较旧且不支援 Tree Shaking 的版本。

啊...那为什麽不直接都用 ES6 版本的程序码就好啊?

: 有些 package 是可以在浏览器也可以在 Node.js 中执行的,例如 Rxjs、Lodash 等套件,在 Node.js 环境下 ES6 就不太适合了。(这个问题要看 Node.js 的版本,新一点的版本就有直接支援 ES6 了。不过程序撰写的方式也是一个原因,目前 Node.js 在开发上还是以 require 的语法为主)

本日小结

我发现每天的小结都是总结今天介绍了什麽好像有点无聊啊...不如来点精神喊话吧...。

终於写完一半了啊!!!!

我每天都写到半夜,真的是快要累死了。不过心里还是很满足的,这次不管有没有得奖都不重要,重要的是我把想写的东西都写出来了,每年热血这一次应该很刚好吧!?也谢谢一直关注系列文的朋友,我知道我的文章篇幅以铁人赛来说有点长,所以耐心观看完的你绝对也值得一个掌声!希望这 15 天过去了,能够让你们有点收获,剩下的一半旅程继续多多指教罗!

明天,将介绍 Polyfill-less Bundling Script 与 File Compression,see u tomorrow ?

References

https://medium.com/@Rich_Harris/tree-shaking-versus-dead-code-elimination-d3765df85c80
https://loveky.github.io/2018/02/26/tree-shaking-and-pkg.module/
https://medium.com/starbugs/the-correct-way-to-import-lodash-libraries-bdf613235927
https://bluepnume.medium.com/javascript-tree-shaking-like-a-pro-7bf96e139eb7


<<:  [Day-15] - Spring 标示说明性注解运用与设计

>>:  使用 Angular 做档案编码检测 (detect-encoding)

[13th][Day7] func -1

不定长度参数 Variadic Functions Golang 中,有些函式的参数长度是不固定的,...

[DAY-27] 适合你的 才是真正的好职涯

经营你的职业生涯 人对於甚麽叫 理想职涯 都有一套自己的想法 在经营职涯时候 追求目标要有弹性 适...

05

蒙特兰在过去曾经讲过,在别人藐视的事中获得成功,是一件了不起的事,因为它证明不但战胜了自己,也战胜了...

铁人赛27天scss杂纪

今天还是想想不到写啥,所以只能又来继续骗天数罗,今天就是会把原本笔记上面的东西,没有提到部分记录在这...

Day 20【ERC-721】They don't know I own this song's non-fungible token

【前言】 就我们所知,所谓的链上数据通常都是一串的数字或者是文字,但艺术品通常都有其相当的外观,那...