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

这是「多标签页工程化」系列的第 4 篇。

上一篇讲滚动位置为什么在切标签后丢失,这一篇讲它的邻居问题:点了「刷新」之后,页面怎么才能真的重来一遍,而不是把 keep-alive 的缓存搞乱。

在线演示:jktantan.github.io/vue-stack-t...

源码:github.com/jktantan/vu...


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)
  }
}

有三件事值得注意:

  1. 缓存键是 vnode.key,没有 key 时才退回组件本身;
  2. include 匹配的是组件 name,不是 key;
  3. 不设 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

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

  1. 不能换 cacheName。换了就意味着新缓存,滚动位置、wrapper、阴影监听全要重新来一遍,视觉上会闪。
  2. 要能播动画。用户点了刷新,需要有反馈。

所以活动页只动 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>。复用它有两个好处:

  1. 用户在 transition 里写的 CSS 不需要改,刷新动画直接沿用;
  2. 语义一致,不用再发明一套 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

入场动画的套路是:

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

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

10. 小结

「刷新 keep-alive 页面」这件事,难点不在动画,而在分清两个 key:

  • 外层 key 是缓存身份,它决定 keep-alive 保留几个实例,不应该随刷新变化;
  • 内层 key 是渲染身份,它决定页面组件什么时候重建,刷新改的是它。

把这两者混在一起,就是「改一个 key 刷新页面,结果缓存越堆越多」的根源。

剩下的部分都是工程量:活动页和非活动页走不同路径、动画要等真实过渡结束、连续点击要合并、卸载后要停止、响应式依赖要足够细。这些细节单看都不难,但少一个就会在某个边界条件下出问题。

在线演示:jktantan.github.io/vue-stack-t...

文档:jktantan.github.io/vue-stack-t...

源码:github.com/jktantan/vu...

License: Apache-2.0

你们的「刷新当前页」是怎么实现的?是改 key、用 provide/inject 发事件,还是干脆 location.reload()?欢迎在评论区聊聊。

相关推荐
瑶池酒剑仙2 小时前
C++变量初始化五法完全指南:默认/拷贝/直接/列表/值初始化与窄化转换陷阱(青云之志)
c语言·开发语言·数据结构·c++·算法·visual studio
用户238003099452 小时前
从零开始实现扫雷游戏(C 语言控制台版)
算法
徐小夕2 小时前
JitWord 4.3 发布!Word 内嵌 Visio 图表、原生流程图一键无损解析
前端·vue.js·github
国科安芯2 小时前
星上计算的第一道防线:低轨遥感卫星载荷处理链中的AS32S601型抗辐射MCU—— 面向高分辨率成像与在轨智能处理的场景化综述
大数据·网络·人工智能·单片机·算法·低轨卫星·抗辐射
thusloop2 小时前
零门槛接入 GPT-6-astra 与 claude-opus-5-5,三大场景实战指南
python·gpt·算法·agent·claude·codex
瓦瓦gogo3 小时前
学回溯算法时,我先弄明白了怎么“退回来”
算法
Lzg_na3 小时前
堆和堆排序
算法
TK泰妞3 小时前
TikTok 带货视频如何做多语言本地化?从翻译脚本到重做场景的实用方法
算法·音视频
房开民3 小时前
DDPM 完整原理 + 全套公式串联
算法