Webpack4: Tree-shaking 深度解析

cTomorrow發表於2019-02-15

什麼是Tree-shaking

所謂Tree-shaking就是‘搖’的意思,作用是把專案中沒必要的模組全部抖掉,用於在不同的模組之間消除無用的程式碼,可列為效能優化的範疇。

Tree-shaking早期由rollup實現,後來webpack2也實現了Tree-shaking的功能,但是至今還不是很完備。至於為什麼不完備,可以看一下百度外賣的Tree-shaking原理

Tree-shading原理

Tree-shaking的本質用於消除專案一些不必要的程式碼。早在編譯原理中就有提到DCE(dead code eliminnation),作用是消除不可能執行的程式碼,它的工作是使用編輯器判斷出某些程式碼是不可能執行的,然後清除。

Tree-shaking同樣的也是消除專案中不必要的程式碼,但是和DCE又有略不相同。可以說是DCE的一種實現,它的主要工作是應用於模組間,在打包過程中抽出有用的部分,用於完成DCE。

Tree-shaking是依賴ES6模組靜態分析的,ES6 module的特點如下:

  1. 只能作為模組頂層的語句出現
  2. import 的模組名只能是字串常量
  3. import binding 是 immutable的

依賴關係確定,與執行時無關,靜態分析。正式因為ES6 module的這些特點,才讓Tree-shaking更加流行。

主要特點還是依賴於ES6的靜態分析,在編譯時確定模組。如果是require,在執行時確定模組,那麼將無法去分析模組是否可用,只有在編譯時分析,才不會影響執行時的狀態。

Webpack4的Tree-shaking

webpack從第2版本就開始支援Tree-shaking的功能,但是至今也並不能實現的那麼完美。凡是具有副作用的模組,webpack的Tree-shaking就歇菜了。

副作用

副作用在我們專案中,也同樣是頻繁的出現。知道函數語言程式設計的朋友都會知道這個名詞。所謂模組(這裡模組可稱為一個函式)具有副作用,就是說這個模組是不純的。這裡可以引入純函式的概念。

對於相同的輸入就有相同的輸出,不依賴外部環境,也不改變外部環境。

符合上述就可以稱為純函式,不符合就是不純的,是具有副作用的,是可能對外界造成影響的。

webpack自身的Tree-shaking不能分析副作用的模組。以lodash-es這個模組來舉個例子

//test.js
import _ from "lodash-es";

const func1 = function(value){
    return _.isArray(value);
}
const func2 = function(value){
    return value=null;
}

export {
    func1,
    func2,
}
//index.js
import {func2} from './test.js'
func2()
複製程式碼

上述程式碼在test.js中引入lodash-es,在func1中使用了loadsh,並且這裡不符合純函式的概念,它是具有副作用的。func2是一個純函式。

在index.js中只引入了func2,並且使用了func2,可見整個程式碼的執行是和func1是沒有任何關係的。我們通過生產環境打包一下試試看(Tree-shaking只在生產環境生效)

Webpack4: Tree-shaking 深度解析
main.js 91.7KB,可見這個結果是符合我們的預期的,因為func1函式的副作用,webpack自身的Tree-shaking並沒有檢測到這裡有沒必要的模組。解決辦法還是用的,webpack的外掛系統是很強大的。

webpack-deep-scope-plugin

webpack-deep-scope-plugin是一位中國同胞(學生)在Google夏令營,在導師Tobias帶領下寫的一個webpack外掛。(此時慢慢的羨慕)

這個外掛主要用於填充webpack自身Tree-shaking的不足,通過作用域分析來消除無用的程式碼。

外掛的原理

這個外掛是基於作用域分析的,那麼都有什麼樣的作用域?

// module scope start

// Block

{ // <- scope start
} // <- scope end

// Class

class Foo { // <- scope start

} // <- scope end

// If else

if (true) { // <- scope start
   
} /* <- scope end */ else { // <- scope start
  
} // <- scope end

// For

for (;;) { // <- scope start
} // <- scope end

// Catch

try {

} catch (e) { // <- scope start

} // <- scope end

// Function

function() { // <- scope start
} // <- scope end

// Scope

switch() { // <- scope start
} // <- scope end

// module scope end
複製程式碼

對於ES6模組來說,上面作用域只有function和class是可以被匯出的,其他的作用域可以稱之為function和class的子作用域並不能被匯出實際上歸屬於父作用域的。

外掛通過分析程式碼的作用域,進而得到作用域與作用域之間的關係。

分析作用域

分析程式碼的作用域的基礎是建立做AST(Abstract Syntax Tree)抽象語法樹上面的。這個可以通過escope來完成。

Webpack4: Tree-shaking 深度解析

拿到解析完的AST抽象語法樹,利用圖的深度優先遍歷找到哪些作用域是可以被使用到的,哪些作用域是不可以被使用到的。從而分析作用域之間的關係和匯出變數之間的關係。進而執行模組消除。

外掛的不足

JavaScript中還是有一些程式碼是不會消去的。

根作用域的引用
import { isNull } from 'lodash-es';

export function scope(...args) {
  return isNull(...args);
}

複製程式碼

在根作用域引用到的作用域不會被消除。

給變數重新賦值
import _ from "lodash-es";

var func1
func1 = function(value){
    return _.isArray(value);
}
const func2 = function(value){
    return value=null;
}

export {
    func1,
    func2,
}
複製程式碼

上述程式碼中先定義了func1,然後又給func1賦值,這樣缺少了資料流分析,同樣外掛也是不可以的。

純函式呼叫

引用作者的例子

import _curry1 from './internal/_curry1';
import curryN from './curryN';
import max from './max';
import pluck from './pluck';

var allPass = /*#__PURE__*/_curry1(function allPass(preds) {
  return curryN(reduce(max, 0, pluck('length', preds)), function () {
    var idx = 0;
    var len = preds.length;
    while (idx < len) {
      if (!preds[idx].apply(this, arguments)) {
        return false;
      }
      idx += 1;
    }
    return true;
  });
});
export default allPass;
複製程式碼

當一個匿名函式被包在一個函式呼叫中(IIFE也是如此),那麼外掛是無法分析的。但是如果加上/*#__PURE__*/註釋的話,這個外掛會把這個函式呼叫當作一個獨立的域,tree-shaking是可以生效的。

探討的一些問題

我們都知道在這個ES6氾濫的時代,ES6的程式碼在專案中出現已經很廣泛。(先不考慮線上環境打包成ES5)。上面提到外掛的利用作用域來分析。能匯出的作用域只有class和funciton。function的情況在上面已經說過,現在來探討一下class的情況。

no plugin

當不使用外掛的時候,我們來看一下會不會Tree-shaking,預期是會被Tree-shaking。書寫下面這樣一段簡單的程式碼。

class Test{
    init(value){
        console.log('test init');
    }
}
export {
    Test,
}
複製程式碼

Webpack4: Tree-shaking 深度解析
我們發現在沒有使用外掛的情況下,被Tree-shaking了。預期相同。

no plugin + 副作用

當我們在不適用外掛的情況下,並且引入副作用,觀察一下會不會打包,預期是不會打包。書寫下面程式碼。

class Test{
    init(value){
        console.log('test init');
        return _.isArray(value);
    }
}
export {
    Test,
}
複製程式碼

Webpack4: Tree-shaking 深度解析
觀察打包結果,main.js 91.7KB,並沒有被Tree-shaking,符合預期的結果。

plugin + 副作用

當我們使用外掛並且程式碼中存在副作用的情況下,觀察打包情況。由於上面的外掛原理的鋪墊,我們預期這次是可以Tree-shaking的。利用上例程式碼來測試。

Webpack4: Tree-shaking 深度解析
我們觀察可以看出main.js 6.78KB,Tree-shaking生效。

plugin + 副作用 + babel

由於使用者瀏覽器對ES6支援度不夠的原因,線上的程式碼不能全是ES6的,有時候我們要把ES6的程式碼打包成ES5的,放到線上環境來執行。利用上例程式碼來測試。

Webpack4: Tree-shaking 深度解析

??? 什麼鬼,我沒有用到它,為什麼這麼大??? 一串懵逼

成也babel,敗也babel

懵逼懵逼,babel成就了線上生產環境,但失去了Tree-shaking優化。我們來看看怎麼回事。

沒有副作用的情況

當去除調副作用的時候我們來打包一下。

Webpack4: Tree-shaking 深度解析
沒有找到test init Tree-shaking生效。為什麼呢?我們使用babel線上工具編譯一下原始碼。

"use strict";

function _instanceof(left, right) { 
    if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) {
         return right[Symbol.hasInstance](left); 
        } else {
             return left instanceof right; 
            } 
        }

function _classCallCheck(instance, Constructor) { 
    if (!_instanceof(instance, 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 Test =
    /*#__PURE__*/
    function () {
        function Test() {
            _classCallCheck(this, Test);
        }

        _createClass(Test, [{
            key: "init",
            value: function init(value) {
                console.log("test init")
            }
        }]);

        return Test;
    }();
複製程式碼

上面可以看到最新的babel和webpack有了契合,在Test立即執行函式的地方使用了 /*#__PURE__*/(忘記可以往上看),讓下面的IIFE變成可分析的,成功了使用了Tree-shaking。

有副作用的情況下

上面探討情況的時候就得知有副作用的情況下,不可以被打包的。ES6編譯程式碼如下。

"use strict";

function _instanceof(left, right) { 
    if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) {
         return right[Symbol.hasInstance](left); 
        } else {
             return left instanceof right; 
            } 
        }

function _classCallCheck(instance, Constructor) { 
    if (!_instanceof(instance, 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 Test =
    /*#__PURE__*/
    function () {
        function Test() {
            _classCallCheck(this, Test);
        }

        _createClass(Test, [{
            key: "init",
            value: function init(value) {
                console.log("test init")
                return _.isArray(value);
            }
        }]);

        return Test;
    }();
複製程式碼

這裡雖然bable新版契合了webpack,但是還是有一些問題。自己也沒有找出是哪裡除了問題,作者說JavaScript程式碼還是有一些是不可以清除的,也許就出現到這裡。提供一個作者的外掛Demo

babel的解決方案

無論是ES6,還是ES5,Tree-shaking不能生效的原因總的歸根結底還是因為程式碼副作用的問題。可想而知程式碼的書寫規範是多麼重要。這裡我所想出的解決方案有兩種。

1.程式碼的書寫規範,儘量避免副作用。

書寫程式碼過程中儘量使用純函式的方式來寫程式碼,保持書寫規範,不讓程式碼有副作用。例如把class類引用的副作用改成純的。

class Test{
    init(value,_){  //引數引入lodash模組
        console.log('test init');
        return _.isArray(value);
    }
}
export{
    Test,
}
複製程式碼

Webpack4: Tree-shaking 深度解析

Webpack4: Tree-shaking 深度解析
可以看出,沒有副作用的ES6程式碼編譯成ES5,Tree-shaking也是生效的。

2.靈活使用ES6程式碼

兩套程式碼。當瀏覽器支援的時候,就使用ES6的程式碼,ES5的程式碼。此方案可參考瀏覽器支援ES6的最優解決方案

總結

專案中難免會一些用不到的模組佔位置影響我們的專案,Tree-shaking的出現也為開發者在效能優化方面提供了非常大的幫助,靈活使用Tree-shaking才能讓Tree-shaking發揮作用,處理好專案中程式碼的副作用可以使專案更加的完美。

引用文章

webpack 如何通過作用域分析消除無用程式碼

Tree-Shaking效能優化實踐 - 原理篇

原文釋出於Webpack4:Tree-shaking深度解析

相關文章