在做移动端H5项目时,你一定遇到过这种糟糕的体验:
从一个长列表点进详情,返回时列表又回到了顶部,用户只能一边默默吐槽一边重新往下滑......
本文以一个实际业务页面为例,完整拆解:如何在Vue+Vant的无限滚动列表中,优雅地记录和恢复滚动位置,实现真正丝滑的返回体验。
1.场景与目标
页面功能很简单:
- 显示列表数据(支持搜索&下拉加载更多)
- 点击列表项,跳转到详情页面
- 从详情页返回列表后,要停留在离开页面时的位置,而不是重新从顶部开始看
但真正实现时,问题并不简单:
- 列表是无限滚动的(van-list分页加载)
- 返回时数据需要重新加载(不能简单依赖浏览器历史缓存)
- DOM更新是异步的,数据没加载够时根本滚不到原来的位置
2.实现思路总览
核心思路可以拆成四步:
- 滚动容器引用:通过
ref拿到列表 DOM 容器 - 持久化滚动位置:离开前用
sessionStorage记住scrollTop - 带状态加载列表:返回后边加载数据边判断「高度是否够滚到原位置」
- 在合适时机恢复滚动:利用
nextTick确保 DOM 高度就绪再设置scrollTop
整体流程可以用一句话概括:
「离开前存位置 → 回来先读位置 → 分页加载到足够高度 → 再一次性把滚动条拉回去」。
下面结合具体代码来看。
3.关键状态:滚动位置与恢复标记
首先在 <script setup> 中定义了几个与滚动相关的状态:
const listAreaRef = ref<HTMLElement | null>(null)
const SCROLL_POSITION_KEY = 'teacher-list-scroll-position'
// 保存的滚动位置
const savedPosition = ref(0)
// 是否正在进行滚动恢复加载
const isRestoringScroll = ref(false)
listAreaRef:绑定到列表区域容器 DOM(用于读取和设置scrollTop)SCROLL_POSITION_KEY:存储在sessionStorage中的 keysavedPosition:记录之前滚动到的垂直位置isRestoringScroll:一个非常关键的标志,用来区分:- 普通列表加载
- 为了恢复滚动而触发的「自动加载更多」
4.离开前:如何记录当前滚动位置?
4.1 获取滚动容器
<div ref="listAreaRef" class="list-area flex-1 overflow-auto">
<van-list
v-if="list.length > 0"
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
:immediate-check="false"
@load="getList"
>
<!-- 列表项... -->
</van-list>
</div>
ref="listAreaRef" 让我们可以在脚本里拿到真正滚动的 DOM 元素,而不是整个窗口。
4.2 保存滚动位置
保存滚动位置的方法非常直接:
const saveScrollPosition = () => {
if (listAreaRef.value) {
const scrollTop = listAreaRef.value.scrollTop
sessionStorage.setItem(SCROLL_POSITION_KEY, scrollTop.toString())
}
}
调用时机选在离开列表前,例如点击列表项跳转到详情页时:
const goToFaceUpload = (teacher: ITeacherItem) => {
// 保存当前滚动位置
saveScrollPosition()
router.push({...})
}
这样,每一次从列表点进详情前,当前的滚动条位置都会被写入 sessionStorage 中。
5. 返回后:如何判断「要不要恢复」?
列表页面挂载时(onMounted),会尝试读取之前保存的位置:
onMounted(() => {
const position = sessionStorage.getItem(SCROLL_POSITION_KEY)
savedPosition.value = position ? Number(position) : 0
// 如果有保存的位置,启动滚动恢复流程
if (savedPosition.value > 0) {
isRestoringScroll.value = true
}
// ...
})
这里有两个细节:
savedPosition.value > 0才认为需要恢复- 一旦需要恢复,先把
isRestoringScroll置为true,再开始加载列表数据getList()
这意味着:页面一进来,先不着急恢复滚动,而是先标记「我要恢复」,然后开始按正常流程拉数据。
6. 分页加载中:如何「加载到可以滚回那个位置」?
核心逻辑在两个函数里:
getList:真正的分页接口请求checkAndLoadMoreForScroll:判断「高度够不够、要不要继续拉数据」
6.1 每次加载完数据后的处理
getList 在成功获取数据后做了这些事:
const getList = async () => {
try {
// 省略 loading & 接口请求...
// 如果正在进行滚动恢复,继续检查
if (isRestoringScroll.value) {
await checkAndLoadMoreForScroll()
}
} catch (error) {
// 错误处理...
isRestoringScroll.value = false
}
}
也就是说:只要当前处在「恢复模式」下,每拉完一次数据,就检查一下高度是否已经足够滚回原位置;如果不够,就继续拉下一页。
6.2 高度是否足够?checkAndLoadMoreForScroll
const checkAndLoadMoreForScroll = async () => {
// 如果没有保存的位置或已加载完所有数据,结束恢复流程
if (!savedPosition.value || finished.value) {
isRestoringScroll.value = false
await nextTick()
restoreScrollPosition()
return
}
// 等待 DOM 更新
await nextTick()
if (!listAreaRef.value) {
isRestoringScroll.value = false
return
}
const scrollHeight = listAreaRef.value.scrollHeight
const clientHeight = listAreaRef.value.clientHeight
const maxScrollTop = scrollHeight - clientHeight
// 如果当前列表高度不足以滚动到保存的位置,继续加载
if (savedPosition.value > maxScrollTop && !finished.value) {
loading.value = true
await getList()
} else {
// 高度足够,恢复滚动位置
isRestoringScroll.value = false
restoreScrollPosition()
}
}
这里有几个关键点:
-
等待 DOM 更新:每次数据进来后,先
await nextTick(),确保scrollHeight是最新的 -
判断最大可滚动高度:
scrollHeight:内容总高度clientHeight:可视区域高度maxScrollTop = scrollHeight - clientHeight
-
比较保存位置与最大高度:
- 如果
savedPosition > maxScrollTop:说明当前内容还不够长,滚动条最多只能到maxScrollTop,还达不到之前的位置 → 再拉一页数据 - 否则:说明当前内容高度已经足够 → 可以安全恢复到
savedPosition
这就完美解决了一个常被忽视的问题:
- 如果
不要在内容还不够高的时候就急着设置 scrollTop,否则恢复位置会失败或不准确。
7. 恢复滚动:谨慎地设置scrollTop
最终真正操作滚动条的是 restoreScrollPosition:
const restoreScrollPosition = () => {
if (savedPosition.value && listAreaRef.value) {
nextTick(() => {
if (listAreaRef.value) {
listAreaRef.value.scrollTop = savedPosition.value
}
})
}
}
可以看到这里又包了一层 nextTick():
checkAndLoadMoreForScroll里已经await nextTick()了一次- 在真正设置
scrollTop时,再套一层nextTick
这种「双层 nextTick」虽然看起来有点啰嗦,但在复杂场景(比如有图片加载、组件内部还在做渲染)的情况下,能进一步提高成功恢复的可靠性。
8. 何时清除保存的滚动位置?
考虑了「用户主动返回上一页」这种场景:
const handleBack = () => {
sessionStorage.removeItem(SCROLL_POSITION_KEY)
}
- 从列表返回更上级页面时,不需要再保留这段滚动状态
- 再次进入列表时就是一次全新的浏览
业务上的行为边界也被清晰地区分了出来。
9. 写在最后
如果你项目里也有「带搜索 + 分页加载 + 跳详情」的长列表,这种方式可以无痛提升用户体验。