【譯】使用 webpack 進行 web 效能優化(二):利用好持久化快取
在優化應用體積之後,下一個提升應用載入時間的策略就是快取。將資源快取在客戶端中,可以避免之後每次都重新下載。
bundle 的版本控制和快取頭的使用
使用快取的通用方法:
-
告訴瀏覽器需要快取一個檔案很長時間(比如,一年)
# Server header Cache-Control: max-age=31536000 複製程式碼
:star:️ 注意:如果你不熟悉
Cache-Control
的原理,請參閱 Jake Archibald 的文章:關於快取的最佳實踐。 -
當檔案改變時,檔案會被重新命名,這樣就迫使瀏覽器重新下載:
<!-- 修改前 --> <script src="./index-v15.js"></script> <!-- 修改後 --> <script src="./index-v16.js"></script> 複製程式碼
這個方法可以告訴瀏覽器去下載 JS 檔案,並將它快取,之後使用的都是它的快取副本。瀏覽器只會在檔名發生改變(或者一年之後快取失效)時才會請求網路。
使用 webpack,同樣可以做到,但使用的不是版本號,而是指定檔案的雜湊值。使用 [chunkhash]
可以將雜湊值寫入檔名中:
// webpack.config.js module.exports = { entry: './index.js', output: { filename: 'bundle.<strong>[chunkhash]</strong>.js', // → bundle.8e0d62a03.js }, }; 複製程式碼
:star:️ 注意: 即使 bundle 不變,webpack 也可能生成不同的雜湊值 – 例如,你重新命名了一個檔案或者在不同的作業系統下編譯了 bundle。 當然,這其實是一個 bug,目前還沒有明確的解決方案, 具體可參閱 GitHub 上的討論 。
如果你需要將檔名傳送給客戶端,可以使用 HtmlWebpackPlugin
或者 WebpackManifestPlugin
。
HtmlWebpackPlugin
是一個簡單但擴充套件性不強的外掛。在編譯期間,它會生成一個 HTML 檔案,檔案包含了所有已經被編譯的資源。如果你的服務端邏輯不是很複雜,那麼它應該能滿足你:
<!-- index.html --> <!doctype html> <!-- ... --> <script src="bundle.8e0d62a03.js"></script> 複製程式碼
WebpackManifestPlugin
是一個擴充套件性更佳的外掛,它可以幫助你解決服務端邏輯比較複雜的那部分。在打包時,它會生成一個 JSON 檔案,裡面包含了原檔名和帶雜湊檔名的對映。在服務端,通過這個 JSON 就能方便的找到我們真正要執行的檔案:
// manifest.json { "bundle.js": "bundle.8e0d62a03.js" } 複製程式碼
擴充套件閱讀
- Jake Archibald關於快取的最佳實踐
將依賴和 runtime 提取到單獨的檔案中
依賴
應用的依賴通常比實際應用內的程式碼變更頻率低。如果將它們移到單獨的檔案中,瀏覽器就可以獨立快取它們 – 這樣每次應用中的程式碼變更也不會去重新下載它們。
關鍵術語:在 webpack 術語中,把帶有應用程式碼的獨立檔案稱之為 chunk 。我們在下面的文章中會使用到這個名稱。
要將依賴項提取到獨立的 chunk 中,需要執行下面三個步驟:
-
將輸出檔名替換為
[name].[chunkname].js
:// webpack.config.js module.exports = { output: { // Before filename: 'bundle.[chunkhash].js', // After filename: '[name].[chunkhash].js', }, }; 複製程式碼
當 webpack 編譯應用時,它會將 [name]
作為 chunk 的名稱。如果我們沒有新增 [name]
的部分,我們將不得不通過雜湊值來區分 chunk - 這樣就變得非常困難!
-
將
entry
的值改為物件:// webpack.config.js module.exports = { // Before entry: './index.js', // After entry: { main: './index.js', }, }; 複製程式碼
在上面這段程式碼中,“main” 是 chunk 的名稱。這個名稱會在第一步時被
[name]
所替代。到目前為止,如果你構建應用,這個 chunk 還是包含了整個應用的程式碼 - 就像我們沒有做過上述這些步驟一樣。但接下來很快就將產生變化。
-
在 webpack 4 中,可以將
optimization.splitChunks.chunks: 'all'
選項新增到 webpack 的配置中:// webpack.config.js (for webpack 4) module.exports = { optimization: { splitChunks: { chunks: 'all', } }, }; 複製程式碼
這個選項可以開啟智慧程式碼拆分。使用了這個功能,webpack 將會提取大於 30KB(壓縮和 gzip 之前)的第三方庫程式碼。它同時也可以提取公共程式碼 - 如果你的構建結果會生成多個 bundle 時這將非常有用。(例如: 假如你通過路由來拆分應用 )。
在 webpack 3 中新增CommonsChunkPlugin 外掛:
// webpack.config.js (for webpack 3) module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ // chunk 的名稱將會包含依賴 // 這個名稱會在第一步時被 [name] 所替代 name: 'vendor', // 這個函式決定哪個模組會被打入 chunk minChunks: module => module.context && module.context.includes('node_modules'), }), ], }; 複製程式碼
這個外掛會將路徑包含
node_modules
的所有模組移到一個名為 vendor.[chunkhash].js 的獨立檔案中。
完成這些更改後,每次打包都將從原來的生成一個檔案變為生成兩個檔案: main.[chunkhash].js
和 vendor.[chunkhash].js
( vendors~main.[chunkhash].js
只有在 webpack 4 才有)。在 webpack 4 中,如果依賴項很小,則可能不會生成 vendor bundle - 這點做的不錯:
$ webpack Hash: ac01483e8fec1fa70676 Version: webpack 3.8.1 Time: 3816ms AssetSizeChunksChunk Names ./main.00bab6fd3100008a42b0.js82 kB0[emitted]main ./vendor.d9e134771799ecdf9483.js47 kB1[emitted]vendor 複製程式碼
瀏覽器會單獨快取這些檔案 - 同時只有程式碼發生改變時才會重新下載。
Webpack runtime 程式碼
遺憾的是,僅僅提取第三方庫程式碼還是不夠的。如果你想嘗試在應用程式碼中修改一些東西:
// index.js … … // 例如,增加這句: console.log('Wat'); 複製程式碼
你會發現 vendor
的雜湊值也會被改變:
AssetSizeChunksChunk Names ./vendor.d9e134771799ecdf9483.js47 kB1[emitted]vendor 複製程式碼
↓
AssetSizeChunksChunk Names ./vendor.e6ea4504d61a1cc1c60b.js47 kB1[emitted]vendor 複製程式碼
這是由於 webpack 打包時,除了模組程式碼之外,webpack 的 bundle 中還包含了 runtime - 一小段可以管理模組執行的程式碼。當你將程式碼拆分成多個檔案時,這小部分程式碼在 chunk id 和匹配的檔案之間會生成一個對映:
// vendor.e6ea4504d61a1cc1c60b.js script.src = __webpack_require__.p + chunkId + "." + { "0": "2f2269c7f0a55a5c1871" }[chunkId] + ".js"; 複製程式碼
Webpack 將 runtime 包含在了最新生成的 chunk 中,這個 chunk 就是我們程式碼中的 vendor
。每次 chunk 有任何變更,這一小部分程式碼也會隨之更改,同時也會導致整個 vendor
chunk 發生改變。
為了解決這個問題,我們可以將 runtime 移動到一個獨立的檔案中。 在 webpack 4 中 ,可以通過開啟 optimization.runtimeChunk
選項來實現:
// webpack.config.js (for webpack 4) module.exports = { optimization: { runtimeChunk: true, }, }; 複製程式碼
在 webpack 3 中,可以通過 CommonsChunkPlugin
建立一個額外的空 chunk:
// webpack.config.js (for webpack 3) module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'vendor', minChunks: module => module.context && module.context.includes('node_modules'), }), // 這個外掛必須在 vendor 生成之後執行(因為 webpack 把執行時打進了最新的 chunk) new webpack.optimize.CommonsChunkPlugin({ name: 'runtime', // minChunks: Infinity 表示任何應用模組都不能打進這個 chunk minChunks: Infinity, }), ], }; 複製程式碼
完成這些變更後,每次構建將生成三個檔案:
$ webpack Hash: ac01483e8fec1fa70676 Version: webpack 3.8.1 Time: 3816ms AssetSizeChunksChunk Names ./main.00bab6fd3100008a42b0.js82 kB0[emitted]main ./vendor.26886caf15818fa82dfa.js46 kB1[emitted]vendor ./runtime.79f17c27b335abc7aaf4.js1.45 kB3[emitted]runtime 複製程式碼
將這幾個檔案按倒序的方式新增到 index.html
中,就完成了:
<!-- index.html --> <script src="./runtime.79f17c27b335abc7aaf4.js"></script> <script src="./vendor.26886caf15818fa82dfa.js"></script> <script src="./main.00bab6fd3100008a42b0.js"></script> 複製程式碼
擴充套件閱讀
-
Webpack 指南關於持久化快取
-
Webpack 文件 關於 webpack 的 runtime 和 manifest 檔案
內聯 webpack 的 runtime 可以節省額外的 HTTP 請求
為了達到更好的體驗,我們可以嘗試把 webpack 的 runtime 內聯到 HTML 中。例如,我們不要這麼做:
<!-- index.html --> <script src="./runtime.79f17c27b335abc7aaf4.js"></script> 複製程式碼
而是像下面這樣:
<!-- index.html --> <script> !function(e){function n(r){if(t[r])return t[r].exports;…}} ([]); </script> 複製程式碼
Runtime 的程式碼不多,內聯到 HTML 中可以幫助我們節省 HTTP 請求(在 HTTP/1 中尤為重要;在 HTTP/2 中雖然沒那麼重要,但仍然能起到一定作用)。
下面就來看看要如何做。
如果你使用 HtmlWebpackPlugin 來生成 HTML
如果你使用 HtmlWebpackPlugin 來生成 HTML 檔案,那麼你一定需要 InlineSourcePlugin :
// webpack.config.js const HtmlWebpackPlugin = require('html-webpack-plugin'); const InlineSourcePlugin = require('html-webpack-inline-source-plugin'); module.exports = { plugins: [ new HtmlWebpackPlugin({ // Inline all files which names start with “runtime~” and end with “.js”. // That’s the default naming of runtime chunks inlineSource: 'runtime~.+\\.js', }), // This plugin enables the “inlineSource” option new InlineSourcePlugin(), ], }; 複製程式碼
如果你通過自定義服務端邏輯來生成 HTML
在 webpack 4 中:
-
新增 WebpackManifestPlugin 外掛可以獲取生成的 runtume chunk 的名稱:
// webpack.config.js (for webpack 4) const ManifestPlugin = require('webpack-manifest-plugin'); module.exports = { plugins: [ new ManifestPlugin(), ], }; 複製程式碼
使用這個外掛構建會生成像下面這樣的檔案:
// manifest.json { "runtime~main.js": "runtime~main.8e0d62a03.js" } 複製程式碼
-
可以用一個便利的方式內聯 runtime chunk 的內容。例如,使用 Node.js 和 Express:
// server.js const fs = require('fs'); const manifest = require('./manifest.json'); const runtimeContent = fs.readFileSync(manifest['runtime~main.js'], 'utf-8'); app.get('/', (req, res) => { res.send(` … <script>${runtimeContent}</script> … `); }); 複製程式碼
在 webpack 3 中:
-
通過指定
filename
,可以使 runtime 的名稱不發生改變 :// webpack.config.js (for webpack 3) module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'runtime', minChunks: Infinity, filename: 'runtime.js', // → Now the runtime file will be called // “runtime.js”, not “runtime.79f17c27b335abc7aaf4.js” }), ], }; 複製程式碼
-
可以用一個便利的方式內聯
runtime.js
的內容。例如,使用 Node.js 和 Express:// server.js const fs = require('fs'); const runtimeContent = fs.readFileSync('./runtime.js', 'utf-8'); app.get('/', (req, res) => { res.send(` … <script>${runtimeContent}</script> … `); }); 複製程式碼
程式碼懶載入
通常,一個網頁會有自身的側重點:
-
假如你在 YouTube 上載入一個視訊頁面,你更關心的肯定是視訊而不是評論。所以,這裡視訊就比評論重要。
-
又比如你在一個新聞網站看一篇文章,你更關心的肯定是文章的文字而不是廣告。所以,這裡文字就比廣告重要。
上面的這些情況,都可以通過優先下載最重要的部分,稍後懶載入剩餘部分,從而來提升頁面首次載入的效能。在 webpack 中,使用 import()
函式 和程式碼拆分即可實現。
// videoPlayer.js export function renderVideoPlayer() { … } // comments.js export function renderComments() { … } // index.js import {renderVideoPlayer} from './videoPlayer'; renderVideoPlayer(); // …Custom event listener onShowCommentsClick(() => { import('./comments').then((comments) => { comments.renderComments(); }); }); 複製程式碼
import()
函式可以幫助你實現按需載入。Webpack 在打包時遇到 import('./module.js')
,就會把這個模組放到單獨的 chunk 中:
$ webpack Hash: 39b2a53cb4e73f0dc5b2 Version: webpack 3.8.1 Time: 4273ms AssetSizeChunksChunk Names ./0.8ecaf182f5c85b7a8199.js22.5 kB0[emitted] ./main.f7e53d8e13e9a2745d6d.js60 kB1[emitted]main ./vendor.4f14b6326a80f4752a98.js46 kB2[emitted]vendor ./runtime.79f17c27b335abc7aaf4.js1.45 kB3[emitted]runtime 複製程式碼
只有當代碼執行到 import()
函式時才會去下載。
這樣可以讓 入口
bundle 變得更小,從而減少首次載入時間。不僅如此,它還可以優化快取 - 如果你修改了入口 chunk 的程式碼,註釋 chunk 不會受到影響。
:star:️ 注意: 如果你使用 Babel 編譯程式碼,會因為 Babel 無法識別 import()
而出現語法錯誤。為了避免這個錯誤,你可以新增 syntax-dynamic-import
外掛。
擴充套件閱讀
-
Webpack 文件
import()
函式的使用 -
JavaScript 提案 實現
import()
語法
將程式碼拆分為路由和頁面
如果你的應用有多個路由或頁面,但是程式碼中只有一個單獨的 JS 檔案(一個單獨的 入口
chunk),這樣似乎會讓你的每次請求都附加了額外的流量。例如,當用戶訪問你網站的首頁:

他們並不需要載入其它頁面上用於渲染文章的程式碼 - 但他們卻載入了。此外,如果這個使用者經常只是訪問首頁,但你更改了其它頁面的文章程式碼,webpack 將會重新編譯,使整個 bundle 失效 - 這樣將導致使用者重新下載整個應用的程式碼。
如果我們將程式碼拆分到頁面中(或者單頁面應用的路由裡),使用者就會只下載真正用到的那部分程式碼。此外,瀏覽器也會更好地快取應用程式碼:當你改變首頁的程式碼時,webpack 只會讓相匹配的 chunk 失效。
單頁面應用
要通過路由來拆分單頁應用,可以使用 import()
(參加上文部分)。如果你使用的是一個框架,目前也有現成的解決方案:
傳統多頁應用
要通過頁面來拆分傳統應用,可以使用 webpack 的entry points。假設你的應用中有三類頁面:主頁、文章頁和使用者賬戶頁,- 那麼就應該有三個入口:
// webpack.config.js module.exports = { entry: { home: './src/Home/index.js', article: './src/Article/index.js', profile: './src/Profile/index.js' }, }; 複製程式碼
對於每個入口檔案,webpack 將構建一個單獨的依賴樹並生成一個 bundle,這個 bundle 裡只有包含這個入口所使用到的模組:
$ webpack Hash: 318d7b8490a7382bf23b Version: webpack 3.8.1 Time: 4273ms AssetSizeChunksChunk Names ./0.8ecaf182f5c85b7a8199.js22.5 kB0[emitted] ./home.91b9ed27366fe7e33d6a.js18 kB1[emitted]home ./article.87a128755b16ac3294fd.js32 kB2[emitted]article ./profile.de945dc02685f6166781.js24 kB3[emitted]profile ./vendor.4f14b6326a80f4752a98.js46 kB4[emitted]vendor ./runtime.318d7b8490a7382bf23b.js1.45 kB5[emitted]runtime 複製程式碼
所以,如果只有 article 頁面使用到了 Lodash,那麼 home 和 profile bundle 就不會包含它 - 使用者也不會在訪問首頁的時候下載到這個庫。
但是,單獨的依賴樹有它們的缺點。如果兩個入口都使用到了 Lodash,同時你沒有將依賴項移到 vendor bundle 中,則兩個入口都將包含 Lodash 的副本。為了解決這個問題, 在 webpack 4 中 ,可以在你的 webpack 配置中加入 optimization.splitChunks.chunks: 'all'
選項:
// webpack.config.js (適用於webpack 4) module.exports = { optimization: { splitChunks: { chunks: 'all', } }, }; 複製程式碼
這個選項可以開啟智慧程式碼拆分。有了這個選項,webpack 將自動查詢到公共程式碼,並且提取到單獨的檔案中。
在 webpack 3 中,可以使用 CommonsChunkPlugin
外掛,它會將公共的依賴項移動到一個新的指定檔案中:
// webpack.config.js (適用於 webpack 3) module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ // chunk 的名稱將會包含公共依賴 name: 'common', // minChunks表示要將一個模組打入公共檔案時必須包含的 `minChunks` chunks 數量 // (注意,外掛會分析所有 chunks 和 entries) minChunks: 2,// 2 is the default value }), ], }; 複製程式碼
你可以嘗試調整 minChunks
的值來找到最優的方案。通常情況下,你希望它是一個較小的值,但隨著 chunk 數量的增加它會隨之增大。例如,有 3 個 chunk 時, minChunks
的值可能是 2 ,但是有 30 個 chunk 時,它的值可能是 8 - 因為如果你把它設定成 2,就會有很多模組要被打包進同一個公共檔案中,這樣檔案就會變得臃腫。
擴充套件閱讀
-
Webpack 文件 關於 entry points 的概念
-
Webpack 文件 關於 CommonsChunkPlugin 外掛
確保模組的 id 更加穩定
構建程式碼時,webpack 會為每個模組分配一個 ID。隨後,這些 ID 將在 bundle 裡的 require()
函式中被使用到。你通常會在編譯輸出的模組路徑前看到這些 ID:
$ webpack Hash: df3474e4f76528e3bbc9 Version: webpack 3.8.1 Time: 2150ms AssetSizeChunksChunk Names ./0.8ecaf182f5c85b7a8199.js22.5 kB0[emitted] ./main.4e50a16675574df6a9e9.js60 kB1[emitted]main ./vendor.26886caf15818fa82dfa.js46 kB2[emitted]vendor ./runtime.79f17c27b335abc7aaf4.js1.45 kB3[emitted]runtime 複製程式碼
↓ 看下面
[0] ./index.js 29 kB {1} [built] [2] (webpack)/buildin/global.js 488 bytes {2} [built] [3] (webpack)/buildin/module.js 495 bytes {2} [built] [4] ./comments.js 58 kB {0} [built] [5] ./ads.js 74 kB {1} [built] + 1 hidden module 複製程式碼
預設情況下,這些 ID 是使用計數器計算出來的(例如,第一個模組的 ID 是 0,第二個模組的 ID 就是 1,以此類推)。但這樣做有個問題,當你新增一個模組時,它會可能出現在模組列表的中間,從而導致之後所有模組的 ID 都被改變:
$ webpack Hash: df3474e4f76528e3bbc9 Version: webpack 3.8.1 Time: 2150ms AssetSizeChunksChunk Names ./0.5c82c0f337fcb22672b5.js22 kB0[emitted] ./main.0c8b617dfc40c2827ae3.js82 kB1[emitted]main ./vendor.26886caf15818fa82dfa.js46 kB2[emitted]vendor ./runtime.79f17c27b335abc7aaf4.js1.45 kB3[emitted]runtime [0] ./index.js 29 kB {1} [built] [2] (webpack)/buildin/global.js 488 bytes {2} [built] [3] (webpack)/buildin/module.js 495 bytes {2} [built] 複製程式碼
↓ 我們添加了一個新模組...
[4] ./webPlayer.js 24 kB {1} [built] 複製程式碼
↓ 看看下面做了什麼! comments.js
的 ID 由 4 變成了 5
[5] ./comments.js 58 kB {0} [built] 複製程式碼
↓ ads.js
的 ID 由 5 變成了 6
[6] ./ads.js 74 kB {1} [built] + 1 hidden module 複製程式碼
這將使包含或依賴於這些被更改 ID 的模組的所有 chunk 都無效 - 即使它們實際程式碼沒有更改。在我們的案例中,ID 為 0
的 chunk ( comments.js
的 chunk) 和 main
chunk (其它應用程式碼的 chunk )都將失效 - 但其實只有 main
應該失效。
為了解決這個問題,可以使用 HashedModuleIdsPlugin
外掛來改變模組 ID 的計算方式。這個外掛用模組路徑的雜湊值代替了基於計數器的 ID:
$ webpack Hash: df3474e4f76528e3bbc9 Version: webpack 3.8.1 Time: 2150ms AssetSizeChunksChunk Names ./0.6168aaac8461862eab7a.js22.5 kB0[emitted] ./main.a2e49a279552980e3b91.js60 kB1[emitted]main ./vendor.ff9f7ea865884e6a84c8.js46 kB2[emitted]vendor ./runtime.25f5d0204e4f77fa57a1.js1.45 kB3[emitted]runtime 複製程式碼
↓ 看下面
[3IRH] ./index.js 29 kB {1} [built] [DuR2] (webpack)/buildin/global.js 488 bytes {2} [built] [JkW7] (webpack)/buildin/module.js 495 bytes {2} [built] [LbCc] ./webPlayer.js 24 kB {1} [built] [lebJ] ./comments.js 58 kB {0} [built] [02Tr] ./ads.js 74 kB {1} [built] + 1 hidden module 複製程式碼
使用了這個方法,只有當重新命名或移動該模組時,模組的 ID 才會更改。新的模組也不會影響到其他模組的 ID。
可以在配置中的 plugins
部分開啟這個外掛:
// webpack.config.js module.exports = { plugins: [ new webpack.HashedModuleIdsPlugin(), ], }; 複製程式碼