Skip to main content

JS Tree Shaking

概述

前端项目就像是一棵树,树上绿色的叶子代表实际用到的代码或者依赖的库,而黄色枯萎的叶子代表未用到代码。为了除去树上枯萎的叶子,我们必须摇动这棵树(Tree Shaking),使它们落下,从而减轻负担,即减少代码最终的体积。

tree shaking 从webpack 2开始就已经支持,但需要配合插件才能使用。而在webpack 4中只要设置modeproduction,tree shaking 便默认开启。

caution

tree shaking 要求必须是ES6模块,不支持commonJS模块,如果引用的模块使用commonJS规范,则 tree shaking 无效。

下面我们将通过一个 demo 来讲述 tree shaking 的相关内容,demo 中的相关代码已经上传到github仓库js-tree-shaking中,可以下载下来执行一下。

相关代码

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

export function cube(x) {
console.log('cube', x);
return x * x * x;
}
src/index.js
import {join} from 'lodash';
import {cube} from './math.js';

console.log(join(['a', 'b', 'c'], '~'));
console.log(cube(2));

index.js文件中我们导入了lodash包的join方法和math.js中的cube函数,

相关配置

webpack.config.json
const path = require('path');
const webpack = require('webpack');

module.exports = {
entry: {
index: './src/index'
},
output: {
path: path.join(__dirname, 'dist'),
filename: '[name]_bundle.js'
},
+ mode: 'production'
- mode: 'development'
- optimization: {
- usedExports: true
- }
};

在 webpack 的官方文档中列举了当配置mode:'development'时,构建后 tree shaking 结果:

/* 1 */
/***/ (function (module, __webpack_exports__, __webpack_require__) {
'use strict';
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__['a'] = cube;
function square(x) {
return x * x;
}

function cube(x) {
return x * x * x;
}
});

通过上面的结果可以看出,在modedevelopment时,webpack虽然识别出了square函数是没有用到的,但是对应的代码却没有被删除,为了删除这些dead code,需要配置modeproduction

tip

modedevelopment时,代码没有被删除是为了方便在开发阶段进行代码调试。删除没有用到的语句可能会造成sourcemap对应行错乱等问题

构建结果

代码的构建过程如下图所示:

通过构建的结果可以看出math.js中的square函数并没有被打包到bundle文件中,只将被引用的cube函数打包。这也证明我们 tree shaking 已经“成功了”。

对于文件模块的 tree shaking 虽然成功了,但是对nodelodash的 tree shaking 并没有成功。在代码中只使用了lodash中的一个join函数,打包后的体积却有72.1KB

npm 包的 tree shaking

上文中提到,导入lodash包并没有 tree shaking 成功。为了找寻原因,我们可以看一下lodash源码,通过源码可以看出lodash打包遵循的是commonJS的规范,通过立即执行函数来注册各个工具函数。

为了减少打包后的体积,一般会有以下两种方案:

方案一:只导入使用的文件

目前业界流行的组件库多是将每一个组件或者功能函数,都打包成单独的文件或目录。如下图是lodash中的单独文件:

然后可以像如下的方式引入:

import join from 'lodash/join';

此时打包的结果(右上角)与之前打包的结果对比如下图所示:

从图中可以看出,打包后的文件只有1.11KB,这说明我们成功的减少了打包后的体积。

caution

但是此方法存在缺陷,当我们导入某一个库的多个模块时,我们需要写多个导入语句。

方案二:只导入 npm 包的 es 版本

有些常用的包像lodashantd等,一般会打包两个版本的npm包,一种是采用umd的导出方式,一种是采用ES的导出方式。

webpack的打包不支持打包成ES模块,当使用webpack打包文件时,我们通常会选用umd的导出方式。因此,如果我们把所有的资源文件通过webpack打包到一个bundle文件里的话,那这个库文件从此与 tree shaking 无缘。

为了保留多种打包模块的方式,我们一般会使用webpack打包生成用于支持CDN载入的模块CommonJS规范),使用Babel分别打包支持CommonJS规范的ES5模块和支持ES6规范的ES6模块

同时我们还需要在package.json中添加moduleunpkg这两个字段,当开发者以ES6模块的方式去加载npm包时,会以module的值为入口文件,当使用url加载npm包时,将会以unpkg的值为入口文件,这样就能够同时兼容多种引入方式。

多种打包模块的方式可以参考ant-toolscore-react的打包配置,下面是ant-designpackage.json的设置:

/* package.json */
{
"main": "lib/index.js",
"module": "es/index.js",
"unpkg": "dist/antd.min.js"
}
caution

因为Babel默认会将所有的模块转换为ES5,所以在打包生成ES6模块时需要关闭Babel的默认设置,例如:

/* babel.rc */
presets: [['env', {module: false}]];

对于我们项目中使用到的lodash,我们可以用lodash-es代替。如下所示:

src/index.js
import {join} from 'lodash-es';
import {cube} from './math.js';

console.log(join(['a', 'b', 'c'], '~'));
console.log(cube(2));

打包后的结果如下所示:

由结果可以看出,使用lodash-es包时 tree shaking 会生效。

sideEffects

webpack做静态分析时,如果导入模块包含“副作用”代码,比如立即执行函数、调用了 window 上的属性等,此时webpack不能对导入的模块做 tree shaking。

为了帮助webpack更好的分析导入的哪些包是具有“副作用”的,哪些包是“纯净”的。我们可以在npm包发布前在package.json中增加一个sideEffects字段,将具有“副作用”的文件路径放到sideEffects的属性值中。sideEffects的值为false或者是一个数组,当sideEffects的值为false时,则代表该包的所有模块都没有“副作用”,webpack可以放心的使用 tree shaking。

caution

初学者一般会对sideEffects字段的作用存在误解,认为自己项目中不添加sideEffects字段会影响到项目的tree shaking功能。其实只有我们的项目发布到 npm 仓库并被其他人引用,添加sideEffects字段才是必要的(方便引用者仓库的 tree shaking),否则项目不需要添加sideEffects字段。

我们以 ant-design 的package.json文件为例,其设置如下:

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

通过上面的配置可以看出,ant-design 的dist目录以及所有样式文件都是有“副作用”的,此时如果项目中引用了 ant-design 中的模块,webpack在打包时不会对sideEffects中的“副作用”文件进行 tree shaking。

caution

之所以将cssless等样式文件添加到sideEffects中,是因为样式文件没有任何文件导出,因此webpack在做静态分析时会将import "./index.less"等样式导入文件移除,从而导致样式问题。

tree shaking 的原理

因为ES6模块可以进行可靠的静态分析,和代码运行时的状态无关,且模块间的依赖关系是确定的,所以可以消除无用的JS代码,支持 tree shaking。

被消除的代码主要分为以下几类

不会执行的代码

当代码不会被执行到时,tree shaking 会消除这部分代码。例如当条件判断为 false时,条件判断的代码会被删除,如下例所示:

修改后的src/math.js文件:

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

export function cube(x) {
// 添加不会执行的代码
if (false) {
console.log('false will delete');
}
console.log('cube', x);
return x * x * x;
}

打包后的部分代码为:

function (e, r, t) {
"use strict";
t.r(r);
var n = Array.prototype.join;
var o, u = function (e, r) {
return null == e ? "" : n.call(e, r)
};
console.log(u(["a", "b", "c"], "~")),
console.log((o = 2, console.log("cube", o), o * o * o))
}

通过打包后的结果可以看出,打包后新添加的语句已经被删除,没有添加到bundle中。

代码的执行结果不会被用到

例如调用函数,但是函数结果并没有赋值给任何变量时,这块代码的执行结果不会被用到,此时 tree shaking 时会消除这部分代码。

修改后的src/math.js文件:

src/math.js
let helloWorld = () => {
return 'hello world';
};

export function square(x) {
console.log('square', x);
return x * x;
}

export function cube(x) {
helloWorld();
console.log('cube', x);
return x * x * x;
}

打包的结果如下图所示:

通过打包后的结果可以看出,打包后的bundle中并没有包含helloWorld函数的返回结果。

caution

如果调用的函数是有“副作用”的,那么即使函数执行结果没被使用,也会被打包到bundle文件中

定义的变量未被使用

例如只定义变量或通过代码改变某个变量,但是该变量不会被使用到时,该变量在 tree shaking 时会被消除。

src/math.js
let helloWorld = () => {
return 'hello world';
};

export function square(x) {
console.log('square', x);
return x * x;
}

export function cube(x) {
let variate = helloWorld();
console.log('cube', x);
return x * x * x;
}

打包的结果如下图所示:

通过打包后的结果可以看出,打包后的bundle中即并没有包含variate变量,也没有helloWorld函数的返回结果。

参考资料