如何做系统升级通知

233 阅读4分钟

单页面应用 SPA 在构建后会生成的资源文件,可能导致已经打开的页面用户点击按钮跳转无响应,问题出现取决于构建配置策略。

开发流程中,经常在开发环境构建与后端进行联调,在测试环境修复 Bug 也进行频繁重新构建发布,从而打断正在测试的 QA 同学,故而有些项目组严格执行测试环境只能由 QA 同学进行构建 😅。

对于线上的系统,由于通常在晚上发布,然而问题一直存在,用户都学会刷新页面自救(强者从不埋怨环境)

早期对于 SPA 项目(vue)都是这样处理的

// vue-router
// ...code

router.onError(handlerRouterError);

/**
 * 路由加载404,提醒发布新版本需要刷新
 */
async function handlerRouterError(error) {
  const patters = [
    'Loading chunk',
    'Failed to fetch dynamically imported',
    'Importing a module script failed'
  ];
  if (patters.some(pattern => error.message.includes(pattern))) {
    // 早期直接刷新,被 QA 同学骂了
    //  window.location.reload(true);
    const { action } = await Notice({
       type: 'upgrade',
       message: '系统发布了新版本,是否立刻刷新?'
     });
     if (action === 'confirm') {
       window.location.reload(true);
     }
  }
}

这种方案只能覆盖路由之间的跳转,而 QA 同学想第一时间知道是否构建了最新页面

方案调研

  • 增量发布,确保旧资源访问成功
    • 缺点:不能同步最新的功能,如果接口有改动会炸
  • 修改构建输出文件名,保证一样,通过插件添加参数 ?v=时间戳
    • 缺点:可能无法饶过浏览器强缓存
  • 插件生成 manifest 文件,轮询检测文件差异
  • 插件生成 version 文件,轮询检测版本号,原理同上
  • pwa 方式,最完美的方式,参考 vuepress
    • 缺点:成本比较高

自撸轮询检测插件

今天来说说自己撸一个轮询检测模式的插件,伸手党可以使用 plugin-web-update-notification

在我们低代码平台可以看到使用的是校验资源产物

4002.png

每次返回的内容太多,http 请求的成本也是成本,作为我们这种小业务线的的前端,有没有低端的技术低成本的可以用一下。

还是使用生成 version.json 文件方式吧,但会生成多余的文件。

有没有更简单的方式,让我们回到 2019 github.com/EtherDream/…

fetch(url, {
  cache: 'no-cache',
})

这就是我们的法宝,详细的自行翻 MDN 文档。

我们只针对单页入口 html 进行轮询访问,既可以使用缓存又可以更新缓存,你真他娘是个小天才。

创建 unplugin-web-update-notification

对于兼容 webpack,vite的插件推荐使用 unplugin

直接 git clone @sxzz 的 github.com/sxzz/unplug…, 已经有编码规范、eslint、typescript配置,tql!!!

我们的目标是给 SPA 的入口文件 main.js 注入轮询代码(留后门,FBI Warning),副作用影响 sourcemap。

那就开始吧

  1. 把包名改了
// package.json
{
    "name": "unplugin-web-update-notification"
}
  1. 修改 index.ts
import { injectWebUpdateNotification } from './core/injectWebUpdateNotification'

const isProd = (mode: string) => {
  // webpack
  if (typeof process.env.NODE_ENV !== 'undefined') {
    return process.env.NODE_ENV === 'production'
  }
  
  // vite import.meta
  return mode === 'production'
}

export default createUnplugin<Options | undefined, false>((rawOptions = {}) => {
  const options = resolveOption(rawOptions)
  const filter = createFilter(options.include, options.exclude)

  const name = 'unplugin-web-update-notification'
  let mode = ''

  return {
    name,
    enforce: options.enforce,

    apply: 'build',

    transformInclude(id) {
      return filter(id)
    },

    transform(code /*, id*/) {
      if (!isProd(mode)) return code
      return [code, injectWebUpdateNotification(options.options)].join('')
    },

    vite: {
      configResolved(config) {
        // 解决获取 vite 环境变量
        mode = config.mode
      },
    },
  }
})
  1. 编写 injectWebUpdateNotification

这里是使用模板的形式,喜欢高亮的同学可以使用 fs 读取文件

import { type NotificationOptions } from './options'

export const injectWebUpdateNotification = (
  options: Partial<NotificationOptions> = {}
) => {
  return `
 // 防止入口文件重名
// eslint-disable-next-line
import { ElNotification as __ElNotification } from 'element-ui';

;(function unPluginWebUpdateNotification() {
  const storeKey = 'Last-Modified'
  let pollingInterval = null
  let lastVersion = window.sessionStorage.getItem(storeKey)

  function setLocalData(version) {
    window.sessionStorage.setItem(storeKey, version)
    lastVersion = version
  }

  async function getVersion() {
    const response = await fetch("${options.baseURL || '/'}", {
      cache: 'no-cache',
    })

    return response.headers.get(storeKey)
  }

  async function checkWebUpdate(showDialog = false) {
    const version = await getVersion()
    if ((lastVersion && version !== lastVersion) || showDialog) {
      showWebUpdateNotification(version)
    }

    return () => {
      clearInterval(pollingInterval)
    }
  }

  function pollingCheck() {
    // 默认每5分钟一次
    pollingInterval = setInterval(checkWebUpdate, ${options.interval} * 60 * 1000)
  }

  function showWebUpdateNotification(version) {
    __ElNotification.closeAll()
    __ElNotification({
      collapse: true,
      title: '📢 系统升级通知',
      duration: 0,
      message: '检测当前系统已更新,是否刷新页面?',
      onClick: () => {
        setLocalData(version)
        window.location.reload();
      }
    });
  }

  async function setup() {
    // 首次访问页面保存版本
    const version = await getVersion()
    setLocalData(version)
    
    // 轮询的命运开启,原神启...
    pollingCheck()
  }

  setup()
  // 导出公共函数,将在路由钩子上使用
  window.unpluginWebUpdateNotification = checkWebUpdate
})();
`
}
  1. 修改路由钩子
// vue-router
// ...code

router.onError(handlerRouterError);

/**
 * 路由加载404,提醒发布新版本需要刷新
 */
async function handlerRouterError(error) {
  const patters = [
    'Loading chunk',
    'Failed to fetch dynamically imported',
    'Importing a module script failed'
  ];
  if (patters.some(pattern => error.message.includes(pattern))) {
    // true 直接显示版本更新通知  
    window?.unpluginWebUpdateNotification?.(true)
  }
}
  1. 发布 npm 包
# 此处省略一大堆 npm scripts
> pnpm release
  1. 使用

vue-cli 项目

module.exports = {
   configureWebpack: {
       plugins: [
           require('unplugin-web-update-notifaction/webpack').default()
       ]
   }
}

vite 项目

import { defineConfig } from 'vite'
import webUpdateNotification from 'unplugin-web-update-notification/vite'

export default defineConfig({
   plugins: [
    // ...
    webUpdateNotification()
   ]
})

最终效果:

preview.png