背景
22年中入职了第一家公司,是做跨境电商的,两三百来号人,主要负责ERP系统的迭代,技术栈Vue2全家桶。团队只有前后端没有运维,导致开发需要兜底一部分运维的工作,当然大部分的运维能力还是在主管老大哥搭的架子下井然有序的进行。
我接手时,用的是虚拟机去存前端发版的产物,每次执行 git push 时,会自动用新产物将目录中的旧产物全量覆盖,这就引发了如标题所说的bug:发版时用户不刷新页面重新拉包的情况下进行路由跳转偶现资源丢失报错,webpack打包时产物的hash会变化。
为什么说是偶现呢???(之前一直没人提出来可能是复现概率不高导致觉得是巧合,随着项目更频繁的迭代问题逐渐暴露)
当时的产物命名策略是contentHash,这样可以最细粒度减少产物hash的变化,复用浏览器缓存。这也就导致只有修改到的chunk的hash才会变化,当用户没有路由到有代码调整的chunk时就不会出现这个报错。
javascript
// vue.config.js
const isProduction = process.env.NODE_ENV === 'production';
module.exports = defineConfig({
// ...
configureWebpack: {
output: {
filename: isProduction ? `[name].[contenthash].js` : '[name].js',
chunkFilename: isProduction ? `[name].[contenthash].chunk.js` : '[name].chunk.js',
},
optimization: {
runtimeChunk: 'single'
}
// ...
},
})
解法分析
后面也baidu了很多偏前端(后端老哥们没资源投入)的解法,但没有找到自己觉得合适的答案。
解法一:打包时生成一个version.txt,前端轮询请求该文件获取最新版本
在webpack进行打包时,利用webpack plugins在dist目录下生成一个内容为版本号的txt文件,前端去轮询这个文件内容获取最新版本号,如果轮询到的版本号比现有版本号大,提示用户刷新页面或强制刷新。
这个文件的内容可以是时间戳、git提交hash、package.json里的version(这里的version打包时不会自动变化,需要辅助代码),也可以是你自定义的任何内容只要能比对出最新版本。
javascript
const { defineConfig } = require('@vue/cli-service')
const fs = require('fs');
const path = require('path');
const isProduction = process.env.NODE_ENV === 'production';
const { version } = require('./package.json');
const { execSync } = require('child_process');
const gitHash = execSync('git rev-parse --short HEAD').toString().trim();
const buildVersion = `${version}-${gitHash}`;
module.exports = defineConfig({
// ...
configureWebpack: {
output: {
filename: isProduction ? `[name].[contenthash].js` : '[name].js',
chunkFilename: isProduction ? `[name].[contenthash].chunk.js` : '[name].chunk.js',
},
optimization: {
runtimeChunk: 'single'
},
plugins: [
// 生成version.txt
{
apply: (compiler) => {
compiler.hooks.done.tap('VersionFilePlugin', () => {
fs.writeFileSync(
path.join(compiler.options.output.path, 'version.txt'),
buildVersion
);
});
},
},
],
},
// ...
})
但这个方法对我们可能不太合适。轮询间隔不好定义,太频繁会增加服务器负担,间隔过长又达不到预期,用户还是会在容易在长间隔期间进行路由跳转引起报错。
解法二:利用VueRouter的router.onError捕获错误并刷新页面
因为资源失效的错误大多发生在路由跳转时,并且报错信息结构都相同:chunk xxx cannot find,所以可以利用VueRouter实例上提供的onError事件进行错误捕获,并用正则匹配错误信息,如果报错结构类似,则主动进行页面刷新或提示用户进行刷新。这也是我们第一版使用的方案。
javascript
const regex = /chunk (\d+) cannot find/i;
router.onError((error) => {
const { errorMessage } = error || {}
if (errorMessage && regex.test(errorMessage)) {
location.reload()
logError({
title: 'Chunk load fail.',
msg: errorMessage
})
}
});
这种方法不涉及和后端交互,只需要前端投入,但它也有几个缺陷:
- 报错虽然大多发生在路由跳转,但也可能发生在图片等静态资源请求时,所以还需要绑定全局错误处理的钩子window.error/Vue.config.errorHandler,以及Promise中的错误window.unhandledrejection,这样比较繁琐,并且也会造成全局事件的污染。
- ERP系统涉及到表单等需要缓存用户输入行为的场景,强制或提示用户刷新,会导致用户已输入的表单数据丢失,实际场景中也有不少用户反映这个问题,让他们活白干了或者关键信息没了。强行给表单组件加上本地缓存又会增加维护负担和内存开销。
最终解法:git push时不再清空服务器产物文件目录,改为追加部署,并做定时清理
解法二短暂的解决了我们的问题,但用户体验一段时间后的反馈并不好。最终我和主管老大哥提议调整虚拟机的部署策略,由全量替换旧版产物改为多版本共存,保留旧版产物。
但也需要考虑虚拟机内存问题,需要定时清理产物(根据用户的习惯,这里我们定的48小时自动清理),光靠webpack打包生成的hash无法判断哪些产物是旧版资源。这里前端手动替换hash生成逻辑,用时间戳代替,保留产物名中最大时间戳的即可。
javascript
// vue.config.js
const { defineConfig } = require('@vue/cli-service')
const isProduction = process.env.NODE_ENV === 'production';
const versionTimeStamp = Date.now()
module.exports = defineConfig({
transpileDependencies: true,
productionSourceMap: false,
configureWebpack: {
output: {
filename: isProduction ? `[name].${versionTimeStamp}.js` : '[name].js',
chunkFilename: isProduction ? `[name].${versionTimeStamp}.chunk.js` : '[name].chunk.js',
},
optimization: {
runtimeChunk: 'single'
}
},
})
这个方案比较完美的解决了我们的诉求:
- 不需要服务端投入
- 不需要用户刷新页面,不会丢失表单等输入信息
- 前端改造成本也不高,可维护性好
结语
现在回过头看,这也属于一种奇淫巧计了,市面上还有其他更成熟的做法,这里作为一种思路分享给大家。