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

前言

隨着前端代碼須要處理的業務愈來愈繁重,咱們不得不面臨的一個問題是前端的代碼體積也變得愈來愈龐大。這形成不管是在調式仍是在上線時都須要花長時間等待編譯完成,而且用戶也不得不花額外的時間和帶寬下載更大致積的腳本文件。javascript

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

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

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

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

如下開始正文react


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

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

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

可是我在這裏告訴你第一種策略對許多的站點來講才更有價值,而且應該是你首先爲頁面作的事github

讓咱們來深刻理解web

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 大小的文件

連續十週也就是 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腳本

也就是 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 包文件,可是她不再用重複的下載同一個包兩次

也就是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

相關文章
相關標籤/搜索