在开发 Vue 3 中后台项目时,有一个需求初看特别简单自然:
首次听到这个需求,你可能会不以为然地一笑:
“这不就是 Tabs + router-view 吗?小半天就能搞定。”
然而半天过去了。
你逐渐察觉到情况有些不对劲。
从列表页切换到详情页再返回,之前设好的查询条件全部丢失;订单详情想根据不同 ID 同时打开多个,结果却复用了同一个实例;编辑页还没保存,用户一旦关闭标签页,数据就直接消失;报表页面是 iframe,缓存和通信还得额外处理;菜单选中状态、面包屑导航、页面标题、地址栏参数各唱各的调。
最后代码里慢慢出现了一些熟悉的场面:
store里塞了一份标签页状态;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
这是最容易想到的方案。
优点很明显:简单、快速、依赖少。
缺点也来得很快:当你开始处理缓存、多开、关闭守卫、iframe、菜单联动等问题时,Tabs 很快就从“展示组件”变成了“业务中枢”。
这就像本来只想让前台接待登记一下访客,结果让她顺便管财务、审批、仓库和门禁。不是不能干,而是迟早会出问题。
适合场景:只是做一个静态标签切换,或者页面生命周期非常简单的项目。
方案二:Vue Router + keep-alive 自己拼
这个方案更工程化一些:用 Vue Router 管理页面,用 keep-alive 缓存组件,再自己维护已打开的路由列表。
它能撑一段时间,很多项目也是这么起步的。
但麻烦在于,多标签页里的“页面”不一定等于“路由”。
比如同一个详情页,不同参数要不要算作不同的 tab?iframe 页面是否算作路由?关闭 tab 时路由应该怎么退?从来源页打开的子页,保存后如何回调来源页?
这些问题越往后越像在修补一张越来越大的网。你补上一个洞,旁边又漏了一个。
适合场景:团队愿意自己维护完整的 tab 运行时,并且需求边界比较稳定。
方案三:直接上完整后台框架
很多成熟的后台框架都自带多标签页能力,而且通常还会提供菜单、权限、布局、请求、主题、工程规范等一整套方案。
如果你是新项目,这非常省心。
但如果你的项目已经跑了几年,有自己的权限系统、菜单协议、UI 规范、状态管理和历史包袱,这时候为了一个多标签页功能迁移到完整框架,成本就有点像:只是想换个门锁,结果顺手把房子重建了。
适合场景:从零搭建后台,或者愿意接受整套框架约束。
方案四:VueTabRouter
VueTabRouter 的定位更窄一些,也更明确一些:
它不提供完整的后台模板,不规定你用什么 UI 库,不接管你的权限系统,也不要求你按某种菜单协议重写项目。
它只专注于多标签工作台这件事:
- 打开、切换、关闭、刷新;
- 单例复用、多开;
- 组件缓存、iframe 缓存;
- 页面级守卫、全局守卫;
- 菜单联动、面包屑、URL 同步;
- 父子页签事件通信;
- 存储适配器、插件 hooks、局部 scoped manager。
如果说完整后台框架是一套精装修,VueTabRouter 更像是一套可接入的工作台内核。你可以把它装进已有项目里,不必推倒重建。
它到底能干什么
一句话概括:把后台多标签页相关的页面生命周期,收敛到同一个模型里。
以前你可能需要在很多地方分散维护状态:
- 菜单里维护当前选中;
- store 里维护打开过的页面;
- router 里维护当前路径;
- keep-alive 里维护缓存;
- 页面里维护关闭确认;
- iframe 里维护消息通信。
用了 VueTabRouter 之后,这些行为会围绕 TabsManager 统一组织起来。
它关注的不是“这排 tab 怎么画”,而是“页面作为一个工作台标签,应该如何被管理”。
快速接入一下
先安装:
pnpm add @xsbcme/vue-tab-router
或者:
npm install @xsbcme/vue-tab-router
创建一个 TabsManager:
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 转成带模块名前缀的形式:
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 应用:
import { createApp } from "vue";
import App from "./App.vue";
import tabsManager from "./plugins/tab-router";createApp(App).use(tabsManager).mount("#app");
在布局里放两个组件:
DynamicTabsComponent 负责标签栏,DynamicContainerComponent 负责渲染当前激活的页面。
然后在业务里打开页面:
import { useTabsManager } from "@xsbcme/vue-tab-router";const tabsManager = useTabsManager();tabsManager.openTab("/src/views/user/page-index.vue", {
_viewName: "用户管理",
userId: 1001,
});
到这里,一个基础的多标签工作台就跑起来了。
单例和多开:别让订单详情互相串门
后台页面里,“复用”这件事很微妙。
比如用户管理、系统配置、数据字典,大多数时候应该是单例。用户重复点击菜单时,回到已有页面即可。
但订单详情、客户详情、审批详情就不一样了。运营同学可能同时打开三个订单进行对比,如果你强行复用同一个 tab,他大概率会对着页面发呆:我刚才那个订单跑哪儿去了?
VueTabRouter 支持单例复用,也支持多开。
打开一个普通详情:
tabsManager.openTab("/src/views/order/detail/page-index.vue", {
_viewName: "订单详情",
orderId: "SO202606130001",
});
打开一个单例页面:
tabsManager.openTab("/src/views/order/list/page-index.vue", {
_viewName: "订单中心",
_viewSingle: true,
});
这类功能看起来不大,但在真实后台里非常关键。因为用户不是按“路由哲学”来使用系统的,用户只关心:我刚才打开的东西还在不在。
缓存:列表查询条件别再离家出走
后台系统里最常见的场景之一:
- 用户在列表页筛选了一堆条件;
- 点进详情看一眼;
- 回到列表页;
- 查询条件全没了。
这时候用户的表情通常不会太好看。
组件页可以通过 keep-alive 保留状态,iframe 页也可以被统一纳入工作台管理。对于报表平台、低代码页面、旧系统嵌入来说,这比“每次切回来重新加载”要舒服得多。
VueTabRouter 不是简单缓存组件,而是把缓存放在 tab 生命周期里统一看待:页面什么时候创建、什么时候激活、什么时候刷新、什么时候关闭,这些行为都应该和 tab 状态保持一致。
守卫:关闭前先问一句,挺有礼貌
编辑页没保存,用户关 tab 了。
如果系统毫无反应,数据直接没了,用户会认为这是 bug。
如果每个页面自己写一套关闭逻辑,代码又容易分散。
VueTabRouter 提供页面级守卫:
import { onBeforeTabLea ve } from "@xsbcme/vue-tab-router";onBeforeTabLea ve(async () => {
const ok = window.confirm("当前页面有未保存内容,确认离开?");
if (!ok) return false;
});
关闭当前 tab 前也可以拦截:
import { onBeforeTabClose } from "@xsbcme/vue-tab-router";onBeforeTabClose(async () => {
const ok = window.confirm("确认关闭当前标签页?");
if (!ok) return false;
});
全局守卫则适合做权限、日志、埋点:
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 的通信模型很直白:谁打开我,我回调给谁。
来源页注册事件:
import { defineTabEvents } from "@xsbcme/vue-tab-router";defineTabEvents({
sa ved: payload => {
console.log("子页完成保存", payload);
},
});
子页发送事件:
import { useTabsManager } from "@xsbcme/vue-tab-router";const tabsManager = useTabsManager();tabsManager.emit("sa ved", { 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 项目补上一块多标签工作台内核。
如果你也被后台多标签页折腾过,不妨看看这个项目。
