Vue 多标签页的加载态设计:为什么我没有用 Suspense 实现 PageLoading

适用版本:vue-stack-tabs 3.2.x、Vue 3.5、Vue Router 4。文中的实现来自库源码,示例做了简化。

一、问题不是"加个 loading",而是"loading 属于谁"

后台管理系统一旦做成多标签页,加载态就会变成一个比想象中复杂的问题。最初我们只有一个全局 loading 遮罩:axios 拦截器在请求开始时打开,请求结束时关闭。单页面应用里这没问题,但在多标签页里它立刻暴露出三个问题:

  1. 遮罩没有归属。A 标签里的请求会让 B、C 标签一起转圈。用户切到别的标签,发现整个内容区被遮罩盖住,但那个标签根本没有在加载。
  2. 请求并发时状态互相覆盖。两个请求同时进行时,先结束的那个会把遮罩关闭,而后结束的请求仍在进行,用户却以为已经完成。
  3. 覆盖不了路由 chunk 的加载 。页面组件通过 () => import() 懒加载,点击菜单后 chunk 仍在下载,页面组件尚未创建,页面内编写的 loading 自然也不会出现。慢网环境下这段"空白期"最长,用户最容易重复点击。

另一种常见做法是每个页面自己写 v-loading。它解决了归属问题,但带来新的重复劳动:每个页面都要维护一份 loading 状态,页面被 keep-alive 缓存后还要考虑切走再切回时状态是否残留;更关键的是,它同样覆盖不了"页面还没挂载"的导航阶段。

所以问题不是"怎么画一个转圈动画",而是:这个加载态到底属于谁?

在 vue-stack-tabs 里,我们把加载态拆成了三类,因为它们的所有者不同:

类型 触发时机 所有者 生命周期
导航级加载 路由组件 chunk 未就绪、标签内前进/后退、打开新标签 标签(tab) 从导航开始到目标页面挂载
页面级异步操作 页面挂载后的请求、提交、导入 页面(page) 由业务代码控制
资源级加载 iframe、图片、局部异步组件 资源本身 由资源事件控制

PageLoading 主要解决前两类。第三类里的 iframe 在库里是另一套实现(load 事件 + 超时),不在本文展开。

三类加载态的所有者关系可以先用一张图概括:

二、第一层:每个缓存页一个遮罩,按 tabId 隔离

先解决"归属"问题。我们没有使用全局单例,而是让每个缓存页的包装组件渲染自己的 PageLoading。在页面工厂里,渲染结构大致是这样:

typescript 复制代码
// pageComponentFactory.tsx(简化)
return (
  <div ref={pageRoot} class="stack-tab__cache-page-wrapper" id={`W-${cacheName}`}>
    {pageContent}
    {/* PageLoading 必须在滚动容器外层:它要覆盖整个内容区 */}
    <PageLoading tabId={tabInfo.id!} />
  </div>
)

组件本身只负责三件事:订阅事件、按 tabId 过滤、显示遮罩。

html 复制代码
<!-- 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 让业务去修改,而是提供了一对命令式方法:

typescript 复制代码
const { openTabLoading, closeTabLoading } = useTabLoading()

const reload = async () => {
  openTabLoading()
  try {
    await fetch('/api/report')
  } finally {
    closeTabLoading()
  }
}

原因是页面里的异步操作形态较多:请求、表单提交、文件导入、导出、轮询。它们不一定有统一的"pending 状态",也不一定走同一个请求库。命令式 API 让业务在任意异步边界前后包裹一下即可,不需要把状态提升到某个 store。

但这里有一个必须说清楚的边界:库本身没有做引用计数 。如果页面里同时有两个请求,各自 open / close,先结束的那个会把遮罩关掉。业务如果确实存在并发,应该自己封装一层计数:

typescript 复制代码
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:

typescript 复制代码
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 的条件必须足够克制,否则会出现"切回缓存页也转圈"的假加载现象:

typescript 复制代码
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:

typescript 复制代码
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:

scss 复制代码
.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 无法感知请求

可以用三个问题快速判断:

  1. loading 的所有者是"标签/路由"还是"组件树"? 前者用本文方案,后者考虑 Suspense。
  2. 加载是否发生在组件开始渲染之前? 是,用本文方案;Suspense 此时还没有边界。
  3. 加载结束后,旧内容是否需要保留到新内容就绪? 是,用本文方案;Suspense 的默认行为是替换。

如果你在一个普通单页应用里,只是想让某个异步组件加载时显示骨架屏,Suspense 是更轻的选择:

html 复制代码
<Suspense>
  <AsyncReportPanel />
  <template #fallback>
    <ReportSkeleton />
  </template>
</Suspense>

但如果这个面板位于多标签页里,且每个标签都要独立缓存、独立加载,那么用 Suspense 包住整个 RouterView 很快会变成一场时序调试。

选型可以按下面的判断路径走:

七、落地时的几个配置

导航级加载默认开启。在内网等 chunk 加载很快的环境中可以关闭;关闭后不显示遮罩,也不会隐藏旧页面(下面假设 isIntranet 来自你的环境配置):

html 复制代码
<template>
  <vue-stack-tabs
    iframe-path="/iframe"
    :page-loading="!isIntranet"
  />
</template>

占位 UI 可以通过 #pageLoading 插槽替换,作用域参数是当前占位标签:

html 复制代码
<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 的页面包装层。

相关推荐
yilaisai1 小时前
前端开发遇到的一些细节问题整理
vue.js
JJJennie7772 小时前
MAI Gateway私有化部署全攻略:大模型网关如何接入?附架构图
算法·gateway·ai网关·魔芋ai
東隅已逝,桑榆非晚2 小时前
算法3.双指针法-快乐数
c++·笔记·学习·算法
专注仿真2 小时前
卷积神经网络(CNN)算法详解
人工智能·python·算法·代码·大模型训练
FII工业富联科技服务2 小时前
多智能体如何进入制造现场?从业务协同到数字孪生验证
大数据·人工智能·算法·机器人·制造·数字孪生
≮傷£≯√2 小时前
httpserver_QNetWorkClient
算法
Evand J2 小时前
【PMSM例程11】永磁同步电机(PMSM)基于UKF的转速与转子位置估计。附下载链接
算法·matlab·卡尔曼滤波·永磁同步电机·ukf·pmsm·位置估计
华玥作者2 小时前
Vue 3 + TypeScript + uni-app 组件库推荐:hy-app(华玥组件库)
vue.js·typescript·uni-app
货拉拉技术2 小时前
记忆即智能:重构对话式AI Agent认知基座的四层渐进式记忆与自进化系统
人工智能·算法