Code前端首页关于Code前端联系我们

基础场景,用 scrollBehavior 实现自动保存

terry 14小时前 阅读数 15 #Vue

做 Vue 项目时,有没有遇到过这样的情况?从列表页滚动浏览内容后,点进详情页,返回列表页时滚动条直接回到顶部,用户得重新找之前看到的位置,体验特别差,这时候就需要让 Vue Router 能“滚动位置,返回时自动恢复,那 Vue Router 到底怎么实现滚动位置保存呢?这篇文章从基础到复杂场景,一步步拆解方法。

Vue Router 提供了 scrollBehavior 这个配置项,专门用来处理路由切换时的滚动行为,它是个函数,接收 to(目标路由)、from(当前路由)、savedPosition(浏览器记录的滚动位置,比如前进/后退时的位置)三个参数,返回值决定页面滚动到哪里。

先看最基础的配置逻辑:
router/index.js 里这样写:

import { createRouter, createWebHistory } from 'vue-router'
import routes from './routes'
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 如果是浏览器前进/后退(比如点击返回按钮),恢复之前的位置
    if (savedPosition) {
      return savedPosition
    } else {
      // 否则滚动到顶部(x:0, y:0)
      return { x: 0, y: 0 }
    }
  }
})
export default router

这个配置能解决浏览器默认前进后退时的滚动恢复,比如用户在列表页滚动后,点击浏览器返回按钮回到列表页,savedPosition 会携带之前的滚动坐标,页面就会自动滚回去。

但它有个局限:编程式导航(比如用 router.push 跳转)时,savedPositionnull,这时候上面的逻辑会直接滚到顶部,没法恢复滚动位置,这时候得结合“主动保存 + 主动恢复”的思路。

编程式导航场景:主动保存与恢复滚动位置

当用 router.push<router-link> 跳转(非浏览器前进后退)时,得自己记录滚动位置,常见做法是在离开页面时保存滚动位置,进入页面时恢复

步骤 1:离开页面时保存滚动位置

可以用路由守卫 beforeRouteLeave(组件内的守卫),把当前滚动位置存到 sessionStorage(会话存储,关闭标签页后清除)里。

假设列表页组件叫 ListView.vue,页面滚动是 window 滚动(不是容器滚动),代码如下:

<template>
  <div class="list-page">
    <!-- 长列表内容 -->
  </div>
</template>
<script>
export default {
  name: 'ListView',
  beforeRouteLeave(to, from, next) {
    // 保存当前 window 的滚动位置(y 轴)
    const scrollY = window.scrollY
    sessionStorage.setItem('listScroll', scrollY)
    next()
  }
}
</script>

步骤 2:进入页面时恢复滚动位置

在组件的 mounted 钩子(或 onMounted 组合式 API)里,读取保存的滚动位置并设置。

如果是选项式 API:

<script>
export default {
  mounted() {
    const savedY = sessionStorage.getItem('listScroll')
    if (savedY) {
      // 用 $nextTick 确保 DOM 渲染完成后再设置滚动
      this.$nextTick(() => {
        window.scrollTo(0, Number(savedY))
      })
    }
  }
}
</script>

如果是组合式 API(Vue 3):

<script setup>
import { onMounted, nextTick } from 'vue'
onMounted(() => {
  const savedY = sessionStorage.getItem('listScroll')
  if (savedY) {
    nextTick(() => {
      window.scrollTo(0, Number(savedY))
    })
  }
})
</script>

这样,即使是编程式导航跳转,返回列表页时也能恢复滚动位置了,但如果页面用了 <keep-alive> 缓存组件,还得考虑组件复用的情况,这时候逻辑会更特殊。

结合 keep-alive:缓存组件时的滚动管理

<keep-alive> 会缓存组件实例,避免重复渲染,提升性能,但组件被缓存后,普通的 mounted 不会再触发(因为组件没被销毁,只是激活/停掉),这时候要用到 activateddeactivated 钩子。

原理:组件激活时恢复,停用时保存

当组件被 keep-alive 包裹时,切换路由如果组件被缓存,会触发 activated(组件激活)和 deactivated(组件停掉),而不是 mounted/unmounted,所以滚动位置的保存和恢复要绑定这两个钩子。

举个容器滚动的例子(比如列表在一个固定高度的容器里滚动,不是 window 滚动):

<template>
  <div class="scroll-container" ref="scrollRef">
    <!-- 列表内容 -->
  </div>
</template>
<script>
export default {
  name: 'CachedListView',
  data() {
    return {
      scrollTop: 0 // 记录滚动位置
    }
  },
  deactivated() {
    // 组件停用时,保存容器的滚动位置
    this.scrollTop = this.$refs.scrollRef.scrollTop
  },
  activated() {
    // 组件激活时,恢复滚动位置
    this.$nextTick(() => {
      this.$refs.scrollRef.scrollTop = this.scrollTop
    })
  }
}
</script>
<style scoped>
.scroll-container {
  height: 500px;
  overflow-y: auto;
}
</style>

如果是window 滚动,逻辑类似,只是把容器换成 window:

<script>
export default {
  data() {
    return {
      scrollY: 0
    }
  },
  deactivated() {
    this.scrollY = window.scrollY
  },
  activated() {
    this.$nextTick(() => {
      window.scrollTo(0, this.scrollY)
    })
  }
}
</script>

这样,即使组件被缓存,切换路由后也能精准恢复滚动位置,但如果遇到动态路由(带参数的路由)嵌套路由,还得更细致地处理。

动态路由与嵌套路由:复杂场景的滚动保存

动态路由(/item/:id)和嵌套路由(/parent/child)的特点是:路由变化但组件可能复用,这时候滚动位置要和具体的路由状态绑定。

动态路由:根据参数保存不同位置

假设路由是 /product/:productId,不同 productId 对应同一个组件 ProductList.vue,用户在 /product/1 滚动后,跳转到 /product/2,再返回 /product/1 时,要恢复 /product/1 对应的滚动位置。

做法是:用路由参数作为 key,保存不同的滚动位置

在组件内:

<script>
export default {
  data() {
    return {
      scrollMap: {} // 键是 productId,值是滚动位置
    }
  },
  beforeRouteLeave(to, from, next) {
    const currentId = this.$route.params.productId
    this.scrollMap[currentId] = window.scrollY
    sessionStorage.setItem('productScrollMap', JSON.stringify(this.scrollMap))
    next()
  },
  mounted() {
    const savedMap = sessionStorage.getItem('productScrollMap')
    if (savedMap) {
      this.scrollMap = JSON.parse(savedMap)
      const currentId = this.$route.params.productId
      const savedY = this.scrollMap[currentId]
      if (savedY) {
        this.$nextTick(() => {
          window.scrollTo(0, savedY)
        })
      }
    }
  }
}
</script>

这样,每个 productId 对应的滚动位置都被单独保存,切换参数时不会互相干扰。

嵌套路由:父路由滚动位置的保留

嵌套路由的结构比如:

const routes = [
  {
    path: '/dashboard',
    component: DashboardLayout,
    children: [
      { path: 'settings', component: SettingsPage },
      { path: 'profile', component: ProfilePage }
    ]
  }
]

当从 /dashboard/settings 切换到 /dashboard/profile 时,父组件 DashboardLayout 不会销毁(因为嵌套路由切换的是子组件),所以父组件的滚动位置要保留,这时候可以在父组件里用 keep-alive 缓存子组件,同时父组件自己处理滚动。

父组件 DashboardLayout.vue 示例:

<template>
  <div class="dashboard-layout">
    <aside class="sidebar">...</aside>
    <main class="main-content" ref="mainScroll">
      <keep-alive>
        <router-view></router-view>
      </keep-alive>
    </main>
  </div>
</template>
<script>
export default {
  data() {
    return {
      mainScrollTop: 0
    }
  },
  beforeRouteUpdate(to, from, next) {
    // 路由更新但父组件复用,保存当前滚动位置
    this.mainScrollTop = this.$refs.mainScroll.scrollTop
    next()
  },
  mounted() {
    this.$nextTick(() => {
      this.$refs.mainScroll.scrollTop = this.mainScrollTop
    })
  }
}
</script>

这里用了 beforeRouteUpdate(路由更新时触发,组件复用)来保存滚动位置,确保子路由切换时父组件的滚动位置不丢失。

多滚动容器与特殊场景:细节里的体验优化

实际项目中,页面可能有多个可滚动区域(比如左侧导航栏和右侧内容区都是可滚动的),或者移动端滚动穿透(弹窗出现时页面还能滚动)等特殊情况,这时候得针对性处理。

多滚动容器:分别保存每个容器的位置

假设页面有两个滚动容器:侧边栏(sidebarmain),都要保存滚动位置。

组件内代码:

<template>
  <div class="double-scroll">
    <aside ref="sidebar" class="sidebar">...</aside>
    <main ref="mainContent" class="main">...</main>
  </div>
</template>
<script>
export default {
  data() {
    return {
      sidebarScroll: 0,
      mainScroll: 0
    }
  },
  beforeRouteLeave(to, from, next) {
    // 保存两个容器的滚动位置
    this.sidebarScroll = this.$refs.sidebar.scrollTop
    this.mainScroll = this.$refs.mainContent.scrollTop
    sessionStorage.setItem('doubleScroll', JSON.stringify({
      sidebar: this.sidebarScroll,
      main: this.mainScroll
    }))
    next()
  },
  mounted() {
    const saved = sessionStorage.getItem('doubleScroll')
    if (saved) {
      const { sidebar, main } = JSON.parse(saved)
      this.$nextTick(() => {
        this.$refs.sidebar.scrollTop = sidebar
        this.$refs.mainContent.scrollTop = main
      })
    }
  }
}
</script>

这样每个容器的滚动位置都被单独记录,恢复时也能精准定位。

移动端滚动穿透:禁止滚动时保存位置

移动端弹窗(比如模态框)出现时,要禁止页面滚动,关闭后恢复滚动并回到原来的位置,做法是:

  1. 弹窗打开时,保存当前滚动位置,给 bodyoverflow: hidden(禁止滚动)。
  2. 弹窗关闭时,移除 overflow: hidden,恢复之前的滚动位置。

示例(假设弹窗组件是 Modal.vue):

<template>
  <div class="modal-mask" v-if="isOpen">
    <!-- 弹窗内容 -->
    <button @click="closeModal">关闭</button>
  </div>
</template>
<script>
export default {
  data() {
    return {
      isOpen: false,
      savedScrollY: 0
    }
  },
  methods: {
    openModal() {
      // 打开时保存滚动位置,禁止 body 滚动
      this.savedScrollY = window.scrollY
      document.body.style.overflow = 'hidden'
      this.isOpen = true
    },
    closeModal() {
      // 关闭时恢复 body 滚动和滚动位置
      document.body.style.overflow = ''
      window.scrollTo(0, this.savedScrollY)
      this.isOpen = false
    }
  }
}
</script>

这样既解决了滚动穿透问题,又保证了关闭弹窗后页面滚动位置不变。

常见问题与优化:让滚动保存更稳定

在实现滚动保存时,会遇到一些“看似生效却失败”的情况,这里总结几个高频问题和优化思路。

问题 1:scrollBehavior 完全不生效

原因可能是:

  • 路由模式用了 createWebHashHistory(哈希模式),但 scrollBehavior 对哈希模式的支持有限(浏览器对哈希路由的滚动记录处理不同),建议用 createWebHistory(history 模式),但要注意服务器配置。
  • 路由切换时,页面内容还没渲染完成,导致滚动设置无效,解决方法是用 $nextTick 确保 DOM 渲染后再设置滚动位置。

问题 2:滚动位置保存太频繁,性能差

如果在 scroll 事件里直接保存滚动位置,频繁触发会导致性能问题,优化方法是用节流函数(lodash 的 throttle),限制保存频率(200ms 保存一次)。

示例:

<script>
import { throttle } from 'lodash'
export default {
  mounted() {
    const saveScroll = throttle(() => {
      sessionStorage.setItem('listScroll', window.scrollY)
    }, 200)
    window.addEventListener('scroll', saveScroll)
    // 组件销毁时移除事件监听
    this.$once('hook:beforeUnmount', () => {
      window.removeEventListener('scroll', saveScroll)
    })
  }
}
</script>

问题 3:不同设备(PC/移动端)滚动行为不一致

移动端浏览器的滚动有“回弹”效果,且 window.scrollY 的兼容性需要注意,可以用 document.documentElement.scrollTopdocument.body.scrollTop 做兼容(不同浏览器对滚动容器的实现不同)。

兼容写法:

const getScrollY = () => {
  return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0
}
const setScrollY = (y) => {
  window.scrollTo(0, y)
  document.documentElement.scrollTop = y
  document.body.scrollTop = y
}

滚动保存的核心逻辑

Vue Router 保存滚动位置,本质是“记录状态 + 恢复状态”的过程,不同场景下,选择合适的工具:

  • 基础前进后退:用 scrollBehavior + savedPosition
  • 编程式导航:用 beforeRouteLeave + sessionStorage + 组件生命周期钩子。
  • 组件缓存(keep-alive):用 activated/deactivated 管理滚动。
  • 复杂场景(动态路由、多容器、移动端):结合路由参数、多容器 ref、节流/兼容等细节优化。

把这些逻辑组合起来,就能让页面在路由切换时“用户的滚动位置,提升交互体验,实际开发中,要根据项目的

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门