在本章節中通過一個引入 Ladash 特定模塊的實例來展示 Tree-shaking 在 Webpack 中的重要作用。通過合理的使用 Tree-shaking 功能可以有效的減少打包后文件的大小,通過本實例我們也可以知道 Tree-shaking 的作用條件和范圍。這樣對于 Webpack 優化策略又掌握了一部分核心知識。
是否需要引入 Tree-shaking
下面是 Ladash 中對外導出的對象:
lodash.isFunction = isFunction;
lodash.isInteger = isInteger;
lodash.isLength = isLength;
lodash.isMap = isMap;
lodash.isMatch = isMatch;
lodash.isMatchWith = isMatchWith;
lodash.isNaN = isNaN;
lodash.isNative = isNative;
lodash.isNil = isNil;
lodash.isNull = isNull;
lodash.isNumber = isNumber;
lodash.isObject = isObject;
lodash.isObjectLike = isObjectLike;
lodash.isPlainObject = isPlainObject;
lodash.isRegExp = isRegExp;
lodash.isSafeInteger = isSafeInteger;
lodash.isSet = isSet;
lodash.isString = isString;
lodash.isSymbol = isSymbol;
lodash.isTypedArray = isTypedArray;
lodash.isUndefined = isUndefined;
lodash.isWeakMap = isWeakMap;
lodash.isWeakSet = isWeakSet;
這是為什么我們可以通過如下方式引入方法的原因:
import { concat, sortBy, map, sample } from 'lodash';
//lodash 其實是一個對象
但是還有一種常見的方法就是只引入我們需要的函數,如下:
import sortBy from 'lodash/sortBy';
import map from 'lodash/map';
import sample from 'lodash/sample';
之所以可以通過這種方法引用是因為在 Lodash 的 npm 包中,每一個方法都對應于一個獨立的文件,并導出了該方法,例如下面就是 sortBy.js 方法的源碼:
var sortBy = baseRest(function(collection, iteratees) {
if (collection == null) {
return [];
}
var length = iteratees.length;
if (length > 1 && isIterateeCall(collection, iteratees[0], iteratees[1])) {
iteratees = [];
} else if (length > 2 && isIterateeCall(iteratees[0], iteratees[1], iteratees[2])) {
iteratees = [iteratees[0]];
}
return baseOrderBy(collection, baseFlatten(iteratees, 1), []);
});
module.exports = sortBy;
注意一點就是,通過后者來導入我們需要的文件比前者全部導入的文件要小的多。上面已經說了原因,即后者將每一個方法都存放在一個獨立的文件中,從而可以按需導入,所以文件也就比較小了。具體可以查看這里來學習如何減少 bundle.js 的大小。
當然,如果使用了 Webpack 3 的 Tree-shaking,那么就不需要考慮這個情況了。Tree-shaking 會讓沒用的代碼在打包的時候直接被剔除。但是,請注意,Tree-shaking 的功能要生效必須滿足一定的條件,即必須是 ES6 模塊
Webpack 引入 Tree-shaking 功能
Webpack 如何使用 Tree-shaking
為了讓 Webpack 2 支持 Tree-shaking 功能,需要對 wcf 的 babel 配置進行修改,其中修改最重要的一點就是去掉 babel-preset-es2015 ,而采用 plugin 處理。在 plugin 處理的時候還需要去掉下面的插件:
require.resolve("babel-plugin-transform-es2015-modules-amd"),
//轉化為 amd 格式,define 類型
require.resolve("babel-plugin-transform-es2015-modules-commonjs"),
//轉化為 commonjs 規范,得到:exports.default = 42,export.name="罄天"
require.resolve("babel-plugin-transform-es2015-modules-umd"),
//umd規范
采用 babel-plugin-transform-es2015-modules-commonjs 以后,代碼如下:
//imported.js
export function foo() {
return 'foo';
}
export function bar() {
return 'bar';
}
//下面是 index.js
import {foo} from './imported';
let elem = document.getElementById('output');
elem.innerHTML = `Output: ${foo()}`;
會被 Webpack 轉化為如下的形式:
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.foo = foo;
exports.bar = bar;
//都轉化為 commonjs 規范了
function foo() {
return 'foo';
}
function bar() {
return 'bar';
}
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
"use strict";
var _imported = __webpack_require__(0);
var elem = document.getElementById('output');
elem.innerHTML = 'Output: ' + (0, _imported.foo)();
/***/ })
/******/ ]);
所以,我們沒有用到的 bar 方法也被引入了。而如果引入 babel-plugin-transform-es2015-modules-amd,打包代碼就會得到如下的內容:
/******/ ([
/* 0 */
/***/ (function(module, exports, __webpack_require__) {
var __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;!(__WEBPACK_AMD_DEFINE_ARRAY__ = [exports], __WEBPACK_AMD_DEFINE_RESULT__ = function (exports) {
'use strict';
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.foo = foo;
exports.bar = bar;
//沒有用到的 bar 方法也被導出了
function foo() {
return 'foo';
}
function bar() {
return 'bar';
}
}.apply(exports, __WEBPACK_AMD_DEFINE_ARRAY__),
__WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__));
/***/ }),
/* 1 */
/***/ (function(module, exports, __webpack_require__) {
var __WEBPACK_AMD_DEFINE_ARRAY__, __WEBPACK_AMD_DEFINE_RESULT__;!(__WEBPACK_AMD_DEFINE_ARRAY__ = [__webpack_require__(0)], __WEBPACK_AMD_DEFINE_RESULT__ = function (_imported) {
'use strict';
var elem = document.getElementById('output');
elem.innerHTML = 'Output: ' + (0, _imported.foo)();
}.apply(exports, __WEBPACK_AMD_DEFINE_ARRAY__),
__WEBPACK_AMD_DEFINE_RESULT__ !== undefined && (module.exports = __WEBPACK_AMD_DEFINE_RESULT__));
/***/ })
/******/ ]);
而如果引入 babel-plugin-transform-es2015-modules-umd 也會面臨同樣的問題,所以我們應該去掉上面三個插件,即不再使用 amd/cmd/umd 規范打包,而使用 ES6 原生模塊打包策略。讓 ES6 模塊不受 Babel 預設(preset)的影響。Webpack 認識 ES6 模塊,只有當保留 ES6 模塊語法時才能夠應用 Tree-shaking。如果將其轉換為 CommonJS 語法,Webpack 不知道哪些代碼是使用過的,哪些不是(就不能應用 Tree-shaking 了)。最后,Webpack 將把它們轉換為 CommonJS語法。最終得到的 babel 默認配置就是如下的內容:
function getDefaultBabelConfig() {
return {
cacheDirectory: tmpdir(),
//We must set!
presets: [
require.resolve('babel-preset-react'),
// require.resolve('babel-preset-es2015'),
//(1)這個必須去掉
require.resolve('babel-preset-stage-0'),
],
plugins: [
require.resolve("babel-plugin-transform-es2015-template-literals"),
require.resolve("babel-plugin-transform-es2015-literals"),
require.resolve("babel-plugin-transform-es2015-function-name"),
require.resolve("babel-plugin-transform-es2015-arrow-functions"),
require.resolve("babel-plugin-transform-es2015-block-scoped-functions"),
require.resolve("babel-plugin-transform-es2015-classes"),
//這里會轉化class
require.resolve("babel-plugin-transform-es2015-object-super"),
require.resolve("babel-plugin-transform-es2015-shorthand-properties"),
require.resolve("babel-plugin-transform-es2015-computed-properties"),
require.resolve("babel-plugin-transform-es2015-for-of"),
require.resolve("babel-plugin-transform-es2015-sticky-regex"),
require.resolve("babel-plugin-transform-es2015-unicode-regex"),
require.resolve("babel-plugin-syntax-object-rest-spread"),
require.resolve("babel-plugin-transform-es2015-parameters"),
require.resolve("babel-plugin-transform-es2015-destructuring"),
require.resolve("babel-plugin-transform-es2015-block-scoping"),
require.resolve("babel-plugin-transform-es2015-typeof-symbol"),
[
require.resolve("babel-plugin-transform-regenerator"),
{ async: false, asyncGenerators: false }
],
// require.resolve("babel-plugin-add-module-exports"),
// 交給 Webpack 2 處理,可以刪除
require.resolve("babel-plugin-check-es2015-constants"),
require.resolve("babel-plugin-syntax-async-functions"),
require.resolve("babel-plugin-syntax-async-generators"),
require.resolve("babel-plugin-syntax-class-constructor-call"),
require.resolve("babel-plugin-syntax-class-properties"),
require.resolve("babel-plugin-syntax-decorators"),
require.resolve("babel-plugin-syntax-do-expressions"),
require.resolve("babel-plugin-syntax-dynamic-import"),
require.resolve("babel-plugin-syntax-exponentiation-operator"),
require.resolve("babel-plugin-syntax-export-extensions"),
require.resolve("babel-plugin-syntax-flow"),
require.resolve("babel-plugin-syntax-function-bind"),
require.resolve("babel-plugin-syntax-jsx"),
require.resolve("babel-plugin-syntax-trailing-function-commas"),
require.resolve("babel-plugin-transform-async-generator-functions"),
require.resolve("babel-plugin-transform-async-to-generator"),
require.resolve("babel-plugin-transform-class-constructor-call"),
require.resolve("babel-plugin-transform-class-properties"),
require.resolve("babel-plugin-transform-decorators"),
require.resolve("babel-plugin-transform-decorators-legacy"),
require.resolve("babel-plugin-transform-do-expressions"),
require.resolve("babel-plugin-transform-es2015-duplicate-keys"),
require.resolve("babel-plugin-transform-es2015-spread"),
require.resolve("babel-plugin-transform-exponentiation-operator"),
require.resolve("babel-plugin-transform-export-extensions"),
// require.resolve("babel-plugin-transform-es2015-modules-amd"),
// require.resolve("babel-plugin-transform-es2015-modules-commonjs"),
// require.resolve("babel-plugin-transform-es2015-modules-umd"),
// (2)去掉這個
require.resolve("babel-plugin-transform-flow-strip-types"),
require.resolve("babel-plugin-transform-function-bind"),
require.resolve("babel-plugin-transform-object-assign"),
require.resolve("babel-plugin-transform-object-rest-spread"),
require.resolve("babel-plugin-transform-proto-to-assign"),
require.resolve("babel-plugin-transform-react-display-name"),
require.resolve("babel-plugin-transform-react-jsx"),
require.resolve("babel-plugin-transform-react-jsx-source"),
require.resolve("babel-plugin-transform-runtime"),
require.resolve("babel-plugin-transform-strict-mode"),
]
};
}
具體文件內容可以點擊 wcf 打包 babel 配置。當然也可以使用下面方式告訴 babel 預設不轉換模塊:
{
"presets": [
["env", {
"loose": true,
"modules": false
}]
]
}
這種方式要簡單的多。但是這種方式會存在副作用,即無法移除多余的類聲明。在使用 ES6 語法定義類時,類的成員函數會被添加到屬性 prototype,沒有什么方法能完全避免這次賦值,所以 Webpack 會認為我們添加到 prototype 上方法的操作也是對類的一種使用,導致無法移除多余的類聲明,編譯過程阻止了對類進行 Tree-shaking ,它僅對函數起作用。UglifyJS 不能夠分辨它僅僅是類聲明,還是其他有副作用的操作,因為 UglifyJS 不能做控制流分析。
Webpack 的 Tree-shaking 標記 VS rollup 標記區別
移除未使用代碼(Dead code elimination) VS 包含已使用代碼(live code inclusion)
Webpack 僅僅標記未使用的代碼而不移除,并且不將其導出到模塊外。它拉取所有用到的代碼,將剩余的(未使用的)代碼留給像 UglifyJS 這類壓縮代碼的工具來移除。UglifyJS 讀取打包結果,在壓縮之前移除未使用的代碼。而 Rollup 不同,它的打包結果只包含運行應用程序所必需的代碼。打包完成后的輸出并沒有未使用的類和函數,壓縮僅涉及實際使用的代碼。
基于 babel-minify-webpack-plugin(即 babili-webpack-plugin)移除多余的類聲明
能將 ES6 代碼編譯為 ES5,移除未使用的類和函數,這就像 UglifyJS 已經支持 ES6 一樣。babel-minify 會在編譯前刪除未使用的代碼
。在編譯為 ES5 之前,很容易找到未使用的類,因此 Tree-shaking 也可以用于類聲明,而不再僅僅是函數。若看下 babili-webpack-plugin 的代碼,會看到下面兩句:
import { transform } from 'babel-core';
import babelPresetMinify from 'babel-preset-minify';
首先是babel-preset-minify,可以看到其內部會調用如 babel-plugin-minify-dead-code-elimination 、 babel-plugin-minify-type-constructors 等來判斷哪些代碼沒有被引用,進而可以在代碼沒有被編譯為 ES5 之前把它移除掉。而 babel-core 就是負責把處理后的 ES6 代碼繼續編譯為 ES5 代碼。
所以,我們只需用 babel-minify-webpack-plugin 替換 UglifyJS ,然后刪除 babel-loader (該 plugin 自己會處理 ES6 代碼,但是 jsx 處理需要自己添加 preset )即可。另一種方式是將babel-preset-minify作為 Babel 的預設,僅使用 babel-loader(移除 UglifyJS 插件,因為 babel-preset-minify 已經壓縮完成)。推薦使用第一種(插件的方式),因為當編譯器不是 Babel(如 Typescript)時,它也能生效。
module: {
rules: []
},
plugins: [
new BabiliPlugin()
//替代 UglifyJS,它可以移除 ES6 的多余類聲明
]
我們需要將 ES6+ 代碼傳給 babel-minify ,否則它不會移除(未使用的)類。所以,這種方式就要求所有的第三方包都必須有 ES6 的代碼發布,否則無法移除。
######### 目前wcf沒有引入 babili-webpack-plugin
這種情況下我們依然會對類的代碼打包成為 ES5,然后交給 UglifyJS 處理,比如下面的例子:
//imported.js
export function foo() {
return 'foo';
}
export function bar() {
return 'bar';
}
export function ql(){
return 'ql'
}
export class Test{
toString(){
return 'test';
}
}
export class Test1{
toString(){
return 'test1';
}
}
//index.js
import {foo} from './imported';
let elem = document.getElementById('app');
elem.innerHTML = `Output: ${foo()}`;
打包后的結果如下:
/***/ (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
/* harmony export (immutable) */
__webpack_exports__["a"] = foo;
/* unused harmony export bar */
/* unused harmony export ql */
/* unused harmony export Test */
/* unused harmony export Test1 */
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck__ = __webpack_require__(8);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck__);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass__ = __webpack_require__(9);
/* harmony import */ var __WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass___default = __webpack_require__.n(__WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass__);
function foo() {
return 'foo';
}
function bar() {
return 'bar';
}
function ql() {
return 'ql';
}
var Test = function () {
function Test() {
__WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck___default()(this, Test);
}
__WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass___default()(Test, [{
key: 'toString',
value: function toString() {
return 'test';
}
}]);
return Test;
}();
var Test1 = function () {
function Test1() {
__WEBPACK_IMPORTED_MODULE_0_babel_runtime_helpers_classCallCheck___default()(this, Test1);
}
__WEBPACK_IMPORTED_MODULE_1_babel_runtime_helpers_createClass___default()(Test1, [{
key: 'toString',
value: function toString() {
return 'test1';
}
}]);
return Test1;
}();
})
此時通過查看harmony export
部分,我們知道 Webpack 導出的僅僅是用到的 foo 模塊而已,而其他的不管是多余的函數聲明還是多余的類聲明都是被標記為無用代碼('unused'
)。通過這種方式打包,經過 UglifyJS 處理就會將類 Test 1、Test 2 的代碼移除,其實事實并不是這樣,經過 UglifyJS 處理后多余的函數是沒有了,但是多余的類聲明打包成的函數代碼依然存在!依然存在!依然存在!
終極解決方法:使用babel-minify-webpack-plugin,即 babili-webpack-plugin。完整實例代碼可以參考這里,而目前wcf沒有采用這種策略,所以多余的 class 是無法去除的。目前,我覺得這種策略是可以接受的,因為第三方發布的包很少是使用 class 發布,而都是編譯為 ES5 代碼后發布的,所以通過 UglifyJS 這種策略已經足夠了。當然,也可以使用babel-preset-minify來將代碼壓縮作為你的預設,這種方式在獨立封裝自己的打包工具的時候比較有用,它是所有 babel 代碼壓縮插件的集合。
Tree-shaking 的局限性
這一部分都是自己的理解,但是基于這樣一個事實:
import {sortBy} from "lodash";
通過 import 引入 sortBy 方法以后,以為僅僅是引入了該方法而已,但是實際上把 concat 等函數都引入了。因為 import 是基于 ES6 的靜態語法分析,而 lodash 第三方包導出的時候并不是基于 ES6 的 import/export 機制,代碼如下:
var _ = runInContext();
if (typeof define == 'function' && typeof define.amd == 'object' && define.amd) {
root._ = _;
define(function() {
return _;
});
}
else if (freeModule) {
// Export for Node.js.
(freeModule.exports = _)._ = _;
// Export for CommonJS support.
freeExports._ = _;
}
else {
// Export to the global object.
root._ = _;
}
}.call(this));
所以,我們在引入一個 lodash 模塊的時候應該使用下面的模式:
import sortBy from 'lodash/sortBy';