體積減少80%!釋放webpack tree-shaking的真正潛力

騰訊IVWEB團隊發表於2018-09-03

在上週末廣州舉辦的feday中,webpack的核心開發者Sean在介紹webpack外掛系統原理時,隆重介紹了一箇中國學生於Google夏令營,在導師Tobias帶領下寫的一個webpack外掛,webpack-deep-scope-analysis-plugin,這個外掛能夠大大提高webpack tree-shaking的效率。

tree-shaking目前的缺陷

tree-shaking 作為 rollup 的一個殺手級特性,能夠利用ES6的靜態引入規範,減少包的體積,避免不必要的程式碼引入,webpack2也很快引入了這個特性,但是目前,webpack只能做比較簡單的解決方案,比如:

體積減少80%!釋放webpack tree-shaking的真正潛力

這個例子中,webpack會尋找引入變數的引用,當發現沒有對isNumber的引用時,就會去除isNumber的程式碼。這其實不太實用,畢竟在現在的vscode中,沒有引用的變數在ide中都會灰顯提示,一般不會犯這種import某個模組卻不用的錯誤了。

如果是接下來這種引入方式呢,我寫了一個demo如下

體積減少80%!釋放webpack tree-shaking的真正潛力

這個例子非常簡單,如果用圖來表示是這樣

體積減少80%!釋放webpack tree-shaking的真正潛力

在index.js中引入了func.js中的func2,並沒有引入func1,但是func1引入了lodash。webpack檢查的時候發現func.js中的確用到了lodash,所以不會把lodash去掉。實際上,我們根本沒用到它。

webpack-deep-scope-analysis-plugin就可以解決這種判斷。

外掛效果

引入前

體積減少80%!釋放webpack tree-shaking的真正潛力

引入後

體積減少80%!釋放webpack tree-shaking的真正潛力

85.8kb -> 不到1kb

當然,我這裡是標題黨了,因為這裡直接把一個lodash庫給去掉了,所以變化才這麼驚人。但是即使在實際專案中,我們也能輕易用一個外掛減少大量的不必要的引入。

原理

那麼這個外掛是怎麼去解決這個問題的呢?這裡根據原作者在Medium上寫的文章,簡單介紹一下他的做法。

webpack的原理,其實就是遍歷所有的模組,把它們打包成一個檔案,在這個過程中,它就知道哪些export的模組有被使用到。那我們同樣也可以遍歷所有的scope(作用域),簡化沒有用到的scope,最後只留下我們需要的。

體積減少80%!釋放webpack tree-shaking的真正潛力

上圖中,func5層層引用fun4 fun3 fun2 fun1,最後解析出來其實只使用了deepEqual模組。

什麼是scope呢,其實scope在各個語言中都有存在,在Wikipedia中是作為計算機術語,有更詳細的解釋,我覺得可以翻譯為作用域或者上下文,在ECMAScript中,有以下明確的定義:

// 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中,module是一種根作用域,只有function和class才能作為子作用域被匯出,所以我們解析的時候,不會把所有的scope都作為節點算進去。

我們提到的這個webpack外掛,正是內建了這樣一個scope分析器,它能夠從入口檔案中分析出scope的引用關係,最後排除掉所有沒有用到的模組。

當然,這個外掛也並不是自己做了所有的事情,它也是依賴於了前人的工作。 escope 是一個分析ES中scope的工具,外掛作者將它改成了ts版本整合到了外掛中,並且利用了webpack暴露的介面,可以解析出來的模組的AST樹,基於這個AST就可以交給escope分析出scope的引用關係。

一些邊際用例

凡事不能完美,這個外掛也有一些情況會導致判斷失誤

情況一:重複賦值變數

比較典型的是以下這個例子:


import { isNull } from 'lodash-es';

var fun = 1;

fun = function scope(...args) {
  return isNull(...args);
}

export { fun }
複製程式碼

這個例子中fun變數一開始被賦值為數字,然後被賦值成一個函式,但是scope分析器會直接跳過這個變數,不把它當作一個單獨的scope。

情況二:純函式

// copy from rambda/es/allPass.js
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;
複製程式碼

在這個例子中,import allPass 會導致_curry1的執行,因此它不會被當作一個單獨的scope,因為它可能會有一些“副作用”,比如改變某個全部變數,對全域性造成影響。 所以作者給了個方案,可以在這個函式前加/*#__PURE__*/,這樣就會把這個函式視為無副作用的純函式,如果我們沒有import allPass,它引用的其他模組都會被去除。

最佳實踐

首先,要用到tree-shaking,必然要保證引用的模組都是ES6規範的。這也是為什麼我在前面的demo中,引入的是lodash-es而不是lodash

在專案中,注意要把babel設定module: false,避免babel將模組轉為CommonJS規範。引入的模組包,也必須是符合ES6規範,並且在最新的webpack中加了一條限制,即在package.json中定義sideEffect: false,這也是為了避免出現import xxx導致模組內部的一些函式執行後影響全域性環境,卻被去除掉的情況。

未來

當時跟這位外掛作者溝通,他說將來有可能Tobias會把這個外掛內建到webpack中,這也是符合webpack4零配置的趨勢。但是我們也看得到,要將前端工程的dead code elimination做到和其他靜態語言一樣好,靠這些工具是遠遠不夠的,模組自身也必須配合做到符合規範。

文章出處:


《IVWEB 技術週刊》 震撼上線了,關注公眾號:IVWEB社群,每週定時推送優質文章。

相關文章