做 Vue 中后台项目时,有一个需求看起来特别人畜无害:
左边菜单,右边内容,顶部来一排标签页。点菜单打开页面,可以切换,可以关闭,最好还能缓存。
第一次听到这个需求,你可能会淡定一笑:
"这不就是 Tabs + router-view 吗?半天搞定。"
然后半天过去了。
你发现事情开始不对劲。
列表页切到详情页再回来,查询条件没了;订单详情想按不同 id 多开,结果全复用了;编辑页还没保存,用户一关 tab,数据直接说走就走;报表是 iframe,缓存和通信另起炉灶;菜单选中、面包屑、页面标题、地址栏状态各唱各的。
最后代码里出现了一些熟悉的景象:
store里塞一份 tab 状态;router里塞一份页面状态;- 菜单组件里偷偷维护选中态;
- 标签栏组件里开始判断业务逻辑;
- 页面组件里到处写刷新、关闭、回调;
- iframe 组件单独活成了另一个世界。
这时候你才会意识到:后台多标签页不是一排长得像浏览器的按钮,它其实是一套工作台运行模型。
这篇文章想介绍的 VueTabRouter,就是为这个场景做的。
它是一个专注 Vue 3 的多标签页路由插件。它不想当后台模板,也不想和 Vue Router 抢饭碗,它主要解决一件事:让 Vue 中后台项目里的多标签工作台别再靠一堆胶水代码硬撑。
先放一张动图感受一下它在 demo 里的样子:

这个登录页主要不是为了"装饰门面",而是演示真实项目里常见的一段流程:先走 Vue Router 的登录、鉴权和 redirect,再进入由 VueTabRouter 接管的多标签工作台。

先说结论:它不是 Tabs 组件
很多多标签页方案的问题,是一开始就把题目看小了。
如果只是展示几个标签,UI 库里的 Tabs 已经很好了。Arco、Element Plus、Ant Design Vue 都能做,颜值还在线。
但后台里的 tab 往往不只是"选项卡"。它还要回答这些问题:
- 这个页面是打开新的,还是复用旧的?
- 这个详情页能不能按不同 id 多开?
- 页面切走以后状态要不要保留?
- 关闭前需不需要拦截?
- iframe 页面怎么缓存?
- 菜单、面包屑、地址栏和当前 tab 怎么同步?
- 子页面保存后,怎么通知打开它的来源页?
这些问题,普通 Tabs 组件不会管,也不应该管。
所以 VueTabRouter 的核心不是一个标签栏,而是 TabsManager。
你可以把 TabsManager 理解成工作台管家:谁打开了、谁激活了、谁缓存了、谁准备关闭、谁从谁那里来、谁要给谁发消息,它都要心里有数。
几种常见方案对比一下
为了避免上来就说"我的插件很好",我们先把几种常见做法摆在桌上看看。
方案一:UI Tabs + router-view
这是最容易想到的方案。
vue
<template>
<Tabs />
<router-view />
</template>
优点很明显:简单、快、依赖少。
缺点也来得很快:当你开始处理缓存、多开、关闭守卫、iframe、菜单联动时,Tabs 很快就从"展示组件"变成"业务中枢"。
这就像本来只想让前台接待登记一下访客,结果让她顺便管财务、审批、仓库和门禁。不是不能干,是迟早会崩。
适合场景:只是做一个静态标签切换,或者页面生命周期很简单。
方案二:Vue Router + keep-alive 自己拼
这个方案更工程化一点:用 Vue Router 管页面,用 keep-alive 缓存组件,再自己维护 opened routes。
它能撑一段时间,很多项目也是这么起步的。
但麻烦在于,多标签页里的"页面"不一定等于"路由"。
比如同一个详情页,不同参数要不要算不同 tab?iframe 页面是不是路由?关闭 tab 时路由怎么退?从来源页打开的子页,保存后怎么回调来源页?
这些问题越往后越像在修一张越来越大的网。你补一个洞,旁边又漏一个。
适合场景:团队愿意自己维护完整 tab 运行时,并且需求边界比较稳定。
方案三:直接上完整后台框架
很多成熟后台框架都带多标签页能力,而且通常还会给你菜单、权限、布局、请求、主题、工程规范一整套。
如果你是新项目,这非常香。
但如果你的项目已经跑了几年,有自己的权限系统、菜单协议、UI 规范、状态管理和历史包袱,这时候为了一个多标签页迁移到完整框架,成本就有点像:只是想换个门锁,结果顺手把房子重建了。
适合场景:从零搭后台,或者愿意接受整套框架约束。
方案四:VueTabRouter
VueTabRouter 的定位更窄一点,也更明确一点:
它不提供完整后台模板,不规定你用什么 UI 库,不接管你的权限系统,也不要求你按某种菜单协议重写项目。
它只专注多标签工作台这件事:
- 打开、切换、关闭、刷新;
- 单例复用、多开;
- 组件缓存、iframe 缓存;
- 页面级守卫、全局守卫;
- 菜单联动、面包屑、URL 同步;
- 父子页签事件通信;
- 存储适配器、插件 hooks、局部 scoped manager。
如果说完整后台框架是整套精装修,VueTabRouter 更像是一套可接入的工作台内核。你可以把它装进已有项目里,不必推倒重来。
它到底能干什么
一句话概括:把后台多标签页相关的页面生命周期,收敛到同一个模型里。
以前你可能要在很多地方维护状态:
- 菜单里维护当前选中;
- store 里维护打开过的页面;
- router 里维护当前路径;
- keep-alive 里维护缓存;
- 页面里维护关闭确认;
- iframe 里维护消息通信。
用了 VueTabRouter 之后,这些行为会围绕 TabsManager 组织起来。
它关注的不是"这排 tab 怎么画",而是"页面作为一个工作台标签,应该如何被管理"。
快速接入一下
先安装:
bash
pnpm add @xsbcme/vue-tab-router
或者:
bash
npm install @xsbcme/vue-tab-router
创建一个 TabsManager:
ts
import { createTabsManager } from "@xsbcme/vue-tab-router";
const modules = import.meta.glob("@/views/**/page-index.vue");
const tabsManager = createTabsManager({
views: {
modules,
},
render: {
viewNameMaxLength: 20,
},
});
export default tabsManager;
这里的 modules 是页面入口注册表。后面调用 openTab(viewUrl) 时,viewUrl 就来自这些模块的 key。
这个地方很容易冒出几个问题:为什么是 import.meta.glob?为什么 key 看起来像路径?为什么页面入口叫 page-index.vue?
先说 import.meta.glob。它是 Vite 提供的能力,可以按 glob 规则自动扫描文件并生成模块映射。VueTabRouter 并不是只能跑在 Vite 里,它真正需要的是 views.modules 这份注册表。Vite 项目刚好可以用 import.meta.glob("@/views/**/page-index.vue") 自动生成,所以少写很多手工 import。
再说路径 key。/src/views/user/page-index.vue 本质上不是浏览器 URL,也不是 Vue Router 的路由地址,它是这个页面入口在 modules 里的 key。用路径当 key,是一个"约定优于配置"的选择:文件路径天然唯一,也能直接定位源码,不需要再给每个页面起一套 user-page、order-detail-page 之类的名字。
跨模块也能处理,但这里要小心一点:import.meta.glob() 不是随便写个 @moduleA 就能扫,它的参数必须是当前项目真实存在的路径,或者是你在 Vite 里配置过的路径别名。
比如同一个项目里有两个业务模块,可以先按真实目录扫描,再把 key 转成带模块名前缀的形式:
ts
function normalizeViewKeys(modules: Record<string, unknown>, moduleName: string, baseDir: string) {
return Object.fromEntries(
Object.entries(modules).map(([key, value]) => [`@${moduleName}/${key.replace(baseDir, "")}`, value])
);
}
const salesViews = import.meta.glob("./modules/sales/views/**/page-index.vue");
const crmViews = import.meta.glob("./modules/crm/views/**/page-index.vue");
const modules = {
...normalizeViewKeys(salesViews, "sales", "./modules/sales/"),
...normalizeViewKeys(crmViews, "crm", "./modules/crm/"),
};
这样 ./modules/sales/views/user/page-index.vue 可以变成 @sales/views/user/page-index.vue,./modules/crm/views/user/page-index.vue 可以变成 @crm/views/user/page-index.vue。两个模块都有用户页,也不会撞车。
如果页面来自依赖包,也可以先配置 Vite 别名,再扫描这个别名指向的真实目录;扫描完成后仍然建议把 key 规范化成你项目认可的模块前缀。实际落地时最好打印一次 Object.keys(modules),菜单、views.meta 和 openTab() 都用这同一套 key,后面就不容易乱。
最后是 page-index.vue。这个名字不是魔法,只是一个推荐约定。它的意义是只扫描"页面入口",不要把页面里的表格、筛选区、弹窗、详情面板全都注册成可以打开的 tab。页面内部组件该怎么命名还是怎么命名,最后由 page-index.vue 组装成一个真正的页面入口。
注册到 Vue 应用:
ts
import { createApp } from "vue";
import App from "./App.vue";
import tabsManager from "./plugins/tab-router";
createApp(App).use(tabsManager).mount("#app");
在布局里放两个组件:
vue
<template>
<div class="layout">
<DynamicTabsComponent />
<DynamicContainerComponent />
</div>
</template>
<script setup lang="ts">
import { DynamicContainerComponent, DynamicTabsComponent } from "@xsbcme/vue-tab-router";
</script>
DynamicTabsComponent 负责标签栏,DynamicContainerComponent 负责渲染当前激活页面。
然后在业务里打开页面:
ts
import { useTabsManager } from "@xsbcme/vue-tab-router";
const tabsManager = useTabsManager();
tabsManager.openTab("/src/views/user/page-index.vue", {
_viewName: "用户管理",
userId: 1001,
});
到这里,一个基础的多标签工作台就跑起来了。
单例和多开:别让订单详情互相串门
后台页面里,"复用"这件事很微妙。
比如用户管理、系统配置、数据字典,大多数时候应该是单例。用户重复点菜单时,回到已有页面就行。
但订单详情、客户详情、审批详情就不一样了。运营同学可能同时打开三个订单对比,如果你强行复用同一个 tab,他大概率会看着页面发呆:我刚才那个订单呢?
VueTabRouter 支持单例复用,也支持多开。
打开一个普通详情:
ts
tabsManager.openTab("/src/views/order/detail/page-index.vue", {
_viewName: "订单详情",
orderId: "SO202606130001",
});
打开一个单例页面:
ts
tabsManager.openTab("/src/views/order/list/page-index.vue", {
_viewName: "订单中心",
_viewSingle: true,
});
这类能力看起来小,但在真实后台里很关键。因为用户不是按"路由哲学"使用系统的,用户只关心:我刚才打开的东西还在不在。
缓存:列表查询条件别再离家出走
后台系统里最常见的场景之一:
- 用户在列表页筛选了一堆条件;
- 点进详情看一眼;
- 回到列表;
- 查询条件没了。
这时候用户的表情一般不会很友善。
组件页可以通过 keep-alive 保留状态,iframe 页也可以被统一纳入工作台管理。对于报表平台、低代码页面、旧系统嵌入来说,这比"每次切回来重新加载"要舒服很多。
VueTabRouter 不是简单缓存组件,而是把缓存放在 tab 生命周期里看:页面什么时候创建、什么时候激活、什么时候刷新、什么时候关闭,这些行为都应该和 tab 状态一致。
守卫:关闭前先问一句,挺有礼貌
编辑页没保存,用户关 tab 了。
如果系统毫无反应,数据直接没了,用户会觉得这是 bug。
如果每个页面自己写一套关闭逻辑,代码又容易散。
VueTabRouter 提供页面级守卫:
ts
import { onBeforeTabLeave } from "@xsbcme/vue-tab-router";
onBeforeTabLeave(async () => {
const ok = window.confirm("当前页面有未保存内容,确认离开?");
if (!ok) return false;
});
关闭当前 tab 前也可以拦截:
ts
import { onBeforeTabClose } from "@xsbcme/vue-tab-router";
onBeforeTabClose(async () => {
const ok = window.confirm("确认关闭当前标签页?");
if (!ok) return false;
});
全局守卫则适合做权限、日志、埋点:
ts
const tabsManager = createTabsManager({
views: {
modules,
},
guards: {
beforeOpen: async (toTab, fromTab) => {
console.log("open", fromTab?.viewUrl, "=>", toTab.viewUrl);
},
beforeEnter: async (toTab, fromTab) => {
console.log("enter", fromTab?.viewUrl, "=>", toTab.viewUrl);
},
beforeClose: async closingTab => {
console.log("close", closingTab.viewUrl);
},
},
});
一句经验:权限、埋点、日志这种横切逻辑放全局守卫;未保存确认这种强业务逻辑放页面级守卫。谁的锅谁背,代码也清爽一点。
页面通信:谁打开你,你就回谁
后台里还有个经典剧情:
列表页打开编辑页,编辑页保存成功后,要通知列表页刷新。
以前可能会用事件总线、全局 store、query 参数、回调函数。小项目还好,大项目里很容易变成"我也不知道这个事件谁在听"。
VueTabRouter 的通信模型很直白:谁打开我,我回调给谁。
来源页注册事件:
ts
import { defineTabEvents } from "@xsbcme/vue-tab-router";
defineTabEvents({
saved: payload => {
console.log("子页完成保存", payload);
},
});
子页发送事件:
ts
import { useTabsManager } from "@xsbcme/vue-tab-router";
const tabsManager = useTabsManager();
tabsManager.emit("saved", { id: 1001 });
这个模型的好处是,通信关系来自 tab 来源关系,而不是把所有页面都扔进一个全局消息大厅。
iframe:它也是工作台公民
很多后台系统绕不开 iframe。
BI 报表、低代码页面、第三方平台、历史系统,总有一些页面不是 Vue 组件,但又必须进入工作台。
如果只是把 iframe 塞进容器里,很快会遇到几个问题:
- 加载完成怎么感知?
- 来源消息怎么校验?
- 切换 tab 时要不要缓存?
- 关闭和刷新是否跟组件页一致?
- 和父页面怎么通信?
VueTabRouter 把 iframe 也当成 tab 页面来管理,支持 iframe 加载回调、消息来源校验、postMessage 通信,以及统一的打开、切换、关闭、缓存体验。
这对需要整合旧系统的项目很实用。毕竟很多公司的旧系统不是不存在,只是平时大家不太愿意提。
菜单、面包屑、URL:别各过各的
多标签工作台还有一个隐藏坑:导航状态同步。
页面明明打开了,菜单没选中;tab 切过去了,面包屑还是旧的;刷新页面后,工作台状态没了;地址栏和当前激活页面对不上。
这些问题单独看都不大,但放在后台系统里,就会让用户觉得系统"不跟手"。
VueTabRouter 提供 useTabMenu、动态面包屑、页面元数据和 URL 同步能力,让菜单、面包屑、地址栏和当前 tab 围绕同一个状态工作。
这就是它和普通 Tabs 组件最大的区别:普通 Tabs 关心"显示哪个标签",VueTabRouter 关心"当前工作台处于什么页面上下文"。
如果把这个状态同步做扎实,用户在菜单、标签页和页面内容之间来回切换时,系统就会更像一个完整工作台,而不是几个组件临时拼在一起:

适合谁,不适合谁
适合:
- Vue 3 中后台管理系统;
- 业务工作台、运营工作台、客服工作台;
- 多文档编辑、低代码配置台、报表平台;
- 需要同时管理组件页和 iframe 页;
- 已有项目想渐进接入多标签能力;
- 不想迁移到完整后台模板,但需要稳定的 tab 运行时。
不适合:
- 只是普通页面跳转;
- 只想要一个静态 Tabs UI;
- 不需要缓存、守卫、iframe、菜单联动;
- 新项目已经决定使用某个完整后台框架,并且接受它的整套约束。
简单说:如果你的需求只是"页面跳转",Vue Router 就够了;如果只是"标签好看",UI Tabs 就够了;如果你的后台开始出现缓存、守卫、多开、iframe、菜单联动、页面通信这些关键词,那就可以看看 VueTabRouter。
小结
多标签页这个需求很有意思。
它刚出现时,像一个小 UI;做到后面,像一个小框架;再做到复杂业务里,它其实是在考验项目怎么管理页面生命周期。
VueTabRouter 想做的,就是把这部分复杂度收拢起来:页面打开、复用、缓存、刷新、关闭、守卫、iframe、菜单联动、URL 同步、页面通信和插件扩展,都围绕 TabsManager 这套模型运转。
它不替代 Vue Router,也不替代后台框架。它更像是给已有 Vue 项目补一块多标签工作台内核。
如果你也被后台多标签页折腾过,可以看看这个项目:
- GitHub:github.com/xsbcme/vue-...
- Gitee:gitee.com/xsbcme/vue-...
- NPM:www.npmjs.com/package/@xs...
- 文档:xsbcme.github.io/vue-tab-rou...
- 在线 Demo:xsbcme.github.io/vue-tab-rou...
欢迎试用,也欢迎提 issue。多标签页这东西,写简单了像玩具,写完整了像基础设施。希望 VueTabRouter 能让你少写一点胶水代码,多一点准点下班的机会。