什么是持久化缓存?
缓存(cache)一直是前端性能优化的重头戏,利用好静态资源的缓存机制,可以使我们的 web 应用更加快速和稳定。仅仅简单的资源缓存是不够的,我们还要为不断更新的资源做持久化缓存(Long term cache)。以前我们能利用服务端模板和构建打包,来给资源增加版本标记,如 app.js?v=1.0.0
,但在大流量的网站中,这种更新部署方式会引起下面的问题:
上述回答中针对前端代码部署的最终方案是:
- 细粒度文件名替换(文件内容摘要)
- 细粒度资源依赖追踪和摘要
- 非覆盖式更新
但想从头实现一整套完整的前端部署方案,对于小公司来说还是非常难的。不仅如此,从目前 Web 发展趋势来看,如今前端早已不是传统 Web 应用架构能够 Hold 住的了,前后端分离,前端应用化、工程化的需求在迅速增加:模块化开发、模块依赖解析、代码压缩、图片压缩、请求数最小化、雪碧图、字体压缩、CSS 预处理、ES2015/6/7 编译、模板引擎等,都是在构建过程中要实现的功能。
自从 Node.js 和 npm 问世后,最理解前端优化需求的前端架构师/工程师也可以用自己最熟悉的 JavaScript 来实现自己想要的工程化工具了,社区也先后创造出了 Grunt、gulp、fis、webpack、rollup 等工程化工具,它们作用及架构各不相同,如 gulp 专注流程化任务,rollup 专注模块打包……
对于今天提出的问题:持久化缓存,它涉及了模块化,模块依赖,静态资源优化,模块打包,文件摘要处理等问题,如今(2016+)能把这些问题解决并做的最好的社区驱动工具有且只有 webpack。
同类模块打包工具横向对比表 -> Comparison - Why webpack?
目前 webpack 2.2.0 已正式发布,是时候用最新的工具来创建更完善的前端构建了。
通过配置,逐步实现持久化缓存
一、文件 Hash 摘要
Hash 文件名(vendor.f02bc2.js)是实现持久化缓存的第一步,目前 webpack 有两种计算 hash 的方式:
- 计算所有 chunks 的 hash —— [hash]
- 为每个 chunk 计算 hash —— [chunkhash]
第一种是每次编译生成一个唯一 hash,适合 chunk 拆分不多的小项目,但所有资源全打上同一个 hash,无法完成持久化缓存的需求。
第二种是 webpack 为每个 chunk 资源都生成与其内容相关的 hash 摘要,为不同的资源打上不同的 hash。
相关官方文档:
- webpack 1.x - Long term cache
- webpack 2 - Generating unique hashes for each file - Caching
webpack 配置
JS 资源的 [chunkhash] 由 webpack 计算,Images/Fonts 的 [hash] 由webpack/file-loader 计算,提取的 CSS 的 [contenthash] 由 webpack/extract-text-webpack-plugin 计算。避免冗杂,这里只写出了部分 webpack 2 配置:
// production
output: {
filename: '[name].[chunkhash:8].bundle.js',
chunkFilename: '[name].[chunkhash:8].js'
},
module: {
rules: [{
test: /.(jpe?g|png|gif|svg)$/i,
loader: 'url-loader',
options: {
limit: 1000,
name: 'assets/imgs/[name].[hash:8].[ext]'
}
}, {
test: /.(woff2?|eot|ttf|otf)$/i,
loader: 'url-loader',
options: {
limit: 10000,
name: 'assets/fonts/[name].[hash:8].[ext]'
}
}]
},
plugins: [
new ExtractTextPlugin('[name].[contenthash:8].css')
]
不要在开发环境使用 [chunkhash]/[hash]/[contenthash],因为不需要在开发环境做持久缓存,而且这样会增加编译时间,开发环境用 [name] 就可以了。
不稳定的 chunkhash
不过,只是计算 chunk MD5 摘要并修改 chunk 资源文件名是不够的。Chunk 的生成还涉及到依赖解析和模块 ID 分配,这是无法稳定实质上没有变化的 chunk 文件的 chunkhash 变动问题的本源,附一个未关闭的相关 issue:
正如问题 [#1315] 描述的那样:虽然只修改了 app.js 的代码,但在最终的构建结果中,vendor.js 的 chunkhash 也被修改了,尽管 vendor.js 的内容没有实质变化。
其实这个场景比较简单,只生成了 entry 和 vendor 两个 chunk,造成上述问题的原因有两个:
- webpack runtime 中包含 chunks ID 及其对应 chunkhash 的对象,但 runtime 被集成到 vendor.js 中;
- entry 内容修改后,由于 webpack 的依赖收集规则导致构建产生的 entry chunk 对应的 ID 发生变化,webpack runtime 也因此被改变。
webpack runtime(
webpackBootstrap
)代码不多,主要包含几个功能:
- 全局
webpackJsonp
方法:模块读取函数,用来区分模块是否加载,并调用__webpack_require__
函数;- 私有
__webpack_require__
方法:模块初始化执行函数,并给执行过的模块做标记;- 异步 chunk 加载函数(用 script 标签异步加载),加载的 chunk 内容均被
webpackJsonp
包裹的,script 加载成功会直接执行。这个函数还包含了所有生成的 chunks 的路径。在 webpack 2 中这个函数用到了 Promise,因此可能需要提供 Promise Polyfill;- 对 ES6 Modules 的默认导出(export default)做处理。
对于复杂项目的构建,由于模块间互相依赖,这种问题影响更为巨大:可能只改动了一个小模块,但在构建后,会发现所有与之直接或间接相关的 chunk 及其 chunkhash 都被更新了……这与我们期望的持久化缓存的需求不符。
解决这个问题的核心在于生成稳定的模块 ID,避免频繁的 chunk 内容变动。
如果你看过 #1315 的回复,可能会了解到 webpack-md5-hash 插件可以解决这个问题,甚至 webpack 2 的文档中也提示用这个插件解决。但我可以负责任的告诉你,这个插件有缺陷……不要使用它,除非你想背黑锅。
webpack-md5-hash 的问题
erm0l0v/webpack-md5-hash(相关源码) 通过模块路径来排序 chunk 的所有依赖模块(仅这个 chunk 中的模块,不含被 CommonsChunkPlugin 剔除的模块),并将这些排序后的模块源代码拼接,最后用 MD5 拼接后内容的 chunkhash。插件这么做的好处是,使 chunkhash 与该 chunk 内代码做直接关联,让 chunk 与其依赖的模块 ID 无关化,无论模块 ID 如何变化,都不会影响父 chunk 的实质内容及 chunkhash。
这个方法比较有效,但在一些情景下,会使 webpack-md5-hash 失效,使构建变得不可信:
Hash does not change when only imported modules IDs change #7。
比如一个简单场景:有两个入口 vendor 和 app。
当 app.js 被修改后,其 chunk ID 随之改变,vendor.js 中 app 对应的 chunk ID 也会改变,即 vendor 内容有变动,其 chunkhash 也理应改变。但 webpack-md5-hash 是根据 chunk 内实际包含模块而生成的 chunkhash,和仅有 ID 引用的 chunk 内容无关,vendor 只包含 app chunk ID 的引用,并不包含其代码,所以此次构建中 vendor 的 chunkhash 并不会改变。这样造成的结果便是:浏览器依然会下载旧的 vendor,直接导致发版失误!
因此 webpack-md5-hash 并没有解决之前的问题:
- 如何生成稳定的模块ID?
- 如何避免频繁的 chunk 内容变动?
我们先来解决第一个问题,第二个下一节解决。
如何生成稳定的模块 ID?
默认,模块的 ID 是 webpack 根据依赖的收集顺序递增的正整数,这种 ID 分配方式不太稳定,因为修改一个被依赖较多的模块,依赖这个模块的 chunks 内容均会跟着模块的新 ID 一起改变,但实际上我们只想让用户下载有真正改动的 chunk,而不是所有依赖这个新模块的 chunk 都重新更新。
因此 webpack (1) 默认的模块 ID 分配不是很合适,我们需要其他工具来帮我们稳定 ID:
-
OccurrenceOrderPlugin
这个插件可以改变默认的 ID 决定方式,让 webpack 以依赖模块出现的次数决定 ID 的值,次数越多 ID 越小。在依赖项变动不大情况下,还是一个比较好的方法,但当依赖出现次数有变化时,输出的模块 ID 则可能会有大幅变动(级联)。(目前 webpack 2 已经将此插件默认启用 ��) -
recordsPath 配置
它会输出每次构建的「模块路径(loaders + module path)」与 ID 键值对 JSON,在下次构建时直接使用 JSON 中的 ID。但当修改模块路径或 loader 时,ID 会更新。
同时,需要注意的是webpack.optimize.DedupePlugin()
插件不可与recordsPath
共存,它会改变存下来的模块 ID。 -
NamedModulesPlugin
这个模块可以将依赖模块的正整数 ID 替换为相对路径(如:将4
替换为./node_modules/es6-promise/dist/es6-promise.js
)。- 开发模式,它可以让 webpack-dev-server 和 HMR 进行热更新时在控制台输出模块名字而不是纯数字;
- 生产构建环境,它可以避免因修改内容导致的 ID 变化,从而实现持久化缓存。
但是有两个缺点:
- 递增 ID 替换为模块相对路径,构建出来的 chunk 会充满各种路径,使文件增大;
- 模块(npm 和自己的模块)路径会泄露,可能导致安全问题。
-
HashedModuleIdsPlugin
这是 NamedModulesPlugin 的进阶模块,它在其基础上对模块路径进行 MD5 摘要,不仅可以实现持久化缓存,同时还避免了它引起的两个问题(文件增大,路径泄露)。用 HashedModuleIdsPlugin 可以轻松地实现 chunkhash 的稳定化!不过这个插件只被添加到了 webpack 2 中,可能是因为 webpack 2 正式版还没有发布,HashedModuleIdsPlugin 一直没有文档,所以这里有必要指明如何使用:
new webpack.HashedModuleIdsPlugin()
如果使用了 HashedModuleIdsPlugin,NamedModulesPlugin 就不要再添加了。
幸运的是,我们可以通过直接添加 HashedModuleIdsPlugin.js 为模块到 webpack 1 的配置中,也能达到同样稳定 chunkhash 的功能。
const HashedModuleIdsPlugin = require('./HashedModuleIdsPlugin') // ... new HashedModuleIdsPlugin()
至此 chunkhash 已经稳定,是时候解决另一个问题了……
二、如何避免频繁的 chunk 内容变动?
一般场景下,我们可能不需要做太多的优化,也不用追求持久化缓存,常规配置即可:
为了节省篇幅,所有配置代码我会尽量缩减,文章最后会提供 DEMO,包含完整配置。
{
entry: { entry },
plugins: [
new HtmlWebpackPlugin({
chunks: ['vendor', 'entry']
}),
new webpack.optimize.CommonsChunkPlugin({
names: 'vendor',
minChunks: Infinity
})
]
}
但随着业务需求变化,最初的单页模式可能无法满足需求,而且把公共模块全部提取到 vendor 中,也无法做到较好的持久化缓存,我们需要更合理地划分并提取公共模块。
合理划分公共模块
稍大型的应用通常会包含这几个部分:
类型 | 公用率 | 使用频率 | 更新频率 | 例 |
---|---|---|---|---|
库和工具 | 高 | 高 | 低 | vue/react/redux/whatwg-fetch 等 |
定制 UI 库和工具 | 高 | 高 | 中 | UI 组件/私有工具/语法 Polyfill/页面初始化脚本等 |
低频库/工具/代码 | 低 | 低 | 低 | 富文本编辑器/图表库/微信 JSSDK/省市 JSON 等 |
业务模块 | 低 | 高 | 高 | 包含业务逻辑的模块/View |
根据公用/使用/更新率来做公共模块的划分是比较科学:
- 库和工具 - libs
- 定制 UI 库和工具 - vendor
- 业务模块 - entries
- 低频库/工具/代码 - 分割为 chunk
我们可通过指定模块的入口 chunk,来直接分离模块。以 Vue 搭建的多入口单页应用为例:
{
entry: {
libs: [
'es6-promise/auto',
'whatwg-fetch',
'vue',
'vue-router'
],
vendor: [
/*
* vendor 中均是非 npm 模块,
* 用 resolve.alias 修改路径,
* 避免冗长的相对路径。
*/
'assets/libs/fastclick',
'components/request',
'components/ui',
'components/bootstrap' // 初始化脚本
],
page1: 'src/pages/page1',
page2: 'src/pages/page2'
},
plugins: [
new HtmlWebpackPlugin({
// 省略部分配置
template: 'src/pages/page1/index.html',
chunks: ['libs', 'vendor', 'page1']
}),
new HtmlWebpackPlugin({
template: 'src/pages/page2/index.html',
chunks: ['libs', 'vendor', 'page2']
})
]
}
多页入口最好用脚本来扫描目录并生成,手动添加维护性较差,可参考 multi-vue。
代码分割
除了入口代码的分离,我们还缺少对「低频库/工具/代码」的处理,对于这类代码最好的办法是做代码分割(Code Splitting),做到按需加载,进一步加速应用。
webpack 提供了几种添加分割点的方法:
- CommonJs:
require.ensure
- AMD:
require
- ES6 Modules (webpack 1 不支持)
添加分割点可以主动将指定的模块分离成另一个 chunk,而不是随当前 chunk 一起打包。对于这几种情况处理非常好:
- 比较大,且不常用的库/工具,如 D3.js、Draft.js、微信 JSSDK、querystring 等;
- 单页应用中不常用的 router view,即某些不常访问的介面。
CommonJs 和 AMD 添加分割点的方法就不再赘述了,详情请查看文档:
注意
如果你使用了 babili (babel-minify) 来压缩你的 ES6+ 代码,请不要使用
require.ensure
/require
,因为 babili 会把require
关键字压缩,导致 webpack 无法识别,造成构建问题。
import()
webpack 2 在 1.x 的基础上增加了对 ES6 模块(ES6 Modules)的支持,这意味着在webpack 2 环境下,import
导入模块语法不再需要编译为 require
了。还优化了 ES6 模块依赖(Tree-shaking,后面会谈到),并实现了 JS Loader Standard 规范定义中的 import(path)
方法。
注意
在 webpack v2.1.0-beta.28 中,
System.import
方法已被废弃,因为System.import
不在提案中了,被import()
代替。
由于 import()
仅仅是个语法,不涉及转换,因此我们需要使用 babel 插件 syntax-dynamic-import 来让 babel 可以识别这个语法。另外 import()
也依赖编译环境,要想让运行环境通过 import()
进行按需加载,需要额外的插件:
- webpack 2 已支持,不需要额外插件
- webpack 1 + babel-plugin-dynamic-import-webpack
- node + babel-plugin-dynamic-import-node
const { search } = window.location
import('./components/querystring.js')
.then(querystring => {
const searchquery = querystring.parse(search)
// ...
})
.catch(err => {
Toast.error(err)
console.error(err)
})
配合 react-router:
import { Router, Route, hashHistory } from 'react-router'
import App from './App'
const lazyLoad = moduleName => _ =>
import(`./components/${moduleName}`)
.then(module => module.default)
.catch(err => console.error(err))
export default function Root () {
return (
<Router history={hashHistory}>
<Route path='/' component={App}>
<Route path='/home' getComponent={lazyLoad('Home')} />
<Route path='/posts' getComponent={lazyLoad('Posts')}>
<Route path=':id' getComponent={lazyLoad('Article')} />
</Route>
<Route path='/about' getComponent={lazyLoad('About')} />
</Route>
</Router>
)
}
用模板字符串来动态加载模块时,webpack 在编译阶段会把可能加载的模块打包,并用正则匹配加载,懒加载示例代码可见 blade254353074/react-router-lazy-import。
提取公共模块
在上述例子中,我们划分了公共模块,并进行了代码分割,下面我们要做的是:提取频繁共用的模块,将 webpack runtime 构建为内联 script。
提取频繁共用的模块
提取公共模块要使用 Commons-chunk-plugin,对于持久化缓存来说,我们只需要将共用的模块打包到 libs/vendor 中即可。
模块有两种共用情况:
- libs/vendor 与其他 chunk 共用的模块,如:vue/react/moment/whatwg-fetch
- 多个 chunks 间共用的模块,如 page1 和 page2 共用 Header 组件
对于想把所有共用的模块全部提取的需求,我们可以做如下配置:
new webpack.optimize.CommonsChunkPlugin({
names: ['libs', 'vendor'].reverse()
})
用上述配置构建时,webpack 会将 webpack runtime 打包到 libs 中(names 数组末尾的 chunk),而 chunks 间共用的模块会打包到 vendor中。
如果你不想让仅有两个 chunks 共用的模块被提取到 vendor 中,而想让 n 个 chunks 共用的模块被提取出来时,可以借助 minChunks 实现。
minChunks 是指限定模块被 chunks 依赖的最少次数,低于设定值(2 ≤ n ≤ chunks 总数)将不会被提取到公共 chunk 中。如果 chunks 太多,又不想让所有公共模块被分离到 vendor 中,可以将 minChunks 设为 Infinity
,则公共 chunk 仅仅包含在 entry 中指定的模块,而不会把其他共用的模块提取进去。
new webpack.optimize.CommonsChunkPlugin({
names: ['libs', 'vendor'].reverse(),
// minChunks: 3
minChunks: Infinity
})
CommonsChunkPlugin 似乎还是有些 Bug,当我用 vue-style-loader 时,其中的 addStyle.js 会被添加到依赖中,但在以下配置中,addStyle.js 在打包后会被 CommonsChunkPlugin 漏掉,导致无法正常运行:
new webpack.optimize.CommonsChunkPlugin({ names: ['libs', 'vendor'].reverse() })
manifest (清单)
尽管我们已经划分好了 chunks,也提取了公共的模块,但仅改动一个模块的代码还是会造成 Initial chunk (libs) 的变化。原因是这个初始块包含着 webpack runtime,而 runtime 还包含 chunks ID 及其对应 chunkhash 的对象。因此当任何 chunks 内容发生变化,webpack runtime 均会随之改变。
webpack runtime 中的 chunks 清单
正如文档 # Manifest File - Code Splitting - Libraries中描述的那样,我们可以通过增加一个指定的公共 chunk 来提取 runtime,从而进一步实现持久化缓存:
new webpack.optimize.CommonsChunkPlugin({
// 将 `manifest` 优先于 libs 进行提取,
// 则可以将 webpack runtime 分离到这个块中。
names: ['manifest', 'libs', 'vendor'].reverse()
// manifest 只是个有意义的名字,也可以改成其他名字。
})
manifest 只是个特定的名字(可能是包含了 chunks 清单,所以起名 manifest),如果仅仅是为了分离 webpack runtime,可以将 manifest 替换成任意你想要的名字。
这样在我们构建之后,就会多打包一个特别小(不足 2kb)的 manifest.js,解决了 libs 经常「被」更新的问题。不过,你可能发现了一个问题 —— manifest.js
实在是太小了,以至于不值得再为一个小 js 增加资源请求数量。
这时候我们可以引入另一个插件:inline-manifest-webpack-plugin。
它可以将 manifest 转为内联在 html 内的 inline script,因为 manifest 经常随着构建而变化,写入到 html 中便不需要每次构建再下载新的 manifest 了,从而减少了一个小文件请求。此插件依赖 html-webpack-plugin 和 manifest 公共块,因此我们要配置 HtmlWebpackPlugin 且保持 manifest 的命名:
{
module: {
rules: [{
test: /.ejs$/,
loader: 'ejs-loader'
}]
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
names: ['manifest', 'libs', 'vendor'].reverse()
}),
new HtmlWebpackPlugin({
template: 'src/pages/page1/index.ejs',
chunks: ['manifest', 'libs', 'vendor', 'page1']
}),
new InlineManifestWebpackPlugin()
]
}
EJS Template:
<!-- ejs template -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Template</title>
<%= htmlWebpackPlugin.files.webpackManifest %>
</head>
<body>
<div id="app"></div>
</body>
</html>
在 inline-manifest-webpack-plugin 的帮助下进行构建,最终我们的 html 便内联了 webpack runtime 脚本,提高了页面的加载速度:内联 manifest 的 html
提取 CSS
这篇文章主要针对 JS 资源的持久化缓存优化,关于 CSS 提取请看 webpack/extract-text-webpack-plugin。
对 chunks 做最后的优化
webpack 中对 chunks 做优化的还有这几个插件:
- DedupePlugin (webpack 2 已废弃)
- OccurrenceOrderPlugin (webpack 2 默认启用)
- LimitChunkCountPlugin
- MinChunkSizePlugin
- UglifyJsPlugin
- AggressiveMergingPlugin
- DllPlugin
- DllReferencePlugin
关于 Tree Shaking
尽管 webpack 2 还未大量使用,但现在我们有一个不得不用 webpack 2 的理由 —— Tree Shaking
注意
为了避免
import x from 'foo'
被 babel 转换为require
,我们需要在.babelrc
的 presets 配置中标明"modules": false
:
{
"presets": [
["latest", {
"es2015": { "modules": false }
}]
],
"plugins": ["transform-runtime", "syntax-dynamic-import"],
"comments": false
}
webpack 在构建过程中只会标记出未使用的 exports,并不会直接将 dead code 去掉,因为为了使工具尽量通用,webpack 被设计为:只标注未使用的 imports/exports。真正的清除死代码工作,交给了 UglifyJS/babili 等工具。
Does webpack include unused imports in the bundle or not?
UglifyJsPlugin 不仅可以将未使用的 exports 清除,还能去掉很多不必要的代码,如无用的条件代码、未使用的变量、不可达代码等。
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: true }
})
如果打开了 UglifyJsPlugin 的 warning 功能,就可以在构建结果中看到清除的代码警告。
因此必须在生产环境中配置 UglifyJsPlugin,并启用 -p
(production) 环境,才能真正发挥 Tree Shaking 的作用。
其他资源
引用
- 大公司里怎样开发和部署前端代码? - 张云龙的回答 - 知乎
- LONG TERM CACHING - webpack.github.io
- Generating unique hashes for each file - webpack.js.org
- Configuration - webpack.js.org
- Migrating from v1 to v2 - webpack.js.org
- Vendor chunkhash changes when app code changes #1315 - webpack/webpack
- 你用 webpack 1.x 输出的 hash 靠谱不 ? #1 - zhenyong/Blog
- HashedModuleIdsPlugin
- Syntax Dynamic Import - Babel
- Manifest File - Code Splitting - Libraries - webpack.js.org
- Long-term caching of static assets with Webpack - Medium
- Caching Assets Long Term with Webpack - Medium