文章目录
- 0. 一个真实场景:三个标签,三种状态
- 1. 为什么单靠 keep-alive 不够
- 1.1 keep-alive 的缓存粒度是「组件」,不是「页面实例」
- 1.2 常见方案对比
- 2. 设计目标与非目标
- 3. 数据模型:Tab 与 Page 是一对多
- 4. 让 keep-alive 按「页面实例」缓存
- 4.1 思路
- 4.2 关键实现
- 4.3 一个必须注意的坑:包装组件必须是 keep-alive 的直接子节点
- 4.4 代价
- 5. 栈式导航:forward / backward 与 `__tab`
- 5.1 forward:先压栈,再导航
- 5.2 backward:必须先导航,再驱逐
- 5.3 `__tab` 到底在做什么
- 5.4 浏览器原生后退怎么办
- 6. 缓存驱逐:为什么不能路由一变就删
- 7. 刷新:活动页和非活动页走两条路
- 8. 刷新恢复:当前版本的契约与边界
- 8.1 当前版本恢复什么
- 8.2 如果要完整对标业务系统,应该怎么改
- 8.3 另一个改进方向:让 `__tab` 更稳定
- 9. iframe 标签:什么时候该用真隔离
- 9.1 懒加载
- 9.2 刷新策略分两种
- 9.3 加载超时与错误占位
- 9.4 安全默认值
- 10. 踩坑清单
- 11. 最小接入
- 11.1 路由
- 11.2 Layout
- 11.3 打开标签与标签内导航
- 11.4 版本前提
- 12. 工程化与真实数据
- 13. 适合谁,不适合谁
- 14. 小结
admin 模板里的 tags-view,本质是「组件名 + 一个 keep-alive」。它能让页面不丢状态,但同一路由多开只能共享一个实例,标签内也没有独立的前进/后退栈。
本文拆解一个基于 Vue Router 的页面级缓存方案:用 UUID 作为每个页面实例的缓存身份,用
Stack<Page>维护标签内导航,用 URL 上的__tab保证刷新和分享后仍能恢复归属,并如实讨论这套设计的边界与代价。在线演示:https://jktantan.github.io/vue-stack-tabs/demo.html
源码:https://github.com/jktantan/vue-stack-tabs
0. 一个真实场景:三个标签,三种状态
先不聊 API,想象一个订单后台:
- 用户在订单列表勾选订单 A,打开详情,停留在「售后记录」Tab;
- 回到列表,又打开订单 B 的详情,停留在「物流信息」Tab;
- 从订单 B 的详情点进物流详情页,再点页面里的「返回」,期望回到订单 B 的详情,而不是订单列表。
这三个动作对应三个很具体的要求:
- 同一路由可以多开:
/order/1和/order/2同时存在,筛选条件、表单草稿、滚动位置互不影响; - 每个标签有独立的导航栈:订单 B 标签里是「详情 → 物流」,订单 A 标签里可能还停在「详情」;
- 刷新后现场还在:按 F5 之后,标签、栈、当前页都还能恢复。
大多数 admin 模板的 tags-view,第一条就做不到。
1. 为什么单靠 keep-alive 不够
1.1 keep-alive 的缓存粒度是「组件」,不是「页面实例」
典型的 tags-view 实现长这样:
<router-view v-slot="{ Component }"> <keep-alive :include="cachedViews"> <component :is="Component" :key="$route.fullPath" /> </keep-alive> </router-view>cachedViews里存的是组件 name,而keep-alive的include匹配的也是组件 name。
问题在于:/order/1和/order/2渲染的是同一个OrderDetail组件,name 一样。于是:
include里只有一个OrderDetail;- keep-alive 只保留一份实例;
- 从 A 切到 B,组件被复用,
onActivated里重新取数,A 的表单状态、滚动位置就没了。
有人会在:key上用$route.fullPath强制区分。但keep-alive的缓存 key 仍然是组件 name,:key只能让组件重建,不能让它同时保留两份实例。这是缓存维度的限制,不是配置问题。
1.2 常见方案对比
| 方案 | 缓存粒度 | 同路由多开 | 标签内导航栈 | 刷新恢复 | 主要代价 |
|---|---|---|---|---|---|
| 单 keep-alive + 路由 name | 组件级 | 不支持,互相覆盖 | 无 | 依赖 URL | 无法保留多实例 |
| admin 模板 tags-view | 组件级 | 不支持 | 无 | 部分 | 同上 |
| 一个标签一个 iframe | 页面级 | 支持 | 浏览器历史 | 支持 | 通信、样式、内存、路由都要另做一套 |
| 本文方案 | 页面实例级 | 支持 | 每标签独立栈 | __tab+ session | 侵入性较强,URL 带参数 |
iframe 的隔离是真的:独立 JS 上下文、独立 CSS、独立路由,一个页面崩了不影响别的标签。但代价也真实:父子通信要自己做、弹窗/下载/登录态要处理、内存占用高、标签切换有白屏感。如果页面都是同源 Vue 页面,用 iframe 属于用重型隔离换一个本来可以靠作用域解决的问题。
本文的方案走中间路线:仍然是同一个 Vue 应用、同一个路由,但把 keep-alive 的缓存单位从「组件」下沉到「页面实例」。
2. 设计目标与非目标
先把边界说清楚,后面的取舍才站得住。
目标:
- 每个标签拥有独立的页面实例和页面栈;
- 标签内支持
forward/backward,行为接近浏览器历史; - 刷新、复制链接、新窗口打开后,能知道当前页面属于哪个标签;
- 支持 iframe 标签,作为老系统渐进式接入的过渡方案;
- 滚动位置、标签增删、批量关闭、右键菜单等后台系统常规能力可用。
非目标:
- 不做安全隔离。同一 JS 上下文里的「作用域隔离」不等于 iframe 的安全隔离,全局变量污染、未捕获异常仍然会影响整个应用;
- 不支持一个 Vue app 里多个
<VueStackTabs>实例。业务系统通常只有一个主框架,多实例会让路由同步、session 隔离、__tab协议的复杂度成倍上升; - 不承诺刷新后保留组件内部状态。keep-alive 不跨刷新,表单草稿这类状态需要业务自己持久化。
还有一个容易被误解的点:__tab为什么不放到内存里?
因为它承担的不是「运行时优化」,而是「刷新/分享/新窗口时的归属契约」。没有它,URL 只剩一个path,当两个标签都停在同一路径时,浏览器刷新后无法判断当前页面属于哪个标签。sessionStorage只能救当前窗口的刷新,救不了复制链接和新窗口。对业务系统来说,URL 可恢复性比 URL 干净更重要。
3. 数据模型:Tab 与 Page 是一对多
核心模型只有三层:
interfaceITabItem{id:stringtitle:stringclosable:booleanrefreshable:booleaniframe:booleanactive:booleanpages:Stack<ITabPage>// 标签内的页面栈}interfaceITabPage{id:string// UUID,同时是 keep-alive 的 cacheNametabId:stringpath:stringquery?:LocationQueryRaw refreshVersion?:number}- Tab 是业务概念:用户看到的那一条标签;
- Page 是缓存身份:栈里的每一页,拥有全局唯一的 UUID;
- 栈顶是当前显示页,
forward入栈,backward出栈。
Tab ├─ id / title / closable / pinned └─ pages: Stack<Page> ├─ Page { id: UUID, path, query } ← 栈顶,当前显示 ├─ Page { id: UUID, path, query } └─ Page { id: UUID, path, query }这个拆分是后面所有机制的基础:标签可以切换,页面可以出栈,但每个页面的缓存身份始终稳定。
4. 让 keep-alive 按「页面实例」缓存
4.1 思路
既然include匹配组件 name,那就给每个页面实例造一个唯一的组件 name:
- 页面入栈时生成 UUID(
cacheName); - 动态
defineComponent({ name: cacheName })包一层; - 把这个
cacheName放进caches,作为include列表; <component :key="activeCacheKey">切到对应的包装组件。
这样,/order/1和/order/2虽然内部渲染的是同一个OrderDetail,但外层包装组件的 name 不同,keep-alive 会为它们保留两份实例。
4.2 关键实现
包装组件工厂的骨架(src/lib/hooks/tabPanel/pageComponentFactory.tsx,有删减):
const cacheComponent = defineComponent<{ vnode?: VNode }>({ name: cacheName, // ← 关键:keep-alive 的 include 按这个 name 匹配 props: { vnode: { type: Object as PropType<VNode>, default: undefined } }, setup(props, context) { const lastCloned = shallowRef<VNode | null>(null) onMounted(() => { context.emit('onLoaded') addPageScroller(cacheName, `#W-${cacheName}`) }) onDeactivated(() => saveScroller(cacheName)) onActivated(() => { context.emit('onLoaded') restoreScroller(cacheName) evictMarkedCaches() // 统一在这里清理待驱逐缓存 }) return () => { lastCloned.value = props.vnode ? cloneVNode(props.vnode, { tId: tabInfo.id, pId: cacheName, ...(backParams ? { _back: backParams } : {}) }) : null return ( <div id={`W-${cacheName}`} class="stack-tab__cache-page-wrapper" style="height: 100%"> {lastCloned.value} <PageLoading tabId={tabInfo.id!} /> </div> ) } } })对应的模板结构(StackKeepAlive.vue):
<router-view v-slot="{ Component, route }"> <StackCacheRenderer v-slot="{ wrappedComponent, activeCacheKey, activePageRefreshVersion, component }" :route="route" :component="Component"> <transition name="stack-tab-swap" appear mode="out-in"> <keep-alive :include="caches"> <component :is="wrappedComponent" :key="`${activeCacheKey}-${activePageRefreshVersion}`" :vnode="component" /> </keep-alive> </transition> </StackCacheRenderer> </router-view>4.3 一个必须注意的坑:包装组件必须是 keep-alive 的直接子节点
如果为了代码整洁,在keep-alive和动态组件之间再包一层普通组件:
<keep-alive :include="caches"> <MyWrapper> <component :is="wrappedComponent" /> </MyWrapper> </keep-alive>include匹配到的会是MyWrapper的 name,而不是页面包装组件的 name,缓存直接失效。
所以这里没有用「包一层」的写法,而是用StackCacheRenderer的scoped slot把解析结果暴露给父级,保证真正的缓存页仍然是keep-alive的直接子节点。这段设计在源码注释里写得很直白,属于踩过一次就不想再踩的坑。
4.4 代价
- 每个页面多一层包装组件,DOM 上多一个容器
div; cacheName必须全局唯一,否则两个页面会抢同一份缓存;caches是一个需要手动维护的include列表,增删时机必须和页面栈严格一致;- 包装组件内部通过
cloneVNode注入tId/pId/_back,页面组件需要知道这些约定。
5. 栈式导航:forward / backward 与__tab
5.1 forward:先压栈,再导航
constforward=(to:RouteLocationPathRaw)=>{constquery=defu(to.query,{__tab:tabInfo})consttransactionVersion=++navigationVersionletoptimisticPageId=''runNavigationTransaction({apply:()=>{constsnapshot=tab?getPageSnapshot(tab.id):[]if(tab){optimisticPageId=createPageId()appendPage(tab.id,{id:optimisticPageId,tabId:tab.id,path:to.path,query:cloneLocationQuery(to.query)})}returnsnapshot},navigate:()=>router.push({path:to.path,query}),rollback:(snapshot)=>{if(tab)replacePages(tab.id,snapshot)},isFailureResult:isNavigationFailure,isCurrent:()=>navigationVersion===transactionVersion,cleanupStale:()=>{if(!tab||!optimisticPageId)returnreplacePages(tab.id,getPages(tab.id).filter((page)=>page.id!==optimisticPageId))}})}注意两件事:
- 先压栈,再
router.push。这样<router-view>重新渲染时,updatePageState能直接在栈顶找到刚创建的 page,复用它的cacheName; - 导航失败要回滚。路由守卫拒绝、重复导航、快速连续点击都可能让
router.push失败,如果不回滚,栈里会留下一个永远渲染不出来的「幽灵页」。
5.2 backward:必须先导航,再驱逐
constrecyclingBin=popPages(tab.id,steps).map((page)=>page.id)consttarget=getTopPage(tab.id)runNavigationTransaction({apply:()=>originalPages,navigate:()=>router.push({path:target.path,query:targetQueryWithTab}),rollback:(snapshot)=>replacePages(tab.id,snapshot),isFailureResult:isNavigationFailure,isCurrent:()=>navigationVersion===transactionVersion,commit:()=>{for(constidofrecyclingBin)evictPageCache(id)},cleanupStale:()=>{constactivePageIds=newSet(getPages(tab.id).map((page)=>page.id))for(constidofrecyclingBin){if(!activePageIds.has(id))evictPageCache(id)}}})这里最容易写错的是顺序:先把页面从栈里弹出,但不能立刻销毁缓存,要等路由导航成功后再驱逐。
原因很直接:router.push之后,页面的卸载/激活是异步的。如果导航被守卫取消,或者用户快速连点导致前一次导航过期,提前销毁缓存会让界面停在白屏或旧页面上。所以这里把「弹出的页面」先放进recyclingBin,导航成功才真正evictPageCache。
5.3__tab到底在做什么
每次打开标签、切换标签、forward,都会把标签的元信息编码进 URL:
exportconstencodeTabInfo=(tabData:ITabBase):string=>{constpayload={version:1,id:tabData.id,title:tabData.title,iframe:tabData.iframe,closable:tabData.closable,refreshable:tabData.refreshable,iframeRefreshMode:tabData.iframeRefreshMode==='reload'?'reload':'postMessage'}returnencodeBase64(JSON.stringify(payload))}URL 变成这样:
/order/1?__tab=eyJ2ZXJzaW9uIjoxLCJpZCI6...它解决三件事:
- 路由归属:页面渲染时,
updatePageState解析__tab,知道这次渲染属于哪个标签、哪个页面; - 刷新恢复:浏览器刷新后,URL 还在,标签身份就还在;
- 分享/新窗口:复制链接给别人,对方打开时能落到同一个标签语义里。
首屏 URL 通常没有__tab,所以初始化时会用router.replace把当前标签编码写回地址,而不是push——避免用户刚进系统就多一条历史记录。
if(typeofroute.query.__tab!=='string'){voidrouter.replace({path:route.path,query:{...cloneLocationQuery(route.query),__tab:encodeTabInfo(tabInfo)},hash:route.hash})}5.4 浏览器原生后退怎么办
用户点浏览器的后退按钮时,不会经过backward(),路由会直接变化。当前实现会在updatePageState里做一次「栈修复」:
- 如果新路径等于栈顶,复用;
- 如果新路径在栈的中间,弹出中间的页面并标记驱逐,把栈顶修回目标页;
- 如果找不到,才创建新页面。
这就是为什么updatePageState里有一段从pages.length - 2往前找的逻辑。它不是冗余代码,而是在兼容「路由是全局的、标签栈是局部的」这个事实。
6. 缓存驱逐:为什么不能路由一变就删
一个很自然的写法是:
watch(()=>route.fullPath,()=>{evictPageCache(oldPageId)})但在多标签场景里,这个写法会遇到两个问题:
- 路由变化不等于页面销毁。切换标签、
forward、backward都会改路由,但大多数页面需要保留; - 路由 watcher 是同步的,导航结果不是。守卫拒绝、重定向、导航取消都可能发生,提前删除缓存会让页面无法恢复。
所以这里把驱逐拆成两步:
// 第一步:只标记markCacheForEviction(cacheName)// 第二步:在安全时机统一执行onActivated(()=>{evictMarkedCaches()})evictMarkedCaches会从caches(也就是 keep-alive 的include列表)里移除这些 name,并删除对应的组件工厂。它被安排在页面激活之后、或下一拍执行,确保路由已经稳定。
代价是状态会短暂多存在一会儿,所以还需要isTabMarkedForEviction这类守卫,防止一个已经被标记的标签在驱逐前又被渲染出来。
7. 刷新:活动页和非活动页走两条路
「刷新标签」看起来是同一件事,但活动页和非活动页不能用同一种实现。
constrefreshPage=(tab:ITabItem)=>{constcurrentPage=getTopPage(tab.id)if(!currentPage)returnif(tab.active){// 活动页:只递增刷新版本,让外层 :key 变化,触发正常卸载重建updatePage(tab.id,currentPage.id,{refreshVersion:(currentPage.refreshVersion??0)+1})return}// 非活动页:直接换一个新的 cacheName,并驱逐旧缓存constoldCacheName=currentPage.idconstnextPageId=createPageId()updatePage(tab.id,oldCacheName,{id:nextPageId,refreshVersion:0})evictPageCache(oldCacheName)addCache(nextPageId)}为什么活动页不能直接换cacheName?
因为活动页正在 keep-alive 里渲染。如果直接把它从include里移除,再换:key,会同时触发「移出缓存」和「重新挂载」两条路径,容易出现重复挂载、闪烁,甚至 keep-alive 内部的缓存树和caches列表短时间不一致。
而递增refreshVersion只改外层:key,组件的卸载/重建完全交给 Vue 的正常生命周期,行为更可预测。非活动页没有这个顾虑,直接换 id 更彻底。
8. 刷新恢复:当前版本的契约与边界
这一节可能是全文最不「爽文」的部分,但它是业务系统真正关心的。
8.1 当前版本恢复什么
当前sessionStorage里保存的是上次激活的那一个标签(包含它的 pages 栈)。刷新后:
| 状态 | 刷新后 |
|---|---|
| 当前激活标签及其页面栈 | ✅ 恢复 |
| 其他已打开标签 | ❌ 不在defaultTabs里就丢失 |
| 当前页的 URL 归属 | ✅ 由__tab恢复 |
| 组件内部状态、表单草稿 | ❌ keep-alive 不跨刷新 |
| 滚动位置 | ⚠️ 需要业务侧持久化 |
| 栈顶之外的页面深链接 | ❌ 单 URL 只能表达栈顶 |
这不是「设计缺陷」,而是当前版本明确的范围。session.ts里只有一个SESSION_TAB_NAMEkey,restoreTabFromSession只反序列化一个ITabItem。
8.2 如果要完整对标业务系统,应该怎么改
如果你的目标是「刷新后我打开的五个标签都还在」,需要把 session 结构从「一个 tab」升级为「标签集合 + 激活项」:
interfaceSessionSnapshot{tabs:Array<{id:stringtitle:stringclosable:booleanrefreshable:booleaniframe:booleanpages:Array<{id:string;path:string;query?:Record<string,unknown>}>}>activeTabId:string}初始化流程变成:
- 先建
defaultTabs/homeTab; - 读取 session 快照,按
id合并缺失的标签; - 用 URL 里的
__tab校正当前激活项(URL 优先于 session); - 对每个恢复的 page 调用
addCache(page.id)。
组件实例仍然会重建,这是 keep-alive 的边界;但标签和导航栈的结构可以完整恢复。对用户来说,「现场还在」的体感主要来自标签和路径,而不是组件内部对象。
8.3 另一个改进方向:让__tab更稳定
当前__tab里编码了title、closable、refreshable等元数据,带来的问题是:
- 标签标题一改,URL 就变;
- 编码串比较长,肉眼不可读;
- 业务字段变化会牵动 URL 协议。
更稳的契约是:URL 只携带稳定的tabId(必要时加pageId),标题等元数据从 session 或路由 meta 补齐。homeTab已经使用固定 id(DEFAULT_HOME_TAB_ID)来保证刷新前后身份一致,这个思路可以推广到所有可恢复标签。
9. iframe 标签:什么时候该用真隔离
路由标签解决不了所有问题。老系统、第三方页面、技术栈完全不同的子系统,仍然需要 iframe。
这里做了几件容易被忽略的事:
9.1 懒加载
未激活的 iframe 标签,src是about:blank,首次激活才换成真实 URL,避免打开系统时同时发起十几个请求。
9.2 刷新策略分两种
postMessage:通知 iframe 内部自己刷新,不重建 DOM,适合同源、能改代码的页面;reload:给 URL 追加__stack_tabs_refresh参数并重建 iframe,适合跨域、无法改代码的页面。
9.3 加载超时与错误占位
默认 15 秒超时,超时后展示错误占位,提供retry()。iframe 的load事件在跨域场景并不可靠,必须有超时兜底。
9.4 安全默认值
sandbox默认是:
allow-scripts allow-forms allow-popups allow-downloads allow-same-origin这是兼容优先,不是强隔离。要强隔离就移除allow-same-origin,并按业务能力继续减少 token;把它当成安全边界会出问题。
postMessage桥接也一样:
onRefreshRequest(callback,{allowedOrigins:['https://app.example.com']})allowedOrigins默认是空数组,此时不校验 origin,任何嵌入该 iframe 的父页面都能触发刷新。这个默认值是为了本地调试方便,生产环境必须显式传。源码注释里也写明了这一点。
10. 踩坑清单
如果你准备自己实现一套,下面这些坑大概率会碰到:
include匹配的是组件 name,不是路由 name。包装组件的name必须等于cacheName。- 包装组件必须是
keep-alive的直接子节点。中间多包一层,缓存就会落到错误组件上。 - 路由未稳定前不要直接驱逐缓存。先 mark,再在
onActivated/ 下一拍统一evict。 - 快速连续导航会让事务过期。用版本号判断
isCurrent,过期事务只能清理,不能回滚,否则会覆盖新状态。 - 首屏写
__tab用replace,不要用push。否则用户一进系统就多一条历史记录。 __tab里不要放易变字段。标题、权限标记这类会变的信息,放进 URL 会让链接失效。- 活动页刷新别直接换
cacheName。用页面级refreshVersion驱动:key,走正常卸载重建。 - iframe 的 sandbox 默认值不是安全边界。
allow-same-origin+allow-scripts组合会显著削弱隔离。 backward的步数可能超过栈深。要 clamp 到pages.length - 1,并给出开发期警告。- session 恢复要校验结构。旧版本数据、被篡改的数据、隐私模式下写入失败,都要能降级,不能白屏。
11. 最小接入
11.1 路由
需要一个承载<VueStackTabs>的父路由,以及一个 iframe 占位路由:
constroutes=[{path:'/',component:()=>import('./Layout.vue'),children:[{path:'',component:()=>import('./pages/Home.vue')},{path:'order/:id',component:()=>import('./pages/OrderDetail.vue')},{path:'iframe',component:()=>import('vue-stack-tabs').then((m)=>m.IFrame)}]}]11.2 Layout
<template> <div style="height: 100vh"> <VueStackTabs iframe-path="/iframe" :default-tabs="defaultTabs" :max="20" /> </div> </template> <script setup lang="ts"> import type { ITabData } from 'vue-stack-tabs' const defaultTabs: ITabData[] = [ { title: '首页', path: '/', closable: false, refreshable: true } ] </script>11.3 打开标签与标签内导航
import{useTabActions}from'vue-stack-tabs'const{openTab}=useTabActions()openTab({title:'订单 A',path:'/order/1'})openTab({title:'订单 B',path:'/order/2'})import{useTabRouter}from'vue-stack-tabs'const{forward,backward}=useTabRouter()// 在当前标签内进入物流页forward({path:'/order/2/logistics'})// 返回上一页;也可以 backward('/order/2')backward(1)11.4 版本前提
当前 2.x 的 peer 依赖是vue ^3.5.43、vue-router ^5.4.0。Vue Router 4 的项目请使用 1.x,或在升级后再接入。安装前先确认你的项目版本,避免 peer 冲突。
12. 工程化与真实数据
这套东西不算大,但为了让它可维护,做了一些基础设施:
- 核心库约6000 行TS/TSX/Vue;
- 33 个测试文件、232 个用例,覆盖缓存驱逐、导航事务回滚、session 恢复、iframe 消息校验等时序敏感逻辑;
- ESM 产物61KB,gzip 约 20KB;样式45KB,gzip 约 7KB;
- 支持 Vue 3.5+、Vue Router 5.4+,提供 Nuxt 3/4 模块;
- 内置中英文 i18n、右键菜单、滚动位置记忆、标签拖拽排序;
iframe-bridge独立子入口,iframe 内页面引它不会带入主库样式。
这里刻意不写 star 数和下载量。一个库有没有用,和它有多少 star 是两件事;对读者来说,能不能解决你的场景才重要。
13. 适合谁,不适合谁
适合:
- 中后台系统,确实需要同一路由多开、每个标签保留自己的页面栈;
- 需要从列表 → 详情 → 子详情层层深入,并且每层都能独立返回;
- 新壳 + 老系统并存,需要 iframe 标签做过渡;
- 团队能接受 URL 带
__tab,并且愿意按约定使用useTabRouter。
不适合:
- 只需要「标签历史 + 页面不丢状态」的普通 admin,直接用模板自带的 tags-view 更轻;
- 需要强安全隔离、故障隔离,直接用 iframe;
- 不能接受单实例、ESM-only、深度依赖 Vue Router 的项目;
- 希望刷新后连表单内容都恢复的团队,需要额外的业务级持久化。
14. 小结
多标签页的难点,从来不是「怎么画标签栏」,而是:
- keep-alive 的缓存 key 是组件,不是页面实例;
- 标签内的导航栈和全局路由历史不是一回事;
- 刷新恢复需要一个能跨刷新存活的归属标识;
- 缓存驱逐必须等导航稳定,不能跟着路由 watcher 同步执行。
这个项目做的事情,本质上是在 Vue Router 之上加了一层「页面实例 + 标签栈」的映射,并接受它带来的侵入性。它不适合所有人,但对「同一路由多开 + 标签内多级返回 + 刷新现场恢复」这三个需求同时成立的后台系统,它比 iframe 轻,比单 keep-alive 完整。
在线演示:https://jktantan.github.io/vue-stack-tabs/demo.html
文档:https://jktantan.github.io/vue-stack-tabs/
源码:https://github.com/jktantan/vue-stack-tabs
License: Apache-2.0
你们的 tags-view 是怎么处理「同一路由多开」的?是共享一个实例、用 iframe,还是自己维护了一套页面栈?欢迎在评论区聊聊。