Yongzhi

theme

monorepo工程化实践 - 纯前端网站更新通知

前言

先说一下背景,传统单页面应用打包后,index.html中的script标签会引入javascript文件,一般情况下随着内容的修改javascript 文件名也会随着文件内容的不同而产生变化。这就会导致,用户在使用应用过程中网站重新部署,但是用户的index.html已经请求到客户端,引入的javascript 文件并不会更新。或者用户在点击按钮时,异步加载javascript文件,如果文件不存在那么就会出现异常。

方案

目前市面上有很多方案都是依赖打包工具开发插件来实现,这样的优点是对业务没有入侵,但是缺点是使用者可能不太明白原理,并且不好排查问题。 所以本项目不会去开发打包工具的插件。目前的思路是每次打包生成一个时间戳,然后将这个时间戳通过属性绑定到body标签上,项目运行中通过获取项目中绑定 的时间戳,和远程服务所绑定的时间戳来对比,如果不同,则认为项目更新。

实现

首先需要分析一下用户需求

  • 轮询间隔
  • 请求地址
  • 自定义请求参数
  • 绑定的key
  • 目标节点
interface FecthInit {
    method?: 'get' | 'post',
    headers?: Headers,
    body?: any,
    mode?: any
    credentials?: any
    cache?: any
    redirect?: any
    referrer?: any
    referrerPolicy?: any
    integrity?: any
}

interface Initial {
    delay?: number // 轮询间隔时间,单位毫秒, 默认30秒
    url?: string // 请求地址,默认`${window.origin}?t=${Date.now()}`
    init?: FecthInit // 请求参数, 默认{ method: 'get' }
    key?: string // 节点属性, 默认 data-hash   
    target?: HTMLElement // 目标节点,默认body
    loop?: boolean // 当检测到变更后,远程地址的value没有变化,是否再次发出通知 默认false
}

另外,我们可以在web worker中发送请求发送请求。 在web worker中进行,会有一些优点

  1. 防止阻塞主线程:当您发送 AJAX 请求时,它将占用主线程,并阻止您应用程序中其他部分的执行。

  2. 增加资源利用率:Web Workers 使我们不必依赖单个线程来处理所有工作,因此可以更好地分配系统资源,提高了应用程序的响应速度;

  3. 更好的安全性:由于工作线程是在独立的上下文中执行,所以无法直接修改主线程的数据,可以大大减少可能存在的安全风险。

所有还需要封装一个web worker的函数,如果您还不太了解web worker,您可以查看Web Worker 使用教程

web worker需要加载一个javascript文件,为了避免文件路径错误,我们选择使用Blob将文件创建在内存中,通过Blob url方式加载。 通过闭包的方式来保存web worker实例,通过实例与主线程交互

const worker = `
self.onmessage = function (e) {
  const { url, init } = e.data
  const request = () => {
  return fetch(url, init)
  }
  request().then(async res => {
  const result = await res.text()
  self.postMessage(result)
  })
}
`

const greenlet = () => {
    const blob = new Blob([worker], { type: 'application/javascript' })
    const workerUrl = URL.createObjectURL(blob)
    const workerInstance = new Worker(workerUrl)
    return (url: string, init: any): Promise<string> => new Promise((resolve, reject) => {
        workerInstance.onmessage = e => {
            resolve(e.data)
        }
        workerInstance.onerror = e => {
            console.log(e, 'error')
            reject(e)
        }
        workerInstance.postMessage({ url: url, init: init })
    })
}

export const useGreenlet = greenlet()

在实际使用场景中,我们需要在页面加载完成后、当前tab激活时以及轮询时进行比较value。当进行请求地址时代理服务器返回的其实是html内容,我们可以当作字符串去解析,并且通过正则拿到value

export function useNotification(params: Initial) {
  const getCurrentHash = () => {
    const target = params.target || document.querySelector('body')
    if (!target) return ''
    const hash = target.getAttribute('data-hash')
    return hash
  }
  const regex = new RegExp(`${params.key}\\s*=\\s*['"]([^'"]+)['"]`)
  let timer: any
  let currentHash = getCurrentHash()
  const loop = params.loop || false
  const useCreateNotify = (notice: boolean, data: Data) => new CustomEvent('siteUpdate', {
    bubbles: true,
    detail: { data: data, status: notice }
  })
  const queryNewHash = useGreenlet.bind(null, params.url || `${window.origin}?t=${Date.now()}`, params.init || {
    method: 'get'
  })
  const validateHash = async () => {
    const hash = await queryNewHash()
    const data = hash.match(regex)
    return data ? data[1] || null : null
  }
  const initEvent = () => {
    window.addEventListener('load', windowLoaded)
    document.addEventListener('visibilitychange', handleVisibilityChange)
  }

  const handleVisibilityChange = async () => {
    if (document.visibilityState === 'visible') {
      const hash = await validateHash()
      if (hash !== currentHash) {
        dispatchEvent(true, {
          siteHash: hash,
          currentHash: currentHash
        })
      } else {
        initTimer()
      }
    } else {
      clearInterval(timer)
    }
  }
  const windowLoaded = async () => {
    const hash = await validateHash()
    if (hash !== currentHash) {
      dispatchEvent(true, {
        siteHash: hash,
        currentHash: currentHash
      })
    }
  }
  const dispatchEvent = (status: boolean, data: Data) => {
    const notice = useCreateNotify(status, data)
    window.dispatchEvent(notice)
    if (!loop) {
      currentHash = data.siteHash
    }
  }
  const initTimer = () => {
    timer = setInterval(async () => {
      const hash = await validateHash()
      if (hash !== currentHash) {
        dispatchEvent(true, {
          siteHash: hash,
          currentHash: currentHash
        })
      }
    }, params.delay)
  }
  if (!currentHash) return
  initEvent()
  initTimer()
}
  • getCurrentHash: 获取当前value
  • useCreateNotify: 创建自定义事件
  • queryNewHash: 获取最新html
  • validateHash: 获取最新value
  • initEvent: 初始化事件
  • handleVisibilityChange: tab切换事件
  • windowLoaded: 页面加载完成事件
  • dispatchEvent: 触发自定义事件
  • initTimer: 轮询请求

使用

搭配webpack

您需要安装html-webpack-plugin,下面详细说明了如何使用

module.exports = {
  ...
  plugins: [
    ...,
    new HtmlWebpackPlugin({
      title: 'title',
      hash: new Data().getTime(),
      template: path.resolve(__dirname, '../index.html')
    })

  ],
  ...
}

在您的模版文件中

<html>
...
<body data-hash="<%= htmlWebpackPlugin.options.hash %>">
  ...
</body>
</html>

在您项目入口位置

import { useNotification } from '@terky/update-notifier'
...
useNotification({
  key: 'data-hash'
})
...



window.addEventListener('siteUpdate', function({ detail }) {
  if (detail.data) {
    // do something
  }
})