Webpack 4 Tree Shaking 終極優化指南

幾個月前,個人任務是將咱們組的 Vue.js 項目構建配置升級到 Webpack 4。咱們的主要目標之一是利用 tree-shaking 的優點,即 Webpack 去掉了實際上並無使用的代碼來減小包的大小。如今,tree-shaking 的好處將根據你的代碼庫而有所不一樣。因爲咱們的幾個架構決策,咱們從公司內部的其餘庫中提取了大量代碼,而咱們只使用了其中的一小部分。css

我寫這篇文章是由於恰當地優化 Webpack 並不簡單。一開始我覺得這是一種簡單的魔法,但後來我花了一個月的時間在網上搜索我遇到的一系列問題的答案。我但願經過這篇文章,其餘人會更容易地處理相似問題。前端

先說好處

在討論技術細節以前,讓我先總結一下好處。不一樣的應用程序將看到不一樣程度的好處。主要的決定因素是應用程序中死代碼的數量。若是你沒有多少死代碼,那麼你就看不到 tree-shaking 的多少好處。咱們項目裏有不少死代碼。node

在咱們部門,最大的問題是共享庫的數量。從簡單的自定義組件庫,到企業標準組件庫,再到莫名其妙地塞到一個庫中的大量代碼。不少都是技術債務,但一個大問題是咱們全部的應用程序都在導入全部這些庫,而實際上每一個應用程序都只須要其中的一小部分react

總的來講,一旦實現了 tree-shaking,咱們的應用程序就會根據應用程序的不一樣,縮減率從25%到75%。平均縮減率爲52%,主要是由這些龐大的共享庫驅動的,它們是小型應用程序中的主要代碼。webpack

一樣,具體狀況會有所不一樣,可是若是你以爲你打的包中可能有不少不須要的代碼,這就是如何消除它們的方法。web

沒有示例代碼倉庫

對不住了各位老鐵,我作的項目是公司的財產,因此我不能分享代碼到 GitHub 倉庫了。可是,我將在本文中提供簡化的代碼示例來講明個人觀點。正則表達式

所以,廢話少說,讓咱們來看看如何編寫可實現 tree-shaking 的最佳 webpack 4 配置。npm

什麼是死代碼

很簡單:就是 Webpack 沒看到你使用的代碼。Webpack 跟蹤整個應用程序的 import/export 語句,所以,若是它看到導入的東西最終沒有被使用,它會認爲那是「死代碼」,並會對其進行 tree-shaking 。json

死代碼並不老是那麼明確的。下面是一些死代碼和「活」代碼的例子,但願能讓你更明白。請記住,在某些狀況下,Webpack 會將某些東西視爲死代碼,儘管它實際上並非。請參閱《反作用》一節,瞭解如何處理。bootstrap

// 導入並賦值給 JavaScript 對象,而後在下面的代碼中被用到
// 這會被看做「活」代碼,不會作 tree-shaking
import Stuff from './stuff';
doSomething(Stuff);
// 導入並賦值給 JavaScript 對象,但在接下來的代碼裏沒有用到
// 這就會被當作「死」代碼,會被 tree-shaking
import Stuff from './stuff';
doSomething();
// 導入但沒有賦值給 JavaScript 對象,也沒有在代碼裏用到
// 這會被當作「死」代碼,會被 tree-shaking
import './stuff';
doSomething();
// 導入整個庫,可是沒有賦值給 JavaScript 對象,也沒有在代碼裏用到
// 很是奇怪,這居然被當作「活」代碼,由於 Webpack 對庫的導入和本地代碼導入的處理方式不一樣。
import 'my-lib';
doSomething();

用支持 tree-shaking 的方式寫 import

在編寫支持 tree-shaking 的代碼時,導入方式很是重要。你應該避免將整個庫導入到單個 JavaScript 對象中。當你這樣作時,你是在告訴 Webpack 你須要整個庫, Webpack 就不會搖它。

以流行的庫 Lodash 爲例。一次導入整個庫是一個很大的錯誤,可是導入單個的模塊要好得多。固然,Lodash 還須要其餘的步驟來作 tree-shaking,但這是個很好的起點。

// 所有導入 (不支持 tree-shaking)
import _ from 'lodash';
// 具名導入(支持 tree-shaking)
import { debounce } from 'lodash';
// 直接導入具體的模塊 (支持 tree-shaking)
import debounce from 'lodash/lib/debounce';

基本的 Webpack 配置

使用 Webpack 進行 tree-shaking 的第一步是編寫 Webpack 配置文件。你能夠對你的 webpack 作不少自定義配置,可是若是你想要對代碼進行 tree-shaking,就須要如下幾項。

首先,你必須處於生產模式。Webpack 只有在壓縮代碼的時候會 tree-shaking,而這隻會發生在生產模式中。

其次,必須將優化選項 「usedExports」 設置爲true。這意味着 Webpack 將識別出它認爲沒有被使用的代碼,並在最初的打包步驟中給它作標記。

最後,你須要使用一個支持刪除死代碼的壓縮器。這種壓縮器將識別出 Webpack 是如何標記它認爲沒有被使用的代碼,並將其剝離。TerserPlugin 支持這個功能,推薦使用。

下面是 Webpack 開啓 tree-shaking 的基本配置:

// Base Webpack Config for Tree Shaking
const config = {
 mode: 'production',
 optimization: {
  usedExports: true,
  minimizer: [
   new TerserPlugin({...})
  ]
 }
};

有什麼反作用

僅僅由於 Webpack 看不到一段正在使用的代碼,並不意味着它能夠安全地進行 tree-shaking。有些模塊導入,只要被引入,就會對應用程序產生重要的影響。一個很好的例子就是全局樣式表,或者設置全局配置的JavaScript 文件。

Webpack 認爲這樣的文件有「反作用」。具備反作用的文件不該該作 tree-shaking,由於這將破壞整個應用程序。Webpack 的設計者清楚地認識到不知道哪些文件有反作用的狀況下打包代碼的風險,所以默認地將全部代碼視爲有反作用。這能夠保護你免於刪除必要的文件,但這意味着 Webpack 的默認行爲其實是不進行 tree-shaking。

幸運的是,咱們能夠配置咱們的項目,告訴 Webpack 它是沒有反作用的,能夠進行 tree-shaking。

如何告訴 Webpack 你的代碼無反作用

package.json 有一個特殊的屬性 sideEffects,就是爲此而存在的。它有三個可能的值:

true 是默認值,若是不指定其餘值的話。這意味着全部的文件都有反作用,也就是沒有一個文件能夠 tree-shaking。

false 告訴 Webpack 沒有文件有反作用,全部文件均可以 tree-shaking。

第三個值 […] 是文件路徑數組。它告訴 webpack,除了數組中包含的文件外,你的任何文件都沒有反作用。所以,除了指定的文件以外,其餘文件均可以安全地進行 tree-shaking。

每一個項目都必須將 sideEffects 屬性設置爲 false 或文件路徑數組。在我公司的工做中,咱們的基本應用程序和我提到的全部共享庫都須要正確配置 sideEffects 標誌。

下面是 sideEffects 標誌的一些代碼示例。儘管有 JavaScript 註釋,但這是 JSON 代碼:

// 全部文件都有反作用,全都不可 tree-shaking
{
 "sideEffects": true
}
// 沒有文件有反作用,全均可以 tree-shaking
{
 "sideEffects": false
}
// 只有這些文件有反作用,全部其餘文件均可以 tree-shaking,但會保留這些文件
{
 "sideEffects": [
  "./src/file1.js",
  "./src/file2.js"
 ]
}

全局 CSS 與反作用

首先,讓咱們在這個上下文中定義全局 CSS。全局 CSS 是直接導入到 JavaScript 文件中的樣式表(能夠是CSS、SCSS等)。它沒有被轉換成 CSS 模塊或任何相似的東西。基本上,import 語句是這樣的:

// 導入全局 CSS
import './MyStylesheet.css';

所以,若是你作了上面提到的反作用更改,那麼在運行 webpack 構建時,你將當即注意到一個棘手的問題。以上述方式導入的任何樣式表如今都將從輸出中刪除。這是由於這樣的導入被 webpack 視爲死代碼,並被刪除。

幸運的是,有一個簡單的解決方案能夠解決這個問題。Webpack 使用它的模塊規則系統來控制各類類型文件的加載。每種文件類型的每一個規則都有本身的 sideEffects 標誌。這會覆蓋以前爲匹配規則的文件設置的全部 sideEffects 標誌。

因此,爲了保留全局 CSS 文件,咱們只須要設置這個特殊的 sideEffects 標誌爲 true,就像這樣:

// 全局 CSS 反作用規則相關的 Webpack 配置
const config = {
 module: {
  rules: [
   {
    test: /regex/,
    use: [loaders],
    sideEffects: true
   }
  ]
 } 
};

Webpack 的全部模塊規則上都有這個屬性。處理全局樣式表的規則必須用上它,包括但不限於 CSS/SCSS/LESS/等等。

什麼是模塊,模塊爲何重要

如今咱們開始進入祕境。表面上看,編譯出正確的模塊類型彷佛是一個簡單的步驟,可是正以下面幾節將要解釋的,這是一個會致使許多複雜問題的領域。這是我花了很長時間才弄明白的部分。

首先,咱們須要瞭解一下模塊。多年來,JavaScript 已經發展出了在文件之間以「模塊」的形式有效導入/導出代碼的能力。有許多不一樣的 JavaScript 模塊標準已經存在了多年,可是爲了本文的目的,咱們將重點關注兩個標準。一個是 「commonjs」,另外一個是 「es2015」。下面是它們的代碼形式:

// Commonjs
const stuff = require('./stuff');
module.exports = stuff;

// es2015 
import stuff from './stuff';
export default stuff;

默認狀況下,Babel 假定咱們使用 es2015 模塊編寫代碼,並轉換 JavaScript 代碼以使用 commonjs 模塊。這樣作是爲了與服務器端 JavaScript 庫的普遍兼容性,這些 JavaScript 庫一般構建在 NodeJS 之上(NodeJS 只支持 commonjs 模塊)。可是,Webpack 不支持使用 commonjs 模塊來完成 tree-shaking。

如今,有一些插件(如 common-shake-plugin)聲稱可讓 Webpack 有能力對 commonjs 模塊進行 tree-shaking,但根據個人經驗,這些插件要麼不起做用,要麼在 es2015 模塊上運行時,對 tree-shaking 的影響微乎其微。我不推薦這些插件。

所以,爲了進行 tree-shaking,咱們須要將代碼編譯到 es2015 模塊。

es2015 模塊 Babel 配置

據我所知,Babel 不支持將其餘模塊系統編譯成 es2015 模塊。可是,若是你是前端開發人員,那麼你可能已經在使用 es2015 模塊編寫代碼了,由於這是全面推薦的方法。

所以,爲了讓咱們編譯的代碼使用 es2015 模塊,咱們須要作的就是告訴 babel 不要管它們。爲了實現這一點,咱們只需將如下內容添加到咱們的 babel.config.js 中(在本文中,你會看到我更喜歡JavaScript 配置而不是 JSON 配置):

// es2015 模塊的基本 Babel 配置
const config = {
 presets: [
  [
   '[@babel/preset-env](http://twitter.com/babel/preset-env)',
   {
    modules: false
   }
  ]
 ]
};

modules 設置爲 false,就是告訴 babel 不要編譯模塊代碼。這會讓 Babel 保留咱們現有的 es2015 import/export 語句。

劃重點:全部可須要 tree-shaking 的代碼必須以這種方式編譯。所以,若是你有要導入的庫,則必須將這些庫編譯爲 es2015 模塊以便進行 tree-shaking 。若是它們被編譯爲 commonjs,那麼它們就不能作 tree-shaking ,而且將會被打包進你的應用程序中。許多庫支持部分導入,lodash 就是一個很好的例子,它自己是 commonjs 模塊,可是它有一個 lodash-es 版本,用的是 es2015模塊。

此外,若是你在應用程序中使用內部庫,也必須使用 es2015 模塊編譯。爲了減小應用程序包的大小,必須將全部這些內部庫修改成以這種方式編譯。

很差意思, Jest 罷工了

其餘測試框架狀況相似,咱們用的是 Jest。

無論怎麼樣,若是你走到了這一步,你會發現 Jest 測試開始失敗了。你會像我當時同樣,看到日誌裏出現各類奇怪的錯誤,慌的一批。別慌,我會帶你一步一步解決。

出現這個結果的緣由很簡單:NodeJS。Jest 是基於 NodeJS 開發的,而 NodeJS 不支持 es2015 模塊。爲此有一些方法能夠配置 Node,可是在 jest 上行不通。所以,咱們卡在這裏了:Webpack 須要 es2015 進行 tree shaking,可是 Jest 沒法在這些模塊上執行測試。

就是爲何我說進入了模塊系統的「祕境」。這是整個過程當中耗費我最多時間來搞清楚的部分。建議你仔細閱讀這一節和後面幾節,由於我會給出解決方案。

解決方案有兩個主要部分。第一部分針對項目自己的代碼,也就是跑測試的代碼。這部分比較容易。第二部分針對庫代碼,也就是來自其餘項目,被編譯成 es2015 模塊並引入到當前項目的代碼。這部分比較複雜。

解決項目本地 Jest 代碼

針對咱們的問題,babel 有一個頗有用的特性:環境選項。經過配置能夠運行在不一樣環境。在這裏,開發和生產環境咱們須要 es2015 模塊,而測試環境須要 commonjs 模塊。還好,Babel 配置起來很是容易:

// 分環境配置Babel 
const config = {
 env: {
  development: {
   presets: [
    [
     '[@babel/preset-env](http://twitter.com/babel/preset-env)',
     {
      modules: false
     }
    ]
   ]
  },
  production: {
   presets: [
    [
     '[@babel/preset-env](http://twitter.com/babel/preset-env)',
     {
      modules: false
     }
    ]
   ]
  },
  test: {
   presets: [
    [
     '[@babel/preset-env](http://twitter.com/babel/preset-env)',
     {
      modules: 'commonjs'
     }
    ]
   ],
   plugins: [
    'transform-es2015-modules-commonjs' // Not sure this is required, but I had added it anyway
   ]
  }
 }
};

設置好以後,全部的項目本地代碼可以正常編譯,Jest 測試能運行了。可是,使用 es2015 模塊的第三方庫代碼依然不能運行。

解決 Jest 中的庫代碼

庫代碼運行出錯的緣由很是明顯,看一眼node_modules 目錄就明白了。這裏的庫代碼用的是 es2015 模塊語法,爲了進行 tree-shaking。這些庫已經採用這種方式編譯過了,所以當 Jest 在單元測試中試圖讀取這些代碼時,就炸了。注意到沒有,咱們已經讓 Babel 在測試環境中啓用 commonjs 模塊了呀,爲何對這些庫不起做用呢?這是由於,Jest (尤爲是 babel-jest) 在跑測試以前編譯代碼的時候,默認忽略任何來自node_modules 的代碼。

這其實是件好事。若是 Jest 須要從新編譯全部庫的話,將會大大增長測試處理時間。然而,雖然咱們不想讓它從新編譯全部代碼,但咱們但願它從新編譯使用 es2015 模塊的庫,這樣才能在單元測試裏使用。

幸虧,Jest 在它的配置中爲咱們提供瞭解決方案。我想說,這部分確實讓我想了好久,而且我感受不必搞得這麼複雜,但這是我能想到的惟一解決方案。

配置 Jest 從新編譯庫代碼

// 從新編譯庫代碼的 Jest 配置 
const path = require('path');
const librariesToRecompile = [
 'Library1',
 'Library2'
].join('|');
const config = {
 transformIgnorePatterns: [
  `[\\\/]node_modules[\\\/](?!(${librariesToRecompile})).*$`
 ],
 transform: {
  '^.+\.jsx?$': path.resolve(__dirname, 'transformer.js')
 }
};

以上配置是 Jest 從新編譯你的庫所須要的。有兩個主要部分,我會一一解釋。

transformIgnorePatterns 是 Jest 配置的一個功能,它是一個正則字符串數組。任何匹配這些正則表達式的代碼,都不會被 babel-jest 從新編譯。默認是一個字符串「node_modules」。這就是爲何Jest 不會從新編譯任何庫代碼。

當咱們提供了自定義配置,就是告訴 Jest 從新編譯的時候如何忽略代碼。也就是爲何你剛纔看到的變態的正則表達式有一個負向先行斷言在裏面,目的是爲了匹配除了庫之外的全部代碼。換句話說,咱們告訴 Jest 忽略 node_modules 中除了指定庫以外的全部代碼。

這又一次證實了 JavaScript 配置比 JSON 配置要好,由於我能夠輕鬆地經過字符串操做,往正則表達式裏插入庫名字的數組拼接。

第二個是 transform 配置,他指向一個自定義的 babel-jest 轉換器。我不敢100%肯定這個是必須的,但我仍是加上了。設置它用於在從新編譯全部代碼時加載咱們的 Babel 配置。

// Babel-Jest 轉換器
const babelJest = require('babel-jest');
const path = require('path');
const cwd = process.cwd();
const babelConfig = require(path.resolve(cwd, 'babel.config'));
module.exports = babelJest.createTransformer(babelConfig);

這些都配置好後,你在測試代碼應該又能跑了。記住了,任何使用庫的 es2015 模塊都須要這樣配置,否則測試代碼跑不動。

接下來輪到另外一個痛點了:連接庫。使用 npm/yarn 連接的過程就是建立一個指向本地項目目錄的符號連接。結果代表,Babel 在從新編譯經過這種方式連接的庫時,會拋出不少錯誤。我之因此花了這麼長時間才弄清楚 Jest 這檔子事兒,緣由之一就是我一直經過這種方式連接個人庫,出現了一堆錯誤。

解決辦法就是:不要使用 npm/yarn link。用相似 「yalc」 這樣的工具,它能夠鏈接本地項目,同時能模擬正常的 npm 安裝過程。它不但沒有 Babel 重編譯的問題,還能更好地處理傳遞性依賴。

針對特定庫的優化。

若是完成了以上全部步驟,你的應用基本上實現了比較健壯的 tree shaking。不過,爲了進一步減小文件包大小,你還能夠作一些事情。我會列舉一些特定庫的優化方法,但這絕對不是所有。它尤爲能爲咱們提供靈感,作出一些更酷的事情。

MomentJS 是出了名的大致積庫。幸虧它能夠剔除多語言包來減小體積。在下面的代碼示例中,我排除了 momentjs 全部的多語言包,只保留了基本部分,體積明顯小了不少。

// 用 IgnorePlugin 移除多語言包
const { IgnorePlugin } from 'webpack';
const config = {
 plugins: [
  new IgnorePlugin(/^\.\/locale$/, /moment/)
 ]
};

Moment-Timezone 是 MomentJS 的老表,也是個大塊頭。它的體積基本上是一個帶有時區信息的超大 JSON 文件致使的。我發現只要保留本世紀的年份數據,就能夠將體積縮小90%。這種狀況須要用到一個特殊的 Webpack 插件。

// MomentTimezone Webpack Plugin
const MomentTimezoneDataPlugin = require('moment-timezone-data-webpack-plugin');
const config = {
 plugins: [
  new MomentTimezoneDataPlugin({
   startYear: 2018,
   endYear: 2100
  })
 ]
};

Lodash 是另外一個致使文件包膨脹的大塊頭。幸虧有一個替代包 Lodash-es,它被編譯成 es2015 模塊,並帶有 sideEffects 標誌。用它替換 Lodash 能夠進一步縮減包的大小。

另外,Lodash-es,react-bootstrap 以及其餘庫能夠在 Babel transform imports 插件的幫助下實現瘦身。該插件從庫的 index.js 文件讀取 import 語句,並使其指向庫中特定文件。這樣就使 webpack 在解析模塊樹時更容易對庫作 tree shaking。下面的例子演示了它是如何工做的。

// Babel Transform Imports
// Babel config
const config = {
 plugins: [
  [
   'transform-imports',
   {
    'lodash-es': {
     transform: 'lodash/${member}',
     preventFullImport: true
    },
    'react-bootstrap': {
     transform: 'react-bootstrap/es/${member}', // The es folder contains es2015 module versions of the files
     preventFullImport: true
    }
   }
  ]
 ]
};
// 這些庫再也不支持全量導入,不然會報錯
import _ from 'lodash-es';
// 具名導入依然支持
import { debounce } from 'loash-es';
// 不過這些具名導入會被babel編譯成這樣子
// import debounce from 'lodash-es/debounce';

總結

全文到此結束。這樣的優化能夠極大地縮減打包後的大小。隨着前端架構開始有了新的方向(好比微前端),保持包大小最優化變得比以往更加劇要。但願本文能給那些正在給應用程序作tree shaking的同窗帶來一些幫助。

交流

歡迎掃碼關注微信公衆號「1024譯站」,爲你奉上更多技術乾貨。
公衆號:1024譯站

相關文章
相關標籤/搜索