Tree Shaking:从原理到实现

5,371 阅读7分钟

时下火热的 Vue.js 3.0 从源码、性能和语法 API 三个大的方面对框架进行了优化。其中,在性能的优化中,对于源码体积的优化,主要体现在移除了一些冷门 Feature(比如 filter、inline-template) 并引入了 Tree-Shaking 减少打包体积。自从 rollup 提出这个术语以来,每每谈及打包性能优化,几乎都有 Tree-Shaking 的一席之地,所以了解 Tree-Shaking 的原理是很有必要的。

阅读完本文,你可以 Get 以下问题的答案,

  • 什么是 Tree-Shaking?Tree-Shaking 的发展历史?
  • Tree-Shaking 的原理是什么?
  • 什么是 Dead code?
  • ECMAScript 6 的模块机制?
  • Webpack 中 Tree-Shaking 的原理?

故事的开始:Rich Harris 和他的 Rollup

业界知名的模块打包器 rollup.js 的作者 Rich Harris 在 2015 年 12 月的一篇博客 《Tree-shaking versus dead code elimination》中首次提到了 Tree-Shaking 的概念,随后 Webpack 2 的正式版本内置支持了 ECMAScript 2015 模块,增加了对 Tree-Shaking 的支持,而在更早前,Google 推出的开发者工具 Closure Compiler 也在做类似的事情。

I’ve been working (albeit sporadically of late, admittedly) on a tool called Rollup, which bundles together JavaScript modules. One of its features is tree-shaking, by which I mean that it only includes the bits of code your bundle actually needs to run.

Rich Harris 在文中提到 Tree-Shaking 是为了 Dead code elimination,这是编译器原理中常见的一种编译优化技术,简单来说就是消除无用代码(Dead code)。那么什么是 Dead code 呢?

Dead code

Dead code,也叫死码,无用代码,它的范畴主要包含了以下两点,

  1. 不会被运行到的代码(unreachable code)
  2. 只会影响道无关程序运行结果的变量(Dead Variables)

我们尝试通过一些 JavaScript 代码片段来理解它。

首先,不会被运行到的代码很好理解,比如在函数 return 语句后的代码,

function foo() {
  return 'foo';
  var bar = 'bar'; // 函数已经返回了,这里的赋值语句永远不会执行
}

或者不会执行的假值条件语句块,

if(0) {
  // 这个条件判断语句块内部的代码永远不会执行
}

Dead Variables 常见的像一些未使用的变量,

function add(a, b) {
  let c = 1; // unused variable 在这里可以被看作死码
  return a + b;
}

需要注意的是,模块如果未使用也可以看作 Dead code,比如下面的 bar 模块,

// foo.js
function foo() {
  console.log('foo');
}
export default foo;

// bar.js
function bar() {
  console.log('bar');
}
export default bar;

// index.js
import foo from './foo.js';
import bar from './bar.js';
foo();

// 这里入口文件虽然引用了模块 bar,但是没有使用,模块 bar 也可以被看作死码

Dead code 我们知道了,那么什么是 Tree-Shaking 呢?

在传统的静态编程语言编译器中,编译器可以判断出某些代码根本不影响输出,我们可以借助编译器将 Dead CodeAST(抽象语法树)中删除,但 JavaScript 是动态语言,编译器不能帮助我们完成死码消除,我们需要自己实现 Dead code elimination

我们说的 Tree-Shaking,就是 Dead code elimination 的一种实现,它借助于 ECMAScript 6 的模块机制原理,更多关注的是对无用模块的消除,消除那些引用了但并没有被使用的模块。

这里为了更好地理解 Tree-Shaking 的原理,我们需要先了解 ES6 的模块机制。

ECMAScript 6 module

JavaScript 的模块化经历一个漫长的发展历程,我们知道刚开始 JavaScript 是没有模块的概念的,最初我们只能借助 IIFE 尽量减少对全局环境的污染,后来社区出现了用于浏览器端的以 RequireJS 为代表的 AMD 规范和以 Sea.js 为代表的 CMD 规范,服务器端也出现了 CommonJS 规范,再后来 JavaScript 原生引入了 ES Module,取代社区方案成为浏览器端一致的模块解决方案。

ES Module 现在也可以用于服务器,Node.js 在 v12.0.0 版本实现了对 ES Module 的支持。

对于 ES Module 基础语法不了解的可以参考下面的文章,我们接下来主要理解它的机制,

对比是理解知识非常有效的一种手段。我们通过对比 ES Module 与 CommonJS 的区别来理解 ES Module 的模块机制,它们的区别主要体现在模块的输出和执行上,

  • ES Module 输出的是值的引用,而 CommonJS 输出的是值的拷贝
  • ES Module 是编译时执行,而 CommonJS 模块是在运行时加载

所以 ES Module 最大的特点就是静态化,在编译时就能确定模块的依赖关系,以及输入和输出的值,这意味着什么?意味着模块的依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,正是基于这个基础,才使得 Tree-Shaking 成为可能,这也是为什么 rollup 和 Webpack 2 都要用 ES6 Module 语法才能支持 Tree-Shaking。

了解原理后,接下来我们来看下如何实现 Tree-Shaking。

Tree Shaking

借助静态模块分析,Tree-Shaking 实现的大体思路:借助 ES6 模块语法的静态结构,通过编译阶段的静态分析,找到没有引入的模块并打上标记,然后在压缩阶段利用像 uglify-js 这样的压缩工具删除这些没有用到的代码。

是这样吗?接下来我们以 webpack 为例,验证下上述思路。

初始化项目安装最新的 webpackwebpack-cli ,笔者写这篇文章时最新的版本是 v5.35.1

$ mkdir tree-shaking && cd tree-shaking
$ npm init -y
$ npm i webpack webpack-cli -D

添加一个简单的配置文件和一个 math 模块,这里我们只引用 math 模块的 cube 函数,

// webpack.config.js
const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  optimization: {
    // 开启 usedExports  收集 Dead code 相关的信息
    usedExports: true,
  },
};

// src/math.js
export function square(x) {
  return x * x;
}

export function cube(x) {
  var a, b, c; // 这里引入了三个未使用的变量作为 Dead code 的一种
  return x * x * x;
}

// src/index.js
import { cube } from "./math.js";

function component() {
  var element = document.createElement("pre");
  element.innerHTML = "5 cubed is equal to " + cube(5);
  return element;
}

document.body.appendChild(component());

运行打包命令,定位到 bundle.jsmath 模块打包后代码,

/***/ "./src/math.js":
/*!*********************!*\
  !*** ./src/math.js ***!
  \*********************/
/***/ ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

eval("/* harmony export */ __webpack_require__.d(__webpack_exports__, {\n/* harmony export */   \"cube\": () => (/* binding */ cube)\n/* harmony export */ });\n/* unused harmony export square */\nfunction square(x) {\r\n  return x * x;\r\n}\r\n\r\nfunction cube(x) {\r\n  var a, b, c;\r\n  return x * x * x;\r\n}\r\n\n\n//# sourceURL=webpack://tree-shaking/./src/math.js?");

/***/ })

为了方便阅读,我们将 eval 函数内的换行符去掉,简单整理下格式,

/* harmony export */
__webpack_require__.d(__webpack_exports__, {
  /* harmony export */
  cube: () => /* binding */ cube /* harmony export */,
});
/* unused harmony export square */
function square(x) {
  return x * x;
}
function cube(x) {
  var a, b, c;
  return x * x * x;
}

可以看到,__webpack_exports__ 只导出了 cube 函数,而没有使用的 square 函数没有被导出,并打上了 /* unused harmony export square */ 的注释标识,但是 square 函数声明以及 cube 函数中未使用的变量声明 a, b, c 还是被打包了。这印证了我们之前推测的 webpack 可以通过 Tree-Shaking 找到没有引入的模块,并不会删除 Dead code。

接着我们将 mode 切换到 production 以启用 uglify-js 进行压缩,然后再次运行打包命令,

(() => {
  "use strict";
  var e, t;
  document.body.appendChild(
    (((t = document.createElement("pre")).innerHTML =
      "5 cubed is equal to " + (e = 5) * e * e),
    t)
  );
})();

结果和我们预期一致,uglify-js 在压缩的同时去除了 Dead code,包括,

  • 没有使用的 square 函数
  • 没有使用的变量 a, b, c

我们也可以单独引入 uglify-js 来验证这一点,

// math.js
function cube(x) {
  var a, b, c;
  return x * x * x;
}

// minify.js
const fs = require("fs");
const UglifyJS = require("uglify-js");
const code = fs.readFileSync("math.js", "utf-8");
const result = UglifyJS.minify(code, {
  compress: {
    dead_code: true, // dead_code 默认为 true
  },
});

console.log(result.code); // function cube(n){return n*n*n}

小结

我们从 Tree-Shaking 的起源切入,了解了它是 Dead code elimination 的一种实现,然后拓展学习了什么是 Dead Code,接着回顾了 JavaScript 模块化的发展史,正是因为 ES Module 的静态结构,使得模块级别的 Tree-Shaking 实现成为可能。最后以打包工具 webpack 为例,结合 uglify-js 压缩工具,解释了 Tree-Shaking 的实现原理。

参考链接

写在最后

本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!

如果有疑问或者发现错误,可以在相应的 issues 进行提问或勘误

如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励

(完)