Vue keep-alive 切标签后滚动位置回顶?因为 onDeactivated 里读到的 scrollTop 是 0

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

前两篇分别讲了「页面实例级缓存」和「状态归属」,这一篇讨论一个更具体、也更容易被低估的问题:为什么你在 onDeactivated 里保存滚动位置,切回来永远是顶部。

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

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


0. 先复现:一个看起来完全正确的方案

多标签页中最常见的需求是:用户在一个长列表里滚动到第 300 行,切换到另一个标签,再切回来时,应该仍停留在第 300 行。

大多数人的第一反应是 onDeactivated 保存、onActivated 恢复:

vue 复制代码
<!-- TabPage.vue -->
<script setup lang="ts">
import { onActivated, onDeactivated, ref } from 'vue'

const scroller = ref<HTMLElement | null>(null)
let savedTop = 0

onDeactivated(() => {
  savedTop = scroller.value?.scrollTop ?? 0
  console.log('[deactivated] scrollTop =', savedTop)
})

onActivated(() => {
  console.log('[activated] restore to', savedTop)
  scroller.value?.scrollTo({ top: savedTop })
})
</script>

<template>
  <div ref="scroller" class="page-scroller">
    <!-- 一个很长的列表 -->
  </div>
</template>

外层是标准的 keep-alive:

vue 复制代码
<template>
  <nav>
    <button v-for="tab in tabs" :key="tab.id" @click="active = tab.id">
      {{ tab.title }}
    </button>
  </nav>

  <keep-alive>
    <component :is="activeComponent" :key="active" />
  </keep-alive>
</template>

逻辑没有问题,时机看起来也正确。但切换一次标签后,控制台会稳定打印:

ini 复制代码
[deactivated] scrollTop = 0
[activated] restore to 0

不是偶尔为 0,是永远为 0。

如果你在 onDeactivated 里再打印一下元素本身:

ts 复制代码
console.log(scroller.value, scroller.value?.isConnected)

会看到一个更反常的现象:元素仍然存在,但 isConnected 为 false------它已经不在文档中了。


1. 根因:deactivated 触发时,DOM 已经被移走了

这不是你用错了 API,而是 Vue 的调用顺序本就如此。

翻一下 @vue/runtime-core 里 KeepAlive 的实现(3.5.x,有删减):

js 复制代码
// packages/runtime-core/src/components/KeepAlive.ts
const storageContainer = createElement('div') // ← 注意:从未 append 到 document

sharedContext.deactivate = (vnode) => {
  const instance = vnode.component

  // ① 先把整棵 DOM 移进这个游离的 div
  move(vnode, storageContainer, null, MoveType.LEAVE, parentSuspense)

  // ② 再在 post-render 阶段调用 deactivated
  queuePostRenderEffect(() => {
    if (instance.da) invokeArrayFns(instance.da)
    instance.isDeactivated = true
  }, parentSuspense)
}

顺序是关键:

  1. move() 是同步执行的,DOM 立刻离开文档;
  2. onDeactivated 是在 queuePostRenderEffect 里调用的,晚于 move()。

所以当你的钩子执行时,元素已经是一个「脱离文档、没有布局」的节点。

浏览器不会为一个脱离文档的元素维护滚动状态。此时读 scrollTop,得到的就是 0。这不是兼容性问题,也不是某个浏览器的 bug,而是「元素没有布局,就没有滚动位置」这一事实的直接结果。

顺带一提,capture 时也一样:

js 复制代码
sharedContext.activate = (vnode, container, anchor, namespace, optimized) => {
  const instance = vnode.component
  // ① 先把 DOM 放回原位置
  move(vnode, container, anchor, MoveType.ENTER, parentSuspense)
  // ② 再调用 activated
  queuePostRenderEffect(() => {
    instance.isDeactivated = false
    if (instance.a) invokeArrayFns(instance.a)
  }, parentSuspense)
}

activate 的顺序相反 :先插回 DOM,再调用 onActivated。这也解释了为什么「恢复」放在 onActivated 里通常能生效------写入 scrollTop 时,元素已经具备滚动条件。

于是结论很清楚:

停用时读 DOM 拿不到真实位置;停用时唯一能拿到的,是上一次滚动过程中记下来的值。


把上面的调用顺序画成时序图,问题就很直观了:

2. 正确的保存时机:从「停用时读」改成「滚动时记」

既然停用时读取已经太迟,就不应等到停用阶段才读取。应将记录动作提前到每一次滚动:

ts 复制代码
// src/lib/hooks/tabPanel/scroll.ts(有删减)
/**
 * 记录主滚动容器的实时位置(由页面滚动事件调用)。
 *
 * keep-alive 停用时会先把页面 DOM 移入隐藏容器,原生 scrollTop 随之归零;
 * 因此不能只在 onDeactivated 时读取 DOM,必须在滚动过程中持续记录。
 */
const recordMainScroller = (pageCacheId: string): void => {
  const positions = scrollPositionsByPageId.get(pageCacheId)
  if (!positions) return

  // 自定义滚动容器:通过适配器读取
  if (adapterGetters.has(pageCacheId)) {
    const position = readAdapterScrollPosition(getPageAdapter(pageCacheId))
    if (position) positions.set(MAIN_SCROLLER_KEY, position)
    return
  }

  // 原生滚动:直接读 wrapper
  const wrapper = document.getElementById(`W-${pageCacheId}`)
  if (!wrapper) return // ← DOM 已被移走,跳过,绝不写入 0
  positions.set(`#W-${pageCacheId}`, {
    top: wrapper.scrollTop,
    left: wrapper.scrollLeft
  })
}

注意 if (!wrapper) return 这一行。它看似只是防御性代码,实则是第一道保险 :DOM 被移走之后,document.getElementById 找不到该元素,函数直接返回,不会把 0 写入缓存。

滚动监听绑定在真实的滚动元素上,而不是页面组件上:

ts 复制代码
// src/lib/hooks/tabPanel/pageComponentFactory.tsx(有删减)
const subscribeAdapterScroll = (listener: () => void): (() => void) => {
  const adapter = getScrollAdapter()

  const handleScroll = () => {
    // 关键:滚动过程中持续记录,而不是等 onDeactivated
    recordMainScroller(cacheName)
    listener()
  }

  // 命令式滚动组件优先用自己的订阅
  if (adapter?.onScroll) return adapter.onScroll(handleScroll)

  const element = adapter?.scrollElement
  if (!element) return () => undefined

  // passive:不阻塞滚动
  element.addEventListener('scroll', handleScroll, { passive: true })
  return () => element.removeEventListener('scroll', handleScroll)
}

至此,保存时机已经正确:最后一次真实滚动发生时,位置已经写入 Map。停用时的读取仅作为兜底,用于同步页面内部的其他滚动容器。


整个保存链路可以概括为下面这张流程图:

3. 假 0 与真 0:一个必须想清楚的判据

但只做到「滚动时记录」还不够,因为还需要回答一个问题:

用户真的 把页面滚回顶部了,和 DOM 被移走导致的假 0,怎么区分?

如果停用时读到 0 就直接覆盖,用户的真实回顶操作会被吞掉------下次切回来又会跳回原来的位置。

这个库用的判据是:

ts 复制代码
// src/lib/hooks/tabPanel/scroll.ts(有删减)
const isZeroPosition = (position: ScrollPosition | undefined): boolean =>
  !position || (position.top === 0 && position.left === 0)

// 遍历已注册的选择器,逐项同步
for (const selector of positions.keys()) {
  // auto key 与主滚动位置已单独处理
  if (selector.startsWith(AUTO_SCROLLER_PREFIX) || selector === MAIN_SCROLLER_KEY) continue

  const element = resolveScrollElement(selector)
  const next = { top: element?.scrollTop ?? 0, left: element?.scrollLeft ?? 0 }
  const previous = positions.get(selector)

  // 元素不可用,或「读到 0 但之前记录过非 0」时,保留记录值
  if (!element || (isZeroPosition(next) && !isZeroPosition(previous))) {
    if (previous) positions.set(selector, previous)
    continue
  }

  positions.set(selector, next)
}

拆开来看,只有两种情况会保留旧值:

  1. 元素根本读不到 (!element)------DOM 已被移走;
  2. 读到 0,但上一次记录是非 0 (isZeroPosition(next) && !isZeroPosition(previous))------这是典型的「假 0」。

那么「用户真的滚回顶部」为什么不会被误判?

因为用户滚动时,recordMainScroller 已经把 previous 更新成了 0。等 saveScroller 执行时,isZeroPosition(previous) 为 true,第二个条件不成立,于是正常覆盖成 0。

判据的本质不是「0 可不可信」,而是「这个 0 是不是滚动过程中产生的」。

这也解释了为什么必须在滚动过程中记录:只有滚动过程才能区分这两个 0。

对应的测试写得很直白:

ts 复制代码
it('keep-alive 停用导致 scrollTop 归零时保留滚动过程中记录的位置', () => {
  // ...
  root.scrollTop = 150
  root.scrollLeft = 15
  // 滚动事件触发时持续记录
  scroll.recordMainScroller(pageId)

  // 模拟 keep-alive 把页面移入隐藏容器:DOM 仍在,但原生位置被重置
  root.scrollTop = 0
  root.scrollLeft = 0
  scroll.saveScroller(pageId)

  scroll.restoreScroller(pageId)
  expect(root.scrollTop).toBe(150)
  expect(root.scrollLeft).toBe(15)
})

it('用户滚动回顶部后保存为 0,不会恢复旧位置', () => {
  // ...
  root.scrollTop = 150
  scroll.recordMainScroller(pageId)
  root.scrollTop = 0
  scroll.recordMainScroller(pageId) // 真实的回顶,记录为 0
  scroll.saveScroller(pageId)

  scroll.restoreScroller(pageId)
  expect(root.scrollTop).toBe(0)
})

两个用例的差别只在「0 是在滚动过程中写的,还是停用时读出来的」。


把判据画成决策树,真假 0 的分支一目了然:

4. 一次切 tab 的完整时序

把上面的片段拼起来,一次切走再切回,实际发生的是:

less 复制代码
切走
├─ Vue: move(wrapper → storageContainer)        // DOM 离开文档
└─ onDeactivated
   ├─ scrollShadow.clear()                      // 先清掉阴影类
   └─ saveScroller(cacheName)                   // 兜底 + 同步内部滚动容器

切回
├─ Vue: move(wrapper → 原位置)                   // DOM 重新插入
└─ onActivated
   ├─ syncScrollRegistration()                  // 重新确认主滚动源
   ├─ syncScrollAdapter()                       // 重新解析自定义滚动组件
   ├─ restoreScroller(cacheName)                // 此时 DOM 已插入,可以写 scrollTop
   ├─ scrollShadow.sync(true)                   // 重新绑定滚动监听与阴影目标
   ├─ evictMarkedCaches()                       // 顺带执行延迟驱逐
   └─ checkAndConsumeBackParams()

源码里对应的是:

ts 复制代码
// src/lib/hooks/tabPanel/pageComponentFactory.tsx(有删减)
onDeactivated(() => {
  scrollShadow.clear()
  saveScroller(cacheName)
})

onActivated(() => {
  context.emit('onLoaded')
  syncScrollRegistration()
  syncScrollAdapter()
  restoreScroller(cacheName)
  scrollShadow.sync(true)
  evictMarkedCaches()
  clearTabEvictionMarks()
  checkAndConsumeBackParams()
})

有两个容易被忽略的点:

第一,restoreScroller 必须在 onActivated 里,不能提前。

如果在路由变化或 watch(route) 回调里就去写 scrollTop,此时 DOM 还没插回来,写入会被丢弃。onActivated 是 Vue 唯一保证「DOM 已经回到容器里」的时机。

第二,saveScroller 仍然保留,即使主位置已经在滚动中记过了。

它负责的是页面内部 的其他滚动容器(下一节),而不是主滚动容器。主滚动位置在 saveScroller 里走的是「读到 0 就保留」的兜底分支。


用状态图表示一次完整的切走与切回,比纯文本时序更清晰:

5. 页面里还有滚动容器怎么办

一个页面里往往不止一个滚动区域:主内容区、左侧树、表格横向滚动、弹窗里的列表。

这里要解决两个问题:怎么发现它们 ,以及怎么不把性能拖垮。

5.1 用属性标记,而不是全量查询

约定一个属性 data-stack-tab-scroll,页面里哪个元素需要记忆位置,就标在哪个元素上:

vue 复制代码
<template>
  <div class="page">
    <aside class="tree" data-stack-tab-scroll>
      <!-- 左侧树 -->
    </aside>

    <main class="content" data-stack-tab-scroll>
      <!-- 主内容 -->
    </main>
  </div>
</template>

问题在于:如果每次 onDeactivated 都 querySelectorAll('[data-stack-tab-scroll]'),页面规模越大、切换越频繁,性能开销越明显。

因此这里使用 MutationObserver 将元素列表缓存起来:

ts 复制代码
// src/lib/hooks/tabPanel/scroll.ts(有删减)
const createAutoScrollCache = (pageRoot: HTMLElement): AutoScrollCache => {
  const collect = (): HTMLElement[] =>
    Array.from(pageRoot.querySelectorAll<HTMLElement>('[data-stack-tab-scroll]'))

  const cache: AutoScrollCache = {
    elements: collect(),
    observer: null
  }

  if (typeof MutationObserver !== 'undefined') {
    cache.observer = new MutationObserver(() => {
      cache.elements = collect()
    })
    // 只监听子树增删与指定属性,不监听字符数据
    cache.observer.observe(pageRoot, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['data-stack-tab-scroll']
    })
  }

  return cache
}

平时直接读取缓存数组,仅在 DOM 结构变化时才重新收集。attributeFilter 限定为这一个属性,避免页面里任何属性变化都触发重算。

5.2 用索引做 key,按 DOM 顺序重建

缓存下来的元素列表中,位置用什么作为 key?

不能用元素本身(Map 会持有 DOM 引用),也不建议用 id(页面里的滚动容器未必有 id)。这里采用元素在列表中的下标:

ts 复制代码
const AUTO_SCROLLER_PREFIX = '__stack-tab-auto-scroll__:'
const getAutoScrollerKey = (index: number): string => `${AUTO_SCROLLER_PREFIX}${index}`

保存时按当前 DOM 顺序重新构建一遍:

ts 复制代码
// 与当前页面 DOM 同步自动标记的容器;移除的元素不保留过期位置
for (const key of positions.keys()) {
  if (key.startsWith(AUTO_SCROLLER_PREFIX)) positions.delete(key)
}

const cache = getAutoScrollCache(pageCacheId)
const elements = cache?.elements ?? []
elements.forEach((element, index) => {
  positions.set(getAutoScrollerKey(index), {
    top: element.scrollTop,
    left: element.scrollLeft
  })
})

先删除全部 auto key,再按当前顺序写入。这样元素被移除后,其对应的过期位置也会一并清除,不会在 Map 中持续累积。

5.3 页面销毁时必须断开 observer

这是很容易遗漏的一步。MutationObserver 会持有 pageRoot 的引用,页面被驱逐后如果不 disconnect(),DOM 就无法回收:

ts 复制代码
const removeScroller = (pageCacheId: string): void => {
  const positions = scrollPositionsByPageId.get(pageCacheId)
  if (positions) {
    positions.clear()
    scrollPositionsByPageId.delete(pageCacheId)
  }
  adapterGetters.delete(pageCacheId)

  // 同步卸载 MutationObserver,避免页面被驱逐后 observer 仍持有 DOM 引用
  const cache = autoScrollCaches.get(pageCacheId)
  if (cache) {
    cache.observer?.disconnect()
    autoScrollCaches.delete(pageCacheId)
  }
}

removeScroller 在缓存包装组件的 onUnmounted 里调用,destroy() 时还会整体 clear() 一次 scrollPositionsByPageId。

如果滚动容器不是用属性标记,而是运行时才知道(比如表格组件内部),也可以用 addScrollTarget 手动注册选择器:

ts 复制代码
const { addScrollTarget } = useTabRouter()

onMounted(() => {
  addScrollTarget('.table-body-wrapper')
})

属性标记适合静态结构,手动注册适合动态插入的节点,两者最终都落到同一个 Map<selector, ScrollPosition> 里。


6. 自定义滚动组件:千万别把 wrapper 当成滚动容器

到上一节为止,前提都是"滚动由原生 scrollTop 驱动"。但真实项目里经常不是:

  • Element Plus 的 ElScrollbar:内容区其实还是原生滚动,只是滚动条是自绘的;
  • BetterScroll / 虚拟滚动:滚动是 transform 或偏移量算出来的,根本没有原生 scrollTop;
  • 有些设计系统会把滚动封装成组件,只暴露 API。

如果这时候还去读 wrapper 的 scrollTop,读到的永远是 0。

6.1 三种滚动策略

这个库将滚动归属划分为三种模式:

值 行为
'native' 库内 wrapper 使用原生滚动(overflow: auto)
'none' 库不接管滚动,由页面自己处理(默认)
Component 传入自定义滚动组件,由它接管滚动
vue 复制代码
<vue-stack-tabs page-scroll="native" />

传组件时,库会在每个缓存页 wrapper 内部渲染它,所以每个标签仍然有独立的滚动容器:

vue 复制代码
<vue-stack-tabs :page-scroll="ElScrollbar" />

6.2 适配器契约:能只暴露一个字段,也能完全接管

自定义组件通过 expose 暴露能力。最小的契约只有一个字段:

ts 复制代码
type Position = { top: number; left: number }
type Metrics = { scrollTop: number; scrollHeight: number; clientHeight: number }

export interface StackTabsScrollAdapter {
  /** 真实滚动元素。原生滚动型组件只需暴露这一个字段。 */
  scrollElement?: HTMLElement | null

  /** 读取位置;缺失时读 scrollElement.scrollTop / scrollLeft */
  getPosition?: () => Position | null | undefined

  /** 写入位置;缺失时写 scrollElement.scrollTop / scrollLeft */
  setPosition?: (position: Position) => void

  /** 阴影计算所需尺寸 */
  getMetrics?: () => Metrics | null | undefined

  /** 订阅滚动;返回取消订阅函数 */
  onScroll?: (listener: () => void) => () => void
}

两类组件各取所需:

  • 原生滚动型 (ElScrollbar、OverlayScrollbars、SimpleBar):只暴露 scrollElement,其余全部回退到原生属性;
  • 命令式滚动型 (BetterScroll、虚拟列表):scrollElement 可以不给,用 getPosition / setPosition / getMetrics / onScroll 自己实现。

所有可选方法缺失时都会逐级回退,最后才判定为「该能力不可用」,不会抛出错误。

resolveScrollAdapter 还处理了两个实际会遇到的情况:scrollElement 可能是 ref 或 computed 的 getter;方法必须以组件实例为 this 调用。所以它不是简单地 Object.assign,而是逐个包一层:

ts 复制代码
// 简化示意:实际实现还会逐项做类型归一化
const getPosition =
  typeof source.getPosition === 'function'
    ? () => normalizePosition(source.getPosition.call(instance))
    : undefined

适配器的解析与回退关系可以这样理解:

6.3 三个真实的坑

坑一:自定义容器存在时,必须跳过 wrapper 的 DOM 读取。

自定义滚动组件接管后,wrapper 本身不再滚动,scrollTop 恒为 0。如果 saveScroller 还去读 #W-<cacheName>,就会把 0 写进主位置,覆盖掉适配器读到的真实值。

ts 复制代码
// 自定义滚动容器存在时,主滚动位置由适配器读写;
// wrapper 不再承载滚动,必须跳过 `#W-...` 的 DOM 读取
if (adapterGetters.has(pageCacheId)) {
  positions.delete(`#W-${pageCacheId}`)
  const position = readAdapterScrollPosition(getPageAdapter(pageCacheId))
  const previous = positions.get(MAIN_SCROLLER_KEY)

  // 停用时滚动元素可能已被移入隐藏容器而读到 0;保留滚动过程中的记录值
  if (position && (!isZeroPosition(position) || isZeroPosition(previous))) {
    positions.set(MAIN_SCROLLER_KEY, position)
  }
}

坑二:恢复时先子后主。

restoreScroller 会先恢复页面内其它滚动元素,最后才恢复主滚动容器:

ts 复制代码
// 先恢复页面内其它滚动元素,最后恢复主滚动容器:
// 运行时切换滚动组件时,普通 key 可能仍指向旧 wrapper,最后写主位置可避免被覆盖
for (const [selector, position] of positions) {
  if (selector === MAIN_SCROLLER_KEY) continue
  // ...写入子滚动元素
}

const mainPosition = positions.get(MAIN_SCROLLER_KEY)
if (!mainPosition) return
const adapter = getPageAdapter(pageCacheId)
if (adapter && writeAdapterScrollPosition(adapter, mainPosition)) return

// 自定义组件被移除(切回默认路径)或适配器暂时不可用时,回退 wrapper
const wrapper = document.getElementById(`W-${pageCacheId}`)
if (wrapper) {
  wrapper.scrollTop = mainPosition.top
  wrapper.scrollLeft = mainPosition.left
}

如果顺序颠倒,子元素的恢复可能触发容器的滚动调整,从而覆盖刚写入的主滚动位置。

坑三:异步组件第一帧还没有 expose。

如果 page-scroll 传的是 defineAsyncComponent 或者包在 Suspense 里,onMounted 时组件实例还没挂载,scrollElement 解析出来是 null。所以挂载后要再等一个 tick 补一次:

ts 复制代码
onMounted(() => {
  syncScrollRegistration()
  syncScrollAdapter()
  scrollShadow.sync(true)

  // 自定义滚动组件可能异步加载;下一 tick 再解析一次
  void nextTick(() => {
    if (refreshDisposed) return
    const wasResolved = Boolean(customScrollAdapter.value)
    syncScrollAdapter()
    // 首次解析成功时补一次位置恢复
    if (!wasResolved && customScrollAdapter.value) restoreScroller(cacheName)
    scrollShadow.sync(true)
  })
})

6.4 运行时切换策略

page-scroll 是可以在运行时切换的。切换时不能直接重建缓存页------那样页面状态就没了。正确顺序是「先保存旧位置,下一拍重新注册,再恢复」:

ts 复制代码
watch(
  () => runtimeContext.pageScroll.value,
  () => {
    saveScroller(cacheName)
    void nextTick(() => {
      if (refreshDisposed) return
      syncScrollRegistration()
      syncScrollAdapter()
      restoreScroller(cacheName)
      scrollShadow.sync(true)
    })
  }
)

这也是为什么 setPageScrollAdapter 存的是 getter 而不是适配器实例:

ts 复制代码
setPageScrollAdapter: (
  pageCacheId: string,
  getter: (() => StackTabsScrollAdapter | null) | null
) => void

保存和恢复始终读取最新的适配器,不会获取到切换前的旧实例。


7. 顺带解决:滚动阴影为什么不能挂在滚动容器上

滚动位置做完,通常会顺手加一个「上下还有内容」的渐变阴影。这里有一个不太直观的坑。

最自然的写法是把 ::after 挂在滚动容器的伪元素上。但只要外层有 transform 动画(比如切页、刷新),问题就来了:

transform 会创建新的包含块,绝对定位的 ::after 会以它为准重新计算位置。 动画过程中,阴影会跟着容器一起位移,看起来就是错位或者消失。

所以阴影类不能挂在滚动容器上,而要挂到一个不参与动画的祖先元素:

ts 复制代码
const scrollShadow = useScrollShadow({
  // 滚动监听绑定真实滚动元素
  target: () => getScrollAdapter()?.scrollElement ?? null,

  // 阴影类挂到不被刷新动画 transform 的 panel 上
  classTarget: () => pageRoot.value?.parentElement ?? null,

  getMetrics: () => readAdapterScrollMetrics(getScrollAdapter()),
  onScroll: subscribeAdapterScroll,

  // 页面级开关优先;未设置时跟随全局配置
  enabled: () => shadowEnabledOverride.value ?? runtimeContext.scrollShadow.value
})

状态本身是四态的,描述的是「哪一侧还有隐藏内容」,而不是「滚动方向」:

ts 复制代码
const distanceToTop = scrollTop
const distanceToBottom = scrollHeight - clientHeight - scrollTop

const showTopShadow = distanceToTop > edgeThreshold // 默认 5px
const showBottomShadow = distanceToBottom > edgeThreshold

state.value =
  showTopShadow && showBottomShadow
    ? 'both'
    : showTopShadow
      ? 'top'
      : showBottomShadow
        ? 'bottom'
        : 'none'

还有一个 CSS 层面的细节:-top 和 -bottom 是两条独立规则,作用在同一个 ::after 上,同时加会互相覆盖(后定义的胜出)。所以类名必须互斥:

ts 复制代码
classElement.classList.toggle(SHADOW_VERTICAL_CLASS, showTopShadow && showBottomShadow)
classElement.classList.toggle(SHADOW_TOP_CLASS, showTopShadow && !showBottomShadow)
classElement.classList.toggle(SHADOW_BOTTOM_CLASS, !showTopShadow && showBottomShadow)

两侧都有内容时用一个单独的 -vertical 类(上下两条渐变),而不是把 -top 和 -bottom 一起加上去。

另外,state 和「是否渲染阴影」是解耦的:就算业务通过 setEnabled(false) 关掉了库的阴影,state 仍然会按滚动位置更新,方便你自绘滚动条或别的提示。


8. 诚实的边界与代价

这套方案能解决「切标签保持位置」,但它有明确的边界。

8.1 不跨刷新

滚动位置存在运行时的 Map<pageId, Map<selector, position>> 里,不写 sessionStorage。

sessionStorage 里存的是当前激活标签的页面栈(ITabItem),没有滚动位置字段。所以 F5 之后,标签、页面栈、栈顶页都能恢复,但滚动位置会回到顶部。

这是一项有意的设计取舍:位置数据量大、结构随页面变化,而且刷新后 DOM 高度可能还没稳定,贸然恢复会跳到错误的位置。如果业务确实需要,应该在页面自己的 onMounted 里读业务缓存,等数据加载完再 scrollTo。

8.2 pageScroll: 'none' 且不注册滚动源时,库什么都不做

默认值就是 'none'。此时:

  • wrapper 是 overflow: hidden;
  • 库不监听任何主滚动源;
  • 阴影状态恒为 'none'。

如果你希望位置记忆生效,要么改成 'native',要么用 useTabPageScroll 把真实滚动源注册进来。「默认不接管」比「猜一个滚动容器」更安全,因为猜错时,用户看到的是「位置偶尔跳一下」,比不记忆更难排查。

8.3 每个缓存页都有常驻的滚动监听

scroll 监听是 passive 的,不会阻塞滚动,但它是常驻的:缓存页越多,监听器越多。这是以「页面切换时零延迟」为代价换来的------如果改成激活时才绑定,第一次滚动之前的位置就会丢失。

真正的成本在于清理:页面被驱逐时,removeScroller 必须同时清掉位置 Map、适配器引用和 MutationObserver。少清一个,就是一个持有 DOM 的泄漏点。

8.4 restoreScroller 是同步写入

scrollTop 直接赋值,没有 requestAnimationFrame,也没有 smooth。原因是恢复必须发生在浏览器绘制之前,否则用户会看到页面先停留在顶部,再跳回原位置。

代价是:如果页面内容在 onActivated 之后才异步渲染(例如表格数据延迟到达),这次恢复会失败------DOM 高度尚不充分,scrollTop 会被截断。这种情况下只能在数据渲染完成后由页面自己再调一次恢复。


9. 一份检查清单

如果你正在自行实现类似功能,可以参考以下检查项:

  1. 保存时机 :不要在 onDeactivated 中读取 DOM------此时元素已脱离文档;
  2. 持续记录 :在 scroll 事件中记录,并设置 passive: true;
  3. 区分真假 0:当读取到 0 且上次值非 0 时保留旧值,但用户实际滚动产生的 0 必须覆盖;
  4. 恢复时机 :在 onActivated 中执行,确保 DOM 已插入容器;
  5. 自定义容器:位置读写全部走适配器,不要读 wrapper;
  6. 恢复顺序:先子滚动元素,后主滚动容器;
  7. 异步组件 :onMounted 后补一次 nextTick 解析;
  8. 清理:页面销毁时断开 observer、清空 Map、删除适配器引用;
  9. 默认值:不确定页面怎么滚时,选择「不接管」,而不是猜一个容器。

10. 小结

滚动位置丢失这件事,表面上是「keep-alive 不记得位置」,实际上是保存时机选择不当。

Vue 的 deactivate 是「先移除 DOM,再调用钩子」,所以在 onDeactivated 中读取 DOM 必然得到 0。一旦明确这一点,解决方案就顺理成章:

  1. 将位置记录提前到滚动过程中;
  2. 通过判断「这个 0 是否由滚动产生」来区分真假 0;
  3. 把恢复放在 onActivated,因为那是 DOM 已经回到容器的时机;
  4. 自定义滚动组件全部走适配器,不要假设 wrapper 在滚。

这套逻辑并不依赖某个特定组件库。哪怕你只用 keep-alive + 一个 Map,也可以照着实现。

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

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

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

License: Apache-2.0

你的项目里,标签切换后滚动位置是怎么处理的?是在 onDeactivated 里存、用 sessionStorage 持久化,还是干脆让用户自己重新找位置?欢迎在评论区聊聊。

相关推荐
邵奈一1 小时前
GPT-6.1 Terra速度是Sol的3倍,价格减半
算法
RaaS1001 小时前
Node.js Brotli RCE 漏洞自查指南:fetch、undici、axios 都在影响面,三分钟确认你的版本
人工智能·深度学习·算法
shehuiyuelaiyuehao2 小时前
算法56,重排链表
数据结构·算法·链表
qq_452396232 小时前
第四篇:《索引原理与实战:B-tree、Hash、GIN、GiST、BRIN》
算法·哈希算法·gin
西索斯coding3 小时前
Qwen3-Max 与 Qwen-Plus:60 个真实修复任务评测方案
大数据·网络·算法·ai
INGNIGHT3 小时前
440 · 背包问题(三)(无限次数)
算法·leetcode·动态规划
suhan3 小时前
【数据结构】七、查找:1.查找的概念、线性结构查找(顺序、折半(二分)、插值、稠密、分块、倒排)
数据结构·算法·排序算法
killerbasd4 小时前
总结 10.26
算法