隨着前端代碼須要處理的業務愈來愈繁重,咱們不得不面臨的一個問題是前端的代碼體積也變得愈來愈龐大。這形成不管是在調式仍是在上線時都須要花長時間等待編譯完成,而且用戶也不得不花額外的時間和帶寬下載更大致積的腳本文件。javascript
然而仔細想一想這徹底是能夠避免的:在開發時難道一行代碼的修改也要從新打包整個腳本?用戶只是粗略瀏覽頁面也須要將整個站點的腳本所有下載下來?因此趨勢必然是按需的、有策略性的將代碼拆分和提供給用戶。最近流行的微前端某種意義上來講也是遵循了這樣的原則(但也並非徹底基於這樣的緣由)css
幸運的是,咱們目前已有的工具已經徹底賦予咱們實現以上需求的能力。例如 Webpack 容許咱們在打包時將腳本分塊;利用瀏覽器緩存咱們可以有的放矢的加載資源。前端
在探尋最佳實踐的過程當中,最讓我疑惑的不是咱們能不能作,而是咱們應該如何作:咱們因該採起什麼樣的特徵拆分腳本?咱們應該使用什麼樣的緩存策略?使用懶加載和分塊是否有殊途同歸之妙?拆分以後究竟能帶來多大的性能提高?最重要的是,在面多諸多的方案和工具以及不肯定的因素時,咱們應該如何開始?這篇文章就是對以上問題的梳理和回答。文章的內容大致分爲兩個方面,一方面在思路制定模塊分離的策略,另外一方面從技術上對方案進行落地。java
本文的主要內容翻譯自 The 100% correct way to split your chunks with Webpack。 這篇文章按部就班的引導開發者步步爲營的對代碼進行拆分優化,因此它是做爲本文的線索存在。同時在它的基礎上,我會對 Webpack 及其餘的知識點作縱向擴展,對方案進行落地。node
如下開始正文react
根據 Webpack 術語表,存在兩類文件的分離。這些名詞聽起來是能夠互換的,但實際上不行:webpack
第二種策略聽起來更吸引人是否是?事實上許多的文章也假定認爲這纔是惟一值得將 JavaScript 文件進行小文件拆分的場景。git
可是我在這裏告訴你第一種策略對許多的站點來講才更有價值,而且應該是你首先爲頁面作的事github
讓咱們來深刻理解web
在正式開始編碼以前,咱們仍是要明確一些概念。例如咱們貫穿全文的「塊」(chunk) ,以及它和咱們經常提到的「包」(bundle)以及「模塊」(module) 到底有什麼區別。
遺憾的事情是即便在查閱了不少資料以後,我仍然無法獲得一個確切的標準答案,因此這裏我選擇我我的比較承認的定義在這裏作一個分享,重要的仍是但願能起到統一口徑的做用
首先對於「模塊」(module)的概念相信你們都沒有異議,它指的就是咱們在編碼過程當中有意識的封裝和組織起來的代碼片斷。狹義上咱們首先聯想到的是碎片化的 React 組件,或者是 CommonJS 模塊又或者是 ES6 模塊,可是對 Webpack 和 Loader 而言,廣義上的模塊還包括樣式和圖片,甚至說是不一樣類型的文件
而「包」(bundle) 就是把相關代碼都打包進入的單個文件。若是你不想把全部的代碼都放入一個包中,你能夠把它們劃分爲多個包,也就是「塊」(chunk) 中。從這個角度上看,「塊」等於「包」,它們都是對代碼再一層的組織和封裝。若是必需要給一個區分的話,一般咱們在討論時,bundle 指的是全部模塊都打包進入的單個文件,而 chunk 指的是按照某種規則的模塊集合,chunk 的體積大於單個模塊,同時小於整個 bundle
(但若是要仔細的深究,Chunk是 Webpack 用於管理打包流程中的技術術語,甚至能劃分爲不一樣類型的 chunk。我想咱們不用從這個角度理解。只須要記住上一段的定義便可)
打包分離背後的思想很是簡單。若是你有一個體積巨大的文件,而且只改了一行代碼,用戶仍然須要從新下載整個文件。可是若是你把它分爲了兩個文件,那麼用戶只須要下載那個被修改的文件,而瀏覽器則能夠從緩存中加載另外一個文件。
值得注意的是由於打包分離與緩存相關,因此對站點的首次訪問者來講沒有區別
(我認爲太多的性能討論都是關於站點的首次訪問。或許部分緣由是由於第一映像很重要,另外一部分由於這部分性能測量起來簡單和討巧)
當談論到頻繁訪問者時,量化性能提高會稍有棘手,可是咱們必須量化!
這將須要一張表格,咱們將每一種場景與每一種策略的組合結果都記錄下來
咱們假設一個場景:
固然包括我在內的部分人但願場景儘量的逼真。但其實可有可無,咱們隨後會解釋爲何。
假設咱們的 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 大小的文件
連續十週也就是 4.12MB
咱們能作的更好
不知道你是否真的理解上面的表述。有幾點須要在這裏澄清:
contenthash
?若是把contenthash
替換成hash
或者chunkhash
有什麼影響?爲了每次訪問時不讓瀏覽器都從新下載同一個文件,咱們一般會把這個文件返回的 HTTP 頭中的Cache-Control
設置爲max-age=31536000
,也就是一年(秒數的)時間。這樣以來,在一年以內用戶訪問這個文件時,都不會再次向服務器發送請求而是直接從緩存中讀取,直到或者手動清除了緩存。
若是我中途修改了文件內容必須讓用戶從新下載怎麼辦?修改文件名就行了,不一樣的文件(名)對應不一樣的緩存策略。而一個哈希字符串就是根據文件內容產生的「簽名」,每當文件內容發生更改時,哈希串也就發生了更改,文件名也就隨之更改。這樣一來,舊版本文件的緩存策略就會失效,瀏覽器就會從新加載新版本的該文件。固然這只是其中一種最基礎的緩存策略,更復雜的場景請參考我以前的一篇文章:設計一個無懈可擊的瀏覽器緩存方案:關於思路,細節,ServiceWorker,以及HTTP/2
因此在 Webpack 中配置的 filename: [name]:[contenthash].js
就是爲了每次發佈時自動生成新的文件名。
然而若是你對 Webpack 稍有了解的話,你應該知道 Webpack 還提供了另外兩種哈希算法供開發者使用:hash
和chunkhash
。那麼爲何不使用它們而是使用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 不是嗎?)
讓咱們把打包文件劃分爲main.js
和vendor.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
腳本
也就是 2.64MB
體積減小了 36%。對於配置裏新增的五行代碼來講結果還不錯。在繼續閱讀以前你能夠馬上就去試試。若是你須要將 Webpack 3 升級到 4,也不要着急,升級不會帶來痛苦(並且是免費的!)
咱們的 vendors.js
承受着和開始 main.js
文件一樣的問題——部分的修改會意味着從新下載全部的文件
因此爲何不把每個 npm 包都分割爲單獨的文件?作起來很是簡單
讓咱們把咱們的react
,lodash
,redux
,moment
等分離爲不一樣的文件
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('@', '')}`;
},
},
},
},
},
};
複製代碼
這份文檔 很是好的解釋了這裏作的事情,可是我仍然須要解釋一下其中精妙的部分,由於它們花了我至關長的時間才搞明白
cacheGroups
是咱們用來制定規則告訴 Webpack 應該如何組織 chunks 到打包輸出文件的地方。我在這裏對全部加載自node_modules
裏的 module 制定了一條名爲 "vendor" 的規則。一般狀況下,你只須要爲你的輸出文件的 name
定義一個字符串。可是我把name
定義爲了一個函數(當文件被解析時會被調用)。在函數中我會根據 module 的路徑返回包的名稱。結果就是,對於每個包我都會獲得一個單獨的文件,好比npm.react-dom.899sadfhj4.js
encodeURI
對包的名詞進行轉義處理。可是我遇到一個問題是.NET服務器不會給名稱中包含@
的文件提供文件服務,因此我在代碼片斷中進行了替換Alice 每週都要從新下載 200KB 的 main.js
文件,而且再她首次訪問時仍然須要下載 200KB 的 npm 包文件,可是她不再用重複的下載同一個包兩次
也就是2.24MB
相對於基線減小了 44%,這是一段你可以從文章裏粘貼複製的很是酷的代碼。
我好奇咱們能超越 50%?
那不是很棒嗎
此時你的疑惑多是,optimization 選項裏的配置怎麼就把 vendor 代碼分離出來了?
接下來的這一小節會針對 Webpack 的 Optimization 選項作講解。我我的並不是 Webpack 的專家,配置和對應的描述功能也並不是一一通過驗證,也並不是所有都覆蓋到,若是有紕漏的地方還請你們諒解。
optimization
配置如其名所示,是爲優化代碼而生。若是你再仔細觀察,大部分配置又在splitChunk
字段下,由於它間接使用 SplitChunkPlugin 實現對塊的拆分功能(這些都是在 Webpack 4 中引入的新的機制。在 Webpack 3 中使用的是 CommonsChunkPlugin,在 4 中已經再也不使用了。因此這裏咱們也主要關注的是 SplitChunkPlugin 的配置)從總體上看,SplitChunksPlugin 的功能只有一個,就是split——把代碼分離出來。分離是相對於把全部模塊都打包成一個文件而言,把單個大文件分離爲多個小文件。
在最初分離 vendor 代碼時,咱們只使用了一個配置
splitChunks: {
chunks: 'all',
},
複製代碼
chunks
有三個選項:initial
、async
和all
。它指示應該優先分離同步(initial)、異步(async)仍是全部的代碼模塊。這裏的異步指的是經過動態加載方式(import()
)加載的模塊。
這裏的重點是優先二字。以async
爲例,假如你有兩個模塊 a 和 b,二者都引用了 jQuery,可是 a 模塊還經過動態加載的方式引入了 lodash。那麼在 async
模式下,插件在打包時會分離出lodash~for~a.js
的 chunk 模塊,而 a 和 b 的公共模塊 jQuery 並不會被(優化)分離出來,因此它可能還同時存在於打包後的a.bundle.js
和b.bundle.js
文件中。由於async
告訴插件優先考慮的是動態加載的模塊
接下來聚焦第二段分離每一個 npm 包的 Webpack 配置中
maxInitialRequests
和minSize
確實就是插件自做多情的傑做了。插件自帶一些分離 chunk 的規則:若是即將分離的 chunk 文件體積小於 30KB 的話,那麼就不會將該 chunk 分離出來;而且限制並行下載的 chunk 最大請求個數爲 3 個。經過覆蓋 minSize
和 maxInitialRequests
配置就可以重寫這兩個參數。注意這裏的maxInitialRequests
和minSize
是在splitChunks
根目錄中的,咱們暫且稱它爲全局配置
cacheGroups
配置纔是最重要,它容許自定義規則分離 chunk。而且每條cacheGroups
規則下都容許定義上面提到的chunks
和minSize
字段用於覆蓋全局配置(又或者將cacheGroups
規則中enforce
參數設爲true
來忽略全局配置)
cacheGroups
裏默認自帶vendors
配置來分離node_modules
裏的類庫模塊,它的默認配置以下:
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10
},
複製代碼
若是你不想使用它的配置,你能夠把它設爲false
又或者重寫它。這裏我選擇重寫,而且加入了額外的配置name
和enforce
:
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
enforce: true,
},
複製代碼
最後介紹以上並無出現可是仍然經常使用的兩個配置:priority
和reuseExistingChunk
reuseExistingChunk
: 該選項只會出如今cacheGroups
的分離規則中,意味重複利用現有的 chunk。例如 chunk 1 擁有模塊 A、B、C;chunk 2 擁有模塊 B、C。若是 reuseExistingChunk
爲 false
的狀況下,在打包時插件會爲咱們單首創建一個 chunk 名爲 common~for~1~2
,它包含公共模塊 B 和 C。而若是該值爲true
的話,由於 chunk 2 中已經擁有公共模塊 B 和 C,因此插件就不會再爲咱們建立新的模塊
priority
: 很容易想象到咱們會在cacheGroups
中配置多個 chunk 分離規則。若是同一個模塊同時匹配多個規則怎麼辦,priority
解決的這個問題。注意全部默認配置的priority
都爲負數,因此自定義的priority
必須大於等於0才行
截至目前爲止,咱們已經看出了一套分離代碼的模式:
本文也同時發佈在個人知乎專欄,歡迎你們關注