Webpack Tree shaking 深入探究

sialvsic發表於2018-10-06

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的原理,在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';
    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

參考

相關文章