聊一聊面试中经常被问到的Tree Shaking
天下武功,唯快不破!最新版的 antd 以及 vue 都对 Tree Shaking 提供了支持。我们内部的组件在支持这部分功能时,也专门梳理了相关的特性。这是四月份写的文章了,长时间不用就会忘,复习一下!
JS 文件绝大多数需要通过网络进行加载,然后执行。DCE(dead code elimination)可以使得加载文件的大小更小,整体执行时间更短。tree shaking 就是通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import
和 export
。
原理
ESM
import 只能作为模块顶层的语句出现 import 的模块名只能是字符串常量 import binding 是 immutable 的
这就是区别于CMJ,ESM 独有的静态分析特性。等等,那什么是静态分析呢,就是不执行代码。CMJ 中的 require,只有执行以后才知道引用的是什么模块。
保证了依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析。静态分析会在绘制依赖图时做DCE,减少打包体积。
ESM 也支持动态引入,类似于下面这种引入方式是不支持Tree Shacking的。
if (false) {
import('./a.js').then(() => {// ...})
} else {
// ...
}
// antd.js
var emptyObject = {};
if (true) {
Object.freeze(emptyObject);
}
module.exports = emptyObject;
Dead Code
Dead Code 通常是指:
代码不会被执行 代码执行的结果不会被用到 代码只会影响死变量(只写不读)
// 导入并赋值给 JavaScript 对象,但在接下来的代码里没有用到
// 这就会被当做“死”代码,会被 tree-shaking
import Stuff from './stuff';
doSomething();
// 导入但没有赋值给 JavaScript 对象,也没有在代码里用到
// 这会被当做“死”代码,会被 tree-shaking
import './stuff';
doSomething();
// 全部导入 (不支持 tree-shaking)
import _ from 'lodash';
// 具名导入(支持 tree-shaking)
import { debounce } from 'lodash';
// 直接导入具体的模块 (支持 tree-shaking)
import debounce from 'lodash/lib/debounce';
// 导入并赋值给 JavaScript 对象,然后在下面的代码中被用到
// 这会被看作“活”代码,不会做 tree-shaking
import Stuff from './stuff';
doSomething(Stuff);
// 导入整个库,但是没有赋值给 JavaScript 对象,也没有在代码里用到
// 非常奇怪,这竟然被当做“活”代码,因为 Webpack 对库的导入和本地代码导入的处理方式不同。
import 'my-lib';
export { default as Title } from './Title';
export { default as Options } from './Options';
export { default as AddonArea } from './AddonArea';
export { default as Answer } from './AddonArea/Answer';
export { default as Analysis } from './AddonArea/Analysis';
export { default as OriginalText } from './AddonArea/OriginalText';
export { default as Labels } from './AddonArea/Labels';
这样的文件结构是无法进行 tree-shaking 的, 因为没有 import?!
自执行的模块 import
自执行模块我们通常会使用 import 'xxx'
来进行模块引用,而不进行显式的调用。因此模块本身就有副作用。
import 'utils/refresh'
对于这种模块可以这样处理:
在 sideEffects 中通过数组声明,使其在 Tree Shaking 的范围之外 模块改造,暴露成员支持显式调用
unused harmony export
如果该模块被标识为 unused harmony export,则说明没有外部引用使用到该成员,webpack 认为是可以安全去除的。
harmony export
部分被标识为 harmony export 的模块也会被去除。这个是跟 UglifyJS 的机制有关系。
没有提供导出成员的模块
// ./src/modules/edu-discount/seckill/index.ts
import * as SeckillTypes from './types';
export { SeckillTypes };
对于只有暴露的成员,但是没有被引用的成员,这种模块会被直接删除。
[x] exports provided [ ] exports used
配置
babel的配置文件
{
"presets": [
["env", {
"modules": false // 配置了这个,babel就不会像默认那样转变成 require 形式。
}],
"stage-2",
"react"
]
}
为 webpack 进行 tree-shaking 创造了条件。
⚠️不能引用类似 @babel/plugin-transform-modules-commonjs
会把模块编译成 commonjs 的插件;
webpack 的配置文件
webpack 4 通过 optimization 取代了4个常用的插件:
废弃插件 | optimization 属性 | 功能 | |
---|---|---|---|
UglifyjsWebpackPlugin | sideEffects | minimizer | Tree Shaking & Minimize |
ModuleConcatenationPlugin | concatenateModules | Scope hoisting | 生产环境默认开启 |
CommonsChunkPlugin | splitChunks | runtimeChunk | OccurrenceOrder |
NoEmitOnErrorsPlugin | NoEmitOnErrors | 编译出现错误时,跳过输出阶段 | 生产环境默认开启 |
usedExports
Webpack 将识别出它认为没有被使用的代码,并在最初的打包步骤中给它做标记。
// Base Webpack Config for Tree Shaking
const config = {
mode: 'production',
optimization: {
usedExports: true,
minimizer: [
new TerserPlugin({...}) // 支持删除死代码的压缩器
]
}
};
package.json 的配置
用过 redux 的童鞋应该对纯函数不陌生,自然也就应该了解函数式编程,函数式编程中就有副作用一说。
照顾一下不知道的同学,那什么是副作用呢?
一个函数会、或者可能会对函数外部变量产生影响的行为。
具有副作用的文件不应该做 tree-shaking,因为这将破坏整个应用程序。比如全局样式表及全局的 JS 配置文件。
webpack 总会害怕把你要用的代码删除了,所以默认所有的文件都有副作用,不能被 Tree Shaking。
// 所有文件都有副作用,全都不可 tree-shaking
{
"sideEffects": true
}
// 没有文件有副作用,全都可以 tree-shaking,即告知 webpack,它可以安全地删除未用到的 export。
{
"sideEffects": false
}
// 除了数组中包含的文件外有副作用,所有其他文件都可以 tree-shaking,但会保留符合数组中条件的文件
{
"sideEffects": [
"*.css",
"*.less"
]
}
所以,首先关闭你的 sideEffects,
直接通过 module.rules
中的 sideEffects 配置可缩小你的影响范围。
加了 sideEffect 配置后,构建出来的一些 IIFE 函数也会加上/PURE/注释,便于后续 treeshaking。
组件不支持DCE?
我们的组件用的是 father,可以看到其依赖的father-build 是基于 rollup 的,那就好办了。webpack 的 Tree Shaking 还是 copy 的 rollup家的。
关键是在应用组件的业务项目里面配置optimization.sideEffects: true
// webpack.config.js
const path = require('path')
const webpackConfig = {
module : {
rules: [
{
test: /\.(jsx|js)$/,
use: 'babel-loader',
exclude: path.resolve(__dirname, 'node_modules')
}
]
},
optimization : {
sideEffects: true,
minimizer: [
// 这里配置成空数组是为了使最终产生的 main.js 不被压缩
]
},
plugins:[]
};
module.exports = webpackConfig;
// package.json
{
"name": "treeshaking-test",
"version": "0.1.0",
"description": "",
"main": "src/index.js",
"scripts": {
"build": "webpack --config webpack.config.js"
},
"author": "lu.lu (https://github.com/lulu27753)" ,
"license": "MIT",
"dependencies": {
"big-module": "^0.1.0",
"big-module-with-flag": "^0.1.0",
"webpack-bundle-analyzer": "^3.7.0"
},
"devDependencies": {
"babel-preset-env": "^1.7.0",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
}
}
// .babelrc
{
"presets": [
["env", { "modules": false }]
]
}
可以看到最终打包后的文件如下:
// dist/main.js
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// CONCATENATED MODULE: ./node_modules/big-module/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module/es/index.js
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/a.js
var a_a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/b.js
var b_b = 'b';
// CONCATENATED MODULE: ./src/index.js
console.log(a, b, a_a, b_b);
/***/ })
/******/ ]);
可以很清楚的看到 big-module-with-flag
中的 c 模块被DCE
了。
做个小小的改动,将 .babelrc
中的 modules
改为"commonjs"
{
"presets": [
["env", { "modules": "commonjs" }]
]
}
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ c; });
// CONCATENATED MODULE: ./node_modules/big-module/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module/es/index.js
/***/ }),
/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ c; });
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/index.js
/***/ })
/******/ ]);
结果是 CDE
失败!
将 modules
的值改回去,并升级big-module-with-flag
为0.2.0。CDE
成功,可以打假一波了(?,网上很多文章都是基于webpack3的,过时了)
升级big-module-with-flag
为0.5.0, 并更改 src/index.js
import { a as a1, b as b1 } from "big-module";
import { a as a2, b as b2, Apple } from "big-module-with-flag";
console.log(a1, b1, a2, b2);
const appleModel = new Apple({model: 'IphoneX'}).getModel()
console.log(appleModel)
var Apple = /*#__PURE__*/function () {
function Apple(_ref) {
var model = _ref.model;
_classCallCheck(this, Apple);
this.className = 'Apple';
this.model = model;
}
_createClass(Apple, [{
key: "getModel",
value: function getModel() {
return this.model;
}
}]);
return Apple;
}();
// CONCATENATED MODULE: ./src/index.js
console.log(a, b, es_a, es_b);
var appleModel = new Apple({
model: 'IphoneX'
}).getModel();
console.log(appleModel);
DCE 成功!
var _bigModule = __webpack_require__(2);
var _bigModuleWithFlag = __webpack_require__(1);
console.log(_bigModule.a, _bigModule.b, _bigModuleWithFlag.a, _bigModuleWithFlag.b);
var appleModel = new _bigModuleWithFlag.Apple({
model: 'IphoneX'
}).getModel();
console.log(appleModel);
/***/ }),
/* 1 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ es_a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ es_b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ es_c; });
__webpack_require__.d(__webpack_exports__, "Person", function() { return /* reexport */ Person; });
__webpack_require__.d(__webpack_exports__, "Apple", function() { return /* reexport */ Apple; });
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/a.js
var a = 'a';
/* harmony default export */ var es_a = (a);
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/b.js
var b = 'b';
/* harmony default export */ var es_b = (b);
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/c.js
var c = 'c';
/* harmony default export */ var es_c = (c);
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/Person.js
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
var Person = /*#__PURE__*/function () {
function Person(_ref) {
var name = _ref.name,
age = _ref.age,
sex = _ref.sex;
_classCallCheck(this, Person);
this.className = 'Person';
this.name = name;
this.age = age;
this.sex = sex;
}
_createClass(Person, [{
key: "getName",
value: function getName() {
return this.name;
}
}]);
return Person;
}();
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/Apple.js
function Apple_classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function Apple_defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function Apple_createClass(Constructor, protoProps, staticProps) { if (protoProps) Apple_defineProperties(Constructor.prototype, protoProps); if (staticProps) Apple_defineProperties(Constructor, staticProps); return Constructor; }
var Apple = /*#__PURE__*/function () {
function Apple(_ref) {
var model = _ref.model;
Apple_classCallCheck(this, Apple);
this.className = 'Apple';
this.model = model;
}
Apple_createClass(Apple, [{
key: "getModel",
value: function getModel() {
return this.model;
}
}]);
return Apple;
}();
// CONCATENATED MODULE: ./node_modules/big-module-with-flag/es/index.js
/***/ }),
/* 2 */
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
// ESM COMPAT FLAG
__webpack_require__.r(__webpack_exports__);
// EXPORTS
__webpack_require__.d(__webpack_exports__, "a", function() { return /* reexport */ a; });
__webpack_require__.d(__webpack_exports__, "b", function() { return /* reexport */ b; });
__webpack_require__.d(__webpack_exports__, "c", function() { return /* reexport */ c; });
// CONCATENATED MODULE: ./node_modules/big-module/es/a.js
var a = 'a';
// CONCATENATED MODULE: ./node_modules/big-module/es/b.js
var b = 'b';
// CONCATENATED MODULE: ./node_modules/big-module/es/c.js
var c = 'c';
// CONCATENATED MODULE: ./node_modules/big-module/es/index.js
// .babelrc
{
"presets": [["env", { "loose": false }]]
}
总结
webpack 官方号称提速 98%,其最重要的前提就是你的模块引入方式要是ESM,而不能是因为兼容性考虑的UMD实现。
如果你是一个第三方库的维护者,请人性化的按业界规范提供ES版本,同时配置 sideEffects: false.
Webpack 只有在压缩代码的时候会 tree-shaking, 通常就指是生产环境 代码的 module 引入必须是 import 的引入方式,也就意味着被转换成 ES5 的代码是无法支持 tree-shaking 的。
满足了文件要求后,简单来说你需要做如下配置操作
[x] 在 package.json 文件中将 sideEffects 设为 false [x] 将css相关 loader中 sideEffects 设为 true [x] 让@babel/preset-env 不编译 ES6 模块语句 [ ] 使用TerserPlugin,js代码压缩插件(webpack 自带)
参考
webpack 官方文档:https://webpack.docschina.org/guides/tree-shaking/
官方DEMO:https://github.com/webpack/webpack/tree/master/examples/side-effects
webpack 新插件系统如何工作:https://medium.com/webpack/the-new-plugin-system-week-22-23-c24e3b22e95
Tree-Shaking原理:https://juejin.im/post/5a4dc842518825698e7279a9
组件没办法DCE?:https://zhuanlan.zhihu.com/p/32831172