深入理解Webpack打包分塊(上)

NO IMAGE

前言

隨著前端代碼需要處理的業務越來越繁重,我們不得不面臨的一個問題是前端的代碼體積也變得越來越龐大。這造成無論是在調式還是在上線時都需要花長時間等待編譯完成,並且用戶也不得不花額外的時間和帶寬下載更大體積的腳本文件。

然而仔細想想這完全是可以避免的:在開發時難道一行代碼的修改也要重新打包整個腳本?用戶只是粗略瀏覽頁面也需要將整個站點的腳本全部下載下來?所以趨勢必然是按需的、有策略性的將代碼拆分和提供給用戶。最近流行的微前端某種意義上來說也是遵循了這樣的原則(但也並不是完全基於這樣的原因)

幸運的是,我們目前已有的工具已經完全賦予我們實現以上需求的能力。例如 Webpack 允許我們在打包時將腳本分塊;利用瀏覽器緩存我們能夠有的放矢的加載資源。

在探尋最佳實踐的過程中,最讓我疑惑的不是我們能不能做,而是我們應該如何做:我們因該採取什麼樣的特徵拆分腳本?我們應該使用什麼樣的緩存策略?使用懶加載和分塊是否有異曲同工之妙?拆分之後究竟能帶來多大的性能提升?最重要的是,在面多諸多的方案和工具以及不確定的因素時,我們應該如何開始?這篇文章就是對以上問題的梳理和回答。文章的內容大體分為兩個方面,一方面在思路制定模塊分離的策略,另一方面從技術上對方案進行落地。

本文的主要內容翻譯自 The 100% correct way to split your chunks with Webpack。 這篇文章循序漸進的引導開發者步步為營的對代碼進行拆分優化,所以它是作為本文的線索存在。同時在它的基礎上,我會對 Webpack 及其他的知識點做縱向擴展,對方案進行落地。

以下開始正文


根據 Webpack 術語表,存在兩類文件的分離。這些名詞聽起來是可以互換的,但實際上不行:

  • 打包分離 (Bundle splitting):為了更好的緩存創建更多、更小的文件(但仍然以每一個文件一個請求的方式進行加載)
  • 代碼分離 (Code splitting):動態加載代碼,所以用戶只需要下載當前他正在瀏覽站點的這部分代碼

第二種策略聽起來更吸引人是不是?事實上許多的文章也假定認為這才是唯一值得將 JavaScript 文件進行小文件拆分的場景。

但是我在這裡告訴你第一種策略對許多的站點來說才更有價值,並且應該是你首先為頁面做的事

讓我們來深入理解

Bundle VS Chunk VS Module

在正式開始編碼之前,我們還是要明確一些概念。例如我們貫穿全文的“塊”(chunk) ,以及它和我們常常提到的“包”(bundle)以及“模塊”(module) 到底有什麼區別。

遺憾的事情是即使在查閱了很多資料之後,我仍然沒法得到一個確切的標準答案,所以這裡我選擇我個人比較認可的定義在這裡做一個分享,重要的還是希望能起到統一口徑的作用

首先對於“模塊”(module)的概念相信大家都沒有異議,它指的就是我們在編碼過程中有意識的封裝和組織起來的代碼片段。狹義上我們首先聯想到的是碎片化的 React 組件,或者是 CommonJS 模塊又或者是 ES6 模塊,但是對 Webpack 和 Loader 而言,廣義上的模塊還包括樣式和圖片,甚至說是不同類型的文件

而“包”(bundle) 就是把相關代碼都打包進入的單個文件。如果你不想把所有的代碼都放入一個包中,你可以把它們劃分為多個包,也就是“塊”(chunk) 中。從這個角度上看,“塊”等於“包”,它們都是對代碼再一層的組織和封裝。如果必須要給一個區分的話,通常我們在討論時,bundle 指的是所有模塊都打包進入的單個文件,而 chunk 指的是按照某種規則的模塊集合,chunk 的體積大於單個模塊,同時小於整個 bundle

(但如果要仔細的深究,Chunk是 Webpack 用於管理打包流程中的技術術語,甚至能劃分為不同類型的 chunk。我想我們不用從這個角度理解。只需要記住上一段的定義即可)

打包分離 (Bundle splitting)

打包分離背後的思想非常簡單。如果你有一個體積巨大的文件,並且只改了一行代碼,用戶仍然需要重新下載整個文件。但是如果你把它分為了兩個文件,那麼用戶只需要下載那個被修改的文件,而瀏覽器則可以從緩存中加載另一個文件。

值得注意的是因為打包分離與緩存相關,所以對站點的首次訪問者來說沒有區別

(我認為太多的性能討論都是關於站點的首次訪問。或許部分原因是因為第一映像很重要,另一部分因為這部分性能測量起來簡單和討巧)

當談論到頻繁訪問者時,量化性能提升會稍有棘手,但是我們必須量化!

這將需要一張表格,我們將每一種場景與每一種策略的組合結果都記錄下來

我們假設一個場景:

  • Alice 連續 10 周每週訪問站點一次
  • 我們每週更新站點一次
  • 我們每週更新“產品列表”頁面
  • 我們也有一個“產品詳情”頁面,但是目前不需要對它進行更新
  • 在第 5 周的時我們給站點新增了一個 npm 包
  • 在第 8 周時我們更新了現有的一個 npm 包

當然包括我在內的部分人希望場景儘可能的逼真。但其實無關緊要,我們隨後會解釋為什麼。

性能基線

假設我們的 JavaScript 打包後的總體積為 400KB, 將它命名為 main.js,然後以單文件的形式加載它

我們有一個類似如下的 Webpack 配置(我已經移除了無關的配置項):

const path = require('path');
module.exports = {
entry: path.resolve(__dirname, 'src/index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
};

當只有單個入口時,Webpack 會自動把結果命名為main.js

(對那些剛接觸緩知識的人我解釋一下:每當我我提及main.js的時候,我實際上是在說類似於main.xMePWxHo.js這種包含一堆帶有文件內容哈希字符串的東西。這意味著當你應用代碼發生更改時新的文件名會生成,這樣就能迫使瀏覽器下載新的文件)

所以當每週我向站點發布新的變更時,包的contenthash就會發生更改。以至於每週 Alice 訪問我們站點時不得不下載一個全新的 400KB 大小的文件

深入理解Webpack打包分塊(上)

連續十週也就是 4.12MB

我們能做的更好

哈希(hash)與性能

不知道你是否真的理解上面的表述。有幾點需要在這裡澄清:

  1. 為什麼帶哈希串的文件名會對瀏覽器緩存產生影響?
  2. 為什麼文件名裡的哈希後綴是contenthash?如果把contenthash替換成hash或者chunkhash有什麼影響?

為了每次訪問時不讓瀏覽器都重新下載同一個文件,我們通常會把這個文件返回的 HTTP 頭中的Cache-Control設置為max-age=31536000,也就是一年(秒數的)時間。這樣以來,在一年之內用戶訪問這個文件時,都不會再次向服務器發送請求而是直接從緩存中讀取,直到或者手動清除了緩存。

如果我中途修改了文件內容必須讓用戶重新下載怎麼辦?修改文件名就好了,不同的文件(名)對應不同的緩存策略。而一個哈希字符串就是根據文件內容產生的“簽名”,每當文件內容發生更改時,哈希串也就發生了更改,文件名也就隨之更改。這樣一來,舊版本文件的緩存策略就會失效,瀏覽器就會重新加載新版本的該文件。當然這只是其中一種最基礎的緩存策略,更復雜的場景請參考我之前的一篇文章:設計一個無懈可擊的瀏覽器緩存方案:關於思路,細節,ServiceWorker,以及HTTP/2

所以在 Webpack 中配置的 filename: [name]:[contenthash].js 就是為了每次發佈時自動生成新的文件名。

然而如果你對 Webpack 稍有了解的話,你應該知道 Webpack 還提供了另外兩種哈希算法供開發者使用:hashchunkhash。那麼為什麼不使用它們而是使用contenthash?這要從它們的區別說起。原則上來說,它們是為不同目的服務的,但在實際操作中,也可以交替使用。

為了便於說明,我們先準備以下這段非常簡單的 Webpack 配置,它擁有兩個打包入口,同時額外提取出 css 文件,最終生成三個文件。filename配置中我們使用的是hash標識符、在 MinCssExtractPlugin中我們使用的是contenthash,為什麼會這樣稍後會解釋。

const CleanWebpackPlugin = require("clean-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
module.exports = {
entry: {
module_a: "./src/module_a.js",
module_b: "./src/module_b.js"
},
output: {
filename: "[name].[hash].js"
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[contenthash].css"
})
]
};

hash

hash針對的是每一次構建(build)而言,每一次構建之後生成的文件所帶的哈希都是一致的。它關心的是整體項目的變化,只要有任意文件內容發生了更改,那麼構建之後其他文件的哈希也會發生更改。

很顯然這不是我們需要的,如果module_a文件內容發生了更改,module_a的打包文件的哈希應該發生變化,但是module_b不應該。這會導致用戶不得不重新下載沒有發生變化的module_b打包文件

chunkhash

chunkhash基於的是每一個 chunk 內容的改變,如果是該 chunk 所屬的內容發生了變化,那麼只有該 chunk 的輸出文件的哈希會發生變化,其它的不會。這聽上去符合我們的需求。

在之前我們對 chunk 進行過定義,即是小單位的代碼聚合形式。在上面的例子中以entry入口體現,也就是說每一個入口對應的文件就是一個 chunk。在後面的例子中我們會看到更復雜的例子

  • contenthash

顧名思義,該哈希根據的是文件的內容。從這個角度上說,它和chunkhash是能夠相互代替的。所以在“性能基線”代碼中作者使用了contenthash

不過特殊之處是,或者說我讀到的關於它的使用說明中,都指示如果你想在ExtractTextWebpackPlugin或者MiniCssExtractPlugin中用到哈希標識,你應該使用contenthash。但就我個人的測試而言,使用hash或者chunkhash也都沒有問題(也許是因為 extract 插件是嚴格基於 content 的?但難道 chunk 不是嗎?)

分離第三方類庫(vendor)類庫

讓我們把打包文件劃分為main.jsvendor.js

很簡單,類似於:

const path = require('path');
module.exports = {
entry: path.resolve(__dirname, 'src/index.js'),
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
optimization: {
splitChunks: {
chunks: 'all',
},
},
};

在你沒有告訴它你想如何拆分打包文件的情況下, Webpack 4 在盡它最大的努力把這件事做的最好

這就導致一些聲音在說:“太驚人了,Webpack 做的真不錯!”

而另一些聲音在說:“你對我的打包文件做了什麼!”

無論如何,添加optimization.splitChunks.chunks = 'all'配置也就是在說:“把所有node_modules裡的東西都放到vendors~main.js的文件中去”

在實現基本的打包分離條件後,Alice 在每次訪問時仍然需要下載 200KB 大小的 main.js 文件, 但是隻需要在第一週、第五週、第八週下載 200KB 的 vendors.js腳本

深入理解Webpack打包分塊(上)

也就是 2.64MB

體積減少了 36%。對於配置裡新增的五行代碼來說結果還不錯。在繼續閱讀之前你可以立刻就去試試。如果你需要將 Webpack 3 升級到 4,也不要著急,升級不會帶來痛苦(而且是免費的!)

分離每一個 npm 包

我們的 vendors.js 承受著和開始 main.js 文件同樣的問題——部分的修改會意味著重新下載所有的文件

所以為什麼不把每一個 npm 包都分割為單獨的文件?做起來非常簡單

讓我們把我們的reactlodashreduxmoment等分離為不同的文件


const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: path.resolve(__dirname, 'src/index.js'),
plugins: [
new webpack.HashedModuleIdsPlugin(), // so that file hashes don't change unexpectedly
],
output: {
path: path.resolve(__dirname, 'dist'),
filename: '[name].[contenthash].js',
},
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxInitialRequests: Infinity,
minSize: 0,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name(module) {
// get the name. E.g. node_modules/packageName/not/this/part.js
// or node_modules/packageName
const packageName = module.context.match(/[\\/]node_modules[\\/](.*?)([\\/]|$)/)[1];
// npm package names are URL-safe, but some servers don't like @ symbols
return `npm.${packageName.replace('@', '')}`;
},
},
},
},
},
};

這份文檔 非常好的解釋了這裡做的事情,但是我仍然需要解釋一下其中精妙的部分,因為它們花了我相當長的時間才搞明白

  • Webpack 有一些不那麼智能的默認“智能”配置,比如當分離打包輸出文件時只允許最多3個文件,並且最小文件的尺寸是30KB(如果存在更小的文件就把它們拼接起來)。所以我把這些配置都覆蓋了
  • cacheGroups是我們用來制定規則告訴 Webpack 應該如何組織 chunks 到打包輸出文件的地方。我在這裡對所有加載自node_modules裡的 module 制定了一條名為 “vendor” 的規則。通常情況下,你只需要為你的輸出文件的 name定義一個字符串。但是我把name定義為了一個函數(當文件被解析時會被調用)。在函數中我會根據 module 的路徑返回包的名稱。結果就是,對於每一個包我都會得到一個單獨的文件,比如npm.react-dom.899sadfhj4.js
  • 為了能夠正常發佈npm 包的名稱必須是合法的URL,所以我們不需要encodeURI對包的名詞進行轉義處理。但是我遇到一個問題是.NET服務器不會給名稱中包含@的文件提供文件服務,所以我在代碼片段中進行了替換
  • 整個步驟的配置設置之後就不需要維護了——我們不需要使用名稱引用任何的類庫

Alice 每週都要重新下載 200KB 的 main.js 文件,並且再她首次訪問時仍然需要下載 200KB 的 npm 包文件,但是她再也不用重複的下載同一個包兩次

深入理解Webpack打包分塊(上)

也就是2.24MB

相對於基線減少了 44%,這是一段你能夠從文章裡粘貼複製的非常酷的代碼。

我好奇我們能超越 50%?

那不是很棒嗎

稍等,那段 Webpack 配置代碼究竟是怎麼回事

此時你的疑惑可能是,optimization 選項裡的配置怎麼就把 vendor 代碼分離出來了?

接下來的這一小節會針對 Webpack 的 Optimization 選項做講解。我個人並非 Webpack 的專家,配置和對應的描述功能也並非一一經過驗證,也並非全部都覆蓋到,如果有紕漏的地方還請大家諒解。

optimization配置如其名所示,是為優化代碼而生。如果你再仔細觀察,大部分配置又在splitChunk字段下,因為它間接使用 SplitChunkPlugin 實現對塊的拆分功能(這些都是在 Webpack 4 中引入的新的機制。在 Webpack 3 中使用的是 CommonsChunkPlugin,在 4 中已經不再使用了。所以這裡我們也主要關注的是 SplitChunkPlugin 的配置)從整體上看,SplitChunksPlugin 的功能只有一個,就是split——把代碼分離出來。分離是相對於把所有模塊都打包成一個文件而言,把單個大文件分離為多個小文件。

在最初分離 vendor 代碼時,我們只使用了一個配置

splitChunks: {
chunks: 'all',
},

chunks有三個選項:initialasyncall。它指示應該優先分離同步(initial)、異步(async)還是所有的代碼模塊。這裡的異步指的是通過動態加載方式(import())加載的模塊。

這裡的重點是優先二字。以async為例,假如你有兩個模塊 a 和 b,兩者都引用了 jQuery,但是 a 模塊還通過動態加載的方式引入了 lodash。那麼在 async 模式下,插件在打包時會分離出lodash~for~a.js的 chunk 模塊,而 a 和 b 的公共模塊 jQuery 並不會被(優化)分離出來,所以它可能還同時存在於打包後的a.bundle.jsb.bundle.js文件中。因為async告訴插件優先考慮的是動態加載的模塊

接下來聚焦第二段分離每個 npm 包的 Webpack 配置中

maxInitialRequestsminSize確實就是插件自作多情的傑作了。插件自帶一些分離 chunk 的規則:如果即將分離的 chunk 文件體積小於 30KB 的話,那麼就不會將該 chunk 分離出來;並且限制並行下載的 chunk 最大請求個數為 3 個。通過覆蓋 minSizemaxInitialRequests 配置就能夠重寫這兩個參數。注意這裡的maxInitialRequestsminSize是在splitChunks根目錄中的,我們暫且稱它為全局配置

cacheGroups配置才是最重要,它允許自定義規則分離 chunk。並且每條cacheGroups規則下都允許定義上面提到的chunksminSize字段用於覆蓋全局配置(又或者將cacheGroups規則中enforce參數設為true來忽略全局配置)

cacheGroups裡默認自帶vendors配置來分離node_modules裡的類庫模塊,它的默認配置如下:

cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},

如果你不想使用它的配置,你可以把它設為false又或者重寫它。這裡我選擇重寫,並且加入了額外的配置nameenforce:

vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
enforce: true,
},

最後介紹以上並沒有出現但是仍然常用的兩個配置:priorityreuseExistingChunk

  • reuseExistingChunk: 該選項只會出現在cacheGroups的分離規則中,意味重複利用現有的 chunk。例如 chunk 1 擁有模塊 A、B、C;chunk 2 擁有模塊 B、C。如果 reuseExistingChunkfalse 的情況下,在打包時插件會為我們單獨創建一個 chunk 名為 common~for~1~2,它包含公共模塊 B 和 C。而如果該值為true的話,因為 chunk 2 中已經擁有公共模塊 B 和 C,所以插件就不會再為我們創建新的模塊

  • priority: 很容易想象到我們會在cacheGroups中配置多個 chunk 分離規則。如果同一個模塊同時匹配多個規則怎麼辦,priority解決的這個問題。注意所有默認配置的priority都為負數,所以自定義的priority必須大於等於0才行

小結

截至目前為止,我們已經看出了一套分離代碼的模式:

  • 首先決定我們想要解決什麼樣的問題(避免用戶在每次訪問時下載額外的代碼);
  • 再決定使用什麼樣的方案(通過將修改頻率低、重複的代碼分離出來,並配上恰當的緩存策略);
  • 最後決定實施的方案是什麼(通過配置 Webpack 來實現代碼的分離)

本文也同時發佈在我的知乎專欄,歡迎大家關注

參考資料

Bundle VS Chunk

Hash

SplitChunksPlugin

相關文章

前端架構101(四):MVC的不足與Flux的崛起

前端架構101(三):MVC啟示錄:模塊的職責,作用域和通信

前端架構101(二):MVC初探

微前端說明書