前端更新部署后通知用户刷新

3,120 阅读5分钟

前言

周五晚上组里说前端有bug,正在吃宵夜的我眉头一紧,立即打开了钉钉(手贱...),看了一下这不是前几天刚解决的吗,果然,使用刷新大法就解决,原因不过是用户一直停留在页面上,新的版本发布后,没有刷新拿不到新的资源。

现在大部分的前端系统都是SPA,用户在使用中对系统更新无感知,切换菜单等并不能获取最新资源,如果前端是覆盖性部署,切换菜单请求旧资源,这个旧资源已经被覆盖(hash打包的文件),还会出现一直无响应的情况。

那么,当前端部署更新后,提示一直停留在系统中的用户刷新系统很有必要。

解决方案

  1. 在public文件夹下加入manifest.json文件,记录版本信息
  2. 前端打包的时候向manifest.json写入当前时间戳信息
  3. 在入口JS引入检查更新的逻辑,有更新则提示更新
    • 路由守卫router.beforeResolve(Vue-Router为例),检查更新,对比manifest.json文件的响应头Etag判断是否有更新
    • 通过Worker轮询,检查更新,对比manifest.json文件的响应头Etag判断是否有更新。当然你如果不在乎这点点开销,可不使用Worker另开一个线程

Public下的加入manifest.json文件

{
    "timestamp":1706518420707,
 	"msg":"更新内容如下:\n--1.添加系统更新提示机制"
}

这里如果是不向用户提示更新内容,可不填,前段开发者也无需维护manifest.json的msg内容,这里主要考虑到如果用户在填长表单的时候,填了一大半,你这时候给用户弹个更新提示,用户无法判断是否影响当前表单填写提交,如果将更新信息展示出来,用户感知更新内容,可判断是否需要立即刷新,还是提交完表单再刷新。

webpack向manifest.json写入当前时间戳信息

	// 版本号文件
    const filePath = path.resolve(`./public`, 'manifest.json')
    // 读取文件内容
    readFile(filePath, 'utf8', (err, data) => {
      if (err) {
        console.error('读取文件时出错:', err)
        return
      }
      // 将文件内容转换JSON
      const dataObj = JSON.parse(data)
      dataObj.timestamp = new Date().getTime()
      // 将修改后的内容写回文件
      writeFile(filePath, JSON.stringify(dataObj), 'utf8', err => {
        if (err) {
          console.error('写入文件时出错:', err)
          return
        }
      })
    })

如果你无需维护更新内容的话,可直接写入timestamp

// 生成版本号文件
const filePath = path.resolve(`./public`, 'manifest.json')
writeFileSync(filePath, `${JSON.stringify({ timestamp: new Date().getTime() })}`)

检查更新的逻辑

入口文件main.js处引入

我这里检查更新的文件是放在utils/checkUpdate

// 检查版本更新
import '@/utils/checkUpdate'

checkUpdate文件内容如下

import router from '@/router'
import { Modal } from 'ant-design-vue'
if (process.env.NODE_ENV === 'production') {
  let lastEtag = ''
  let hasUpdate = false
  let worker = null

  async function checkUpdate() {
    try {
      // 检测前端资源是否有更新
      let response = await fetch(`/manifest.json?v=${Date.now()}`, {
        method: 'head'
      })
      // 获取最新的etag
      let etag = response.headers.get('etag')
      hasUpdate = lastEtag && etag !== lastEtag
      lastEtag = etag
    } catch (e) {
      return Promise.reject(e)
    }
  }

  async function confirmReload(msg = '', lastEtag) {
    worker &&
      worker.postMessage({
        type: 'pause'
      })
    try {
      Modal.confirm({
        title: '温馨提示',
        content: '系统后台有更新,请点击“立即刷新”刷新页面\n' + msg,
        okText: '立即刷新',
        cancelText: '5分钟后提示我',
        onOk() {
          worker.postMessage({
            type: 'destroy'
          })
          location.reload()
        },
        onCancel() {
          worker &&
            worker.postMessage({
              type: 'recheck',
              lastEtag: lastEtag
            })
        }
      })
    } catch (e) {}
  }

  // 路由拦截
  router.beforeEach(async (to, from, next) => {
    next()
    try {
      await checkUpdate()
      if (hasUpdate) {
        worker.postMessage({
          type: 'destroy'
        })
        location.reload()
      }
    } catch (e) {}
  })

  // 利用worker轮询
  worker = new Worker(
    /* webpackChunkName: "checkUpdate.worker" */ new URL('../worker/checkUpdate.worker.js', import.meta.url)
  )

  worker.postMessage({
    type: 'check'
  })
  worker.onmessage = ({ data }) => {
    if (data.type === 'hasUpdate') {
      hasUpdate = true
      confirmReload(data.msg, data.lastEtag)
    }
  }
}

这里因为缺换路由本来就要刷新页面,用户可无需感知系统更新信息,直接通过请求头的Etag即可,这里的Fetch方法就用head获取相应头就好了。

checkUpdate.worker.js文件如下

let lastEtag
let hasUpdate = false
let intervalId = ''
async function checkUpdate() {
  try {
    // 检测前端资源是否有更新
    let response = await fetch(`/manifest.json?v=${Date.now()}`, {
      method: 'get'
    })
    // 获取最新的etag和data
    let etag = response.headers.get('etag')
    let data = await response.json()
    hasUpdate = lastEtag !== undefined && etag !== lastEtag
    if (hasUpdate) {
      postMessage({
        type: 'hasUpdate',
        msg: data.msg,
        lastEtag: lastEtag,
        etag: etag
      })
    }
    lastEtag = etag
  } catch (e) {
    return Promise.reject(e)
  }
}

// 监听主线程发送过来的数据
addEventListener('message', ({ data }) => {
  if (data.type === 'check') {
    // 每5分钟执行一次
    // 立即执行一次,获取最新的etag,避免在setInterval等待中系统更新,第一次获取的etag是新的,但是lastEtag还是undefined,不满足条件,错失刷新时机
    checkUpdate()
    intervalId = setInterval(checkUpdate,5 * 60 * 1000)
  }
  if (data.type === 'recheck') {
    // 每5分钟执行一次
    hasUpdate = false
    lastEtag = data.lastEtag
    intervalId = setInterval(checkUpdate,  5 * 60 * 1000)
  }
  if (data.type === 'pause') {
    clearInterval(intervalId)
  }
  if (data.type === 'destroy') {
    clearInterval(intervalId)
    close()
  }
})

如果不使用worker直接讲轮询逻辑放在checkUpdate即可

Worker引入

从 webpack 5 开始,你可以使用 Web Workers 代替 worker-loader

new Worker(new URL('./worker.js', import.meta.url));

以下版本的就只能用worker-loader

也可以逻辑写成字符串,然后通过ToURL给new Worker,如下:

function createWorker(f) {
  const blob = new Blob(['(' + f.toString() +')()'], {type: "application/javascript"});
  const blobUrl = window.URL.createObjectURL(blob);
  const worker = new Worker(blobUrl);
  return worker;
}

createWorker(function () {
  self.addEventListener('message', function (event) {
    // 消费信息
      self.postMessage('send message')
  }, false);
})

worker数据通信

// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
  uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);
// Worker 线程
self.onmessage = function (e) {
  var uInt8Array = e.data;
  postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
  postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};
​

但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。

如果要直接转移数据的控制权,就要使用下面的写法。

// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);
​
// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);

Web Worker 使用教程 - 阮一峰的网络日志 (ruanyifeng.com)

然而,并不是所有的对象都可以被转移。只有那些被设计为可转移的对象(用[ Transferable ] IDL 扩展属性修饰),比如ArrayBuffer、MessagePort,ImageBitmap,OffscreenCanvas,才能通过这种方式来传递。转移操作是不可逆的,一旦对象被转移,原始上下文中的引用将不再有效。转移对象可以显著减少复制数据所需的时间和内存。