webpack 如何有助于进行资源缓存
接下来(在优化应用大小以缩短应用加载时间之后)是缓存。使用它可在客户端上保留应用的部分内容,避免每次都重新下载。
使用软件包版本控制和缓存标头
进行缓存的常用方法是:
指示浏览器将某个文件缓存很长时间(例如一年):
# 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' } } };
此选项可实现智能代码拆分。有了它,当供应商代码超过 30 kB(在缩减大小和 gzip 之前)时,webpack 将提取该供应商代码。它还能够提取通用代码,如果您的 build 生成了多个软件包(例如,将应用拆分为多个路线),这种做法非常有用。
在 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
的单独文件中。
完成这些更改后,每个 build 都会生成两个文件,而不是一个: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
})
]
};
完成这些更改后,每个 build 都会生成三个文件:
$ 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() ] };
使用此插件的 build 将创建一个如下所示的文件:
// 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 文档- 实现
import()
语法的 JavaScript 提案
将代码拆分为路线和页面
如果您的应用有多个路由或页面,但只有一个包含代码的 JS 文件(单个 main
分块),则可能会为每个请求提供额外的字节。例如,当用户访问您网站的首页时:
![WebFundamentals 首页](https://web.developers.google.cn/static/articles/use-long-term-caching/image/a-webfundamentals-home-pa-086040f923b7f.png?authuser=5&hl=nb)
他们无需加载代码来呈现位于不同页面上的文章,但会加载页面。此外,如果用户始终只访问首页,而您更改了文章代码,则 webpack 会让整个 app bundle 失效,用户将必须重新下载整个应用。
如果我们将应用拆分为多个页面(如果是单页应用,则为路线),用户将仅下载相关代码。此外,浏览器会更好地缓存应用代码:如果您更改首页代码,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()
。您通常在模块路径前面的 build 输出中看到 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 文档
汇总
- 缓存软件包并通过更改软件包名称来区分版本
- 将 bundle 拆分为应用代码、供应商代码和运行时
- 内嵌运行时以保存 HTTP 请求
- 使用
import
延迟加载非关键代码 - 按路线/页面拆分代码,避免加载不必要的内容