Webpack Tree shaking 深刻探究

Tree shaking的目的

App每每有一個入口文件,至關於一棵樹的主幹,入口文件有不少依賴的模塊,至關於樹枝。實際狀況中,雖然依賴了某個模塊,但其實只使用其中的某些功能。經過Tree shaking,將沒有使用的模塊搖掉,這樣來達到刪除無用代碼的目的。html

模塊

CommonJS的模塊require modules.exports,exportsreact

var my_lib;
if (Math.random()) {
    my_lib = require('foo');
} else {
    my_lib = require('bar');
}

module.exports = xx
複製代碼

ES2015(ES6)的模塊import,exportwebpack

// lib.js
export function foo() {}
export function bar() {}

// main.js
import { foo } from './lib.js';
foo();
複製代碼

Tree shaking的原理

關於Tree shaking的原理,在Tree Shaking性能優化實踐 - 原理篇已經說的比較清楚,簡單來講。git

Tree shaking的本質是消除無用的JavaScript代碼。
由於ES6模塊的出現,ES6模塊依賴關係是肯定的,`和運行時的狀態無關`,能夠進行可靠的靜態分析,
這就是Tree shaking的基礎。
複製代碼

支持Tree-shaking的工具

  • Webpack/UglifyJS
  • rollup
  • Google closure compiler

今天,咱們來看一下Webpack的Tree shaking作了什麼github

Webpack Tree shaking

Tree shaking到底能作哪些事情??web

1.Webpack Tree shaking從ES6頂層模塊開始分析,能夠清除未使用的模塊

官網的例子來看 代碼npm

//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的代碼被移除json

function(e, t, r) {
  "use strict";
  r.r(t), console.log("cube")
}
複製代碼

2.Webpack Tree shaking會對多層調用的模塊進行重構,提取其中的代碼,簡化函數的調用結構

代碼redux

//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: 簡化後的代碼以下segmentfault

//摘錄核心代碼
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';
    Asset      Size 
bundle.js  70.5 KiB

import { last } from 'lodash';
    Asset      Size
bundle.js  70.5 KiB

import last from 'lodash/last';   <=這種引用方式明顯下降了打包後的大小
    Asset      Size
bundle.js  1.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

參考

相關文章
相關標籤/搜索