移动端长列表「返回原位置」的完整实践

在做移动端H5项目时,你一定遇到过这种糟糕的体验:

从一个长列表点进详情,返回时列表又回到了顶部,用户只能一边默默吐槽一边重新往下滑......

本文以一个实际业务页面为例,完整拆解:如何在Vue+Vant的无限滚动列表中,优雅地记录和恢复滚动位置,实现真正丝滑的返回体验。

1.场景与目标

页面功能很简单:

  • 显示列表数据(支持搜索&下拉加载更多)
  • 点击列表项,跳转到详情页面
  • 从详情页返回列表后,要停留在离开页面时的位置,而不是重新从顶部开始看

但真正实现时,问题并不简单:

  1. 列表是无限滚动的(van-list分页加载)
  2. 返回时数据需要重新加载(不能简单依赖浏览器历史缓存)
  3. DOM更新是异步的,数据没加载够时根本滚不到原来的位置

2.实现思路总览

核心思路可以拆成四步:

  1. 滚动容器引用 :通过 ref 拿到列表 DOM 容器
  2. 持久化滚动位置 :离开前用 sessionStorage 记住 scrollTop
  3. 带状态加载列表:返回后边加载数据边判断「高度是否够滚到原位置」
  4. 在合适时机恢复滚动 :利用 nextTick 确保 DOM 高度就绪再设置 scrollTop

整体流程可以用一句话概括:

「离开前存位置 → 回来先读位置 → 分页加载到足够高度 → 再一次性把滚动条拉回去」。

下面结合具体代码来看。

3.关键状态:滚动位置与恢复标记

首先在 <script setup> 中定义了几个与滚动相关的状态:

ts 复制代码
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 中的 key
  • savedPosition:记录之前滚动到的垂直位置
  • isRestoringScroll:一个非常关键的标志,用来区分:
    • 普通列表加载
    • 为了恢复滚动而触发的「自动加载更多」

4.离开前:如何记录当前滚动位置?

4.1 获取滚动容器

ts 复制代码
<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 保存滚动位置

保存滚动位置的方法非常直接:

ts 复制代码
const saveScrollPosition = () => {
  if (listAreaRef.value) {
    const scrollTop = listAreaRef.value.scrollTop
    sessionStorage.setItem(SCROLL_POSITION_KEY, scrollTop.toString())
  }
}

调用时机选在离开列表前,例如点击列表项跳转到详情页时:

ts 复制代码
const goToFaceUpload = (teacher: ITeacherItem) => {
  // 保存当前滚动位置
  saveScrollPosition()

  router.push({...})
}

这样,每一次从列表点进详情前,当前的滚动条位置都会被写入 sessionStorage 中。

5. 返回后:如何判断「要不要恢复」?

列表页面挂载时(onMounted),会尝试读取之前保存的位置:

ts 复制代码
onMounted(() => {
  const position = sessionStorage.getItem(SCROLL_POSITION_KEY)
  savedPosition.value = position ? Number(position) : 0

  // 如果有保存的位置,启动滚动恢复流程
  if (savedPosition.value > 0) {
    isRestoringScroll.value = true
  }
  // ...
})

这里有两个细节:

  1. savedPosition.value > 0 才认为需要恢复
  2. 一旦需要恢复,先把 isRestoringScroll 置为 true,再开始加载列表数据 getList()

这意味着:页面一进来,先不着急恢复滚动,而是先标记「我要恢复」,然后开始按正常流程拉数据

6. 分页加载中:如何「加载到可以滚回那个位置」?

核心逻辑在两个函数里:

  • getList:真正的分页接口请求
  • checkAndLoadMoreForScroll:判断「高度够不够、要不要继续拉数据」

6.1 每次加载完数据后的处理

getList 在成功获取数据后做了这些事:

ts 复制代码
const getList = async () => {
  try {
    // 省略 loading & 接口请求...

    // 如果正在进行滚动恢复,继续检查
    if (isRestoringScroll.value) {
      await checkAndLoadMoreForScroll()
    }

  } catch (error) {
    // 错误处理...
    isRestoringScroll.value = false
  }
}

也就是说:只要当前处在「恢复模式」下,每拉完一次数据,就检查一下高度是否已经足够滚回原位置;如果不够,就继续拉下一页。

6.2 高度是否足够?checkAndLoadMoreForScroll

ts 复制代码
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()
  }
}

这里有几个关键点:

  1. 等待 DOM 更新 :每次数据进来后,先 await nextTick(),确保 scrollHeight 是最新的

  2. 判断最大可滚动高度

    • scrollHeight:内容总高度
    • clientHeight:可视区域高度
    • maxScrollTop = scrollHeight - clientHeight
  3. 比较保存位置与最大高度

    • 如果 savedPosition > maxScrollTop:说明当前内容还不够长,滚动条最多只能到 maxScrollTop,还达不到之前的位置 → 再拉一页数据
    • 否则:说明当前内容高度已经足够 → 可以安全恢复到 savedPosition

    这就完美解决了一个常被忽视的问题:

不要在内容还不够高的时候就急着设置 scrollTop,否则恢复位置会失败或不准确。

7. 恢复滚动:谨慎地设置scrollTop

最终真正操作滚动条的是 restoreScrollPosition

ts 复制代码
const restoreScrollPosition = () => {
  if (savedPosition.value && listAreaRef.value) {
    nextTick(() => {
      if (listAreaRef.value) {
        listAreaRef.value.scrollTop = savedPosition.value
      }
    })
  }
}

可以看到这里又包了一层 nextTick()

  • checkAndLoadMoreForScroll 里已经 await nextTick() 了一次
  • 在真正设置 scrollTop 时,再套一层 nextTick

这种「双层 nextTick」虽然看起来有点啰嗦,但在复杂场景(比如有图片加载、组件内部还在做渲染)的情况下,能进一步提高成功恢复的可靠性。

8. 何时清除保存的滚动位置?

考虑了「用户主动返回上一页」这种场景:

ts 复制代码
const handleBack = () => {
  sessionStorage.removeItem(SCROLL_POSITION_KEY)
}
  • 从列表返回更上级页面时,不需要再保留这段滚动状态
  • 再次进入列表时就是一次全新的浏览

业务上的行为边界也被清晰地区分了出来。

9. 写在最后

如果你项目里也有「带搜索 + 分页加载 + 跳详情」的长列表,这种方式可以无痛提升用户体验。

相关推荐
mldong3 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!6 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi7 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen8 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马9 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu9 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss