思路
输入网址,打开页面:
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 特性
- 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);
}
}