Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配

内部滚动恢复看起来只是两行代码:离开时记 scrollTop,回来时再写回去。

真正做进业务以后,它会很快变成一个生命周期问题。el-table 的滚动容器不在宿主元素上,<keep-alive> 会把组件树移出再插回,激活后接口刷新又可能把滚动位置打回 0;如果用户已经开始滚动,恢复逻辑还不能继续把位置顶回旧值。

这类问题最好不要直接沉淀成一段 directive 模板。更稳的拆法是先分清三件事:有没有开源库已经覆盖,Vue2 和 Vue3 的生命周期差异在哪里,以及能不能用一份核心逻辑承载共同部分。

现成库能帮到哪一步

Vue Router 的 scrollBehavior 解决的是路由导航时的页面滚动。它可以在前进 / 后退时返回 savedPosition,也可以滚到指定 el。这适合页面级滚动,不适合直接管理一个 KeepAlive 页面里 el-table 内部的 .el-table__body-wrapper.el-scrollbar__wrap

VueUse 的 useScroll 更接近底层能力。它负责把元素的 x / y 滚动位置变成响应式状态,也能直接设置位置。它适合做滚动状态读写,但不会替业务决定 cache key、激活时机、数据刷新回顶纠正和用户打断。

v-keep-scroll 是目前看起来最接近这个需求的 Vue3 小包,提供 directive 和 component,用来在 activated / deactivated 之间保留元素滚动位置。它的实现很轻,适合作为参考;但它用元素可见性推导激活状态,而可见性不等于 KeepAlive 生命周期。复杂表格、数据刷新回顶、多滚动容器和明确 cache key 的场景里,把它当黑盒依赖会让边界变得不够清楚。

vue-keep-scroll-position 是 Vue2 时代的老 directive 包,README 里的定位就是 Vue 2.0 keep-alive 组件滚动位置恢复。它能证明这个问题不是新问题,但包本身已经很久没有维护,不适合作为新项目的默认基础设施。

这里更合适的结论是把现成能力放到正确层级:VueUse 这类库可以负责事件清理和滚动状态读写;Vue Router 负责页面导航滚动;KeepAlive 内部容器恢复仍然需要一层贴着项目生命周期和 DOM 结构的薄封装。

真正难的是生命周期

滚动恢复最容易写错的是下面这些时序,scrollTop 字段本身反而简单:

  • 组件失活后,DOM 可能已经被移出文档,这时读到的滚动位置可能不可信。
  • 组件激活后,DOM 已经插回,但表格数据可能还在刷新,内部滚动容器可能短暂回到 0。
  • 用户返回页面后立刻滚动,恢复窗口必须停止,不能继续把用户滚动覆盖掉。
  • 同一个 KeepAlive 组件里可能有多个滚动容器,快照不能只按组件实例存一份。
  • el-table 宿主节点未必是实际滚动节点,Element UI 和 Element Plus 的内部结构也不完全一样。

这些时序和框架生命周期绑定得很深。只写一个「元素 mounted 时监听 scroll,unmounted 时清理」的 directive,很难覆盖 KeepAlive 重新激活这条线。

Vue2 和 Vue3 的差别

Vue2 的常见写法,是 directive 在 inserted 里从 vnode.context 拿到组件实例,再监听 hook:activatedhook:deactivated。这条路能跑,也解释了很多 Vue2 老项目里的实现为什么会把滚动记忆做成 directive:directive 能拿到元素,也能通过组件实例事件知道 KeepAlive 状态。

这条路的代价是,directive 和组件实例生命周期耦合在一起。inserted 还要做幂等,因为 keep-alive 重新激活时可能再次触发初始化路径;如果同一个组件里有多个 v-scroll-memory,只按 vm 存一份上下文就会互相覆盖。

Vue3 的官方路径更明确。Vue KeepAlive 文档里直接给了 onActivated()onDeactivated();同一页也说明这两个 hook 不只作用于被 <KeepAlive> 包住的根组件,也作用于缓存树里的后代组件。也就是说,Vue3 里业务组件本身就可以用公开生命周期表达「我被重新插回缓存树了」。

Vue3 directive 的定位反而更窄。自定义指令文档把 directive 放在低层 DOM 访问这一层,而状态逻辑复用更多交给 composable。Vue core 里也有一个还打开的需求:给 directive 增加 activated / deactivated hook。这个 issue 本身就说明,Vue3 directive 不是 KeepAlive 生命周期的理想 owner。

所以跨版本的共同点不应该放在 directive 里,而应该放在不依赖 Vue 的核心控制器里。Vue2 用 directive 适配它,Vue3 用 composable 适配它。

用一份核心减少理解成本

可复制源码包采用下面这个结构:

copy/src/scroll-memory/
  createScrollMemoryController.ts  # 不依赖 Vue 的核心控制器
  scroller-resolvers.ts            # Element UI / Element Plus / 自定义滚动容器解析
  useVue3ScrollMemory.ts           # Vue3 composable adapter
  vue2ScrollMemoryDirective.ts     # Vue2 directive adapter
  index.ts

核心层只回答滚动语义问题:当前滚动容器是谁、用哪个 key 读写快照、什么时候保存、什么时候恢复、恢复窗口什么时候结束。它不理解 Vue2,也不理解 Vue3。

// source/files/2026-08-14 - Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配/kits/vue-keepalive-scroll-memory/copy/src/scroll-memory/createScrollMemoryController.ts:97-180
const save = () => {
  if (!scroller || restoring || !isScrollerAttached(scroller)) return
  store.write(options.getKey(), normalizeScrollSnapshot(scroller))
}

const refreshScroller = () => {
  const nextScroller = options.resolveScroller()
  if (nextScroller === scroller) return scroller

  scroller?.removeEventListener('scroll', onScroll)
  scroller = nextScroller
  scroller?.addEventListener('scroll', onScroll, { passive: true })

  return scroller
}

const restore = () => {
  stopRestoreWindow()
  refreshScroller()

  const snapshot = readSnapshot()
  if (!scroller || !snapshot) return false

  applySnapshot()
  if (!snapshot.top && !snapshot.left) return true

  restoring = true
  restoreStartedAt = performance.now()
  bindUserInterrupt()

  restoreTimer = setInterval(() => {
    if (!restoring) return

    refreshScroller()
    const currentScroller = scroller
    const currentSnapshot = readSnapshot()

    if (!currentScroller || !currentSnapshot || performance.now() - restoreStartedAt > restoreWindowMs) {
      stopRestoreWindow()
      return
    }

    // 只纠正数据刷新把位置打回 0 的场景,不覆盖用户正常滚到的非 0 位置。
    if ((currentSnapshot.top > 0 && currentScroller.scrollTop === 0) || (currentSnapshot.left > 0 && currentScroller.scrollLeft === 0)) {
      applySnapshot()
    }
  }, restorePollMs)

  return true
}

这里有两个边界值得保留在核心层。

第一,refreshScroller() 会在滚动容器变化时解绑旧 listener,再绑定新 listener。KeepAlive 重新激活、表格重建或条件渲染替换 DOM 时,不能只更新 ctx.scroller 引用;旧节点上的监听也必须同步撤掉。

第二,恢复窗口只纠正回到 0 的回弹。用户如果已经滚到了另一个非 0 位置,核心层不再覆盖。用户触发 wheeltouchstartpointerdown 后,恢复窗口会停止并保存当前位置。

Vue3 适配层只接生命周期

Vue3 适配层应该很薄。它只负责等 DOM patch 完成,再在 onMounted / onActivated 里调用恢复;在 onDeactivatedonBeforeUnmount 里保存和清理。

// source/files/2026-08-14 - Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配/kits/vue-keepalive-scroll-memory/copy/src/scroll-memory/useVue3ScrollMemory.ts:18-48
export function useVue3ScrollMemory(options: Vue3ScrollMemoryOptions) {
  const controllerOptions: ScrollMemoryControllerOptions = {
    getKey: options.getKey,
    resolveScroller: options.getScroller,
    store: options.store ?? defaultScrollMemoryStore,
    restoreWindowMs: options.restoreWindowMs,
    restorePollMs: options.restorePollMs,
  }
  const controller = createScrollMemoryController(controllerOptions)

  const restoreAfterDomPatch = async () => {
    await nextTick()
    controller.restore()
  }

  onMounted(restoreAfterDomPatch)
  onUpdated(() => {
    controller.refreshScroller()
  })
  onActivated(restoreAfterDomPatch)
  onDeactivated(() => {
    controller.save()
    controller.stopRestoreWindow()
  })
  onBeforeUnmount(() => {
    controller.save()
    controller.dispose()
  })

  return controller
}

用法也应该把 key 和滚动容器显式交给调用方。getKey() 决定「记谁」,getScroller() 决定「滚谁」。

<!-- source/files/2026-08-14 - Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配/kits/vue-keepalive-scroll-memory/examples/vue3-element-plus-table.vue:1-14 -->
<script setup lang="ts">
import { shallowRef } from 'vue'
import { useRoute } from 'vue-router'
import { resolveElementTableScroller, useVue3ScrollMemory } from '../copy/src/scroll-memory'

const route = useRoute()
const tableHost = shallowRef<HTMLElement | null>(null)

useVue3ScrollMemory({
  getKey: () => `orders:${route.fullPath}`,
  getScroller: () => (tableHost.value ? resolveElementTableScroller(tableHost.value) : null),
  restoreWindowMs: 1500,
})
</script>

这比全局 directive 更啰嗦一点,但读者能直接看到滚动快照属于哪条业务路径,也能看到实际滚动容器来自哪里。后面出问题时,不需要猜 directive 在哪个内部实例上注册了什么监听。

Vue2 适配层只保留兼容入口

Vue2 里继续用 directive 是为了贴近老项目模板写法,不是因为 directive 更适合作为通用抽象。适配层仍然只做两件事:从 vnode.context 拿组件实例,把核心控制器接到 hook:activatedhook:deactivated

这里有一个很容易藏起来的坑:componentUpdated 里如果只写 context.binding = binding,但核心控制器的闭包仍然捕获第一次 insertedelbinding,那么 selector 变化或宿主 DOM 替换后,恢复逻辑还是会落到旧滚动容器上。适配层需要保存一份可变 state,让 getKey()resolveScroller() 每次都从最新 state 读取。

// source/files/2026-08-14 - Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配/kits/vue-keepalive-scroll-memory/copy/src/scroll-memory/vue2ScrollMemoryDirective.ts:143-172
const state = {
  hostEl: el,
  binding,
}
const controllerOptions: ScrollMemoryControllerOptions = {
  getKey: () => getCacheKey(vm, state.binding),
  resolveScroller: () => resolveDefaultScroller(state.hostEl, getSelector(state.binding)),
  store,
  restoreWindowMs: getRestoreWindowMs(state.binding, defaults),
  restorePollMs: getRestorePollMs(state.binding, defaults),
}
const controller = createScrollMemoryController(controllerOptions)
const onActivated = () => {
  controller.restore()
}
const onDeactivated = () => {
  controller.save()
  controller.stopRestoreWindow()
}

controller.refreshScroller()
vm.$on('hook:activated', onActivated)
vm.$on('hook:deactivated', onDeactivated)
contextMap.set(ownerKey, {
  ownerKey,
  state,
  controller,
  onActivated,
  onDeactivated,
})

componentUpdated 时再同步最新宿主元素和 binding,然后让核心层重新解析滚动容器。这样 selector 从 .first 变成 .second 时,旧滚动节点上的 listener 会被撤掉,新节点会被接管。

// source/files/2026-08-14 - Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配/kits/vue-keepalive-scroll-memory/copy/src/scroll-memory/vue2ScrollMemoryDirective.ts:175-186
const contextMap = contextsByVm.get(vm)
if (!contextMap) return

const context = findContext(contextMap, getOwnerKey(binding), el)
if (!context) return

updateContextState(contextMap, context, el, binding)
context.controller.refreshScroller()

Vue2 示例里,业务 key 仍然显式放在组件 computed 里:

<!-- source/files/2026-08-14 - Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配/kits/vue-keepalive-scroll-memory/examples/vue2-element-ui-table.vue:1-8 -->
<el-table
  v-scroll-memory="{ key: scrollMemoryKey }"
  height="100%"
  :data="rows"
>
  <el-table-column prop="id" label="订单 ID" />
  <el-table-column prop="status" label="状态" />
</el-table>

一个组件里有多个滚动容器时,要给不同 key。只按组件实例存一份快照,后面一定会遇到两个表格互相覆盖的问题。

滚动容器解析要独立

el-table 的一个细节是,滚动条通常不在 <el-table> 根节点上。Element UI 里常见的是 .el-table__body-wrapper,Element Plus 里常见的是 .el-scrollbar__wrap。这个解析逻辑应该独立出来,避免散在每个页面里。

// source/files/2026-08-14 - Vue KeepAlive 内部滚动恢复怎么封:库、生命周期和跨版本适配/kits/vue-keepalive-scroll-memory/copy/src/scroll-memory/scroller-resolvers.ts:11-37
export function resolveElementTableScroller(host: HTMLElement): ScrollMemoryScroller | null {
  const selector = [
    '.el-table__body-wrapper',
    '.el-scrollbar__wrap',
    '.el-table__body .el-scrollbar__wrap',
  ].join(',')

  return host.querySelector<HTMLElement>(selector) ?? null
}

export function resolveDefaultScroller(host: HTMLElement, selector?: string): ScrollMemoryScroller {
  const customScroller = resolveScrollerBySelector(host, selector)
  if (customScroller) return customScroller

  const tableScroller = resolveElementTableScroller(host)
  if (tableScroller) return tableScroller

  if (hasScrollableOverflow(host)) return host

  return host
}

这里仍然保留自定义 selector。项目里的滚动容器可能来自弹窗 body、虚拟列表 viewport 或第三方组件内部节点,统一封装不能把这些结构写死。

可复制源码包

完整源码包放在文章资源目录里。读者可以直接看 Code Lab,也可以按 FILES.json 逐文件复制。

请接入 Vue KeepAlive Scroll Memory 工具包。
工具包根路径:https://shengsheng.fun/files/vue-keepalive-scroll-memory-cross-version/kits/vue-keepalive-scroll-memory/
先读取 README.md、MANIFEST.json、FILES.json、CHANGELOG.md、AGENT_PROMPT.md;再按 FILES.json 读取 copy/src/scroll-memory/、copy/tests/ 和 examples/,根据当前项目是 Vue2 还是 Vue3 选择对应 adapter。
Vue KeepAlive 内部滚动恢复源码正在加载代码工作区...

这个源码包不是公共 npm 包契约。它更像一份可复制的工程笔记:核心逻辑可以直接迁移,适配层要按项目的 Vue 版本、表格组件、路由 key 和 store 约定调整。

测试守住反常时序

这类工具的测试不应该只测「调用 restore 后 scrollTop 变了」。真正容易回退的是反常时序:

  • 元素已经移出文档时,不应该保存读到的 0。
  • 滚动容器替换后,旧节点上的 listener 要撤掉,新节点要重新绑定。
  • 历史位置超过当前最大滚动距离时,要 clamp 到可达范围。
  • 恢复窗口里数据刷新把位置打回 0 时,可以纠正。
  • 用户主动滚动后,恢复窗口停止,不能再纠正。
  • Vue2 directive 的 binding 或宿主元素更新后,要重新解析滚动容器。

源码包里的核心测试覆盖了这些边界。迁移到业务项目后,还要补真实浏览器验证:从列表中部进入详情页,再返回;返回后接口刷新;用户返回后立刻滚动;同页多个表格互不影响。

总结

  • 开源库可以借力,但要分层使用。Vue Router 管页面导航滚动,VueUse 管滚动状态读写,KeepAlive 内部容器恢复仍需要项目自己的生命周期适配。
  • Vue2 可以用 directive 兼容旧项目模板,但 directive 必须幂等,并且不能只按组件实例存一份上下文。
  • Vue3 优先用 composable 承接 onActivated() / onDeactivated(),directive 只适合低层 DOM 操作。
  • 一份核心控制器加两个 adapter,比写一段“通吃 Vue2/Vue3 的 directive”更容易读、也更容易测试。
  • 滚动恢复、分页追加、虚拟列表尺寸补偿和聊天贴底是四种不同语义。它们可能都写 scrollTop,但不应该混在同一个 watcher 里。

滚动恢复之所以显得复杂,是因为它夹在浏览器 DOM、组件缓存、表格内部结构和用户输入之间。把生命周期适配和滚动控制器拆开以后,复杂度还在,但每一层只需要解释自己那一件事。