SharedWorker项目实践——纯前端检测版本更新

702 阅读12分钟

前言

之前我曾介绍过 2种纯前端检测版本更新提示,主要思路是通过轮询检测来实现,但这种方法存在一些缺陷。例如,当用户打开多个页面时,每个页面都会独立启动轮询检测,频繁请求相同的接口,导致资源浪费。此外,当资源更新时,每个页面都会显示更新提示,用户需要逐一点击确认,才能完成页面更新,过程较为繁琐。

为了优化这一流程,我采用了 SharedWorker 来改进版本更新检测机制。通过将轮询检测任务放在 SharedWorker 子线程中执行,多个页面在同一域名下可以共享同一个子线程,从而实现只需一次轮询检测即可覆盖所有页面。这样一来,不仅减少了服务器请求次数,降低了资源消耗,还能在某个页面接收到更新通知后,通过 SharedWorker 进行跨页面通信,通知其他标签页进行更新,提升了用户体验。

1. 基础知识

1. 作用

SharedWorker 是 Web Workers API 的一部分,它允许在多个浏览上下文(如不同的窗口、iframe 或其他 worker)之间共享一个工作线程。

2. 特点

  1. 长期运行:一旦创建,SharedWorker 会持续运行,直到所有客户端都断开连接或者浏览器关闭。

  2. 跨上下文共享:SharedWorker 可以由同一个源的多个页面或 iframe 访问,这意味着它们可以用于实现跨窗口通信。

  3. 消息传递:通过 postMessage 方法发送消息,并通过监听 message 事件接收消息,实现了客户端与 SharedWorker 之间的双向通信。

3. 限制

  1. 同源限制:只能在同一域名、端口和协议下的页面间共享

  2. DOM限制:无法读取主线程所在网页的DOM对象,无法使用document、window这些对象。但是,可以使用navigator对象和location对象

2. 基本使用

1. 新建worker.js

self.onconnect = function(event) {
  const port = event.ports[0];

  port.onmessage = function(event) {
      console.log('Received message:', event.data);
      port.postMessage('Hello from the SharedWorker!');
  };
};

self.onconnect:监听SharedWorker的连接。在监听事件中会获取到与SharedWorker连接的port,每个页面都会有一个port,通过这个port就可以和主线程进行通信了。

port.onmessage:监听消息

port.postMessage:发送消息

2. 创建SharedWorker实例

新建index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <script>
      //创建实例
      const worker = new SharedWorker("./worker.js", { name: "shared_worker", type: "module" });
      //发送消息
      worker.port.postMessage("2");
      //接收消息
      worker.port.onmessage = function (val) {};
    </script>
  </body>
</html>

name:用于标识 SharedWorker 的名称,不同名称可创建不同实例。如果没有name,那么相同URL会共享一个SharedWorker

type:默认为"classic",可指定为“module”以使用ES6语法。对于不支持ES6的浏览器,可使用默认的classic并通过Babel进行兼容性处理。

使用VS Code的Live Server插件,启动index.html

1730783643477.jpg

在SharedWorker中打印的日志并没有出现在控制台,这是因为SharedWorker必须通过chrome://inspect命令才能看到输出的内容,在Chrome浏览器地址栏输入chrome://inspect

在Shared workers栏点击inspect就能看到控制台内容

1730783685648.jpg

3. 多页面通信

SharedWorker线程就一个,但每个tab页都是独立的端口,当SharedWorker接收到一个消息时,可以给每个端口发送消息进行通知

1. 修改worker.js

var clients = [];
onconnect = function (e) {
  var port = e.ports[0];
  clients.push(port);
  let index;
  port.onmessage = function (e) {
    const { type, data } = e.data;
    switch (type) {
      case "close":
        index = clients.indexOf(port);
        clients.splice(index, 1);
        break;
      case "message":
        broadcast(data);
        break;
    }
  };
};
// 给所有页面发送消息
function broadcast(data) {
  clients.forEach((port) => {
    port.postMessage(data);
  });
}

使用clients数组记录连接端口信息,当worker接收到消息时,如果type为message,则给所有端口发送消息。

2. 修改index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <h3>共享线程 Shared Worker</h3>
    <button id="likeBtn">点赞</button>
    <p>一共收获了<span id="likedCount">0</span>个👍</p>
    <script>
      let likeBtn = document.querySelector("#likeBtn");
      let likedCountEl = document.querySelector("#likedCount");
      let worker = new SharedWorker("./worker.js", { name: "shared_worker", type: "module" });
      //点击按钮,发送消息
      likeBtn.addEventListener("click", function () {
        worker.port.postMessage({
          type: "message",
          data: 1,
        });
      });
      //监听消息
      worker.port.onmessage = function (val) {
        likedCountEl.innerHTML = +likedCountEl.innerHTML + val.data;
      };
      window.addEventListener("beforeunload", () => {
        //当页面刷新或关闭时,通知worker
        worker.port.postMessage({
          type: "close",
        });
      });
    </script>
  </body>
</html>

Live Server默认端口为5500,同时在Chrome浏览器打开两个http://127.0.0.1:5500/index.html页面,点击任一页面的按钮,两个页面的数值同时更新

4. 优化检测版本更新

检测版本更新策略:当页面可见时,通过轮询检测Etag值,如果Etag值不同,说明页面有更新;当页面不可见时,关闭轮询。

1. worker.js

在Vite创建的项目根目录中,新建worker.js

worker.js的port.onmessage监听函数中,根据消息类型type来做不同的事件处理。在这里分为四种类型:开始轮询的start;停止轮询的stop;当前页面刷新或关闭时的close;当前页面主动刷新通知其他页面刷新的refresh;

const portList = []; // 存储端口
const visiblePorts = []; //存储页面可见情况
let intervalId = null;
// eslint-disable-next-line no-undef
onconnect = function (e) {
  const port = e.ports[0];
  port.id = generateUUID();
  // 存储端口
  portList.push(port);
  // 监听port推送
  port.onmessage = async function (e) {
    // 取数据
    const data = e.data || {};
    const type = data.type || '';
    switch (type) {
      case 'start': //开启轮询
        //防止重复添加
        if (!visiblePorts.find((o) => o === port.id)) {
          visiblePorts.push(port.id);
        }
        if (intervalId !== null) {
          clearInterval(intervalId);
        }
        intervalId = setInterval(() => {
          getETag().then((res) => {
            broadcast({
              type: 'reflectGetEtag',
              data: res,
            });
          });
        }, 30000);
        break;
      case 'stop': //停止轮询
        {
          const visibleIndex = visiblePorts.indexOf(port.id);
          if (visibleIndex > -1) visiblePorts.splice(visibleIndex, 1);
        }
        //当所有页面不可见时,才停止轮询
        if (intervalId !== null && visiblePorts.length === 0) {
          clearInterval(intervalId);
          intervalId = null;
        }
        break;
      case 'close': //关闭当前端口
        {
          const index = portList.indexOf(port);
          if (index > -1) {
            portList.splice(index, 1);
          }
        }
        break;
      case 'refresh': //主动刷新,通知其他页面刷新
        sendMessage(port, {
          type: 'reflectRefresh',
        });
        break;
      default:
        broadcast({ type: 'error', message: 'Unknown message type' });
        break;
    }
  };
};
//给除自己外的窗口发送消息
function sendMessage(port, message) {
  portList.forEach((o) => {
    o.id !== port.id && o.postMessage(message);
  });
}
// 给所有窗口发送消息
function broadcast(message) {
  portList.forEach((port) => {
    port.postMessage(message);
  });
}
// 使用函数生成一个UUID
function generateUUID() {
  function s4() {
    return Math.floor((1 + Math.random()) * 0x10000)
      .toString(16)
      .substring(1);
  }
  return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}
// 获取当前etag
const getETag = async () => {
  try {
    const response = await fetch(location.origin, {
      method: 'HEAD',
      cache: 'no-cache',
    });
    return response.headers.get('etag') || response.headers.get('last-modified');
  } catch (error) {
    throw new Error(`Fetch failed: ${error.message}`);
  }
};

使用自定义函数generateUUID,为每个连接端口生成唯一uuid,用来区分页面连接SharedWorker的端口。

在start中(页面可见),将当前端口加入visiblePorts数组中,由此知道有多少个页面可见。并开启轮询,调用getETag函数,将获取到的Etag发送给主线程。

在stop中(页面不可见或关闭),在visiblePorts去掉当前端口id,并且visiblePorts为空,所有页面不可见时,才停止轮询。

在close中(页面刷新或关闭),在portList数组中,去掉当前端口

在refresh中(当前页面主动更新),通过调用sendMessage函数,通知其他页面更新(不包含自己,因为自身已经刷新了,防止重复刷新)

核心代码就是worker.js内容,接下来分别介绍在React和Vue中的使用

2. React版

为了方便理解,将代码拆成两个hooks,减小单个文件的代码量

1. 新建useCheckUpdateWorker.ts

MessageType 枚举定义了四种消息类型,分别用于控制 SharedWorker 的行为。

ReflectMessageType 枚举定义了 SharedWorker 处理消息后返回的事件类型:1.REFLECT_GET_ETAG:表示已经获取到 ETag。2. REFLECT_REFRESH:表示刷新操作已完成。

workerRef 是一个引用对象,用于存储 SharedWorker 实例,确保在整个组件生命周期内保持引用的一致性。

start、stop、close 和 refresh 是四个控制方法,分别用于发送 START、STOP、CLOSE 和 REFRESH 类型的消息。

在组件初始化时,创建一个新的 SharedWorker 实例,添加 beforeunload 事件监听器,当页面即将卸载时调用 close 方法。在组件卸载时移除 beforeunload 事件监听器。

import { useEffect, useRef, useCallback } from 'react';
//发送消息的类型
enum MessageType {
  START = 'start', //开启轮询,检测Etag版本
  STOP = 'stop', //停止轮询
  CLOSE = 'close', //关闭或刷新页面时,关闭SharedWorker的端口
  REFRESH = 'refresh', //主动刷新
}
//SharedWorker接收到MessageType类型事件后,处理后对应的事件返回,以reflect开头
export enum ReflectMessageType {
  REFLECT_GET_ETAG = 'reflectGetEtag',
  REFLECT_REFRESH = 'reflectRefresh',
}
// 用户消息推送Websocket连接
export default function useSharedWorker(url: string, options: WorkerOptions) {
  const workerRef = useRef<SharedWorker>();
  
  const sendMessage = useCallback((type: MessageType, data?: any) => {
    workerRef.current?.port.postMessage({
      type,
      ...data,
    });
  }, []);
  
  const start = useCallback(() => {
    sendMessage(MessageType.START);
  }, [sendMessage]);
  
  const stop = useCallback(() => {
    sendMessage(MessageType.STOP);
  }, [sendMessage]);
  
  const close = useCallback(() => {
    sendMessage(MessageType.CLOSE);
  }, [sendMessage]);
  
  const refresh = useCallback(() => {
    sendMessage(MessageType.REFRESH);
  }, [sendMessage]);
  
  useEffect(() => {
    if (!workerRef.current) {
      workerRef.current = new SharedWorker(url, options);
    }
    window.addEventListener('beforeunload', close);
    return () => {
      window.removeEventListener('beforeunload', close);
    };
  }, [close, options, url]);
  
  return { start, stop, refresh, workerRef };
}

2. 新建useVersion.tsx

forbidUpdate:一个引用对象,用于防止多次弹出更新提示。

versionRef:一个引用对象,用于存储当前版本的 ETag。

useCheckUpdateWorker:导入的自定义 Hook,用于管理 SharedWorker 的创建和消息传递。

openNotification:一个回调函数,用于显示更新通知弹窗。当用户点击“确认更新”按钮时,会调用 refresh 方法通知其他标签页刷新,并重新加载当前页面。

handlePageUpdateCheck:一个回调函数,用于根据 ETag 判断是否需要更新。如果当前版本与新版本不同且未禁止更新提示,则显示更新通知。

stopPollingPageUpdate:一个回调函数,用于停止版本更新检测。

startPollingPageUpdate:一个回调函数,用于启动版本更新检测。在开发环境中,默认不进行版本更新提示。

handleVisibilitychange:一个回调函数,用于处理页面可见性变化。当页面变为可见时,启动版本更新检测;当页面不可见时,停止检测。

初始化时启动版本更新检测,并添加 visibilitychange 事件监听器。组件卸载时移除 visibilitychange 事件监听器。

监听 SharedWorker 发送的消息,根据消息类型调用相应的处理函数:1. REFLECT_GET_ETAG:调用 handlePageUpdateCheck 检查版本更新。2. REFLECT_REFRESH:重新加载当前页面。

import { useCallback, useEffect, useRef } from 'react';
import { notification, Button } from 'antd';
import useCheckUpdateWorker, { ReflectMessageType } from './useCheckUpdateWorker';
const useVersion = () => {
  const forbidUpdate = useRef(false);
  const versionRef = useRef<string>();
  const { start, stop, refresh, workerRef } = useCheckUpdateWorker(
    new URL('./worker.js', import.meta.url).href,
    {
      name: 'updateModal',
      type: 'module',
    },
  );
  
  //通知更新弹窗
  const openNotification = useCallback(() => {
    forbidUpdate.current = true;
    const btn = (
      <Button
        type="primary"
        size="small"
        onClick={() => {
          //通知其他tab页刷新
          refresh();
          //刷新页面
          window.location.reload();
        }}
      >
        确认更新
      </Button>
    );
    notification.open({
      message: '版本更新提示',
      description: '检测到系统当前版本已更新,请刷新后使用。',
      btn,
      duration: 0,
      onClose: () => (forbidUpdate.current = false),
    });
  }, [refresh]);
  
  //根据版本判断是否更新
  const handlePageUpdateCheck = useCallback(
    (etag: string) => {
      if (etag) {
        const version = versionRef.current;
        if (!version) {
          versionRef.current = etag;
        } else if (version === etag) {
          // eslint-disable-next-line no-console
          console.log('最新版本');
        } else {
          // 版本更新,弹出提示,forbidUpdate防止重复弹出
          !forbidUpdate.current && openNotification();
        }
      }
    },
    [openNotification],
  );
  
  //关闭检测
  const stopPollingPageUpdate = useCallback(() => {
    stop();
  }, [stop]);
  
  //开启检测
  const startPollingPageUpdate = useCallback(() => {
    //开发环境不进行版本更新提示
    // if (process.env.NODE_ENV === 'development') return;
    stopPollingPageUpdate();
    //重新计时
    start();
  }, [start, stopPollingPageUpdate]);
  
  const handleVisibilitychange = useCallback(() => {
    if (document.visibilityState === 'visible') {
      startPollingPageUpdate();
    } else {
      stopPollingPageUpdate();
    }
  }, [startPollingPageUpdate, stopPollingPageUpdate]);
  
  useEffect(() => {
    //初始化时,不会触发visibilitychange事件,先主动开启轮询检测
    startPollingPageUpdate();
    document.addEventListener('visibilitychange', handleVisibilitychange);
    return () => {
      document.removeEventListener('visibilitychange', handleVisibilitychange);
    };
  }, [handleVisibilitychange, startPollingPageUpdate]);
  
  useEffect(() => {
    if (workerRef.current) {
      workerRef.current.port.onmessage = (e) => {
        const data = e.data || {};
        switch (data.type) {
          case ReflectMessageType.REFLECT_GET_ETAG:
            handlePageUpdateCheck(data.data);
            break;
          case ReflectMessageType.REFLECT_REFRESH:
            //其他tab页面手动更新,同步更新
            window.location.reload();
            break;
          default:
            break;
        }
      };
    }
  }, [handlePageUpdateCheck, workerRef]);
};
export default useVersion;

之后在全局调用useVersion这个hook

3. Vue版

1. 新建useCheckUpdateWorker.ts

MessageType 枚举定义了四种消息类型,分别用于控制 SharedWorker 的行为。

ReflectMessageType 枚举定义了 SharedWorker 处理消息后返回的事件类型:1.REFLECT_GET_ETAG:表示已经获取到 ETag。2. REFLECT_REFRESH:表示刷新操作已完成。

onMounted 钩子用于管理 SharedWorker 的初始化:如果 workerRef.value 为空,则创建一个新的 SharedWorker 实例。添加 beforeunload 事件监听器,当页面即将卸载时调用 close 方法。 onBeforeUnmount 钩子用于在组件卸载前移除 beforeunload 事件监听器。

import { onBeforeUnmount, onMounted, ref } from 'vue';
//发送消息的类型
enum MessageType {
  START = 'start', //开启轮询,检测Etag版本
  STOP = 'stop', //停止轮询
  CLOSE = 'close', //关闭或刷新页面时,关闭SharedWorker的端口
  REFRESH = 'refresh', //主动刷新
}
//SharedWorker接收到MessageType类型事件后,处理后对应的事件返回,以reflect开头
export enum ReflectMessageType {
  REFLECT_GET_ETAG = 'reflectGetEtag',
  REFLECT_REFRESH = 'reflectRefresh',
}
// 用户消息推送Websocket连接
export default function useSharedWorker(url: string, options: WorkerOptions) {
  const workerRef = ref<SharedWorker>();
  
  const sendMessage = (type: MessageType, data?: any) => {
    workerRef.value?.port.postMessage({
      type,
      ...data,
    });
  };
  
  const start = () => {
    sendMessage(MessageType.START);
  };
  
  const stop = () => {
    sendMessage(MessageType.STOP);
  };
  
  const close = () => {
    sendMessage(MessageType.CLOSE);
  };
  
  const refresh = () => {
    sendMessage(MessageType.REFRESH);
  };
  
  onMounted(() => {
    if (!workerRef.value) {
      workerRef.value = new SharedWorker(url, options);
    }
    window.addEventListener('beforeunload', close);
  });
  
  onBeforeUnmount(() => {
    window.removeEventListener('beforeunload', close);
  });
  return { start, stop, refresh, workerRef };
}

2. 新建useVersion.ts

forbidUpdate:一个响应式引用对象,用于防止多次弹出更新提示。

versionRef:一个响应式引用对象,用于存储当前版本的 ETag。

useCheckUpdateWorker:导入的自定义 Hook,用于管理 SharedWorker 的创建和消息传递。

openNotification:一个函数,用于显示更新通知弹窗。当用户关闭通知时,会调用 refresh 方法通知其他标签页刷新,并重新加载当前页面。

handlePageUpdateCheck:一个函数,用于根据 ETag 判断是否需要更新。如果当前版本与新版本不同且未禁止更新提示,则显示更新通知。

startPollingPageUpdate:一个函数,用于启动版本更新检测。在开发环境中,默认不进行版本更新提示。

stopPollingPageUpdate:一个函数,用于停止版本更新检测。

handleVisibilitychange:一个函数,用于处理页面可见性变化。当页面变为可见时,启动版本更新检测;当页面不可见时,停止检测。

onMounted:在组件挂载时,启动版本更新检测,并添加 visibilitychange 事件监听器。同时,监听 SharedWorker 发送的消息,根据消息类型调用相应的处理函数。

onBeforeUnmount:在组件卸载前,移除 visibilitychange 事件监听器。

import { onBeforeUnmount, onMounted, ref } from 'vue';
import { ElNotification } from 'element-plus';
import useCheckUpdateWorker, { ReflectMessageType } from './useCheckUpdateWorker';

const useCheckUpdate = () => {
  const forbidUpdate = ref(false); //防止弹出多个框
  const versionRef = ref<string>();
  const { start, stop, refresh, workerRef } = useCheckUpdateWorker(
    new URL('./worker.js', import.meta.url).href,
    {
      name: 'updateModal',
      type: 'module',
    },
  );
  
  const openNotification = () => {
    forbidUpdate.value = true;
    //强制更新
    ElNotification({
      title: '版本更新提示',
      message: '检测到系统当前版本已更新,请刷新后使用。',
      duration: 0,
      onClose: () => {
        //通知其他tab页刷新
        refresh();
        //刷新页面
        window.location.reload();
      },
    });
  };
  
  const handlePageUpdateCheck = (etag: string) => {
    if (etag) {
      const version = versionRef.value;
      if (!version) {
        versionRef.value = etag;
      } else if (version === etag) {
        // eslint-disable-next-line no-console
        console.log('最新版本');
      } else {
        // 版本更新,弹出提示,forbidUpdate防止重复弹出
        !forbidUpdate.value && openNotification();
      }
    }
  };
  
  //开启检测
  const startPollingPageUpdate = () => {
    //开发环境不进行版本更新提示
    // if (process.env.NODE_ENV === 'development') return;
    stopPollingPageUpdate();
    start();
  };
  
  const stopPollingPageUpdate = () => {
    stop();
  };
  
  const handleVisibilitychange = () => {
    if (document.visibilityState === 'visible') {
      startPollingPageUpdate();
    } else {
      stopPollingPageUpdate();
    }
  };
  
  onMounted(() => {
    //初始化时,不会触发visibilitychange事件,先主动开启轮询检测
    startPollingPageUpdate();
    document.addEventListener('visibilitychange', handleVisibilitychange);
    if (workerRef.value) {
      //监听worker事件
      workerRef.value.port.onmessage = (e) => {
        const data = e.data || {};
        switch (data.type) {
          case ReflectMessageType.REFLECT_GET_ETAG:
            //forbidUpdate防止重复弹出
            handlePageUpdateCheck(data.data);
            break;
          case ReflectMessageType.REFLECT_REFRESH:
            //其他tab页面手动更新,同步更新
            window.location.reload();
            break;
          default:
            break;
        }
      };
    }
  });
  
  onBeforeUnmount(() => {
    document.removeEventListener('visibilitychange', handleVisibilitychange);
  });
};
export default useCheckUpdate;

结尾

文章中只是介绍了大概的实现方式与思路,还有些细节可根据自己的实际情况实现。例如在开发环境下,不要弹出版本更新提示弹窗等功能。

如果有其他更好的方式实现版本更新提示,可以在评论区留言,大家积极探讨。

最后,创造不易,欢迎大家点赞支持!!!