这是「多标签页工程化」系列的第 3 篇。
前两篇分别讲了「页面实例级缓存」和「状态归属」,这一篇讨论一个更具体、也更容易被低估的问题:为什么你在
onDeactivated里保存滚动位置,切回来永远是顶部。
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)
}
顺序是关键:
move()是同步执行的,DOM 立刻离开文档;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)
}
拆开来看,只有两种情况会保留旧值:
- 元素根本读不到 (
!element)------DOM 已被移走; - 读到 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. 一份检查清单
如果你正在自行实现类似功能,可以参考以下检查项:
- 保存时机 :不要在
onDeactivated中读取 DOM------此时元素已脱离文档; - 持续记录 :在
scroll事件中记录,并设置passive: true; - 区分真假 0:当读取到 0 且上次值非 0 时保留旧值,但用户实际滚动产生的 0 必须覆盖;
- 恢复时机 :在
onActivated中执行,确保 DOM 已插入容器; - 自定义容器:位置读写全部走适配器,不要读 wrapper;
- 恢复顺序:先子滚动元素,后主滚动容器;
- 异步组件 :
onMounted后补一次nextTick解析; - 清理:页面销毁时断开 observer、清空 Map、删除适配器引用;
- 默认值:不确定页面怎么滚时,选择「不接管」,而不是猜一个容器。
10. 小结
滚动位置丢失这件事,表面上是「keep-alive 不记得位置」,实际上是保存时机选择不当。
Vue 的 deactivate 是「先移除 DOM,再调用钩子」,所以在 onDeactivated 中读取 DOM 必然得到 0。一旦明确这一点,解决方案就顺理成章:
- 将位置记录提前到滚动过程中;
- 通过判断「这个 0 是否由滚动产生」来区分真假 0;
- 把恢复放在
onActivated,因为那是 DOM 已经回到容器的时机; - 自定义滚动组件全部走适配器,不要假设 wrapper 在滚。
这套逻辑并不依赖某个特定组件库。哪怕你只用 keep-alive + 一个 Map,也可以照着实现。
在线演示:jktantan.github.io/vue-stack-t...
文档:jktantan.github.io/vue-stack-t...
License: Apache-2.0
你的项目里,标签切换后滚动位置是怎么处理的?是在 onDeactivated 里存、用 sessionStorage 持久化,还是干脆让用户自己重新找位置?欢迎在评论区聊聊。