从过去到现在,聊聊 Tree-shaking

前言

Tree-shaking 这一术语在前端社区内,起初是 Rich Harris 在 Rollup 中提出。简单概括起来,Tree-shaking 可以使得项目最终构建(Bundle)结果中只包含你实际需要的代码。

而且,说到 Tree-shaking,不难免提及 Dead Code Elimation,相信很多同学在一些关于 Tree-shaking 的文章中都会看到诸如这样的描述:Tree-shaking 是一项 Dead Code Elimation(以下统称 DCE)技术。

那么,既然有了 DCE 这一术语,为什么又要造一个 Tree-shaking 术语?存在既有价值,下面,让我们一起来看看 Rich Harris 是如何回答这个问题的。

1 Tree-shaking Vs Dead Code Elimaion

在当时 Rich Haris 针对这一提问专门写了这篇文章《Tree-shaking versus dead code elimination》,文中表示 DCE 和 Tree-shaking 最终的目标是一致的(更少的代码),但是它们仍然是存在区别的。

Rich Haris 举了个做蛋糕的例子,指出 DCE 就好比在做蛋糕的时候直接把鸡蛋放入搅拌,最后在做好的蛋糕中取出蛋壳,这是不完美的做法,而 Tree-shaking 则是在做蛋糕的时候只放入我想要的东西,即不会把蛋壳放入搅拌制作蛋糕。

因此,Tree-shaking 表达的不是指消除 Dead Code,而是指保留 Live Code。即使最终 DCE 和 Tree-shaking 的结果是一致的,但是由于 JavaScript 静态分析的局限性,实际过程并不同。并且,包含有用的代码可以得到更好的结果,从表面看(做蛋糕的例子)这也是一种更符合逻辑的方法。

此外,当时 Rich Haris 也认为 Tree-shaking 可能不是一个很好的名称,考虑过用 Live Code Inclusion 这个短语来表示,但是似乎会造成更多的困惑......让我们看一下 Rich Haris 的原话:

I thought about using the ‘live code inclusion’ phrase with Rollup, but it seemed that I’d just be adding even more confusion seeing as tree-shaking is an existing concept. Maybe that was the wrong decision?

所以,我想到这里同学们应该清楚一点,Tree-shaking 和 DCE 只是最终的结果是一致的,但是 2 者实现的过程不同,Tree-shaking 是保留 Live Code,而 DCE 是消除 Dead Code。

并且,当时 Rich Harris 也指出 Rollup 也不是完美的,最好的结果是使用 Rollup + Uglify 的方式。不过,显然现在的 Rollup v2.55.1 已经臻至完美。那么,接下来让我们沿着时间线看看 Tree-shaking 的演变~

2 Tree-shaking 的演变

Tree-shaking 在最初被提出的时候它只会做一件事,那就是利用 ES Module 静态导入的特点来检测模块内容的导出、导入以及被使用的情况,从而实现保留 Live Code 的目的。

也许这个时候你会问 Tree-shaking 不是还会消除 Dead Code 吗?确实,但是也不一定,如果你使用的是现在的 Rollup v2.55.1,它是会进行 DCE,即消除 Dead Code。但是,如果你用的是 Webpack 的话,那就是另一番情况了,它需要使用 Uglify 对应的插件来实现 DCE。

下面,我们以 Rollup 为例,聊聊过去和现在的 Tree-shaking。

2.1 过去的 Tree-shaking

在早期, Rollup 提出和支持 Tree-shaking 的时候,它并不会做额外的 DCE,这也可以在 15 年 Rich Haris 写的那篇文章中看出,当时他也提倡大家使用 Rollup + Uglify。所以,这里让我们一起把时间倒回 Rollup v0.10.0 的 Tree-shaking。

回到 Rollup v0.10.0 版本,你会发现非常有趣的一点,就是它的 GitHub README 介绍是这样的:

Rollup 的命名来源于一首名为《Roll up》的说唱歌曲,我想这应该出乎了很多同学的意料。不过话说 Evan You 也喜欢说唱,然后我(你)也喜欢说唱,所以这也许可以论证我(你)选择前端似乎没错?这里附上这首歌,你可以选择听这首歌来拉近 Rollup 的距离。

传送门: https://www.youtube.com/watch...

下面,我们使用 Rollup v0.10.0 版本来做一个简单示例来验证一下前面说的。并且,在这个过程中需要注意,如果你的 Node 版本过高会导致一些不兼容,所以建议用 Node v11.15.0 来运行下面的例子。

首先,初始化项目和安装基础的依赖:

npm init -y
npm i [email protected] -D

然后,分别新建 3 个文件:

utils.js

export const foo = function () {
  console.log("foo");
};

export const bar = function () {
  console.log("bar");
};

main.js

import { foo, bar } from "./utils.js";

const unused = "a";

foo();

index.js

const rollup = require("rollup");

rollup
  .rollup({
    entry: "main.js",
  })
  .then(async (bundle) => {
    bundle.write({
      dest: "bundle.js",
    });
  });

其中,main.js 是构建的入口文件,然后 index.js 负责使用 Rollup 进行构建,它会将最终的构建结果写入到 bundle.js 文件中:

// bundle.js
const foo = function () {
  console.log("foo");
};

const unused = "a";

foo();

可以看到,在 bundle.js保留了 utils.js 中的 foo() 函数(因为被调用了),而导入的 uitls.js 中的 bar() 函数(没有被调用)则不会保留,并且定义的变量 ununsed 虽然没有被使用,但是仍然保留了下来。

所以,通过这么一个小的示例,我们可以验证得知 Rollup 的 Tree-shaking 最初并不支持 DCE,它仅仅只是在构建结果中保留你导入的模块中需要的代码

2.2 现在的 Tree-shaking

前面,我们从过去的 Tree-shaking 开始了解,大致建立起了对 Tree-shaking 的初印象。这里我们来看一下现在 Rollup 官方上对 Tree-shaking 的介绍:

Tree-shaking,也被称为 Live Code Inclusion,是指 Rollup 消除项目中实际未使用的代码的过程,它是一种 Dead Code Elimation 的方式,但是在输出方面会比其他方法更有效。该名称源自模块的抽象语法树(Abstract Sytanx Tree)。该算法首先会标记所有相关的语句,然后通过摇动语法树来删除所有的 Dead Code。它在思想上类似于 GC(Garbage Collection)中的标记清除算法。尽管, 该算法不限于 ES Module,但它们使其效率更高,因为它允许 Rollup 将所有模块一起视为具有共享绑定的大抽象语法树。

从这段话,我们可以很容易地发现随着时间的推移,Rollup 对 Tree-shaking 的定义已经不仅仅是 ES Module 相关,此外它还支持了 DCE。所以,有时候我们看到一些文章介绍 Tree-shaking 实现会是这样:

  • 利用 ES Module 可以进行静态分析的特点来检测模块内容的导出、导入以及被使用的情况,保留 Live Code
  • 消除不会被执行没有副作用(Side Effect) 的 Dead Code,即 DCE 过程

那么,在前面我们已经知道 Tree-shaking 基于 ES Module 静态分析的特点会做的事情。所以,这里我们来仔细看一下第 2 点,换个角度看,它指的是当代码没有被执行,但是它会存在副作用,这个时候 Tree-shaking 就不会把这部分代码消除。

那么,显然对副作用建立良好的认知,可以让项目中代码能更好地被 Tree-shaking。所以,下面让我们来通过一个简单的例子来认识一下副作用。

2.2.1 副作用(Side Effect)

在 Wiki 上对副作用(Side Effect)做出的介绍:

在计算机科学中,如果操作、函数或表达式在其本地环境之外修改某些状态变量值,则称其具有副作用。

把这段话转换成我们熟悉的,它指的是当你修改了不包含在当前作用域的某些变量值的时候,则会产生副作用。这里我们把上面的例子稍作修改,把 sayHi() 函数的形参删掉,改为直接访问定义好的 name 变量:

utils.js

export const name = "wjc";

export const sayHi = function () {
  console.log(`Hi ${name}`);
};

main.js

import { sayHi } from "./maths.js";

sayHi();

然后,我们把这个例子通过 Rollup 提供的 REPL 来 Tree-shaking 一下,输出的结果会是这样:

const name = "wjc";

const sayHi = function () {
  console.log(`Hi ${name}`);
};

sayHi();

可以看到,这里我们并没有直接导入 utils.js 文件中的 name 变量,但是由于在 sayHi() 函数中访问了它作用域之外的变量 name,产生了副作用,所以最后输出的结果也会有 name 变量。

当然,这仅仅只是一个非常简单的产生副作用的场景,也是很多同学不会犯的错误。此外,一个很有趣的场景就是使用 Class 关键字声明的类经过 Babel 转换为 ES5 的代码(为了保证 Class 可枚举)后会产生副作用。

对上面提到的这个问题感兴趣的同学,可以看这篇文章 你的 Tree-Shaking 并没什么用 仔细了解,这里就不做重复论述了~

结语

写这篇文章的动机主要是出于对 Tree-shaking 和 DCE 这两个术语十分相似,但是 Tree-shaking 必然有其存在的意义,所以就诞生了这篇文章。虽然,文章中并没有涉及 Tree-shaking 的底层实现,但是我想有时候搞清楚一些模糊的概念的优先级是优于了解其底层实现的

并且,通过对比 2015 年 Rich Harris 在提出 Tree-shaking 的初衷,到现在 Tree-shaking 所具备的能力来说,随着时间的演变 Rollup 的 Tree-shaking 默认也支持了 DCE,这也难免会造成一些同学对 Tree-shaking 的理解产生混乱。所以,如果想要追溯本源(Tree-shaking 由来)的同学,我还是蛮推荐仔细阅读一下《Tree-shaking versus dead code elimination》这篇文章的。

最后,如果文中存在表达不当或错误的地方,欢迎各位同学提 Issue ~

点赞

通过阅读本篇文章,如果有收获的话,可以点个赞,这将会成为我持续分享的动力,感谢~

我是五柳,喜欢创新、捣鼓源码,专注于源码(Vue 3、Vite)、前端工程化、跨端等技术学习和分享。此外,我的所有文章都会收录在 https://github.com/WJCHumble/Blog,欢迎 Watch Or Star!

你可能感兴趣的:(从过去到现在,聊聊 Tree-shaking)