适用版本:vue-stack-tabs 3.2.x、Vue 3.5、Vue Router 4。文中的实现来自库源码,示例做了简化。
一、问题不是“加个 loading”,而是“loading 属于谁”
后台管理系统一旦做成多标签页,加载态就会变成一个比想象中复杂的问题。最初我们只有一个全局 loading 遮罩:axios 拦截器在请求开始时打开,请求结束时关闭。单页面应用里这没问题,但在多标签页里它立刻暴露出三个问题:
- 遮罩没有归属。A 标签里的请求会让 B、C 标签一起转圈。用户切到别的标签,发现整个内容区被遮罩盖住,但那个标签根本没有在加载。
- 请求并发时状态互相覆盖。两个请求同时进行时,先结束的那个会把遮罩关闭,而后结束的请求仍在进行,用户却以为已经完成。
- 覆盖不了路由 chunk 的加载。页面组件通过
() => import()懒加载,点击菜单后 chunk 仍在下载,页面组件尚未创建,页面内编写的 loading 自然也不会出现。慢网环境下这段“空白期”最长,用户最容易重复点击。
另一种常见做法是每个页面自己写 v-loading。它解决了归属问题,但带来新的重复劳动:每个页面都要维护一份 loading 状态,页面被 keep-alive 缓存后还要考虑切走再切回时状态是否残留;更关键的是,它同样覆盖不了“页面还没挂载”的导航阶段。
所以问题不是“怎么画一个转圈动画”,而是:这个加载态到底属于谁?
在 vue-stack-tabs 里,我们把加载态拆成了三类,因为它们的所有者不同:
| 类型 | 触发时机 | 所有者 | 生命周期 |
|---|---|---|---|
| 导航级加载 | 路由组件 chunk 未就绪、标签内前进/后退、打开新标签 | 标签(tab) | 从导航开始到目标页面挂载 |
| 页面级异步操作 | 页面挂载后的请求、提交、导入 | 页面(page) | 由业务代码控制 |
| 资源级加载 | iframe、图片、局部异步组件 | 资源本身 | 由资源事件控制 |
PageLoading 主要解决前两类。第三类里的 iframe 在库里是另一套实现(load 事件 + 超时),不在本文展开。
三类加载态的所有者关系可以先用一张图概括:
二、第一层:每个缓存页一个遮罩,按 tabId 隔离
先解决“归属”问题。我们没有使用全局单例,而是让每个缓存页的包装组件渲染自己的 PageLoading。在页面工厂里,渲染结构大致是这样:
// pageComponentFactory.tsx(简化)
return (
<div ref={pageRoot} class="stack-tab__cache-page-wrapper" id={`W-${cacheName}`}>
{pageContent}
{/* PageLoading 必须在滚动容器外层:它要覆盖整个内容区 */}
<PageLoading tabId={tabInfo.id!} />
</div>
)
组件本身只负责三件事:订阅事件、按 tabId 过滤、显示遮罩。
<!-- PageLoading.vue(简化) -->
<template>
<div v-if="isLoading" class="stack-tab-loading__mask" role="status" aria-live="polite">
<div class="stack-tab-loading__spin" aria-hidden="true" />
</div>
</template>
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import { TabEventType, useTabEmitter } from '../hooks/useTabEventBus'
import { getMaxZIndex, invalidateZIndexCache } from '../utils/scrollUtils'
const props = withDefaults(defineProps<{ tabId: string; visible?: boolean }>(), {
visible: undefined
})
const emitter = useTabEmitter()
const eventLoading = ref(false)
const isLoading = computed(() => props.visible ?? eventLoading.value)
const handleLoadingEvent = (payload: { tId: string; value: boolean }) => {
if (props.visible !== undefined) return
if (payload.tId === props.tabId) eventLoading.value = payload.value
}
emitter.on(TabEventType.PAGE_LOADING, handleLoadingEvent)
onUnmounted(() => emitter.off(TabEventType.PAGE_LOADING, handleLoadingEvent))
</script>
这里有几个刻意的设计:
- 遮罩在页面 DOM 内,而不是挂在 body 上。 因此它天然只覆盖当前标签的内容区,标签栏和其他标签完全不受影响。即使事件传错了
tabId,也只会影响相应tabId对应的缓存页。 - 事件携带
tabId,而不是全局广播。useTabLoading()从页面上下文里解析所属标签,发出{ tId, value };每个PageLoading只处理自己的那份。页面上下文由缓存包装层provide,页面内任意层级的组件都能inject,不需要把tabId作为 props 一层层传下去。 visible是受控模式。 导航占位阶段还没有页面实例,useTabLoading()不可用;此时由StackTabs直接传:visible="true"。受控模式下事件被忽略,避免外部状态被事件覆盖。- 卸载时取消订阅。 关闭标签、页面缓存被驱逐时,组件卸载,监听器一起清理,不会留下泄漏。
遮罩的层叠值也有一个小坑:页面里可能有 position: fixed 的弹窗、下拉层,固定的 z-index 不一定够。我们在显示时读取当前页面子树的最大 z-index,并先失效缓存再读取,避免拿到遮罩显示前的旧值。
三、第二层:命令式 API,而不是“把请求状态交给组件”
页面级的加载态,我们没有做成 useTabLoading() 返回一个 isLoading ref 让业务去修改,而是提供了一对命令式方法:
const { openTabLoading, closeTabLoading } = useTabLoading()
const reload = async () => {
openTabLoading()
try {
await fetch('/api/report')
} finally {
closeTabLoading()
}
}
原因是页面里的异步操作形态较多:请求、表单提交、文件导入、导出、轮询。它们不一定有统一的“pending 状态”,也不一定走同一个请求库。命令式 API 让业务在任意异步边界前后包裹一下即可,不需要把状态提升到某个 store。
但这里有一个必须说清楚的边界:库本身没有做引用计数。如果页面里同时有两个请求,各自 open / close,先结束的那个会把遮罩关掉。业务如果确实存在并发,应该自己封装一层计数:
let pending = 0
const withLoading = async <T>(task: () => Promise<T>): Promise<T> => {
pending++
if (pending === 1) openTabLoading()
try {
return await task()
} finally {
pending--
if (pending === 0) closeTabLoading()
}
}
这个选择是有意为之的:库无法知道业务的一次 open 对应几次 close,强行做计数反而会让“手动控制”变得不可预测。把并发语义留给业务,库只保证“显示/隐藏”这个最小契约。
另外,useTabLoading 在 onUnmounted 时会兜底关闭一次。这不是为了让业务偷懒,而是为了处理页面在请求尚未结束时被关闭的情况,避免事件状态残留。
四、第三层:导航级加载,难的是时序而不是动画
导航级加载是这套设计里最容易被低估的部分。它要解决的不是“转圈”,而是“什么时候该转、什么时候不该转、转的时候旧页面怎么办”。
4.1 占位页签:先让页签出现
openTab() 的目标很明确:用户点了菜单,即使页面 chunk 还在下载,也应该立刻看到页签出现。所以它先同步创建一个占位标签(pending: true),再执行 router.push:
const createPendingTab = (tabInfo: ITabData): ITabItem => ({
id: tabInfo.id!,
title: tabInfo.title,
closable: tabInfo.closable ?? true,
refreshable: tabInfo.refreshable ?? true,
iframe: Boolean(tabInfo.iframe),
url: tabInfo.iframe ? tabInfo.path : undefined,
active: true,
pages: new Stack<ITabPage>(),
pending: true
})
占位标签的页面栈是空的,内容区由 StackTabs 显示 PageLoading。导航成功后,页面栈被补齐,等目标页面真正挂载后再撤掉 pending;导航失败则移除占位标签,并把激活项交还给之前的标签。
这一步是 Suspense 做不到的:Suspense 只能在组件开始渲染后显示 fallback,而我们需要在 router.push 之前就把“标签”显示出来。 此时目标组件连渲染都没开始,没有任何 Suspense 边界存在。
4.2 路由守卫:只在“真的需要等”时显示
标签页内的前进/后退以及浏览器的前进/后退不会经过 openTab,因此我们注册了全局 beforeEach,将“导航开始”映射到目标标签的 pending 状态。但触发 loading 的条件必须足够克制,否则会出现“切回缓存页也转圈”的假加载现象:
router.beforeEach((to, from) => {
if (!props.pageLoading) return
if (to.fullPath === from.fullPath) return
const tabId = resolveTabId(to)
if (!tabId || !hasTab(tabId)) return
// 已经是该标签的已加载页面:不显示 loading
if (isCurrentPage(to, from, tabId)) return
// 路由组件已经加载过(chunk 命中缓存):导航会立即完成,不显示 loading
if (isRouteComponentLoaded(to)) {
setTabPending(tabId, false)
return
}
// 快速连续切标签:先撤掉上一个导航的占位
if (pendingTabId && pendingTabId !== tabId) setTabPending(pendingTabId, false)
pendingTabId = tabId
setTabPending(tabId, true)
})
判断“组件是否已经加载过”的方法比较直接:遍历 to.matched,如果 record.components.default 仍是函数(() => import()),说明 chunk 尚未解析;如果已经变为组件对象,则说明命中了缓存,导航会立即提交,无需显示 loading。
这个判断是在 3.2.1 中才补充的。在此之前,用户切回一个已经访问过的页面时,也会短暂闪现 loading,体验很不自然。
4.3 撤销时机:不能在 afterEach 撤
这是整个实现里最关键的一条时序约束:导航完成不等于页面挂载完成。
如果在 afterEach 里撤掉 loading,此时旧页面还没离场,新页面的入场动画也还没开始,用户会先看到旧页面闪回一下,再看到新页面。正确做法是把撤销放到页面组件的 onMounted / onActivated:
const onComponentLoaded = () => {
const activeTab = getActiveTab()
if (activeTab) setTabPending(activeTab.id, false)
emit('onPageLoaded')
}
StackKeepAlive 在缓存页加载/激活时触发 loaded,StackTabs 收到后清除当前标签的 pending。这样 loading 会一直持续到新页面真正准备好,视觉上是“旧页面淡出 → 遮罩 → 新页面淡入”,不会回闪。
导航失败的情况由 router.onError 和 useTabRouter 的 forward/backward 异常兜底清除;否则用户会看到一个永远转圈的占位标签。
4.4 旧页面淡出,但不要销毁
占位期间,旧页面仍然保持挂载,只是被加上了 opacity: 0 和 pointer-events: none:
.stack-tab__page-host {
transition: opacity 160ms ease;
&.is-pending {
opacity: 0;
pointer-events: none;
}
}
这样做的好处是:页面实例没有被销毁,输入内容、滚动位置、组件状态都还在。如果用户在导航过程中取消或导航失败,回到原标签时状态仍然是完整的。占位遮罩本身使用不透明背景,并与页面宿主进行交叉淡入淡出,避免半透明遮罩下透出上一个页面。
4.5 性能细节:pending 不重建 tabs 数组
pending 是标签视图对象上的 getter,切换它只会修改底层 record 的响应式字段,不调用 syncTabsView()。原因是标签内导航非常频繁,如果每次 loading 切换都重建整个 tabs 数组,标签栏、iframe 列表和刷新版本计算都会随之重新求值。
另外,占位标签不会写入 sessionStorage。否则用户刷新页面后,会恢复出一个“有标签、无页面”的空壳。
把 4.1 到 4.3 的时序串起来,一次导航的完整生命周期是这样的:
五、为什么不用 Suspense
写到这里,可以正面回答这个问题。
先说结论:Suspense 解决的是“组件树内的异步依赖”,而我们要解决的是“路由/标签维度的加载状态机”。 两者有交集,但不是同一层问题。
具体有六个原因。
5.1 Suspense 没有“导航开始”这个时机
Suspense 的 fallback 是在组件树开始渲染、遇到尚未 resolve 的异步依赖时显示的。它无法感知用户点击菜单的动作,也无法感知 router.push 已经发起的导航。
而多标签页最需要 loading 的时刻,恰恰是导航已经发起、目标组件还没开始渲染的那段空窗期。openTab() 的占位标签必须在 router.push 之前创建;Suspense 在这个时刻没有任何边界可以挂 fallback。
5.2 fallback 是祖先边界,做不到按标签隔离
Suspense 的 fallback 会覆盖它包裹的整个子树。如果在 <RouterView> 外面套一个 <Suspense>,那么任何一个标签的页面挂起时,整个内容区都会显示 fallback;如果给每个缓存页各套一个 Suspense,又会产生大量边界,而且每个边界的 fallback 生命周期绑定在组件实例上,与 keep-alive 的 activated/deactivated 不是同一套语义。
我们需要的是“A 标签加载时只遮 A”,这本质上要求 loading 的宿主是标签,而不是组件树的某个祖先。PageLoading 按 tabId 渲染,天然满足这一点。
5.3 keep-alive 的缓存页不应该重新挂起
多标签页的核心体验是:切走再切回,页面状态还在。keep-alive 激活一个已缓存的页面时,不应该再走一次“挂起 → fallback → resolve”流程。
Suspense 的 pending 状态描述的是“这个异步依赖是否已经 resolve”,它不区分“首次加载”与“从缓存激活”。在一个复杂的 Transition + KeepAlive + 动态组件 组合里,把加载态交给 Suspense,意味着你要同时协调三套生命周期:Suspense 的 resolve、keep-alive 的激活/停用、Transition 的离场/入场。这不是不可行,但会让时序变得非常难以推理。
5.4 导航失败、回滚、占位标签,Suspense 都不管
我们的导航是一个事务:openTab 先添加占位标签,再执行 router.push;失败时需要移除占位标签、恢复之前的激活标签、清空 pending;forward 还需要回滚页面栈。
Suspense 只关心“组件是否 resolve”,它没有“导航失败”这个概念,也没有“把占位标签移除”的能力。这些逻辑必须在路由守卫和事务层完成,Suspense 替代不了。
5.5 命令式加载态不在 Suspense 的射程内
useTabLoading() 要覆盖的是页面挂载后的任意异步操作:提交表单、导出文件、导入数据。这些操作不涉及组件加载,Suspense 完全无法感知。
Suspense 能等的是 async setup() 返回的 Promise 或异步组件。你不能在点击“保存”时让 Suspense 重新进入 pending——除非你刻意销毁并重建组件,那又会丢掉页面状态。
5.6 Suspense 仍是实验性 API
Vue 官方文档至今仍将 <Suspense> 标记为实验性,其 API 和行为可能发生变化;它与 <Transition>、<KeepAlive> 的组合也没有稳定的行为保证。对于一个需要长期维护的标签页库来说,把核心加载态建立在实验性 API 上,风险过高,并不划算。
需要说明的是,这并不是说 Suspense 不好。它在擅长的场景中很好用,只是其抽象层级与我们的需求不匹配。
这六个原因可以归纳为两类:时机与边界不匹配,以及能力覆盖不足。
六、什么时候该用这套,什么时候该用 Suspense
把选择标准归纳成一张表:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 多标签页、每个标签独立缓存 | 本文方案(PageLoading + pending) | loading 需要按标签隔离 |
| 路由级懒加载、标签内前进/后退 | 本文方案 | 需要在导航开始前显示,且要处理失败回滚 |
| 页面挂载后的请求、提交、导出 | useTabLoading() | 命令式,能包住任意异步边界 |
| 需要在新页面就绪前保留旧页面 | 本文方案 | 导航守卫 + 生命周期撤销,避免回闪 |
| iframe 加载/超时 | 库内 iframe loading | 由 load 事件和超时驱动,与页面加载不是一回事 |
| 单页面应用的首屏/局部异步组件 | Suspense | fallback 就是该区域的占位,语义正好 |
组件内 async setup() 数据预取 | Suspense(可配合 onErrorCaptured) | 等待的是组件树中的异步依赖 |
| SSR / 流式渲染 / Nuxt 的异步页面 | Suspense / Nuxt 数据获取机制 | 服务端渲染需要把异步依赖交给框架调度 |
| 希望一个 fallback 覆盖整块内容 | Suspense | 祖先边界正好符合 |
| 需要对任意请求做命令式开关 | 本文方案 | Suspense 无法感知请求 |
可以用三个问题快速判断:
- loading 的所有者是“标签/路由”还是“组件树”? 前者用本文方案,后者考虑 Suspense。
- 加载是否发生在组件开始渲染之前? 是,用本文方案;Suspense 此时还没有边界。
- 加载结束后,旧内容是否需要保留到新内容就绪? 是,用本文方案;Suspense 的默认行为是替换。
如果你在一个普通单页应用里,只是想让某个异步组件加载时显示骨架屏,Suspense 是更轻的选择:
<Suspense>
<AsyncReportPanel />
<template #fallback>
<ReportSkeleton />
</template>
</Suspense>
但如果这个面板位于多标签页里,且每个标签都要独立缓存、独立加载,那么用 Suspense 包住整个 RouterView 很快会变成一场时序调试。
选型可以按下面的判断路径走:
七、落地时的几个配置
导航级加载默认开启。在内网等 chunk 加载很快的环境中可以关闭;关闭后不显示遮罩,也不会隐藏旧页面(下面假设 isIntranet 来自你的环境配置):
<template>
<vue-stack-tabs
iframe-path="/iframe"
:page-loading="!isIntranet"
/>
</template>
占位 UI 可以通过 #pageLoading 插槽替换,作用域参数是当前占位标签:
<template>
<vue-stack-tabs iframe-path="/iframe">
<template #pageLoading="{ tab }">
<div class="my-loading">正在打开「{{ tab.title }}」…</div>
</template>
</vue-stack-tabs>
</template>
页面内的请求仍然用 useTabLoading(),两者互不覆盖,可以同时存在。
八、总结
这次实现中,真正耗时的不是动画,而是把加载态拆解清楚:
- 导航级加载属于标签:用占位标签和
pending标记,在router.push之前就让页签出现;路由守卫仅在 chunk 未就绪时触发;撤销操作放在页面挂载后,而不是afterEach。 - 页面级加载属于页面:用事件总线按
tabId隔离,命令式 API 包裹任意异步边界;并发计数交由业务处理。 - iframe 加载属于资源:单独用
load事件和超时,不复用页面 loading。
至于 Suspense,它不是“更现代的 loading”,而是另一种抽象:等待组件树中的异步依赖。它适用于单页面、组件级、SSR 的占位场景;不适用于需要按标签隔离、需要在导航开始前显示、需要失败回滚的多标签页场景。
选型时不要问“哪个更先进”,而要问:这个加载态到底属于谁? 答案清楚了,方案也就清楚了。
文中实现来自开源库 vue-stack-tabs。如果你也在做多标签页,欢迎对照源码看
PageLoading.vue、StackTabs.vue的导航守卫和pageComponentFactory.tsx的页面包装层。