前端版本过低引导弹窗方案分享
作者:费昀锋
背景
作为 TOB 的业务方,我们偶尔会收到一些如下图所示的反馈。
作为 PC 页面为主的业务方,大多数用户在一天的工作中,可能都不太会刷新或者重新打开我们的页面,导致我们在下午或者白天发布的前端版本,往往需要到几个小时甚至第二天,才能覆盖到 98% 以上的用户。
我们统计了 bscm 平台 5 次下午 2-3 点左右发布的版本,在发布后每个时间段内老版本用户的占比情况。选择这个时间点发布的原因是这个时间点基本是平台用户的上班时间,是最有可能出现用户已经打开了页面同时我们在发布新代码的场景的,比较具有代表性。按平台用户六七点下班来看,我们可以看到还有将近 6% 的用户在当天是会一直访问老版本的前端代码的,按照 bscm 平台 1w+的 uv 来看,约有 600 多人会可能遇到前端版本过低导致的使用问题。
方案
弹窗内容
弹窗的触发条件
首先介绍两个概念,本地版本号和云端版本号。本地版本号是用户请求到的前端页面的代码版本号,是用户访问页面时决定;云端版本号可以理解为最新前端版本号,它是每次开发者发布前端代码时决定的。
判断触发条件的时机
有了弹窗的触发条件,我们还需要去决定什么时候判断弹窗是否满足触发的条件,上面也提到了,出现这类问题的场景多见于用户在使用过程中,开发者进行了前端代码发布,那我们主要可以有两个类型的时机去进行触发条件的判断。
前端代码去感知什么时候有新版本的代码发布了,去进行条件判断(消息推送)
前端在一定的条件下主动去判断触发条件(轮询,请求后端接口时,一些中频前端事件的监听)
我们对这些时机在更新是否及时,判断次数多少、实现成本高低等维度进行一个对比。
⭐️ 越多表示这个维度得分越高
根据表格可以看到 websocket 消息推送和前端事件监听这两种方案综合来看是更合适一些的,但是前端事件监听其实它的劣势在实际运用场景中会被弱化(一天的上线数量有限,请求次数一天不会多太多次),但是实现成本远低于 websocket,所以无疑是实际落地场景中比较理想的选择。
根据 can i use 的结果我们也可以发现 visibilitychange 事件也基本符合我们目前 B 端页面对于 PC 浏览器的要求。
版本号的生成
本地版本号
本地版本号是用户访问时决定的,那无疑页面的 html 文件就是这个版本号存在的最佳载体,我们可以在打包时通过 plugin 给 html 文件注入一个版本号。
云端版本号
云端版本号的选择则有很多方式了,数据库、cdn 等等都可以满足需求。不过考虑到实现成本和泳道的情况,想了一下两个思路一个是打包的同时生成一个 version.json 文件,配一个路由去访问;另一个是直接访问对应的 html 代码,解析出注入的版本号,二者各自有适合的场景。
微前端的适配
我们现在的大多数项目都包含了主应用和子应用,那其实不管是子应用的更新还是主应用的更新都应该有相关的提示,而且相互独立,但同时又需要保证弹窗不同时出现。
想要沿用之前的方案其实只需要解决三个问题。
- 主子应用的本地版本号标识需要有区分,因为 html 文件只有一个,需要能在 html 文件中区分出哪个应用的版本是什么,这个我们只需在 plugin 中注入标识即可解决。
- 云端版本号请求时也要请求对应的云端版本号,这个目前采用的方案是主应用去请求唯一的 version.json 文件,因为主应用路由是唯一的,子应用则去请求最新的 html 资源文件,解析出云端版本号。
- 不重复弹窗我们只需要在展示弹窗前,多加一个是否已经有弹窗展示的判断即可了。
具体实现
版本号的写入和读取
监听时机和频控逻辑
正如前文提到的,本身版本发布不是一个高频事件,但是监听事件的频次有时候可能过高了,不希望频繁的去进行触发条件判断。同时如果出现一天内多次发布的场景,也不希望这个弹窗对于用户有过多的打扰,所以需要去添加一个频控逻辑。
具体代码
plugin
/* eslint-disable */
import { CoraWebpackPlugin, WebpackCompiler } from '@ies/eden-web-build';
const fs = require('fs');
const path = require('path');
const cheerio = require('cheerio');
interface IVersion {
name?: string; // 编译完的文件夹名称
subName?: string; // 子应用的名称,主应用可以不传
}
export class VersionPlugin implements CoraWebpackPlugin {
readonly name = 'versionPlugin'; // 插件必须要有一个名字,这个名字不能和已有插件冲突
private _version: number;
private _name: string;
private _subName: string;
constructor(params: IVersion) {
this._version = new Date().getTime();
this._name = params?.name || 'build';
this._subName = params?.subName || ''
}
apply(compiler: WebpackCompiler): void {
compiler.hooks.afterCompile.tap('versionPlugin', () => {
try {
const filePath = path.resolve(`./${this._name}/template/version.json`);
fs.writeFile(filePath, JSON.stringify({ version: this._version }), (err: any) => {
if (err) {
console.log('@@@err', err);
}
});
const htmlPath = path.resolve(`./${this._name}/template/index.html`);
const data = fs.readFileSync(htmlPath);
const $ = cheerio.load(data);
$('body').append(`
${this._subName}versionTag" style="display: none">${this._version}
`);
fs.writeFile(htmlPath, $.html(), (err: any) => {
if (err) {
console.log('@@@htmlerr', err);
}
});
} catch (err) {
console.log(err);
}
});
}
}
弹窗组件
import React, { useEffect } from 'react';
import { Modal } from '@ecom/auxo';
import axios from 'axios';
import moment from 'moment';
export interface IProps {
isSub?: boolean; // 是否为子应用
subName?: string; // 子应用名称
resourceUrl?: string; // 子应用的资源url
}
export type IType = 'visibilitychange' | 'popstate' | 'init';
export default React.memo<IProps>(props => {
const { isSub = false, subName = '', resourceUrl = '' } = props || {};
const cb = (latestVersion: number | undefined, currentVersion: number | undefined, type: IType) => {
try {
// 版本落后,提示可以刷新页面
if (latestVersion && currentVersion && latestVersion > currentVersion) {
// 提醒过了就设置一个更新提示过期时间,一天内不需要再提示了,弹窗过期时间暂时全局只需要一个!!
localStorage.setItem(`versionUpdateExpireTime`, moment().endOf('day').format('x'));
if (!document.getElementById('versionModalTitle')) {
Modal.confirm({
title: <div id="versionModalTitle">版本更新提示div>,
content:
'您已经长时间未使用此页面,在此期间平台有过更新,如您此时在页面中没有填写相关信息等操作,请点击刷新页面使用最新版本!',
okText: <div data-text={`前端版本升级引导-立即更新 ${type}`}>刷新页面div>,
cancelText: <div data-text={`前端版本升级引导-我知道了 ${type}`}>我知道了div>,
onCancel: () => {
console.log('fe-version-watcher INFO: 未更新~');
},
onOk: () => {
location.reload();
},
});
}
}
// 不管版本是否落后,半小时内都不需要去重新请求判断
localStorage.setItem(`versionInfoExpireTime`, String(new Date().getTime() + 1000 * 60 * 30));
} catch {}
};
const formatVersion = (text?: string) => (text ? Number(text) : undefined);
useEffect(() => {
try {
const fn = function (type: IType) {
if (document.visibilityState === 'visible') {
/**
* @desc 为了防止打扰,版本更新每个应用一天只提示一次 所以过期时间设为当天23:59:59,没过期则直接return
*/
if (Number(localStorage.getItem(`versionUpdateExpireTime`) || 0) >= new Date().getTime()) {
return;
}
/**
* @desc 不需要每次切换页面都去判断资源,每次从服务器获取到的版本信息,给半个小时的缓存时间,需要区分子应用
*/
if (Number(localStorage.getItem(`versionInfoExpireTime`) || 0) > new Date().getTime()) {
return;
}
if (!isSub) {
/**
* @desc 主应用使用version.json文件来获取最新的版本号
*/
const dom = document.getElementById('versionTag');
const currentVersion = formatVersion(dom?.innerText);
axios.get(`/version?timestamp=${new Date().getTime()}`).then(res => {
const latestVersion = res?.data?.version;
cb(latestVersion, currentVersion, type);
});
} else {
/**
* @desc 子应用使用最新html中的innerText来获取最新版本号
*/
if (resourceUrl) {
const dom = document.getElementById(`${subName}versionTag`);
const currentVersion = dom?.innerText ? Number(dom?.innerText) : undefined;
axios.get(resourceUrl).then(res => {
/** ignore_security_alert */
try {
const html = res.data;
const doc = new DOMParser().parseFromString(html, 'text/html');
const latestVersion = formatVersion(doc.getElementById(`${subName}versionTag`)?.innerText);
cb(latestVersion, currentVersion, type);
} catch {}
});
}
}
}
};
const visibleFn = () => {
fn('visibilitychange');
};
const routerFn = () => {
fn('popstate');
};
if (isSub) {
// 子应用可能会有缓存,初始化的时候先判断一次
fn('init');
}
document.addEventListener('visibilitychange', visibleFn);
window.addEventListener('popstate', routerFn);
return () => {
document.removeEventListener('visibilitychange', visibleFn);
window.removeEventListener('popstate', routerFn);
};
} catch {}
}, []);
return <div />;
});
如何接入
主应用版本
- 安装依赖
npm i @ecom/fe-version-watcher-plugin # 安装plugin
npm i @ecom/logistics-supply-chain-fe-version-watcher # 安装引导弹窗
- 引入 versionPlugin,自动生成 version.json + html 文件中自动注入
import { VersionPlugin } from '@ecom/fe-version-watcher-plugin';
// 有些项目打包后template文件夹下的名字不是build而是build_cn
// 可以根据自己项目的实际情况传入{name: build_cn}
{
...,
plugins: [
...,
[VersionPlugin, {}],
]
}
- 引入版本引导弹窗
import { FeVersionWatcher } from '@ecom/logistics-supply-chain-fe-version-watcher';
<FeVersionWatcher />
- goofy 新增路由配置,/version 指向 version.json 文件 (或者其它方式可以使得/version 的路由指向该 version.json 文件)
预告
采用 version.json 的方案,引入 FersionWatcher 组件就不再需要任何参数,目前主应用只支持这种模式。未来也将参考子应用,主应用支持读取 html 中版本标识的能力,将配置路由的工作改成组件 props 传入资源 url,开发者可以根据实际情况自行选择。
子应用版本
- 安装依赖
npm i @ecom/fe-version-watcher-plugin # 安装plugin
npm i @ecom/logistics-supply-chain-fe-version-watcher # 安装引导弹窗
- 引入 versionPlugin, html 文件中自动注入版本号,需要子应用标识参数(必填)
import { VersionPlugin } from '@ecom/fe-version-watcher-plugin';
// 有些项目打包后template文件夹下的名字不是build而是build_cn
// 可以根据自己项目的实际情况传入{name: build_cn}
{
...,
plugins: [
...,
[VersionPlugin, {subName: 'general-supplier', name: 'build_cn'}],
]
}
- 引入版本引导弹窗(subName 和 plugin 中保持一致,resourceUrl 为配置的子应用路由)
import { FeVersionWatcher } from '@ecom/logistics-supply-chain-fe-version-watcher';
// subName需要和plugin的参数保持一致,resourceUrl为子应用资源的路径(子引用goofy上配置的路由)
<FeVersionWatcher isSub subName="general-supplier" resourceUrl="/webApp/general-supplier" />
resourceUrl一般就是goofy上配置的路由设置,,如果不同平台有区分,可以动态传入。
如何调试/效果展示
发布成功后,可以根据如下步骤测试:
删除 localstorage 中相关的 value
修改 html 中的 version,改成一个比较小的数值即可
切换路由,或者隐藏/打开页面,出现弹窗
收益统计
同样我们截取了 4 次该平台 2-3 点发布的版本情况,可以看到老版本用户的 uv 占比有着明显的下降。
上线至今共计提示 10 万+用户,帮助约 5 万人次及时更新了前端代码。
来源:juejin.cn/post/7301530293377843235