缓存(cache)一直是前端性能优化的重头戏,利用好静态资源的缓存机制,可使咱们的 web 应用更加快速和稳定。仅仅简单的资源缓存是不够的,咱们还要为不断更新的资源作持久化缓存(Long term cache)。之前咱们能利用服务端模板和构建打包,来给资源增长版本标记,如 app.js?v=1.0.0
,但在大流量的网站中,这种更新部署方式会引发下面的问题:javascript
上述回答中针对前端代码部署的最终方案是:html
但想从头实现一整套完整的前端部署方案,对于小公司来讲仍是很是难的。不只如此,从目前 Web 发展趋势来看,现在前端早已不是传统 Web 应用架构可以 Hold 住的了,先后端分离,前端应用化、工程化的需求在迅速增长:模块化开发、模块依赖解析、代码压缩、图片压缩、请求数最小化、雪碧图、字体压缩、CSS 预处理、ES2015/6/7 编译、模板引擎等,都是在构建过程当中要实现的功能。前端
自从 Node.js 和 npm 问世后,最理解前端优化需求的前端架构师/工程师也能够用本身最熟悉的 JavaScript 来实现本身想要的工程化工具了,社区也前后创造出了 Grunt、gulp、fis、webpack、rollup 等工程化工具,它们做用及架构各不相同,如 gulp 专一流程化任务,rollup 专一模块打包……vue
对于今天提出的问题:持久化缓存,它涉及了模块化,模块依赖,静态资源优化,模块打包,文件摘要处理等问题,现在(2016+)能把这些问题解决并作的最好的社区驱动工具备且只有 webpack。java
同类模块打包工具横向对比表 -> Comparison - Why webpack?node
目前 webpack 2.2.0 已正式发布,是时候用最新的工具来建立更完善的前端构建了。react
1、文件 Hash 摘要webpack
2、如何避免频繁的 chunk 内容变更git
Hash 文件名(vendor.f02bc2.js)是实现持久化缓存的第一步,目前 webpack 有两种计算 hash 的方式:
第一种是每次编译生成一个惟一 hash,适合 chunk 拆分很少的小项目,但全部资源全打上同一个 hash,没法完成持久化缓存的需求。
第二种是 webpack 为每一个 chunk 资源都生成与其内容相关的 hash 摘要,为不一样的资源打上不一样的 hash。
相关官方文档:
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] 就能够了。
不过,只是计算 chunk MD5 摘要并修改 chunk 资源文件名是不够的。Chunk 的生成还涉及到依赖解析和模块 ID 分配,这是没法稳定实质上没有变化的 chunk 文件的 chunkhash 变更问题的本源,附一个未关闭的相关 issue:
正如问题 [#1315] 描述的那样:虽然只修改了 app.js 的代码,但在最终的构建结果中,vendor.js 的 chunkhash 也被修改了,尽管 vendor.js 的内容没有实质变化。
其实这个场景比较简单,只生成了 entry 和 vendor 两个 chunk,形成上述问题的缘由有两个:
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 的文档中也提示用这个插件解决。但我能够负责任的告诉你,这个插件有缺陷……不要使用它,除非你想背黑锅。
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 是 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
)。
可是有两个缺点:
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 已经稳定,是时候解决另外一个问题了……
通常场景下,咱们可能不须要作太多的优化,也不用追求持久化缓存,常规配置便可:
为了节省篇幅,全部配置代码我会尽可能缩减,文章最后会提供 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 |
根据公用/使用/更新率来作公共模块的划分是比较科学:
咱们可经过指定模块的入口 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 提供了几种添加分割点的方法:
require.ensure
require
添加分割点能够主动将指定的模块分离成另外一个 chunk,而不是随当前 chunk 一块儿打包。对于这几种状况处理很是好:
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()
进行按需加载,须要额外的插件:
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 中便可。
模块有两种共用状况:
对于想把全部共用的模块所有提取的需求,咱们能够作以下配置:
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() })
尽管咱们已经划分好了 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
这篇文章主要针对 JS 资源的持久化缓存优化,关于 CSS 提取请看 webpack/extract-text-webpack-plugin。
webpack 中对 chunks 作优化的还有这几个插件:
尽管 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 的做用。