这是「多标签页工程化」系列的第 4 篇。
上一篇讲滚动位置为什么在切标签后丢失,这一篇讲它的邻居问题:点了「刷新」之后,页面怎么才能真的重来一遍,而不是把 keep-alive 的缓存搞乱。
在线演示:https://jktantan.github.io/vue-stack-tabs/demo.html 源码:https://github.com/jktantan/vue-stack-tabs
0. 先看最常见的写法,以及它埋下的坑
后台系统里几乎都有「刷新当前页」的按钮。在 keep-alive 场景下,最常见的实现是给组件换一个 key:
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="`${route.fullPath}-${refreshKey}`" />
</keep-alive>
</router-view>
const refreshKey = ref(0)
const refresh = () => refreshKey.value++
点一下,页面确实重建了,setup 重新执行,数据重新请求。看起来没问题。
但如果你打开 Vue DevTools,或者写个脚本数一下缓存实例,会发现另一件事:
每点一次刷新,keep-alive 里就多留一条缓存条目。
第一次刷新后是 2 条(旧 key + 新 key),第二次 3 条,第 N 次 N + 1 条。旧条目对应的实例没有被销毁,只是被移进了 keep-alive 的隐藏容器。页面越用越卡,最后只能整页 F5。
很多人以为是 keep-alive 的 bug,其实是 key 的语义没搞清楚。
用一张图概括这个「越刷越多」的过程:
#mermaid-svg-SdmZs1G4UjjVm6bb{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SdmZs1G4UjjVm6bb .error-icon{fill:#552222;}#mermaid-svg-SdmZs1G4UjjVm6bb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SdmZs1G4UjjVm6bb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SdmZs1G4UjjVm6bb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SdmZs1G4UjjVm6bb .marker.cross{stroke:#333333;}#mermaid-svg-SdmZs1G4UjjVm6bb svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SdmZs1G4UjjVm6bb p{margin:0;}#mermaid-svg-SdmZs1G4UjjVm6bb .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-SdmZs1G4UjjVm6bb .cluster-label text{fill:#333;}#mermaid-svg-SdmZs1G4UjjVm6bb .cluster-label span{color:#333;}#mermaid-svg-SdmZs1G4UjjVm6bb .cluster-label span p{background-color:transparent;}#mermaid-svg-SdmZs1G4UjjVm6bb .label text,#mermaid-svg-SdmZs1G4UjjVm6bb span{fill:#333;color:#333;}#mermaid-svg-SdmZs1G4UjjVm6bb .node rect,#mermaid-svg-SdmZs1G4UjjVm6bb .node circle,#mermaid-svg-SdmZs1G4UjjVm6bb .node ellipse,#mermaid-svg-SdmZs1G4UjjVm6bb .node polygon,#mermaid-svg-SdmZs1G4UjjVm6bb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SdmZs1G4UjjVm6bb .rough-node .label text,#mermaid-svg-SdmZs1G4UjjVm6bb .node .label text,#mermaid-svg-SdmZs1G4UjjVm6bb .image-shape .label,#mermaid-svg-SdmZs1G4UjjVm6bb .icon-shape .label{text-anchor:middle;}#mermaid-svg-SdmZs1G4UjjVm6bb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SdmZs1G4UjjVm6bb .rough-node .label,#mermaid-svg-SdmZs1G4UjjVm6bb .node .label,#mermaid-svg-SdmZs1G4UjjVm6bb .image-shape .label,#mermaid-svg-SdmZs1G4UjjVm6bb .icon-shape .label{text-align:center;}#mermaid-svg-SdmZs1G4UjjVm6bb .node.clickable{cursor:pointer;}#mermaid-svg-SdmZs1G4UjjVm6bb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SdmZs1G4UjjVm6bb .arrowheadPath{fill:#333333;}#mermaid-svg-SdmZs1G4UjjVm6bb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SdmZs1G4UjjVm6bb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SdmZs1G4UjjVm6bb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SdmZs1G4UjjVm6bb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SdmZs1G4UjjVm6bb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SdmZs1G4UjjVm6bb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SdmZs1G4UjjVm6bb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SdmZs1G4UjjVm6bb .cluster text{fill:#333;}#mermaid-svg-SdmZs1G4UjjVm6bb .cluster span{color:#333;}#mermaid-svg-SdmZs1G4UjjVm6bb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SdmZs1G4UjjVm6bb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SdmZs1G4UjjVm6bb rect.text{fill:none;stroke-width:0;}#mermaid-svg-SdmZs1G4UjjVm6bb .icon-shape,#mermaid-svg-SdmZs1G4UjjVm6bb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SdmZs1G4UjjVm6bb .icon-shape p,#mermaid-svg-SdmZs1G4UjjVm6bb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SdmZs1G4UjjVm6bb .icon-shape .label rect,#mermaid-svg-SdmZs1G4UjjVm6bb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SdmZs1G4UjjVm6bb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SdmZs1G4UjjVm6bb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SdmZs1G4UjjVm6bb :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
初始:key = path-0
点击刷新
key = path-1
keep-alive 新建缓存条目
旧条目仍在 cache 中
再刷新:key = path-2
缓存条目继续累加
1. 机制:keep-alive 到底按什么缓存
看 Vue 3 里 KeepAlive 的核心逻辑(@vue/runtime-core 3.5.x,有删减):
// KeepAlive 渲染函数
const comp = vnode.type
const name = getComponentName(comp)
const { include, exclude, max } = props
// ① include 不匹配:直接返回,不进入缓存
if (include && (!name || !matches(include, name))) {
vnode.shapeFlag &= –257
return rawVNode
}
// ② 缓存键:vnode.key 优先,没有 key 才退回组件本身
const key = vnode.key == null ? comp : vnode.key
const cachedVNode = cache.get(key)
pendingCacheKey = key
if (cachedVNode) {
// 命中:复用实例
vnode.el = cachedVNode.el
vnode.component = cachedVNode.component
vnode.shapeFlag |= 512 // COMPONENT_KEPT_ALIVE
keys.delete(key)
keys.add(key)
} else {
// 未命中:新建一条缓存
keys.add(key)
// 只有设置了 max 才会淘汰,而且是淘汰最久未使用的那一条
if (max && keys.size > parseInt(max, 10)) {
pruneCacheEntry(keys.values().next().value)
}
}
有三件事值得注意:
所以当你把 :key 从 route.fullPath-0 改成 route.fullPath-1:
- keep-alive 认为这是一个全新的组件,给它建了一条新缓存;
- 组件 name 没变,include 依然匹配,旧条目不会失效;
- 旧条目不会自动销毁,因为它还在 cache 里被引用着。
这就是「缓存越刷越多」的完整原因。
改 key 不是「刷新这个实例」,而是「再创建一个实例,并且不告诉 keep-alive 旧的可以扔了」。
把上面的逻辑画成判定流程:
#mermaid-svg-Mue4zoATlaqEWv9G{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Mue4zoATlaqEWv9G .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Mue4zoATlaqEWv9G .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Mue4zoATlaqEWv9G .error-icon{fill:#552222;}#mermaid-svg-Mue4zoATlaqEWv9G .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Mue4zoATlaqEWv9G .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Mue4zoATlaqEWv9G .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Mue4zoATlaqEWv9G .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Mue4zoATlaqEWv9G .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Mue4zoATlaqEWv9G .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Mue4zoATlaqEWv9G .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Mue4zoATlaqEWv9G .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Mue4zoATlaqEWv9G .marker.cross{stroke:#333333;}#mermaid-svg-Mue4zoATlaqEWv9G svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Mue4zoATlaqEWv9G p{margin:0;}#mermaid-svg-Mue4zoATlaqEWv9G .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Mue4zoATlaqEWv9G .cluster-label text{fill:#333;}#mermaid-svg-Mue4zoATlaqEWv9G .cluster-label span{color:#333;}#mermaid-svg-Mue4zoATlaqEWv9G .cluster-label span p{background-color:transparent;}#mermaid-svg-Mue4zoATlaqEWv9G .label text,#mermaid-svg-Mue4zoATlaqEWv9G span{fill:#333;color:#333;}#mermaid-svg-Mue4zoATlaqEWv9G .node rect,#mermaid-svg-Mue4zoATlaqEWv9G .node circle,#mermaid-svg-Mue4zoATlaqEWv9G .node ellipse,#mermaid-svg-Mue4zoATlaqEWv9G .node polygon,#mermaid-svg-Mue4zoATlaqEWv9G .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Mue4zoATlaqEWv9G .rough-node .label text,#mermaid-svg-Mue4zoATlaqEWv9G .node .label text,#mermaid-svg-Mue4zoATlaqEWv9G .image-shape .label,#mermaid-svg-Mue4zoATlaqEWv9G .icon-shape .label{text-anchor:middle;}#mermaid-svg-Mue4zoATlaqEWv9G .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Mue4zoATlaqEWv9G .rough-node .label,#mermaid-svg-Mue4zoATlaqEWv9G .node .label,#mermaid-svg-Mue4zoATlaqEWv9G .image-shape .label,#mermaid-svg-Mue4zoATlaqEWv9G .icon-shape .label{text-align:center;}#mermaid-svg-Mue4zoATlaqEWv9G .node.clickable{cursor:pointer;}#mermaid-svg-Mue4zoATlaqEWv9G .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Mue4zoATlaqEWv9G .arrowheadPath{fill:#333333;}#mermaid-svg-Mue4zoATlaqEWv9G .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Mue4zoATlaqEWv9G .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Mue4zoATlaqEWv9G .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Mue4zoATlaqEWv9G .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Mue4zoATlaqEWv9G .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Mue4zoATlaqEWv9G .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Mue4zoATlaqEWv9G .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Mue4zoATlaqEWv9G .cluster text{fill:#333;}#mermaid-svg-Mue4zoATlaqEWv9G .cluster span{color:#333;}#mermaid-svg-Mue4zoATlaqEWv9G div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Mue4zoATlaqEWv9G .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Mue4zoATlaqEWv9G rect.text{fill:none;stroke-width:0;}#mermaid-svg-Mue4zoATlaqEWv9G .icon-shape,#mermaid-svg-Mue4zoATlaqEWv9G .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Mue4zoATlaqEWv9G .icon-shape p,#mermaid-svg-Mue4zoATlaqEWv9G .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Mue4zoATlaqEWv9G .icon-shape .label rect,#mermaid-svg-Mue4zoATlaqEWv9G .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Mue4zoATlaqEWv9G .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Mue4zoATlaqEWv9G .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Mue4zoATlaqEWv9G :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
命中
未命中
是
否
渲染 KeepAlive 子节点
include 是否匹配组件 name
直接返回,不进入缓存
取缓存键:vnode.key 优先,否则用组件本身
cache 中是否命中
复用实例,更新 LRU 顺序
新建缓存条目
是否设置 max
超出上限时淘汰最久未使用条目
缓存无上限,持续增长
2. 换一个思路:外层 key 稳定,刷新下沉到内部
既然外层 key 不能动,那就把它固定住,把「重建」这件事挪到缓存组件内部。
这个库的结构是三层:
<keep-alive :include="caches">
└─ cacheComponent name = cacheName(UUID,每个页面唯一)
key = activeCacheKey ← 只有切换页面时才变,刷新时不动
└─ PageShell key = `${cacheName}-${renderedRefreshVersion}`
└─ 页面 vnode ← 刷新时重建的是这一层
对应模板:
<!– src/lib/components/StackKeepAlive/StackKeepAlive.vue(有删减) –>
<router-view v-slot="{ Component, route }">
<StackCacheRenderer
v-slot="{ wrappedComponent, activeCacheKey, component }"
:route="route"
:component="Component"
>
<transition :name="transitionName" appear mode="out-in">
<keep-alive :include="caches">
<component
:is="wrappedComponent"
:key="activeCacheKey"
:vnode="component"
:refresh-version="getPageRefreshVersion(activeCacheKey)"
:transition-name="transitionName"
@on-loaded="emitLoaded"
/>
</keep-alive>
</transition>
</StackCacheRenderer>
</router-view>
注意两个 key 的分工:
| activeCacheKey | 外层 component | 切换页面时 | keep-alive 的缓存身份 |
| renderedRefreshVersion | 内层 PageShell | 刷新时 | 触发页面重建 |
刷新时,外层 key 一个字都没变,keep-alive 的缓存条目数保持稳定;变的是缓存组件内部那个壳的 key。
这个壳不额外渲染 DOM,只是承接 key:
// src/lib/hooks/tabPanel/pageComponentFactory.tsx(有删减)
/**
* 页面壳组件:承接刷新时的 key 变化,让页面重建发生在缓存 wrapper 内部,
* 避免直接修改 router-view 页面 vnode 的 key 破坏 keep-alive 的实例复用。
* 不额外渲染元素,页面可以是单根,也可以是多根节点(Fragment)。
*/
const PageShell = defineComponent({
name: `${cacheName}-shell`,
props: {
vnode: {
type: Object as PropType<VNode | null>,
required: false,
default: null
}
},
setup(props) {
return () => props.vnode
}
})
渲染时把壳的 key 和刷新版本绑在一起:
const pageContent = lastCloned.value ? (
<PageShell key={`${cacheName}–${renderedRefreshVersion.value}`} vnode={lastCloned.value} />
) : null
结果就是:
- 缓存条目数 = 打开的页面数,与刷新次数无关;
- 刷新时重建的只是 cacheComponent 的孩子,旧实例会被 Vue 正常卸载;
- 页面可以是单根节点,也可以是多根(Fragment),因为壳不渲染真实元素。
三层结构对应的 key 分工,可以这样理解:
#mermaid-svg-FWzwAu01upWIRzpP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FWzwAu01upWIRzpP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FWzwAu01upWIRzpP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FWzwAu01upWIRzpP .error-icon{fill:#552222;}#mermaid-svg-FWzwAu01upWIRzpP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FWzwAu01upWIRzpP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FWzwAu01upWIRzpP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FWzwAu01upWIRzpP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FWzwAu01upWIRzpP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FWzwAu01upWIRzpP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FWzwAu01upWIRzpP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FWzwAu01upWIRzpP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FWzwAu01upWIRzpP .marker.cross{stroke:#333333;}#mermaid-svg-FWzwAu01upWIRzpP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FWzwAu01upWIRzpP p{margin:0;}#mermaid-svg-FWzwAu01upWIRzpP .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-FWzwAu01upWIRzpP .cluster-label text{fill:#333;}#mermaid-svg-FWzwAu01upWIRzpP .cluster-label span{color:#333;}#mermaid-svg-FWzwAu01upWIRzpP .cluster-label span p{background-color:transparent;}#mermaid-svg-FWzwAu01upWIRzpP .label text,#mermaid-svg-FWzwAu01upWIRzpP span{fill:#333;color:#333;}#mermaid-svg-FWzwAu01upWIRzpP .node rect,#mermaid-svg-FWzwAu01upWIRzpP .node circle,#mermaid-svg-FWzwAu01upWIRzpP .node ellipse,#mermaid-svg-FWzwAu01upWIRzpP .node polygon,#mermaid-svg-FWzwAu01upWIRzpP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FWzwAu01upWIRzpP .rough-node .label text,#mermaid-svg-FWzwAu01upWIRzpP .node .label text,#mermaid-svg-FWzwAu01upWIRzpP .image-shape .label,#mermaid-svg-FWzwAu01upWIRzpP .icon-shape .label{text-anchor:middle;}#mermaid-svg-FWzwAu01upWIRzpP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FWzwAu01upWIRzpP .rough-node .label,#mermaid-svg-FWzwAu01upWIRzpP .node .label,#mermaid-svg-FWzwAu01upWIRzpP .image-shape .label,#mermaid-svg-FWzwAu01upWIRzpP .icon-shape .label{text-align:center;}#mermaid-svg-FWzwAu01upWIRzpP .node.clickable{cursor:pointer;}#mermaid-svg-FWzwAu01upWIRzpP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FWzwAu01upWIRzpP .arrowheadPath{fill:#333333;}#mermaid-svg-FWzwAu01upWIRzpP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FWzwAu01upWIRzpP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FWzwAu01upWIRzpP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FWzwAu01upWIRzpP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FWzwAu01upWIRzpP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FWzwAu01upWIRzpP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FWzwAu01upWIRzpP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FWzwAu01upWIRzpP .cluster text{fill:#333;}#mermaid-svg-FWzwAu01upWIRzpP .cluster span{color:#333;}#mermaid-svg-FWzwAu01upWIRzpP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FWzwAu01upWIRzpP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FWzwAu01upWIRzpP rect.text{fill:none;stroke-width:0;}#mermaid-svg-FWzwAu01upWIRzpP .icon-shape,#mermaid-svg-FWzwAu01upWIRzpP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FWzwAu01upWIRzpP .icon-shape p,#mermaid-svg-FWzwAu01upWIRzpP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FWzwAu01upWIRzpP .icon-shape .label rect,#mermaid-svg-FWzwAu01upWIRzpP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FWzwAu01upWIRzpP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FWzwAu01upWIRzpP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FWzwAu01upWIRzpP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
切换页面时变化
刷新时变化
keep-alive :include=caches
cacheComponentkey = activeCacheKey
PageShellkey = cacheName + renderedRefreshVersion
页面 vnode
3. 两条刷新路径:活动页和非活动页
不是所有页面都值得「播动画再重建」。这个库把刷新分成两条路:
// src/lib/hooks/tabPanel/refresh.ts(有删减)
const refreshPage = (tab: ITabItem): void => {
const currentPage = getTopPage(tab.id)
if (!currentPage) return
// 路径一:当前可见的页面
if (tab.active) {
updatePage(tab.id, currentPage.id, {
refreshVersion: (currentPage.refreshVersion ?? 0) + 1
})
return
}
// 路径二:后台页面
const oldCacheName = currentPage.id
const nextPageId = createPageId()
updatePage(tab.id, oldCacheName, {
id: nextPageId,
refreshVersion: 0
})
evictPageCache(oldCacheName)
addCache(nextPageId)
}
3.1 活动页:id 不变,版本 +1
活动页是用户正在看的页面,有两个约束:
所以活动页只动 refreshVersion,页面 id 保持稳定。缓存身份不变,wrapper 不变,变的只是内部壳的 key。
3.2 非活动页:直接换 id
后台页面用户看不见,没必要播动画。直接生成一个新的 pageId,把旧缓存标记驱逐、新缓存加进去:
- 下次切到这个标签时,cacheComponent 是一个全新实例,setup 重新执行;
- 旧缓存从 caches 数组里移除,keep-alive 的 include 不再匹配,实例被真正销毁。
这里为什么不用「版本 +1」?
因为非活动页不在渲染树里。只改 refreshVersion 的话,页面组件不会被重建,刷新要等到下次激活才生效;而且在等待期间,缓存里还留着旧实例。换 id 是「立即作废」,语义更干净。
3.3 批量刷新:把 N 次更新合并成 2 次
refreshAllTabs 如果对每个标签都调用一次 updatePage + evictPageCache + addCache,会触发 N 次响应式更新。所以它先把变更收集起来,最后统一提交:
// src/lib/hooks/tabPanel/refresh.ts(有删减)
const refreshAllTabs = () => {
const cacheNamesToEvict: string[] = []
const cacheNamesToAdd: string[] = []
const pageUpdates: PageUpdate[] = []
for (const tab of getTabs()) {
if (!tab.refreshable) continue
if (tab.iframe) {
// iframe 走另一条路:postMessage 或 reload
continue
}
refreshPage(tab, cacheNamesToEvict, cacheNamesToAdd, pageUpdates)
}
// 所有页面补丁一次应用,最多重建一次 tabs 视图
updatePages(pageUpdates)
// 所有缓存增删一次提交,只 triggerRef 一次
replacePageCaches(cacheNamesToEvict, cacheNamesToAdd)
}
replacePageCaches 内部也是先算完再统一通知:
// src/lib/hooks/tabPanel/evict.ts(有删减)
const replacePageCaches = (
cacheNamesToEvict: Iterable<string>,
cacheNamesToAdd: Iterable<string>
): void => {
syncCacheSet()
const toEvict = new Set(cacheNamesToEvict)
let changed = false
if (toEvict.size > 0) {
for (let index = cacheList.length – 1; index >= 0; index—) {
const cacheName = cacheList[index]
if (cacheName && toEvict.has(cacheName)) {
cacheList.splice(index, 1)
changed = true
}
}
for (const cacheName of toEvict) {
cacheSet.delete(cacheName)
cacheIdsToEvict.delete(cacheName)
components.delete(cacheName)
}
}
for (const cacheName of cacheNamesToAdd) {
if (!cacheSet.has(cacheName)) {
cacheSet.add(cacheName)
cacheList.push(cacheName)
changed = true
}
}
// 所有变更完成后只通知一次
if (changed) triggerRef(caches)
}
「刷新全部」是最容易被写成 N² 的操作之一:N 个标签,每个都触发一次数组重建和一次 keep-alive 重新计算。合并之后,无论打开多少标签,响应式通知都只有一次。
两条刷新路径的分流逻辑:
#mermaid-svg-RsnkFoHMnaHbNcAE{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RsnkFoHMnaHbNcAE .error-icon{fill:#552222;}#mermaid-svg-RsnkFoHMnaHbNcAE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RsnkFoHMnaHbNcAE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RsnkFoHMnaHbNcAE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RsnkFoHMnaHbNcAE .marker.cross{stroke:#333333;}#mermaid-svg-RsnkFoHMnaHbNcAE svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RsnkFoHMnaHbNcAE p{margin:0;}#mermaid-svg-RsnkFoHMnaHbNcAE .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-RsnkFoHMnaHbNcAE .cluster-label text{fill:#333;}#mermaid-svg-RsnkFoHMnaHbNcAE .cluster-label span{color:#333;}#mermaid-svg-RsnkFoHMnaHbNcAE .cluster-label span p{background-color:transparent;}#mermaid-svg-RsnkFoHMnaHbNcAE .label text,#mermaid-svg-RsnkFoHMnaHbNcAE span{fill:#333;color:#333;}#mermaid-svg-RsnkFoHMnaHbNcAE .node rect,#mermaid-svg-RsnkFoHMnaHbNcAE .node circle,#mermaid-svg-RsnkFoHMnaHbNcAE .node ellipse,#mermaid-svg-RsnkFoHMnaHbNcAE .node polygon,#mermaid-svg-RsnkFoHMnaHbNcAE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-RsnkFoHMnaHbNcAE .rough-node .label text,#mermaid-svg-RsnkFoHMnaHbNcAE .node .label text,#mermaid-svg-RsnkFoHMnaHbNcAE .image-shape .label,#mermaid-svg-RsnkFoHMnaHbNcAE .icon-shape .label{text-anchor:middle;}#mermaid-svg-RsnkFoHMnaHbNcAE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-RsnkFoHMnaHbNcAE .rough-node .label,#mermaid-svg-RsnkFoHMnaHbNcAE .node .label,#mermaid-svg-RsnkFoHMnaHbNcAE .image-shape .label,#mermaid-svg-RsnkFoHMnaHbNcAE .icon-shape .label{text-align:center;}#mermaid-svg-RsnkFoHMnaHbNcAE .node.clickable{cursor:pointer;}#mermaid-svg-RsnkFoHMnaHbNcAE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-RsnkFoHMnaHbNcAE .arrowheadPath{fill:#333333;}#mermaid-svg-RsnkFoHMnaHbNcAE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-RsnkFoHMnaHbNcAE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-RsnkFoHMnaHbNcAE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RsnkFoHMnaHbNcAE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-RsnkFoHMnaHbNcAE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RsnkFoHMnaHbNcAE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-RsnkFoHMnaHbNcAE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-RsnkFoHMnaHbNcAE .cluster text{fill:#333;}#mermaid-svg-RsnkFoHMnaHbNcAE .cluster span{color:#333;}#mermaid-svg-RsnkFoHMnaHbNcAE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-RsnkFoHMnaHbNcAE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-RsnkFoHMnaHbNcAE rect.text{fill:none;stroke-width:0;}#mermaid-svg-RsnkFoHMnaHbNcAE .icon-shape,#mermaid-svg-RsnkFoHMnaHbNcAE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-RsnkFoHMnaHbNcAE .icon-shape p,#mermaid-svg-RsnkFoHMnaHbNcAE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-RsnkFoHMnaHbNcAE .icon-shape .label rect,#mermaid-svg-RsnkFoHMnaHbNcAE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-RsnkFoHMnaHbNcAE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-RsnkFoHMnaHbNcAE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-RsnkFoHMnaHbNcAE :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
否
是
是
否
refreshPage(tab)
能否取到栈顶页
直接返回
tab.active 是否为真
活动页:refreshVersion + 1
cacheName 不变,播刷新动画
非活动页:生成新 pageId
驱逐旧缓存 + 加入新缓存
下次激活时全新实例
4. 刷新动画:为什么 renderedRefreshVersion 要故意落后
到这里,刷新已经能工作了。但直接提升版本会有一个问题:页面「啪」地一下换掉,没有过渡。
所以包装组件里有两个版本号:
// 外部传入的目标版本
props.refreshVersion
// 实际渲染用的版本,故意短暂落后
const renderedRefreshVersion = ref(props.refreshVersion ?? 0)
刷新时的完整时序是:
① wrapper 加 leave-active / leave-to → 旧内容离场
② transitionend(或超时) → 离场结束
③ renderedRefreshVersion = 目标版本 → 页面重建
④ nextTick → 新内容挂载
⑤ wrapper 加 enter-from / enter-active → 新内容入场
⑥ 下一帧移除 enter-from → 过渡开始
⑦ transitionend(或超时) → 清理类名
对应源码:
// src/lib/hooks/tabPanel/pageComponentFactory.tsx(有删减)
const runRefreshAnimation = async (targetVersion: number): Promise<void> => {
const element = pageRoot.value
const name = props.transitionName
// 没有转场配置,或用户开启了「减少动态效果」:直接切换
if (!element || !name || prefersReducedMotion()) {
renderedRefreshVersion.value = targetVersion
return
}
appliedTransitionName = name
// —- 离场 —-
element.classList.add(`${name}-leave-active`)
const leaveDuration = getTransitionDurationMs(element)
if (leaveDuration <= 0) {
removeRefreshClasses()
renderedRefreshVersion.value = targetVersion
return
}
await nextFrame() // 让 leave-active 先生效
if (refreshDisposed) return
element.classList.add(`${name}-leave-to`)
await waitForRefreshEnd(leaveDuration)
removeRefreshClasses()
if (refreshDisposed) return
// —- 重建 —-
renderedRefreshVersion.value = targetVersion
await nextTick()
if (refreshDisposed) return
// —- 入场 —-
appliedTransitionName = name
element.classList.add(`${name}-enter-from`)
void element.offsetWidth // 强制 reflow,固定入场起点
element.classList.add(`${name}-enter-active`)
const enterDuration = getTransitionDurationMs(element)
if (enterDuration <= 0) {
removeRefreshClasses()
return
}
await nextFrame()
if (refreshDisposed) return
element.classList.remove(`${name}-enter-from`)
await waitForRefreshEnd(enterDuration)
removeRefreshClasses()
}
4.1 为什么动画主体是 wrapper,而不是页面 vnode
因为页面可能是多根节点:
<template>
<header>…</header>
<main>…</main>
<footer>…</footer>
</template>
这种页面没有单一根 DOM 元素,你没法给它加 leave-active 类。而 cacheComponent 渲染出来的 wrapper 是一个稳定的真实 div,无论页面有几个根节点都存在。
代价就是 renderedRefreshVersion 必须落后:先让 wrapper 离场,再换里面的内容,最后入场。如果一开始就把版本提上去,内容会在离场动画播放到一半时突然变化。
4.2 为什么复用 Vue Transition 的类名
${name}-leave-active、-leave-to、-enter-from、-enter-active 这套命名来自 Vue 的 <transition>。复用它有两个好处:
外面的 <transition :name="transitionName" mode="out-in"> 负责切页动画,内部的这套类名负责刷新动画。两者共用同一份 CSS,但不会互相干扰,因为刷新时页面并没有切换。
把刷新时序画成状态流转:
"页面 vnode"
"renderedRefreshVersion"
"wrapper 元素"
"props.refreshVersion"
"页面 vnode"
"renderedRefreshVersion"
"wrapper 元素"
"props.refreshVersion"
#mermaid-svg-KFblVuKAZpsA7VxP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KFblVuKAZpsA7VxP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KFblVuKAZpsA7VxP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KFblVuKAZpsA7VxP .error-icon{fill:#552222;}#mermaid-svg-KFblVuKAZpsA7VxP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KFblVuKAZpsA7VxP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KFblVuKAZpsA7VxP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KFblVuKAZpsA7VxP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KFblVuKAZpsA7VxP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KFblVuKAZpsA7VxP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KFblVuKAZpsA7VxP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KFblVuKAZpsA7VxP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KFblVuKAZpsA7VxP .marker.cross{stroke:#333333;}#mermaid-svg-KFblVuKAZpsA7VxP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KFblVuKAZpsA7VxP p{margin:0;}#mermaid-svg-KFblVuKAZpsA7VxP .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KFblVuKAZpsA7VxP text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-KFblVuKAZpsA7VxP .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KFblVuKAZpsA7VxP .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-KFblVuKAZpsA7VxP .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-KFblVuKAZpsA7VxP .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-KFblVuKAZpsA7VxP #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-KFblVuKAZpsA7VxP .sequenceNumber{fill:white;}#mermaid-svg-KFblVuKAZpsA7VxP #sequencenumber{fill:#333;}#mermaid-svg-KFblVuKAZpsA7VxP #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-KFblVuKAZpsA7VxP .messageText{fill:#333;stroke:none;}#mermaid-svg-KFblVuKAZpsA7VxP .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KFblVuKAZpsA7VxP .labelText,#mermaid-svg-KFblVuKAZpsA7VxP .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-KFblVuKAZpsA7VxP .loopText,#mermaid-svg-KFblVuKAZpsA7VxP .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-KFblVuKAZpsA7VxP .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-KFblVuKAZpsA7VxP .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-KFblVuKAZpsA7VxP .noteText,#mermaid-svg-KFblVuKAZpsA7VxP .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-KFblVuKAZpsA7VxP .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KFblVuKAZpsA7VxP .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KFblVuKAZpsA7VxP .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-KFblVuKAZpsA7VxP .actorPopupMenu{position:absolute;}#mermaid-svg-KFblVuKAZpsA7VxP .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-KFblVuKAZpsA7VxP .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-KFblVuKAZpsA7VxP .actor-man circle,#mermaid-svg-KFblVuKAZpsA7VxP line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-KFblVuKAZpsA7VxP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
版本提升,触发 watch
加 leave-active / leave-to
transitionend 或超时
更新为目标版本
卸载旧子树,挂载新子树
nextTick 后新内容就绪
加 enter-from / enter-active
下一帧移除 enter-from
transitionend 或超时,清理类名
5. 手写过渡的四个细节
如果你也打算自己控制 transition(而不是用 <transition> 组件),这四个点绕不开。
5.1 读真实的过渡时长
不能假设 0.3s。用户可能在 CSS 里写了 transition: opacity .2s, transform .35s .05s。所以要从 getComputedStyle 里读:
// src/lib/hooks/tabPanel/pageComponentFactory.tsx(有删减)
const parseTimeList = (value: string): number[] =>
value.split(',').map((item) => Number.parseFloat(item.trim()) || 0)
const getTransitionDurationMs = (element: HTMLElement): number => {
const style = window.getComputedStyle(element)
const durations = parseTimeList(style.transitionDuration || '0s')
const delays = parseTimeList(style.transitionDelay || '0s')
if (durations.length === 0) return 0
// 取最长的那条:duration + 对应的 delay
return (
Math.max(
…durations.map((duration, index) => duration + (delays[index % delays.length] ?? 0))
) * 1000
)
}
两个容易出错的地方:
- transitionDuration 是逗号分隔的列表,不是单个值;
- delay 的个数可能少于 duration,要按 index % delays.length 取模。
5.2 transitionend 不一定来,必须有超时兜底
transitionend 在几种情况下不会触发:
- 元素在过渡期间被隐藏或移除;
- 过渡属性没有实际变化(比如从 opacity: 1 到 opacity: 1);
- 某些浏览器在后台标签页里会节流。
所以监听事件的同时,必须挂一个定时器:
const waitForRefreshEnd = (duration: number): Promise<void> =>
new Promise((resolve) => {
const element = pageRoot.value
if (!element || duration <= 0) {
resolve()
return
}
let settled = false
const finish = () => {
if (settled) return
settled = true
cancelRefreshWait = null
clearRefreshTimer()
element.removeEventListener('transitionend', handleEnd)
resolve()
}
const handleEnd = (event: TransitionEvent) => {
// 只认自己的过渡,忽略子元素冒泡上来的 transitionend
if (event.target === element) finish()
}
cancelRefreshWait = finish
element.addEventListener('transitionend', handleEnd)
// 兜底:多等 80ms,避免事件丢失后永远卡住
refreshTimer = setTimeout(finish, duration + 80)
})
event.target === element 这一行很重要:transitionend 会冒泡,页面里任何一个子元素的过渡结束都可能被监听到。不过滤的话,一个按钮的 hover 动画就能提前结束整页刷新。
5.3 入场前必须强制 reflow
入场动画的套路是:
如果第 1 步和第 3 步在同一个微任务里完成,浏览器会把它们合并成一次样式计算,看不到过渡。所以要读一次布局属性强制 reflow:
element.classList.add(`${name}-enter-from`)
void element.offsetWidth // ← 强制浏览器先算一次布局
element.classList.add(`${name}-enter-active`)
这是手写过渡里最容易被忽略的一步。没有它,动画会「偶尔生效」,取决于浏览器这一帧有没有被其它操作打断。
5.4 尊重 prefers-reduced-motion
const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
命中时直接提升版本,不播动画。刷新是功能性操作,不是装饰,用户关闭动效时不应当被强制观看。
6. 连续点刷新:排队,而不是叠加
用户在 200ms 内点了三次刷新,会发生什么?
如果每次都启动一个异步动画流程,三个流程会同时操作同一个元素:A 在离场,B 把版本提上去,C 又把类名清掉。结果是页面闪一下、状态错乱。
这里用的是一个「覆盖式队列」:
let isFlushingRefresh = false
let queuedRefreshVersion: number | null = null
const flushRefreshQueue = async (): Promise<void> => {
if (isFlushingRefresh) return
isFlushingRefresh = true
try {
while (!refreshDisposed && queuedRefreshVersion !== null) {
const targetVersion = queuedRefreshVersion
queuedRefreshVersion = null
// 已经是目标版本,跳过
if (targetVersion === renderedRefreshVersion.value) continue
await runRefreshAnimation(targetVersion)
}
} finally {
isFlushingRefresh = false
}
}
watch(
() => props.refreshVersion ?? 0,
(nextVersion) => {
if (nextVersion === renderedRefreshVersion.value && queuedRefreshVersion === null) {
return
}
// 后到的版本覆盖先到的,中间态不需要播
queuedRefreshVersion = nextVersion
void flushRefreshQueue()
}
)
关键点:
- 只有一个流程在跑(isFlushingRefresh);
- 新版本直接覆盖旧版本(queuedRefreshVersion = nextVersion),而不是排队播三次动画。用户要的是「最终刷新到最新」,不是看三次转场;
- 循环条件里带着 refreshDisposed,组件卸载时立刻停止。
6.1 卸载时的清理
每一个 await 之后都要检查 refreshDisposed:
onBeforeUnmount(() => {
refreshDisposed = true
queuedRefreshVersion = null
cancelRefreshWait?.()
clearRefreshTimer()
removeRefreshClasses()
})
否则会出现这种情况:动画播到一半,用户关掉了标签,pageRoot 已经不存在,但回调还在继续执行 classList.add,直接抛错。
cancelRefreshWait 是 waitForRefreshEnd 暴露出来的「立即结束」函数,它会 resolve 掉挂起的 Promise,避免 flushRefreshQueue 永远卡在 await 上。
6.2 为什么要记 appliedTransitionName
let appliedTransitionName = ''
const removeRefreshClasses = () => {
const element = pageRoot.value
const name = appliedTransitionName
if (!element || !name) return
element.classList.remove(
`${name}-leave-active`,
`${name}-leave-to`,
`${name}-enter-active`,
`${name}-enter-from`
)
appliedTransitionName = ''
}
因为 transitionName 是 prop,运行时会变。如果清理时用「当前的 prop 值」拼类名,而动画是用「开始时的 prop 值」加的类,就会留下删不掉的类名,下次刷新会带着上一次的残留状态。
记住实际用过的名字,清理时才不会错位。
7. 版本号怎么通知到渲染层(这里踩过一个坑)
刷新逻辑改的是数据,真正要重建的是组件。这中间隔着一层响应式,很容易断。
7.1 一个看起来能工作的写法
最早的实现是:
// 反例(旧实现,有删减)
if (tab.active) {
const currentPage = getTopPage(tab.id)
currentPage.refreshVersion = (currentPage.refreshVersion ?? 0) + 1
return
}
然后用一个 computed 读取它:
const activePageRefreshVersion = computed(() => {
const activeTab = getActiveTab()
return activeTab ? (getTopPage(activeTab.id)?.refreshVersion ?? 0) : 0
})
模板里:
<component :key="`${activeCacheKey}-${activePageRefreshVersion}`" />
问题在于这个 computed 的依赖路径:它依赖 tabs.value(通过 getActiveTab() 遍历数组),但刷新改的是「栈顶页面对象上的一个字段」。对页面对象的 mutation 不会让 tabs.value 这个数组本身发生变化,computed 也就不会重新求值。
结果就是:数据改了,computed 不重新求值,key 不变,刷新不生效。
当时的修复是 triggerRef(tabs) 或 tabs.value = […tabs.value],强制通知。但这属于「用大锤敲」,会让所有依赖 tabs 的计算和渲染全部重算一遍。
7.2 现在的做法:让读取发生在渲染时
现在的状态是规范化的,页面存在 reactive(new Map()) 里:
// src/lib/hooks/tabPanel/tabStore.ts(有删减)
const pagesById = reactive(new Map<string, ITabPage>())
const getPageRefreshVersion = (pageId: string): number => pagesById.get(pageId)?.refreshVersion ?? 0
const applyPagePatch = (tabId, pageId, patch) => {
// …
Object.assign(page, patch) // page 是响应式代理,改字段会触发依赖
// …
}
模板里直接调用:
<component
:is="wrappedComponent"
:key="activeCacheKey"
:refresh-version="getPageRefreshVersion(activeCacheKey)"
/>
getPageRefreshVersion 在渲染函数执行期间读取 pagesById.get(pageId)?.refreshVersion,于是这次渲染就依赖了这个字段。字段一变,只有这个包装组件重新渲染,其它标签不受影响。
对比一下两种做法:
| computed + tabs.value | 整个 tabs 数组 | 所有标签栏/页面重新求值 |
| 渲染时读 pagesById.get(id).refreshVersion | 单个页面字段 | 只有目标包装组件重渲染 |
这也是一个通用的经验:如果某个状态只影响一个很小的渲染单元,就不要用一个聚合的 computed 去承接它。 让读取发生在真正需要它的地方,依赖链自然就细了。
8. 边界与代价
8.1 刷新的是「栈顶页」,不是「整个标签」
refreshTab(id) 只处理 getTopPage(tab.id)。标签内如果还有更深的历史页面,它们不刷新。
这是符合直觉的:用户看到的是栈顶页,刷新它就够了。如果返回上一页,那一页的状态还在,也算合理。
要「连历史一起刷新」,得遍历页面栈逐页处理。这个库没有做,因为语义上「刷新当前页」和「重置整个标签」是两件事。
8.2 refreshable: false 的标签会被跳过
标签可以在打开时声明 refreshable。跳过发生在遍历的第一步:
for (const tab of getTabs()) {
if (!tab.refreshable) continue
// …
}
右键菜单里的「刷新」也会据此置灰。
8.3 刷新 = 组件状态清零,不等于重新请求数据
PageShell 的 key 变化会让页面组件重新 setup、重新挂载。所以:
- ref / reactive 里的局部状态会重置;
- onMounted 会再次执行,里面的请求会重新发;
- 但如果你把数据放在 Pinia / 全局 store 里,那份数据不会因为刷新页面而重置。
这是「真刷新」和「重新取数」的区别。如果你的页面数据全在 store 里,点刷新可能看起来什么都没发生——需要在 onMounted 里显式重新拉取。
8.4 动画时长依赖 CSS
如果 transitionName 对应的 CSS 里没有定义 transition,getTransitionDurationMs 返回 0,代码会直接提升版本,不播动画。
这不是降级,而是正确行为:没有过渡定义就没有动画可播。反过来说,如果你发现刷新「没有动画」,先检查 CSS 里的类名拼写,而不是怀疑版本号。
8.5 刷新不会丢滚动位置,但可能被截断
刷新时 wrapper 没有重建,scrollTop 保持不变。但如果新内容比旧内容矮,浏览器会把滚动位置 clamp 到新的最大可滚动范围。
这个行为无法完全避免。真要精确恢复,得等新内容渲染完成后再写一次位置——这属于业务层的责任,库不替你做。
8.6 iframe 标签走的是另一条路
iframe 没有「组件实例」这个概念,刷新只有两种语义:
- postMessage:通知 iframe 内部自己刷新,不重建 DOM,动画正常;
- reload:改 src 上的刷新参数,强制浏览器重新加载。
这条链路和本文讲的 refreshVersion 完全独立,值得单独写一篇。
9. 一份检查清单
如果你正在实现 keep-alive 的刷新,可以按这几条对照:
10. 小结
「刷新 keep-alive 页面」这件事,难点不在动画,而在分清两个 key:
- 外层 key 是缓存身份,它决定 keep-alive 保留几个实例,不应该随刷新变化;
- 内层 key 是渲染身份,它决定页面组件什么时候重建,刷新改的是它。
把这两者混在一起,就是「改一个 key 刷新页面,结果缓存越堆越多」的根源。
剩下的部分都是工程量:活动页和非活动页走不同路径、动画要等真实过渡结束、连续点击要合并、卸载后要停止、响应式依赖要足够细。这些细节单看都不难,但少一个就会在某个边界条件下出问题。
在线演示: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
你们的「刷新当前页」是怎么实现的?是改 key、用 provide/inject 发事件,还是干脆 location.reload()?欢迎在评论区聊聊。

![[项目] --- 基于 Spring Boot + Redis的多设计模式抽奖系统-171主机测评](https://www.171host.com/wp-content/uploads/2026/11/20261031220757-6ae666bdaa3f5-220x150.png)

