react 实现前端发版监测
先说下前端发版流程
1. 前端打包输出产物 /dist 文件
2. 删除远程服务下打包的旧代码
3. 将打包参物 /dist 文件 copy 到远程服务器目录
4. 重启服务器
问题1
在步骤2,3,4中用户访问目标服务器会报JS错误,正常情况打开网页的控制面板会看下报错信息 `Failed to fetch dynamically imported module`
前端发版检测原理
这个报错信息其实会触发react的错误边界,我们可以利用这个错误边界来获取是否在发版,可以看下面检测流程
1. 修改配置,让打包产物多出一个manifest.json 文件
vite配置如下,其他打包工具自行看官方文档配置
build: { manifest: true, //加上此配置可生成 manifest.json 文件 assetsDir: 'static', rollupOptions: { input: { index: resolve(__dirname, 'index.html') }, output: { chunkFileNames: 'static/js/[name]-[hash].js', entryFileNames: 'static/js/[name]-[hash].js' } }, commonjsOptions: { transformMixedEsModules: true } },
2. 默认获取manifest.json 的etag ,一般情况,manifest.json 内容没有变更,etag值是不会变的,只有manifest.json变了,etag才会变,由此可见,当manifest.json的etag值变更了,意味着发版走到了发版步骤3
3. 步骤3中,copy是一个过程,而不是立马就可以结束,所以我们下一步就要监测步骤3什么时候结束
4. 随机抽取manifest.json中的文件,抽取数量大家可以随意修改,我这边检测的是3个
5. 这些文件检测完之后再等待个5s,继续去请求manifest.json文件,请求成功之后再刷新浏览器
为啥还要等5s再继续请求manifest.json?
因为你把文件全部获取到了,服务可能需要重启,这个时候如果重启过程中,你也是获取不到服务器资源的
下面开始贴代码块
eTag管理,主要是检测mainfest.json的etag是值是否被修改
/** * eTag管理 * 服务器发版检测用 * */ export const eTag = { init: (doNotCache?: boolean) => { return new Promise((resolve, reject) => { fetchRequestHeader().then((headers) => { const etag = headers.get('etag'); if (!doNotCache) { eTag.set(etag); } resolve(etag); }); }); }, //获取远程eTag getRemoteETag: () => { return new Promise((resolve, reject) => { eTag .init(true) .then((data) => { resolve(data); }) .catch(() => { reject(); }); }); }, get get() { return window.localStorage.getItem('eTag') || ''; }, set: (value: string | null) => { value && window.localStorage.setItem('eTag', value); } };
获取请求的头部信息
/** 获取请求的头部信息 */ export const fetchRequestHeader = (): Promise<Headers> => { return fetch(`/admin/manifest.json`, { method: 'HEAD', cache: 'no-cache' }).then((response) => response.headers); };
求随机数,随机获取文件时可用
/** * 求min与max之间的随机数 * */ export const rand = (min: number, max: number) => { return Math.round(Math.random() * (max - min)) + min; };
QkErrorBound/index.tsx 错误边界代码块
/** * 版本检测逻辑 * 1. 先比对manifest.json文件是否有变动 * 1.1 变动,则随机向manifest.json抽出三个文件 * 1.1.1 轮询同时请求这三个文件 * 1.1.1.1 请求成功,刷新界面 * 1.2 不变动,继续1.1 * */ import React, { PureComponent } from 'react'; import { Result, Badge } from 'antd'; import { eTag, rand } from '@/utils/tools.ts'; import { fetchManifestJson } from '@/services/common.ts'; type QkErrorBoundType = { children: React.ReactNode; }; export default class QkErrorBound extends PureComponent< QkErrorBoundType, { hasError: boolean; type: number; time: number; count: number; errMsg: string; loadEerr: boolean; } > { detectionTimerId: NodeJS.Timeout | null = null; //检测 countdownTimerId: NodeJS.Timeout | null = null; //倒计时 constructor(props: NonNullable<any>) { super(props); this.state = { hasError: false, type: 1, time: 30, count: 0, errMsg: '', loadEerr: false }; } static getDerivedStateFromError(error: Error & { componentStack: string }) { console.log({ error, type: 'getDerivedStateFromError' }); return { hasError: true }; } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.log({ error, errorInfo }); let loadEerr = false; if ( error?.message?.includes('Failed to fetch dynamically imported module') ) { this.handleVersionUpdates(); loadEerr = true; } this.timedOutFefresh(); this.setState({ hasError: true, errMsg: error.message || JSON.stringify(errorInfo), loadEerr }); } getManifestJson() { fetchManifestJson() .then(async (data) => { const len = Object.keys(data).length; const files = [rand(0, len), rand(0, len), rand(0, len)]; const manifestJson: [string, Record<string, any>][] = Object.entries(data); console.log(1111); const fetchs: boolean[] = []; for (let i = 0; i < files.length; i++) { await new Promise((resolve, reject) => { fetch(manifestJson[files[i]][1]?.file, { method: 'HEAD', cache: 'no-cache' }) .then((response) => { console.log(response); fetchs.push(response.ok); resolve(response.ok); }) .catch((reason) => { console.log(reason); fetchs.push(false); resolve(false); }); }); } if (fetchs.filter(Boolean).length === files.length) { window.reload(); console.log('3'); } else { console.log('请求失败,3s重新请求中....'); setTimeout(() => { this.getManifestJson(); }, 3000); } }) .catch(() => { setTimeout(() => { this.getManifestJson(); }, 3000); }); } /** 检测是否有版本更新 */ handleVersionUpdates = () => { this.detectionTimerId && clearInterval(this.detectionTimerId); this.detectionTimerId = setInterval(() => { eTag.getRemoteETag().then((data) => { if (data !== eTag.get) { this.detectionTimerId && clearInterval(this.detectionTimerId); this.getManifestJson(); } }); }, 3000); }; /** 超过1分钟进行刷新 */ timedOutFefresh = () => { this.countdownTimerId && clearInterval(this.countdownTimerId); this.countdownTimerId = setInterval(() => { this.setState({ count: this.state.count + 1 }); /** 升级超过一分钟自动刷新页面 */ console.log({ count: this.state.count }); if (this.state.count >= 60) { this.countdownTimerId && clearInterval(this.countdownTimerId); window.reload(); } }, 1000); }; render() { if (this.state.hasError) { return ( <div> <Result status="500" title={ <Badge offset={[7, 0]} dot={!this.state.loadEerr}> <h2 className="font-normal">系统升级</h2> </Badge> } subTitle={ this.state.type === 1 ? ( '检测到系统功能已升级,正在获取最新系统...' ) : ( <div> 系统正在升级中,预计 <span className="text-primary">{this.state.time}s</span> 后完成升级 </div> ) } /> </div> ); } return this.props.children; } }
愿你走出半生,归来仍是少年