禁止打开多个浏览器标签页访问相同地址的页面:Cookie + SessionStorage

思路

输入网址,打开页面:

1、 先检查SessionStorage中记录的 "允许打开标识",sessionStorage.getItem(ALLOW_PAGE_OPENED) === "1", 等于'1'正常打开;

2、 不存在则检查Cookie 中记录 "最后活跃时间戳",Date.now() - Number(CookieUtils.getItem(LAST_ACTIVE_TIME)) < 2000,2s不活跃,则正常打开;

3、 正常打开后,SessionStorage设置 ALLOW_PAGE_OPENED,用定时器 setInterval,每隔一秒钟往 Cookie 存入时间戳。同时注册 unload 事件,移除定时器和Cookie里面时间戳。

  • Cookie "在同一域名下共享状态",因此默认支持不同标签页的相同域名地址共享;
  • SessionStorage "为单个标签页的临时会话存储数据",其核心规则是 "数据与标签页强绑定",即使是相同域名的不同标签页,默认也无法共享

项目入口

allowOpenPage 判断是否允许打开页面,允许打开则 Vue 挂载正常的页面,否则提示请勿重复打开标签页

ts 复制代码
// main.ts 

import { createApp } from "vue";
import "./style.css";
import App from "./App.vue";
import { allowOpenPage } from "./access-utils";


if (allowOpenPage()) {
  createApp(App).mount("#app");
} else {
  createApp({ template: `<h1>请勿重复打开标签页</h1>` }).mount("#app");
}

同时要改一下vite配置:

ts 复制代码
// vite.config.ts

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    // 将 "vue" 别名指向包含编译器的版本
    alias: {
      'vue': 'vue/dist/vue.esm-bundler.js'
    }
  }
})

因为Vue3 有两种构建版本:

  • 运行时版(runtime-only):默认版本,体积更小,不支持编译字符串模板(只能通过 .vue 文件的 <template> 或 render 函数渲染)。
  • 完整版(runtime + compiler):包含模板编译器,支持动态编译字符串模板(如 template: '<h1>...</h1>')

判断是否允许访问页面

ts 复制代码
// access-utils.ts

import { CookieUtils } from "./cookie-utils";

const ALLOW_PAGE_OPENED = "allow-page-opened";
const LAST_ACTIVE_TIME = "last-active-time";

/**
 *  是否允许打开页面
 */
export function allowOpenPage() {
  // 1、已经正常打开的页面(设置有ALLOW_PAGE_OPENED)刷新,允许打开
  if (sessionStorage.getItem(ALLOW_PAGE_OPENED) === "1") {
    setAccessSwitcher();
    return true;
  }

  // 2、存在时间戳且活跃时间在2s内,说明有已打开的窗口在定时刷新 LAST_ACTIVE_TIME,不允许重复打开
  /**
   * 如果手动删除了 SessionStorage 的 ALLOW_PAGE_OPENED,
   * 情景1:刷新正常打开的标签页,因为 unload 事件会删除 LAST_ACTIVE_TIME,
           lastActiveTime不存在,依然可以正常打开
   * 情景2:刷新重复打开的标签页,没有注册 unload事件,
           lastActiveTime 存在且在2s内,依然提示重复打开
   */
  const lastActiveTime = CookieUtils.getItem(LAST_ACTIVE_TIME);
  if (lastActiveTime && Date.now() - Number(lastActiveTime) < 2000) {
    return false;
  }

  // 3、初次打开页面,没有ALLOW_PAGE_OPENED,没有LAST_ACTIVE_TIME,允许打开
  setAccessSwitcher();
  return true;
}

/**
 * 设置页面访问权限开关,并初始化定时器
 */
function setAccessSwitcher() {
  // sessionStorage 标记当前标签页允许访问
  sessionStorage.setItem(ALLOW_PAGE_OPENED, "1");

  // 定时器每秒更新一次 Cookie 中的活跃时间戳,用于判断是否有其他标签页已打开
  const intervalID = initActiveTimer();

  // 页面关闭时,清除定时器和 Cookie 中的时间戳
  window.addEventListener("unload", () => {
    clearInterval(intervalID);
    CookieUtils.removeItem(LAST_ACTIVE_TIME, "/");
  });
}


function initActiveTimer() {
  return setInterval(() => CookieUtils.setItem(LAST_ACTIVE_TIME, String(Date.now()), "/"), 1000);
}

操作Cookie的工具类

ts 复制代码
// cookie-utils

/**
 * SessionStorage 数据作用域限定在单个标签页或窗口
 * Cookie 同一浏览器的不同标签页(访问相同网址), 只要域名domain和路径path一样,可以共享 Cookie
 */
export class CookieUtils {
    public static getItem(sKey: string) {
        // encodeURIComponent编码避免特殊字符干扰(如空格、逗号)
        // replace(/[-.+*]/g, "\\$&"):对正则中的特殊字符(. + * -)转义
        const cookieKey = encodeURIComponent(sKey).replace(/[-.+*]/g, "\\$&");
        // 匹配 key=value 结构,捕获 value 部分($1)
        const regex = new RegExp("(?:(?:^|.*;)\\s*" + cookieKey + "\\s*\\=\\s*([^;]*).*$)|^.*$");
        // decodeURIComponent对捕获的 value 解码
        return decodeURIComponent(document.cookie.replace(regex, "$1")) || null;
    }

    /**
     * @param sPath path=/:全站所有路径可见
     */
    public static setItem(sKey: string, sValue: string, sPath = "/", vEnd?: number | string | Date, sDomain?: string) {
        // 校验key合法性:不能为空,且不能是Cookie保留字(expires、path等)
        if (!sKey || /^(?:expires|max-age|path|domain|secure)$/i.test(sKey)) {
            return false;
        }
        let sExpires = "";
        if (vEnd) {
            switch (vEnd.constructor) {
                case Number:
                    // max-age 优先级高于 Expires
                    sExpires = vEnd === Infinity ? "; expires=Fri, 31 Dec 9999 23:59:59 GMT" : "; max-age=" + vEnd;
                    break;
                case String:
                    // 字符串类型:直接作为expires值(需符合日期格式)
                    sExpires = "; expires=" + vEnd;
                    break;
                case Date:
                    // Date对象:转换为UTC字符串
                    sExpires = "; expires=" + (vEnd as Date).toUTCString();
                    break;
            }
        }

        const keyValue = encodeURIComponent(sKey) + "=" + encodeURIComponent(sValue);

        document.cookie =
            keyValue +
            sExpires + // expires 过期时间, 没有定义则为"会话Cookie"(关闭浏览器失效)
            (sDomain ? "; domain=" + sDomain : "") + // domain 域名, 没有定义则默认为当前域名
            (sPath ? "; path=" + sPath : ""); // path 路径, 没有定义则默认为当前路径

        return true;
    }

    public static removeItem(sKey: string, sPath = "/", sDomain?: string) {
        if (!sKey || !this.hasItem(sKey)) {
            return false;
        }
        // 设置过期时间为1970年(已过期),实现删除
        document.cookie =
            encodeURIComponent(sKey) +
            "=; expires=Thu, 01 Jan 1970 00:00:00 GMT" +
            (sDomain ? "; domain=" + sDomain : "") +
            (sPath ? "; path=" + sPath : "");
        return true;
    }

    public static hasItem(sKey: string) {
        const cookieKey = encodeURIComponent(sKey).replace(/[-.+*]/g, "\\$&");
        return new RegExp("(?:^|;\\s*)" + cookieKey + "\\s*\\=").test(document.cookie);
    }
}
相关推荐
!win !2 小时前
不定高元素动画实现方案(上)
前端·动画
xw52 小时前
不定高元素动画实现方案(上)
前端·css
RoyLin4 小时前
TypeScript设计模式:解释器模式
前端·后端·typescript
Codebee5 小时前
魔改 OneCode-RAD 实现 LLM 编程:打造自然语言驱动的低代码助手
前端·人工智能·前端框架
我是日安5 小时前
从零到一打造 Vue3 响应式系统 Day 11 - Effect:Link 节点的复用实现
前端·vue.js
TeamDev5 小时前
用一个 prompt 搭建带 React 界面的 Java 桌面应用
java·前端·后端
北辰alk5 小时前
React 组件状态更新机制详解:从原理到实践
前端
Mintopia6 小时前
在 Next.js 项目中驯服代码仓库猛兽:Husky + Lint-staged 预提交钩子全攻略
前端·javascript·next.js