- 原文地址: use long term caching
- 原文作者: Ivan Akulov
- 譯文地址: 利用好持久化快取
- 譯者: 周文康
- 校對者: 閆蒙、泥坤
在優化應用體積之後,下一個提升應用載入時間的策略就是快取。將資源快取在客戶端中,可以避免之後每次都重新下載。
bundle 的版本控制和快取頭的使用
使用快取的通用方法:
-
告訴瀏覽器需要快取一個檔案很長時間(比如,一年)
# Server header Cache-Control: max-age=31536000 複製程式碼
⭐️ 注意:如果你不熟悉
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
},
};
複製程式碼
⭐️ 注意: 即使 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
Asset Size Chunks Chunk Names
./main.00bab6fd3100008a42b0.js 82 kB 0 [emitted] main
./vendor.d9e134771799ecdf9483.js 47 kB 1 [emitted] vendor
複製程式碼
瀏覽器會單獨快取這些檔案 - 同時只有程式碼發生改變時才會重新下載。
Webpack runtime 程式碼
遺憾的是,僅僅提取第三方庫程式碼還是不夠的。如果你想嘗試在應用程式碼中修改一些東西:
// index.js
…
…
// 例如,增加這句:
console.log('Wat');
複製程式碼
你會發現 vendor
的雜湊值也會被改變:
Asset Size Chunks Chunk Names
./vendor.d9e134771799ecdf9483.js 47 kB 1 [emitted] vendor
複製程式碼
↓
Asset Size Chunks Chunk Names
./vendor.e6ea4504d61a1cc1c60b.js 47 kB 1 [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
Asset Size Chunks Chunk Names
./main.00bab6fd3100008a42b0.js 82 kB 0 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 1 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [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 檔案
- “CommonsChunkPlugin 的最佳實踐”
optimization.splitChunks
和optimization.runtimeChunk
的工作原理
內聯 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
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./main.f7e53d8e13e9a2745d6d.js 60 kB 1 [emitted] main
./vendor.4f14b6326a80f4752a98.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [emitted] runtime
複製程式碼
只有當程式碼執行到 import()
函式時才會去下載。
這樣可以讓 入口
bundle 變得更小,從而減少首次載入時間。不僅如此,它還可以優化快取 - 如果你修改了入口 chunk 的程式碼,註釋 chunk 不會受到影響。
⭐️ 注意: 如果你使用 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
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./home.91b9ed27366fe7e33d6a.js 18 kB 1 [emitted] home
./article.87a128755b16ac3294fd.js 32 kB 2 [emitted] article
./profile.de945dc02685f6166781.js 24 kB 3 [emitted] profile
./vendor.4f14b6326a80f4752a98.js 46 kB 4 [emitted] vendor
./runtime.318d7b8490a7382bf23b.js 1.45 kB 5 [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 外掛
- “CommonsChunkPlugin 的最佳實踐”
optimization.splitChunks
和optimization.runtimeChunk
的工作原理
確保模組的 id 更加穩定
構建程式碼時,webpack 會為每個模組分配一個 ID。隨後,這些 ID 將在 bundle 裡的 require()
函式中被使用到。你通常會在編譯輸出的模組路徑前看到這些 ID:
$ webpack
Hash: df3474e4f76528e3bbc9
Version: webpack 3.8.1
Time: 2150ms
Asset Size Chunks Chunk Names
./0.8ecaf182f5c85b7a8199.js 22.5 kB 0 [emitted]
./main.4e50a16675574df6a9e9.js 60 kB 1 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [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
Asset Size Chunks Chunk Names
./0.5c82c0f337fcb22672b5.js 22 kB 0 [emitted]
./main.0c8b617dfc40c2827ae3.js 82 kB 1 [emitted] main
./vendor.26886caf15818fa82dfa.js 46 kB 2 [emitted] vendor
./runtime.79f17c27b335abc7aaf4.js 1.45 kB 3 [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
Asset Size Chunks Chunk Names
./0.6168aaac8461862eab7a.js 22.5 kB 0 [emitted]
./main.a2e49a279552980e3b91.js 60 kB 1 [emitted] main
./vendor.ff9f7ea865884e6a84c8.js 46 kB 2 [emitted] vendor
./runtime.25f5d0204e4f77fa57a1.js 1.45 kB 3 [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(),
],
};
複製程式碼
擴充套件閱讀
- Webpack 文件 關於 HashedModuleIdsPlugin
總結
- 快取 bundle 並通過更改 bundle 名稱來進行版本控制
- 將 bundle 拆分成 app(應用) 程式碼、vendor(第三方庫) 程式碼和 runtime
- 內聯 runtime 可以節省 HTTP 請求
- 使用
import
懶載入非關鍵程式碼 - 按路由或頁面拆分程式碼,從而避免載入不必要的檔案