Webpack Tree shaking 深入探究
App往往有一個入口檔案,相當於一棵樹的主幹,入口檔案有很多依賴的模組,相當於樹枝。實際情況中,雖然依賴了某個模組,但其實只使用其中的某些功能。通過Tree shaking,將沒有使用的模組搖掉,這樣來達到刪除無用程式碼的目的。
模組
CommonJS的模組require
modules.exports
,exports
var my_lib; if (Math.random()) { my_lib = require('foo'); } else { my_lib = require('bar'); } module.exports = xx 複製程式碼
ES2015(ES6)的模組import
,export
// lib.js export function foo() {} export function bar() {} // main.js import { foo } from './lib.js'; foo(); 複製程式碼
Tree shaking的原理
關於Tree shaking的原理,在ofollow,noindex">Tree Shaking效能優化實踐 - 原理篇 已經說的比較清楚,簡單來說。
Tree shaking的本質是消除無用的JavaScript程式碼。 因為ES6模組的出現,ES6模組依賴關係是確定的,`和執行時的狀態無關`,可以進行可靠的靜態分析, 這就是Tree shaking的基礎。 複製程式碼
支援Tree-shaking的工具
- Webpack/UglifyJS
- rollup
- Google closure compiler
今天,我們來看一下Webpack的Tree shaking做了什麼
Webpack Tree shaking
Tree shaking到底能做哪些事情??
1.Webpack Tree shaking從ES6頂層模組開始分析,可以清除未使用的模組
從官網的例子來看程式碼 :
//App.js import { cube } from './utils.js'; cube(2); //utils.js export function square(x) { console.log('square'); return x * x; } export function cube(x) { console.log('cube'); return x * x * x; } 複製程式碼
result:square的程式碼被移除
function(e, t, r) { "use strict"; r.r(t), console.log("cube") } 複製程式碼
2.Webpack Tree shaking會對多層呼叫的模組進行重構,提取其中的程式碼,簡化函式的呼叫結構
//App.js import { getEntry } from './utils' console.log(getEntry()); //utils.js import entry1 from './entry.js' export function getEntry() { return entry1(); } //entry.js export default function entry1() { return 'entry1' } 複製程式碼
result:簡化後的程式碼如下
//摘錄核心程式碼 function(e, t, r) { "use strict"; r.r(t), console.log("entry1") } 複製程式碼
3.Webpack Tree shaking不會清除IIFE(立即呼叫函式表示式)
IIFE是什麼??IIFE in MDN
//App.js import { cube } from './utils.js'; console.log(cube(2)); //utils.js var square = function(x) { console.log('square'); }(); export function cube(x) { console.log('cube'); return x * x * x; } 複製程式碼
result:square和cude都存在
function(e, t, n) { "use strict"; n.r(t); console.log("square"); console.log(function(e) { return console.log("cube"), e * e * e }(2)) } 複製程式碼
這裡的問題會是為什麼不會清除IIFE?在你的Tree-Shaking並沒什麼卵用 中有過分析,裡面有一個例子比較好,見下文
原因很簡單:因為IIFE比較特殊,它在被翻譯時(JS並非編譯型的語言)就會被執行,Webpack不做程式流分析,它不知道IIFE會做什麼特別的事情,所以不會刪除這部分程式碼
比如:
var V8Engine = (function () { function V8Engine () {} V8Engine.prototype.toString = function () { return 'V8' } return V8Engine }()) var V6Engine = (function () { function V6Engine () {} V6Engine.prototype = V8Engine.prototype // <---- side effect V6Engine.prototype.toString = function () { return 'V6' } return V6Engine }()) console.log(new V8Engine().toString()) 複製程式碼
result:
輸出V6,而並不是V8 複製程式碼
如果V6這個IIFE裡面再搞一些全域性變數的宣告,那就當然不能刪除了。
4.Webpack Tree shaking對於IIFE的返回函式,如果未使用會被清除
當然Webpack也沒有那麼的傻,如果發現IIFE的返回函式沒有地方呼叫的話,依舊是可以被刪除的
//App.js import { cube } from './utils.js'; console.log(cube(2)); //utils.js var square = function(x) { console.log('square'); return x * x; }(); function getSquare() { console.log('getSquare'); square(); } export function cube(x) { console.log('cube'); return x * x * x; } 複製程式碼
result:結果如下
function(e, t, n) { "use strict"; n.r(t); console.log("square");<= square這個IIFE內部的程式碼還在 console.log(function(e) { return console.log("cube"), e * e * e<= square這個IIFEreturn的方法因為getSquare未被呼叫而被刪除 }(2)) } 複製程式碼
5.Webpack Tree shaking結合第三方包使用
//App.js import { getLast } from './utils.js'; console.log(getLast('abcdefg')); //utils.js import _ from 'lodash';<=這裡的引用方式不同,會造成bundle的不同結果 export function getLast(string) { console.log('getLast'); return _.last(string); } 複製程式碼
result:結果如下
import _ from 'lodash'; AssetSize bundle.js70.5 KiB import { last } from 'lodash'; AssetSize bundle.js70.5 KiB import last from 'lodash/last';<=這種引用方式明顯降低了打包後的大小 AssetSize bundle.js1.14 KiB 複製程式碼
Webpack Tree shaking做不到的事情
在體積減少80%!釋放webpack tree-shaking的真正潛力 一文中提到了,Webpack Tree shaking雖然很強大,但是依舊存在缺陷
//App.js import { Add } from './utils' Add(1 + 2); //utils.js import { isArray } from 'lodash-es'; export function array(array) { console.log('isArray'); return isArray(array); } export function Add(a, b) { console.log('Add'); return a + b } 複製程式碼
result:不該匯入的程式碼
這個`array`函式未被使用,但是lodash-es這個包的部分程式碼還是會被build到bundle.js中 複製程式碼
可以使用這個外掛webpack-deep-scope-analysis-plugin 解決
小結
如果要更好
的使用Webpack Tree shaking,請滿足:
- 使用ES2015(ES6)的模組
- 避免使用IIFE
- 如果使用第三方的模組,可以嘗試直接從檔案路徑引用的方式使用(這並不是最佳的方式)
import { fn } from 'module'; => import fn from 'module/XX'; 複製程式碼
Babel帶來的問題1-語法轉換(Babel6)
以上的所有示例都沒有使用Babel進行處理
,但是我們明白在真實的專案中,Babel對於我們還是必要的。那麼如果使用了Babel會帶來什麼問題呢?(以下討論建立在Babel6
的基礎上)
我們看程式碼 :
//App.js import { Apple } from './components' const appleModel = new Apple({<==僅呼叫了Apple model: 'IphoneX' }).getModel() console.log(appleModel) //components.js export class Person { constructor ({ name, age, sex }) { this.className = 'Person' this.name = name this.age = age this.sex = sex } getName () { return this.name } } export class Apple { constructor ({ model }) { this.className = 'Apple' this.model = model } getModel () { return this.model } } //webpack.config.js const path = require('path'); module.exports = { entry: [ './App.js' ], output: { filename: 'bundle.js', path: path.resolve(__dirname, './build'), }, module: {}, mode: 'production' }; 複製程式碼
result:結果如下
function(e, t, n) { "use strict"; n.r(t); const r = new class { constructor({ model: e }) { this.className = "Apple", this.model = e } getModel() { return this.model } }({ model: "IphoneX" }).getModel(); console.log(r) } //僅有Apple的類,沒有Person的類(Tree shaking成功) //class還是class,並沒有經過語法轉換(沒有經過Babel的處理) 複製程式碼
但是如果加上Babel(babel-loader)的處理呢?
//App.js和component.js保持不變 //webpack.config.js const path = require('path'); module.exports = { entry: [ './App.js' ], output: { filename: 'bundle.js', path: path.resolve(__dirname, './buildBabel'), }, module: { rules: [ { test: /\.js$/, use: { loader: 'babel-loader', options: { presets: ['env'] } } } ] }, mode: 'production' }; 複製程式碼
result:結果如下
function(e, n, t) { "use strict"; Object.defineProperty(n, "__esModule", { value: !0 }); var r = function() { function e(e, n) { for(var t = 0; t < n.length; t++) { var r = n[t]; r.enumerable = r.enumerable || !1, r.configurable = !0, "value" in r && (r.writable = !0), Object.defineProperty(e, r.key, r) } } return function(n, t, r) { return t && e(n.prototype, t), r && e(n, r), n } }(); function o(e, n) { if(!(e instanceof n)) throw new TypeError("Cannot call a class as a function") } n.Person = function() { function e(n) { var t = n.name, r = n.age, u = n.sex; o(this, e), this.className = "Person", this.name = t, this.age = r, this.sex = u } return r(e, [{ key: "getName", value: function() { return this.name } }]), e }(), n.Apple = function() { function e(n) { var t = n.model; o(this, e), this.className = "Apple", this.model = t } return r(e, [{ key: "getModel", value: function() { return this.model } }]), e }() } //這次不僅Apple類在,Person類也存在(Tree shaking失敗了) //class已經被babel處理轉換了 複製程式碼
結論:Webpack的Tree Shaking有能力除去匯出但沒有使用的程式碼塊,但是結合Babel(6)使用之後就會出現問題
那麼我們看看Babel到底幹了什麼,這是被Babel6處理的程式碼
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); //_createClass本質上也是一個IIFE var _createClass = 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); } } return function(Constructor, protoProps, staticProps) { if(protoProps) defineProperties(Constructor.prototype, protoProps); if(staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); function _classCallCheck(instance, Constructor) { if(!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } //Person本質上也是一個IIFE var Person = exports.Person = 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, [{<==這裡呼叫了另一個IIFE key: 'getName', value: function getName() { return this.name; } }]); return Person; }(); 複製程式碼
從最開始,我們就清楚Webpack Tree shaking是不處理IIFE的,所以這裡即使沒有呼叫Person類在bundle中也存在了Person類的程式碼。
我們可以設定使用loose: true
來使得Babel在轉化時使用寬鬆的模式,但是這樣也僅僅只能去除_createClass
,Person本身依舊存在
//webpack.config.js { loader: 'babel-loader', options: { presets: [["env", { loose: true }]] } } 複製程式碼
result:結果如下
function(e, t, n) { "use strict"; function r(e, t) { if(!(e instanceof t)) throw new TypeError("Cannot call a class as a function") } t.__esModule = !0; t.Person = function() { function e(t) { var n = t.name, o = t.age, u = t.sex; r(this, e), this.className = "Person", this.name = n, this.age = o, this.sex = u } return e.prototype.getName = function() { return this.name }, e }(), t.Apple = function() { function e(t) { var n = t.model; r(this, e), this.className = "Apple", this.model = n } return e.prototype.getModel = function() { return this.model }, e }() } 複製程式碼
Babel6的討論
Class declaration in IIFE considered as side effect
詳見:github.com/mishoo/Ugli…
總結:
- Uglify doesn't perform program flow analysis. but rollup did(Uglify不做程式流的分析,但是rollup做了)
- Variable assignment could cause an side effect(變數的賦值可能會引起副作用)
-
Add some
/*#__PURE__*/
annotation could help with it(可以嘗試添加註釋/*#__PURE__*/
的方式來宣告一個無副作用的函式,使得Webpack在分析處理時可以過濾掉這部分程式碼)
關於第三點:新增/*#__PURE__*/
,這也是Babel7
的執行行為,這是被Babel7處理的程式碼
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; }(); exports.Person = Person; 複製程式碼
所以,在Babel7的執行環境下,經過Webpack的處理是可以過濾掉這個未使用的Person類的。
Babel帶來的問題2-模組轉換(Babel6/7)
我們已經清楚,CommonJS模組和ES6的模組是不一樣的,Babel在處理時預設將所有的模組轉換成為了exports
結合require
的形式,我們也清楚Webpack是基於ES6的模組才能做到最大程度的Tree shaking的,所以我們在使用Babel時,應該將Babel的這一行為關閉,方式如下:
//babel.rc presets: [["env", { module: false } ]] 複製程式碼
但這裡存在一個問題:什麼情況下我們該關閉這個轉化?
如果我們都在一個App中,這個module的關閉是沒有意義的,因為如果關閉了,那麼打包出來的bundle是沒有辦法在瀏覽器裡面執行的(不支援import)。所以這裡我們應該在App依賴的某個功能庫打包時去設定。
比如:像lodash/lodash-es
,redux
,react-redux
,styled-component
這類庫都同時存在ES5和ES6的版本
- redux - dist - es - lib - src ... 複製程式碼
同時在packages.json中設定入口配置,就可以讓Webpack優先讀取ES6的檔案 eg:Redux ES 入口
//package.json "main": "lib/redux.js", "unpkg": "dist/redux.js", "module": "es/redux.js", "typings": "./index.d.ts", 複製程式碼
Webpack Tree shaking - Side Effect
在官方文件中提到了一個sideEffects的標記,但是關於這個標記的作用,文件詳述甚少,甚至執行官方給了例子 ,在最新的版本的Webpack中也無法得到它解釋的結果,因此對這個標記的用法存在更多的疑惑。讀完Webpack中的sideEffects到底該怎麼用? 這篇大致會對做了什麼?怎麼用? 有了基本的認知,我們可以接著深挖
Tree shaking到底做了什麼
Demo1:
//App.js import { a } from 'tree-shaking-npm-module-demo' console.log(a); //index.js export { a } from "./a"; export { b } from "./b"; export { c } from "./c"; //a.js export var a = "a"; //b.js export var b = "b"; //c.js export var c = "c"; 複製程式碼
result: 僅僅留下了a的程式碼
function(e, t, r) { "use strict"; r.r(t); console.log("a") } 複製程式碼
Demo2:
//App.js import { a } from 'tree-shaking-npm-module-demo' console.log(a); //index.js export { a } from "./a"; export { b } from "./b"; export { c } from "./c"; //a.js export var a = "a"; //b.js (function fun() { console.log('fun'); })() window.name = 'name' export var b = "b"; //c.js export var c = "c"; 複製程式碼
result: 留下了a的程式碼,同時還存在b中的程式碼
function(e, n, t) { "use strict"; t.r(n); console.log("fun"), window.name = "name"; console.log("a") } 複製程式碼
Demo3: 新增sideEffects標記
//package.json { "sideEffects": false, } 複製程式碼
result: 僅留下了a的程式碼,b模組中的所有的副作用的程式碼被刪除了
function(e, t, r) { "use strict"; r.r(t); console.log("a") } 複製程式碼
綜上:參考What Does Webpack 4 Expect From A Package With sideEffects: false
中@asdfasdfads(那個目前只有三個贊)
的回答
實際上:
The consensus is that "has no sideEffects" phrase can be decyphered as "doesn't talk to things external to the module at the top level". 譯為: "沒有副作用"這個短語可以被解釋為"不與頂層模組以外的東西進行互動"。 複製程式碼
在Demo3中,我們添加了"sideEffects": false
也就意味著:
1.在b模組中雖然有一些副作用的程式碼(IIFE和更改全域性變數/屬性的操作),但是我們不認為刪除它是有風險的
2.模組被引用過
(被其他的模組import過或重新export過)
情況A //b.js (function fun() { console.log('fun'); })() window.name = 'name' export var b = "b"; //index.js import { b } from "./b"; 分析: b模組一旦被import,那麼其中的程式碼會在翻譯時執行 情況B //b.js (function fun() { console.log('fun'); })() window.name = 'name' export var b = "b"; //index.js export { b } from "./b"; 分析: According to the ECMA Module Spec, whenever a module reexports all exports (regardless if used or unused) need to be evaluated and executed in the case that one of those exports created a side-effect with another. b模組一旦被重新re-export,根據ECMA模組規範,每當模組重新匯出所有匯出(無論使用或未使用)時,都需要對其中一個匯出與另一個匯出產生副作用的情況進行評估和執行 情況C //b.js (function fun() { console.log('fun'); })() window.name = 'name' export var b = "b"; //index.js //沒有import也沒有export 分析: 沒用的當然沒有什麼影響 複製程式碼
只要滿足以上兩點:我們就可以根據情況安全的新增這個標記來通知Webpack可以安全的刪除這些無用的程式碼。 當然如果你的程式碼確實有一些副作用,那麼可以改為提供一個數組:
"sideEffects": [ "./src/some-side-effectful-file.js" ] 複製程式碼
總結:
如果想利用好Webpack的Tree shaking,需要對自己的專案進行一些改動。 建議:
1.對第三方的庫:
-
團隊的維護的:視情況加上
sideEffects
標記,同時更改Babel配置來匯出ES6模組
- 第三方的:儘量使用提供ES模組的版本
2.工具:
- 升級Webpack到4.x
- 升級Babel到7.x
參考
- 你的Tree-Shaking並沒什麼卵用:juejin.im/post/5a5652…
- Tree-Shaking效能優化實踐 - 原理篇:juejin.im/post/5a4dc8…
- Tree-Shaking效能優化實踐 - 實踐篇:juejin.im/post/5a4dca…
- segmentfault.com/a/119000001…
- 使用Tree-Shaking:www.xbhub.com/wiki/webpac…
- 體積減少80%!釋放webpack tree-shaking的真正潛力:juejin.im/post/5b8ce4…
- webpack 如何通過作用域分析消除無用程式碼:vincentdchan.github.io/2018/05/bet… (github.com/vincentdcha… )
- 今天,你升級Webpack2了嗎?www.aliued.com/?p=4060
- Webpack中文網站:www.webpackjs.com/guides/prod…
-
Webpack中的
sideEffects
到底該怎麼用?juejin.im/post/5b4ff9…