欢迎光临
我们一直在努力

keep-alive 页面怎么“真刷新”?别改外层 key——一个 refreshVersion 的完整实现

这是「多标签页工程化」系列的第 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)
}
}

有三件事值得注意:

  • 缓存键是 vnode.key,没有 key 时才退回组件本身;
  • include 匹配的是组件 name,不是 key;
  • 不设 max 时缓存没有上限;设了 max,淘汰的也是「最久未使用」的条目,而不是「同一个页面的旧实例」。
  • 所以当你把 :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 的分工:

    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

    活动页是用户正在看的页面,有两个约束:

  • 不能换 cacheName。换了就意味着新缓存,滚动位置、wrapper、阴影监听全要重新来一遍,视觉上会闪。
  • 要能播动画。用户点了刷新,需要有反馈。
  • 所以活动页只动 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 里写的 CSS 不需要改,刷新动画直接沿用;
  • 语义一致,不用再发明一套 refreshing / refreshed 类。
  • 外面的 <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

    入场动画的套路是:

  • 加上 enter-from(初始态:透明、位移);
  • 加上 enter-active(过渡定义);
  • 下一帧移除 enter-from。
  • 如果第 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 的刷新,可以按这几条对照:

  • 不要改外层 keep-alive 子节点的 key——那会新增缓存条目,而不是替换;
  • 缓存身份(cacheName)在刷新时保持稳定,把重建下沉到缓存组件内部;
  • 用内层 key 承接刷新版本,让 Vue 卸载旧子树、挂载新子树;
  • 活动页只递增版本,非活动页直接换 id——可见性决定要不要播动画;
  • 批量操作先收集再提交,避免 N 次响应式通知;
  • 手写过渡要读 computedStyle、监听 transitionend、加超时兜底、强制 reflow;
  • 连续刷新用覆盖式队列,只保留最后一个目标版本;
  • 每个 await 后检查卸载标志,避免组件销毁后继续操作 DOM;
  • 让响应式依赖落在最小渲染单元上,不要用聚合 computed 承接局部状态。

  • 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()?欢迎在评论区聊聊。

    赞(0)
    未经允许不得转载:171主机测评 » keep-alive 页面怎么“真刷新”?别改外层 key——一个 refreshVersion 的完整实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址