1. 程式人生 > >釋放webpack tree-shaking潛力之webpack-deep-scope-analysis-plugin

釋放webpack tree-shaking潛力之webpack-deep-scope-analysis-plugin

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

tree-shaking 目前的缺陷

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

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

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

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

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

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

外掛效果

引入前

引入後

85.8kb -> 不到 1kb

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

原理

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

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

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

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

  1. // module scope start
  2. // Block
  3. { // <- scope start
  4. } // <- scope end
  5. // Class
  6. class Foo { // <- scope start
  7. } // <- scope end
  8. // If else
  9. if (true) { // <- scope start
  10. } /* <- scope end */ else { // <- scope start
  11. } // <- scope end
  12. // For
  13. for (;;) { // <- scope start
  14. } // <- scope end
  15. // Catch
  16. try {
  17. } catch (e) { // <- scope start
  18. } // <- scope end
  19. // Function
  20. function() { // <- scope start
  21. } // <- scope end
  22. // Scope
  23. switch() { // <- scope start
  24. } // <- scope end
  25. // module scope end

複製程式碼

在 ES6 中, module 是一種根作用域, 只有 function 和 class 才能作為子作用域被匯出, 所以我們解析的時候, 不會把所有的 scope 都作為節點算進去.

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

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

一些邊際用例

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

情況一: 重複賦值變數

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

  1. import { isNull } from 'lodash-es';
  2. var fun = 1;
  3. fun = function scope(...args) {
  4. return isNull(...args);
  5. }
  6. export { fun }

複製程式碼

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

情況二: 純函式

  1. // copy from rambda/es/allPass.js
  2. import _curry1 from './internal/_curry1';
  3. import curryN from './curryN';
  4. import max from './max';
  5. import pluck from './pluck';
  6. var allPass = /*#__PURE__*/_curry1(function allPass(preds) {
  7. return curryN(reduce(max, 0, pluck('length', preds)), function () {
  8. var idx = 0;
  9. var len = preds.length;
  10. while (idx < len) {
  11. if (!preds[idx].apply(this, arguments)) {
  12. return false;
  13. }
  14. idx += 1;
  15. }
  16. return true;
  17. });
  18. });
  19. 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 做到和其他靜態語言一樣好, 靠這些工具是遠遠不夠的, 模組自身也必須配合做到符合規範.

參考連結:

github專案地址:

https://github.com/vincentdchan/webpack-deep-scope-analysis-plugin

Medium原文出處:

https://medium.com/webpack/better-tree-shaking-with-deep-scope-analysis-a0b788c0ce77