这是「多标签页工程化」系列的第 4 篇。
上一篇讲滚动位置为什么在切标签后丢失,这一篇讲它的邻居问题:点了「刷新」之后,页面怎么才能真的重来一遍,而不是把 keep-alive 的缓存搞乱。
0. 先看最常见的写法,以及它埋下的坑
后台系统里几乎都有「刷新当前页」的按钮。在 keep-alive 场景下,最常见的实现是给组件换一个 key:
html
<router-view v-slot="{ Component, route }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="`${route.fullPath}-${refreshKey}`" />
</keep-alive>
</router-view>
ts
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 的语义。
用一张图概括这个「越刷越多」的过程: 
1. 机制:keep-alive 到底按什么缓存
看 Vue 3 里 KeepAlive 的核心逻辑(@vue/runtime-core 3.5.x,有删减):
js
// 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 旧的可以扔了」。
把上面的逻辑画成判定流程:

2. 换一个思路:外层 key 稳定,刷新下沉到内部
既然外层 key 不能动,那就把它固定住,把「重建」这件事挪到缓存组件内部。
这个库的结构是三层:
ini
<keep-alive :include="caches">
└─ cacheComponent name = cacheName(UUID,每个页面唯一)
key = activeCacheKey ← 只有切换页面时才变,刷新时不动
└─ PageShell key = `${cacheName}-${renderedRefreshVersion}`
└─ 页面 vnode ← 刷新时重建的是这一层
对应模板:
html
<!-- 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:
ts
// 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 和刷新版本绑在一起:
ts
const pageContent = lastCloned.value ? (
<PageShell key={`${cacheName}-${renderedRefreshVersion.value}`} vnode={lastCloned.value} />
) : null
结果就是:
- 缓存条目数 = 打开的页面数,与刷新次数无关;
- 刷新时重建的只是
cacheComponent的孩子,旧实例会被 Vue 正常卸载; - 页面可以是单根节点,也可以是多根(Fragment),因为壳不渲染真实元素。
三层结构对应的 key 分工,可以这样理解:

3. 两条刷新路径:活动页和非活动页
不是所有页面都值得「播动画再重建」。这个库把刷新分成两条路:
ts
// 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 次响应式更新。所以它先把变更收集起来,最后统一提交:
ts
// 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 内部也是先算完再统一通知:
ts
// 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 重新计算。合并之后,无论打开多少标签,响应式通知都只有一次。
两条刷新路径的分流逻辑:

4. 刷新动画:为什么 renderedRefreshVersion 要故意落后
到这里,刷新已经能工作了。但直接提升版本会有一个问题:页面「啪」地一下换掉,没有过渡。
所以包装组件里有两个版本号:
ts
// 外部传入的目标版本
props.refreshVersion
// 实际渲染用的版本,故意短暂落后
const renderedRefreshVersion = ref(props.refreshVersion ?? 0)
刷新时的完整时序是:
css
① wrapper 加 leave-active / leave-to → 旧内容离场
② transitionend(或超时) → 离场结束
③ renderedRefreshVersion = 目标版本 → 页面重建
④ nextTick → 新内容挂载
⑤ wrapper 加 enter-from / enter-active → 新内容入场
⑥ 下一帧移除 enter-from → 过渡开始
⑦ transitionend(或超时) → 清理类名
对应源码:
ts
// 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
因为页面可能是多根节点:
html
<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,但不会互相干扰,因为刷新时页面并没有切换。
把刷新时序画成状态流转:

5. 手写过渡的四个细节
如果你也打算自己控制 transition(而不是用 <transition> 组件),这四个点绕不开。
5.1 读真实的过渡时长
不能假设 0.3s。用户可能在 CSS 里写了 transition: opacity .2s, transform .35s .05s。所以要从 getComputedStyle 里读:
ts
// 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); - 某些浏览器在后台标签页里会节流。
所以监听事件的同时,必须挂一个定时器:
ts
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:
ts
element.classList.add(`${name}-enter-from`)
void element.offsetWidth // ← 强制浏览器先算一次布局
element.classList.add(`${name}-enter-active`)
这是手写过渡里最容易被忽略的一步。没有它,动画会「偶尔生效」,取决于浏览器这一帧有没有被其它操作打断。
5.4 尊重 prefers-reduced-motion
ts
const prefersReducedMotion = (): boolean =>
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches
命中时直接提升版本,不播动画。刷新是功能性操作,不是装饰,用户关闭动效时不应当被强制观看。
6. 连续点刷新:排队,而不是叠加
用户在 200 ms 内点了三次刷新,会发生什么?
如果每次都启动一个异步动画流程,三个流程会同时操作同一个元素:A 在离场,B 把版本提上去,C 又把类名清掉。结果是页面闪一下、状态错乱。
这里用的是一个「覆盖式队列」:
ts
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:
ts
onBeforeUnmount(() => {
refreshDisposed = true
queuedRefreshVersion = null
cancelRefreshWait?.()
clearRefreshTimer()
removeRefreshClasses()
})
否则会出现这种情况:动画播到一半,用户关掉了标签,pageRoot 已经不存在,但回调还在继续执行 classList.add,直接抛错。
cancelRefreshWait 是 waitForRefreshEnd 暴露出来的「立即结束」函数,它会 resolve 掉挂起的 Promise,避免 flushRefreshQueue 永远卡在 await 上。
6.2 为什么要记 appliedTransitionName
ts
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 一个看起来能工作的写法
最早的实现是:
ts
// 反例(旧实现,有删减)
if (tab.active) {
const currentPage = getTopPage(tab.id)
currentPage.refreshVersion = (currentPage.refreshVersion ?? 0) + 1
return
}
然后用一个 computed 读取它:
ts
const activePageRefreshVersion = computed(() => {
const activeTab = getActiveTab()
return activeTab ? (getTopPage(activeTab.id)?.refreshVersion ?? 0) : 0
})
模板里:
html
<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()) 里:
ts
// 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 是响应式代理,改字段会触发依赖
// ...
}
模板里直接调用:
html
<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。跳过发生在遍历的第一步:
ts
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 刷新页面,结果缓存越堆越多」的根源。
剩下的部分都是工程量:活动页和非活动页走不同路径、动画要等真实过渡结束、连续点击要合并、卸载后要停止、响应式依赖要足够细。这些细节单看都不难,但少一个就会在某个边界条件下出问题。
在线演示:jktantan.github.io/vue-stack-t...
文档:jktantan.github.io/vue-stack-t...
License: Apache-2.0
你们的「刷新当前页」是怎么实现的?是改 key、用 provide/inject 发事件,还是干脆 location.reload()?欢迎在评论区聊聊。