浅析tree shaking工作原理

tree-shaking

最近朋友面试问了关于tree shaking相关问题,之前对tree shaking不是很了解,只知道是减少打包体积用的。趁此机会了解了一下它的原委。

当前端项目到达一定的规模后,我们一般会采用按模块方式组织代码,这样可以方便代码的组织及维护。但会存在一个问题,比如我们有一个utils工具类,在另一个模块中导入它。这会在打包的时候将utils中不必要的代码也打包,从而使得打包体积变大,这时候就需要用到Tree shaking技术了。

Tree shaking 是一种通过清除多余代码方式来优化项目打包体积的技术,专业术语叫Dead code elimination

首先,新建一个简单的webpack项目,项目结构如下(推荐使用vscode编辑器):

image

主要文件如下:

  • package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
"name": "tree-shaking",
"version": "1.0.0",
"description": "tree-shaking demo",
"main": "./index.js",
"scripts": {
"build": "webpack",
"webpack": "webpack"
},
"author": "twindyorg",
"license": "MIT",
"homepage": "https://twindy.org",
"devDependencies": {
"@babel/core": "^7.2.2",
"@babel/plugin-transform-runtime": "^7.2.0",
"@babel/preset-env": "^7.3.1",
"babel-loader": "^8.0.5",
"cross-env": "^5.2.0",
"jest": "^24.0.0",
"uglifyjs-webpack-plugin": "^1.3.0",
"webpack": "^4.29.0",
"webpack-cli": "^3.2.1"
}
}
  • webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const path = require('path');

module.exports = {
entry: './src/index.js',
mode: 'development',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
plugins: [
// new UglifyJsPlugin()
],
module: {
rules: [{
test: /\.m?js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: [
'@babel/plugin-transform-runtime'
]
}
}
}]
}
};

可在项目根目录运行打包命令

1
npm run build

接下来,创建utils.js文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function add(a, b) {
console.log('add');
return a + b;
}

export function minus(a, b) {
console.log('minus');
return a - b;
}

export function multiply(a, b) {
console.log('multiply');
return a * b;
}

export function divide(a, b) {
console.log('divide');
return a / b;
}

index.js文件中导入utils.jsadd方法并调用:

1
2
3
import { add } from './utils';

add(10, 2);

运行npm run build后查看dist/bundle.js文件,可以发现utils.js中所有的代码都打包了,并没有像我们预期的那样只打包add()函数。

image

当启用tree shaking后,多余的代码就不会打入最终的文件。

tree shaking 如何工作的呢?

虽然tree shaking的概念在1990就提出了,但知道ES6的ES6-style模块出现后才真正被利用起来。这是因为tree shaking只能在静态modules下工作。ECMAScript 6 模块加载是静态的,因此整个依赖树可以被静态地推导出解析语法树。所以在ES6中使用tree shaking是非常容易的。而且,tree shaking不仅支持import/export级别,而且也支持statement(声明)级别。

ES6以前,我们可以使用CommonJS引入模块:require(),这种引入是动态的,也意味着我们可以基于条件来导入需要的代码:

1
2
3
4
5
6
7
let dynamicModule;
// 动态导入
if(condition) {
myDynamicModule = require("foo");
} else {
myDynamicModule = require("bar");
}

CommonJS的动态特性模块意味着tree shaking不适用。因为它是不可能确定哪些模块实际运行之前是需要的或者是不需要的。在ES6中,进入了完全静态的导入语法:import。这也意味着下面的导入是不可行的:

1
2
3
4
5
6
// 不可行,ES6 的import是完全静态的
if(condition) {
myDynamicModule = require("foo");
} else {
myDynamicModule = require("bar");
}

我们只能通过导入所有的包后再进行条件获取。如下:

1
2
3
4
5
6
7
8
import foo from "foo";
import bar from "bar";

if(condition) {
// foo.xxxx
} else {
// bar.xxx
}

ES6import语法完美可以使用tree shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。

如何使用Tree shaking

webpack 2开始支持实现了Tree shaking特性,webpack 2正式版本内置支持ES2015 模块(也叫做harmony模块)和未引用模块检测能力。新的webpack 4 正式版本,扩展了这个检测能力,通过package.jsonsideEffects属性作为标记,向compiler 提供提示,表明项目中的哪些文件是 “pure(纯的 ES2015 模块)”,由此可以安全地删除文件中未使用的部分。

本项目中使用的是webpack4,只需要将mode设置为production即可开启tree shaking

1
2
3
4
5
6
entry: './src/index.js',
mode: 'production', // 设置为production模式
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},

如果是使用webpack2,可能你会发现tree shaking不起作用。因为babel会将代码编译成CommonJs模块,而tree shaking不支持CommonJs。所以需要配置不转义:

1
options: { presets: [ [ 'es2015', { modules: false } ] ] }

参考tree-shaking-es6-modules-in-webpack-2

关于side effects(副作用)

side effects是指那些当import的时候会执行一些动作,但是不一定会有任何export。比如ployfill,ployfills不对外暴露方法给主程序使用。

tree shaking 不能自动的识别哪些代码属于side effects,因此手动指定这些代码显得非常重要,如果不指定可能会出现一些意想不到的问题。

webapck中,是通过package.jsonsideEffects属性来实现的。

1
2
3
4
{
"name": "tree-shaking",
"sideEffects": false
}

如果所有代码都不包含副作用,我们就可以简单地将该属性标记为false,来告知 webpack,它可以安全地删除未用到的export导出。

如果你的代码确实有一些副作用,那么可以改为提供一个数组:

1
2
3
4
5
6
{
"name": "tree-shaking",
"sideEffects": [
"./src/common/polyfill.js"
]
}

总结

  • tree shaking 不支持动态导入(如CommonJS的require()语法),只支持纯静态的导入(ES6的import/export)
  • webpack中可以在项目package.json文件中,添加一个 “sideEffects” 属性,手动指定由副作用的脚本

tree shaking 其实很好理解:一颗树,用力摇一摇,枯萎的叶子会掉落下来。剩下的叶子都是存活的

参考: