使用长期缓存
可以改善应用下载时间的第二步(第一步是压缩体积)就是开启缓存。这样应用的部分资源就可以驻留在客户端,防止每次访问都重新下载。
使用 bundle 版本号和缓存头
使用缓存的一般步骤是:
1. 让浏览器长期缓存某资源(比如,一年)
# 服务器头
Cache-Control: max-age=31536000
如果你不熟悉 Cache-Control
的用法,可参考 Jake Archibald 的关于缓存的最佳实践精彩博文。
2. 当文件内容变化时,重命名文件,以便重新下载
<!-- 改变前 -->
<script src="./index-v15.js"></script>
<!-- 改变后 -->
<script src="./index-v16.js"></script>
通过 webpack 可以达到同样目的。但不是用版本号,而是指定文件哈希值。为了在文件名中包含哈希值,可以使用 [chunkhash]
:
/** webpack.config.js */
module.exports = {
entry: './index.js',
output: {
filename: 'bundle.[chunkhash].js'
}
}
✨ 注意:即使 bundle 内容一样,webpack 也可能产生不同的哈希值 - 比如,重命名文件或在不同操作系统编译打包。这是已知 bug。
如果需要把文件名称发送到客户端,可以使用 HtmlWebpackPlugin 或 WebpackManifestPlugin 。
HtmlWebpackPlugin
是一个简单的方案,缺点是不太灵活。在编译过程中,它会产生一个 HTML 文件,该文件包含所有的编译资源。如果你的服务器逻辑不太复杂,这个插件就够用了:
<!-- index.html -->
<!doctype html>
<!-- ... -->
<script src="bundle.8e0d62a03.js"></script>
WebpackManifestPlugin
是一个更灵活的方案,适用于复杂逻辑的服务器。编译过程中,它会产生一个 JSON 文件,其中包含一个映射:没有哈希值的文件名与有哈希值文件名的映射。通过该 JSON 文件就可以知道使用哪个文件:
{
"bundle.js": "bundle.8e0d62a03.js"
}
延伸阅读
- Jake Archibald 关于缓存的最佳实践
将依赖和运行时提取到单独文件
依赖
应用的依赖一般不如实际的应用业务代码变化频繁。如果你把它们移动到单独文件,浏览器就可以单独缓存它们,业务代码变化时也无需重新下载它们。
🔑 关键术语:在 webpack 术语中,app 代码之外的独立代码文件称为 chunks。后面会使用这个名称。
为了把依赖提取到单独的 chunk 中,需要分三个步骤:
1. 将输出文件名称替换为 [name].[chunkhash].js
/** webpack.config.js */
module.exports = {
output: {
filename: '[name].[chunkhash].js'
}
}
当 webpack 编译 app 时,会把 [name]
替换为 chunk 的名字。如果不加名字,就需要根据哈希值辨别 chunk ,这会十分困难。
2. 将 entry
字段转换为对象类型
/** webpack.config.js */
module.exports = {
entry: {
main: './index.js'
}
}
在上面的片段中,main
是 chunk 的名字,它会替换步骤 1 中的 [name]
部分。现在如果编译 app,这个 chunk 会包含所有的 app 代码,会像以前一模一样。继续往下看,很快就不一样了。
3. 增加 CommonsChunkPlugin
插件
/** webpack.config.js */
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
/** 指定 chunk 的名字,它将包含所有依赖,它的名字会替换步骤 1 中的 [name] */
name: 'vendor',
/** 用来指定哪些模块可以进入该 chunk 的条件 */
minChunks: module => module.context && module.context.includes('node_modules')
})
]
}
这个插件会把所有路径包含 node_modules
的模块移动到一个单独文件,名称为 vendor.[chunkhash].js
。
经过这些步骤,每次编译会产生两个文件,而不是原来的一个。浏览器可以分别缓存他们,只有在变化后才重新下载。
webpack 运行时代码
不幸的是,仅仅提取第三方库是不够的。如果改变 app 的代码:
/** index.js */
// 比如,增加如下代码
console.log('Wat')
将会注意到,vendor
哈希值也会发生变化。
这是因为当 webpack 打包时,除了模块代码,还有一个运行时 - 一小段管理模块执行的代码。当你将代码分离为多个文件时,这段代码会包含 chunk ID 与具体文件的映射关系:
/** vendor.e6ea4504.js */
script.src = __webpack_require__.p + chunkId + "." + {
"0": "2f2269c7f0a55a5c1871"
}[chunkId] + ".js"
Webpack 会将运行时打包到最后产生的 chunk 中,在本例中就是 vendor
。每次任何 chunk 的改变,运行时的这段映射代码都会变化,从而导致整个 vendor
chunk 变化。
译者注:谷歌官网教程使用的 webpack 版本是 v3.8.1,可能有这个问题。具体操作中,在 webpack 3.11.0 没有复现 vendor 哈希值变化,是否已经被修复了?
为了解决它,可以把运行时打包到单独文件中,具体做法是通过 CommonsChunkPlugin
创建一个额外空 chunk :
/** webpack.config.js */
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 意味着其中不会包含任何 app 模块 */
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
内联 webpack 运行时以减少额外 HTTP 请求
为了更好的体验,可以把运行时代码内联到页面,可以帮你减小一个 HTTP 请求。
下面展示具体做法
如果使用 HtmlWebpackPlugin 产生页面
如果使用 HtmlWebpackPlugin 插件产生静态页面,InlineChunkWebpackPlugin 足够了。
/** webpack.config.js */
const HtmlWebpackInlineChunkPlugin = require('html-webpack-inline-chunk-plugin')
module.exports = {
// ...
plugins: [
// ...
new HtmlWebpackPlugin({
title: 'Hello Webpack'
}),
new HtmlWebpackInlineChunkPlugin({
inlineChunks: ['runtime']
})
]
}
如果使用自定义后端逻辑产生 HTML
1. 通过设定 filename
让运行时的名称固定
/** webpack.config.js */
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'runtime',
minChunks: Infinity,
/** 从此运行时的名称固定为 runtime.js */
filename: 'runtime.js'
}),
],
};
2. 将 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')
时,它会把该模块移动到单独 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()
函数时,才下载该 chunk 。
这可以让 main
bundle 更小,首次下载时间更短。更棒的是,他还能优化缓存 - 如果你只是修改了主 chunk 代码,评论 chunk 不会受到任何影响。
✨ 注意:如果你使用 Babel 转译代码,会遇到一个语法报错。因为 Babel 默认不理解
import()
的用法。为了避免该错误,可以增加syntax-dynamic-import
插件。
延伸阅读
- webpack 文档之
import()
函数 - JavaScript 提案之实现
import()
语法
将代码拆分为路由和页面
如果你的页面有多个路由或页面,但是只有一个单一 JS 文件(一个 main
chunk),很可能每次请求时都会带上多余的字节。比如,当用户访问你的首页时,他们其实不需要其他页面的代码,但是也不得不下载。如果用户经常只访问首页,而且你修改了文章页的代码,webpack 就会让整个 bundle 失效,用户就不得不下载整个 app 代码。
如果我们将代码拆分为多个页面(如果是单页,就拆分为多个路由),用户仅需要下载相关的代码。另外,浏览器会更好的缓存 app 代码:如果你修改了首页代码,webpack 仅会让相应的 chunk 失效。
对于单页应用
若要将单页 app 按路由拆分,可以使用 import()
(参见上一小节)。如果你使用了框架,框架很可能已经自带解决方案:
对于传统的多页应用
为了将传统应用按页拆分,可以使用 webpack 的 entry points
。如果你的 app 包含三种类型页面:首页、文章页和用户账号页,它的入口可以这么设置:
/** webpack.config.js */
module.exports = {
entry: {
home: './src/Home/index.js',
article: './src/Article/index.js',
profile: './src/Profile/index.js'
},
}
对每个入口文件,webpack 都会构建一个单独的依赖树,并产生一个相应的 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
因此,如果只有文章页使用 Lodash,home
和 profile
bundle 就不会包含它,用户也无需在访问首页时下载整个库。
但是多依赖树也有缺点。如果两个入口都用到了 Lodash,而且尚未将依赖移动到 vendor
bundle,两个入口就会各自包含一份 Lodash 副本。为了解决这个问题,可以使用 CommonsChunkPlugin
- 它会将公共的依赖移动到独立的文件中:
/** webpack.config.js */
module.exports = {
plugins: [
new webpack.optimize.CommonsChunkPlugin({
/** 包含公共依赖的 chunk 名字 */
name: 'common',
/**
* 该插件会把引用数大于 2 的模块移动到公共文件
* (注意插件会分析所有 chunk,而不是仅仅入口模块)
*/
minChunks: 2, // 2 is the default value
}),
],
}
需要多次尝试设置 minChunks
的值,才能找到最佳数值。通常,希望其数值越小越好,但会随着 chunk 数量增多而变大。比如,chunk 为 3 时,minChunks
可以为 2,但是对于 30 个 chunk,数值可能会变为 8 - 因为如果你将其控制在 2,会有太多的模块进入 common 文件,导致其体积急剧膨胀。
延伸阅读
- webpack 文档之 entry points 概念
- webpack 文档之 CommonsChunkPlugin
- 物尽其用之 CommonsChunkPlugin
使模块 id 更稳定
当构建代码时,webpack 会给每个模块分配一个 ID。这些 ID 会在之后的 require()
语句中使用。通常可以在构建输出中看到模块 ID,就在模块路径之前。
默认情况下,ID 通过一个计数器计算(比如,第一个模块是 0,第二个模块是 1,等等)。这种处理方式的问题在于如果你增加了新的模块,它可能出现在模块列表中间,让后面的模块 ID 全部变化。
ID 的变化会让所有依赖它的 chunk 失效 - 即使它们的实际代码没有变化。为了解决这个问题,可以使用 HashedModuleIdsPlugin
计算模块 ID。它会用模块路径的哈希值代替计数器算出的 ID。
通过这种方式,只有重命名或者移动模块路径时,才会改变模块的 ID。新的模块加入不会影响其他模块的 ID。
为了开启该模块,可以将其加入到 plugins
选项:
/** webpack.config.js */
module.exports = {
plugins: [
new webpack.HashedModuleIdsPlugin(),
],
}
延伸阅读
- webpack 文档之
HashedModuleIdsPlugin
总结
- 缓存 bundle,通过改变名称更新缓存
- 将 bundle 拆分为 app 代码,厂商代码和运行时代码
- 内联运行时代码,节省 HTTP 请求
- 使用
import()
懒加载不重要的代码 - 将代码按路由或页面拆分,防止加载非必须的代码
REF
- Make use of long-term caching, by Ivan Akulov, Google Developers
- Caching best practices & max-age gotchas, by Jake Archibald, 2016/04/27