Webpack 如何協助提供素材資源快取
其次 (在最佳化應用程式大小後,有助於縮短應用程式載入時間後),就是快取。可用來將應用程式的部分內容保留在用戶端上,避免每次都重新下載。
使用套件版本管理和快取標頭
快取的常見方法是:
要求瀏覽器快取檔案一段時間 (例如 1 年):
# Server header Cache-Control: max-age=31536000
如果您不熟悉
Cache-Control
的功用,請參閱 Jake Archibald 的快取最佳做法文章。檔案變更後,請重新命名檔案,以強制重新下載:
<!-- Before the change --> <script src="./index-v15.js"></script> <!-- After the change --> <script src="./index-v16.js"></script>
這個方法會指示瀏覽器下載 JS 檔案、快取檔案並使用快取副本。只有在檔案名稱有所變更 (或一年過後),瀏覽器才會上網。
使用 Webpack 時,您可以執行相同的操作,但須指定檔案雜湊。如要在檔案名稱中加入雜湊,請使用 [chunkhash]
:
// webpack.config.js
module.exports = {
entry: './index.js',
output: {
filename: 'bundle.[chunkhash].js' // → bundle.8e0d62a03.js
}
};
如果您需要檔案名稱來傳送至用戶端,請使用 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 關於快取最佳做法
將依附元件和執行階段擷取至個別檔案
依附元件
應用程式依附元件的變更頻率通常低於實際的應用程式程式碼。如果將這些檔案移至單獨的檔案,瀏覽器可以分別快取,而且不會在每次應用程式程式碼變更時重新下載。
如要將依附元件解壓縮到獨立區塊,請執行三個步驟:
將輸出檔案名稱替換為
[name].[chunkname].js
:// webpack.config.js module.exports = { output: { // Before filename: 'bundle.[chunkhash].js', // After filename: '[name].[chunkhash].js' } };
Webpack 建構應用程式時,會將
[name]
替換成區塊的名稱。如果我們未加入[name]
部分,就必須依雜湊區分區塊,這實在不容易!將
entry
欄位轉換為物件:// webpack.config.js module.exports = { // Before entry: './index.js', // After entry: { main: './index.js' } };
在這個程式碼片段中,「main」是區塊的名稱。這個名稱會取代步驟 1 中的
[name]
。目前,當您建構應用程式時,這個區塊會包含整個應用程式程式碼,就跟我們尚未完成這些步驟一樣。但這項變更很快就會改變。
在 Webpack 4 中,將
optimization.splitChunks.chunks: 'all'
選項新增至 Webpack 設定:// webpack.config.js (for webpack 4) module.exports = { optimization: { splitChunks: { chunks: 'all' } } };
這個選項會啟用智慧型程式碼分割功能。有了它,Webpack 大小超過 30 kB 就會擷取供應商程式碼 (在壓縮和 gzip 之前)。這項作業還會擷取通用程式碼,如果建構會產生多個套件 (例如將應用程式分割成路徑),這項功能就非常實用。
在 Webpack 3 中,新增
CommonsChunkPlugin
:// webpack.config.js (for webpack 3) module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ // A name of the chunk that will include the dependencies. // This name is substituted in place of [name] from step 1 name: 'vendor', // A function that determines which modules to include into this chunk minChunks: module => module.context && module.context.includes('node_modules'), }) ] };
這個外掛程式會取得路徑包含
node_modules
的所有模組,並將這些模組移至名為vendor.[chunkhash].js
的獨立檔案。
變更後,每個版本都會產生兩個檔案,而不是一個:main.[chunkhash].js
和 vendor.[chunkhash].js
(Webpack 4 的 vendors~main.[chunkhash].js
)。以 Webpack 4 來說,如果依附元件較小,系統可能無法產生供應商套件,這一切正常:
$ 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 執行階段程式碼
不過,只擷取供應商程式碼是不夠的。如果您嘗試變更應用程式碼中的某些內容:
// index.js
…
…
// E.g. add this:
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 套件 (模組程式碼除外) 含有「執行階段」,也就是管理模組執行作業的一小段程式碼。如果您將程式碼分割成多個檔案,這段程式碼就會開始在區塊 ID 和對應檔案之間進行對應:
// vendor.e6ea4504d61a1cc1c60b.js
script.src = __webpack_require__.p + chunkId + "." + {
"0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js";
Webpack 會將這個執行階段納入最近產生的區塊中,也就是我們範例中為 vendor
。每當任何區塊變更時,這段程式碼都會一併變更,導致整個 vendor
區塊變更。
為解決這個問題,請將執行階段移至獨立檔案中。在 Webpack 4 中,只要啟用 optimization.runtimeChunk
選項即可達成此目的:
// webpack.config.js (for webpack 4)
module.exports = {
optimization: {
runtimeChunk: true
}
};
在 Webpack 3 中,做法是使用 CommonsChunkPlugin
建立一個額外的空白區塊:
// webpack.config.js (for webpack 3)
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: module => module.context && module.context.includes('node_modules')
}),
// This plugin must come after the vendor one (because webpack
// includes runtime into the last chunk)
new webpack.optimize.CommonsChunkPlugin({
name: 'runtime',
// minChunks: Infinity means that no app modules
// will be included into this 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 文件
- 「充分發揮 CommonsChunkPlugin 的功用」
optimization.splitChunks
和optimization.runtimeChunk
的運作方式
內嵌 Webpack 執行階段,可儲存額外的 HTTP 要求
如要進一步改善效果,請嘗試將 Webpack 執行階段內嵌至 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>
執行階段很小,內嵌程式碼可以幫助您儲存 HTTP 要求 (對 HTTP/1 來說相當重要;對 HTTP/2 來說較不重要,但仍可能發揮效用)。
做法如下。
如果您使用 HtmlWebpackPlugin 產生 HTML
如果使用 HtmlWebpackPlugin 產生 HTML 檔案,只需使用 InlineSourcePlugin 即可:
const HtmlWebpackPlugin = require('html-webpack-plugin');
const InlineSourcePlugin = require('html-webpack-inline-source-plugin');
module.exports = {
plugins: [
new HtmlWebpackPlugin({
inlineSource: 'runtime~.+\\.js',
}),
new InlineSourcePlugin()
]
};
如果您使用自訂伺服器邏輯產生 HTML
使用 Webpack 4 時:
請新增
WebpackManifestPlugin
,瞭解系統產生的執行階段區塊名稱:// 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" }
使用方便的方式內嵌執行階段區塊內容。例如使用 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
,將執行階段名稱設為靜態:module.exports = { plugins: [ new webpack.optimize.CommonsChunkPlugin({ name: 'runtime', minChunks: Infinity, filename: 'runtime.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 上載入影片頁面,那麼比起留言,更重視影片。比起留言,影片更重要。
- 在新聞網站中開啟文章時,您會重視報導內文,而不是廣告。這裡的文字比廣告更重要。
在這種情況下,請先只下載最重要的內容,稍後再延遲載入其他部分,以改善初始載入效能。如要執行此作業,請使用 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')
時,會將這個模組移至獨立區塊:
$ 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()
函式時才下載。
這會縮小 main
套件,改善初始載入時間。此外,即使變更主要區塊中的程式碼,註解區塊也不會因此提升快取。
其他資訊
- 適用於
import()
函式的 Webpack 文件 - JavaScript 提案:導入
import()
語法
將程式碼分割為路徑和頁面
如果您的應用程式有多個路徑或網頁,但只有一個包含程式碼的 JS 檔案 (單一 main
區塊),則有可能針對每個要求提供額外位元組。舉例來說,當使用者造訪您網站的首頁時:
不需要載入程式碼 就能轉譯位於不同網頁上的文章此外,如果使用者只造訪首頁,而您變更了文章程式碼,Webpack 會將整個套件失效,而使用者就必須重新下載整個應用程式。
如果我們將應用程式分成多個網頁 (如果是單頁應用程式,則為路徑),使用者只會下載相關程式碼。此外,瀏覽器會快取應用程式程式碼:如果您變更首頁程式碼,Webpack 只會將對應的區塊失效。
單頁應用程式
如要按路徑分割單頁應用程式,請使用 import()
(請參閱「延遲載入您目前不需要的程式碼」一節)。如果您使用架構,其現有的解決方案可能就能滿足以下需求:
適用於傳統多頁面應用程式
如要依網頁分割傳統應用程式,請使用 Webpack 的進入點。如果應用程式有三種頁面:首頁、文章頁面和使用者帳戶頁面,則應用程式應有三個項目:
// webpack.config.js
module.exports = {
entry: {
home: './src/Home/index.js',
article: './src/Article/index.js',
profile: './src/Profile/index.js'
}
};
Webpack 會為每個項目檔案建立獨立的依附元件樹狀結構,並產生只包含該項目使用的模組的套裝組合:
$ 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
因此,如果只有文章頁面使用 Lodash,home
和 profile
套裝組合就不會包含這個項目,使用者前往首頁時也不需要下載這個程式庫。
不過,個別的依附元件樹狀結構有其缺點。如果兩個進入點使用 Lodash,且您尚未將依附元件移至供應商套件,則這兩個進入點都會包含 Lodash 的副本。如要解決這個問題,請在 Webpack 4 中,將 optimization.splitChunks.chunks: 'all'
選項新增至 webpack 設定:
// webpack.config.js (for webpack 4)
module.exports = {
optimization: {
splitChunks: {
chunks: 'all'
}
}
};
這個選項會啟用智慧型程式碼分割功能。選取這個選項後,Webpack 會自動尋找通用程式碼,並將其解壓縮至獨立的檔案。
或者,在 Webpack 3 中使用 CommonsChunkPlugin
,即可將常見的依附元件移至新的指定檔案:
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'common',
minChunks: 2 // 2 is the default value
})
]
};
您可以試試 minChunks
值,找出最適合的值。一般而言,您會想要縮減大小,但只要區塊數量變大即可增加。舉例來說,對於 3 個區塊,minChunks
可能是 2,但對於 30 個區塊,這個值可能是 8 - 因為如果保持在 2 個區塊,太多模組會進入同一個檔案中,導致過度加載。
其他資訊
- 關於進入點概念的 Webpack 說明文件
- 關於 CommonsChunkPlugin 的 Webpack
- 「充分發揮 CommonsChunkPlugin 的功用」
optimization.splitChunks
和optimization.runtimeChunk
的運作方式
讓模組 ID 更加穩定
建構程式碼時,Webpack 會為每個模組指派 ID。這些 ID 稍後會用在套件中的 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 5 (而非 4)
[5] ./comments.js 58 kB {0} [built]
↓ ads.js
現在使用 ID 6 而非 5
[6] ./ads.js 74 kB {1} [built]
+ 1 hidden module
這樣會將包含或依附具有變更 ID 的模組的所有區塊失效,即使實際程式碼未變更也一樣。以上述情況來說,0
區塊 (含有 comments.js
的區塊) 和 main
區塊 (包含其他應用程式程式碼的區塊) 會失效,而只有 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()
]
};
其他資訊
- 關於 HashedModuleIdsPlugin 的 Webpack 文件
加總
- 變更套件名稱,藉此快取套件並區分不同版本
- 將套件分割成應用程式程式碼、供應商程式碼和執行階段
- 內嵌執行階段以儲存 HTTP 要求
- 使用
import
延遲載入非重要程式碼 - 按路徑/網頁分割程式碼,避免載入不必要的資料