【Vue 路由系列 05】SPA 状态保持与滚动还原:scrollBehavior + keep-alive
"A 页面被销毁,再回来重新挂载,状态丢失。" —— 我在面试中正确识别了这个问题,但给出的解决方案不够精准。
在前几篇中,我们从路由的物理层(01篇:Hash/History/Abstract)讲到交通管制(02篇:守卫系统),再到路由配置(03篇:嵌套/动态路由)和打包优化(04篇:懒加载/分组)。这一篇关注用户体验的最后一公里——用户后退时期望的不只是回到上一个页面,而是完全恢复到之前的状态:滚动位置、表单数据、选中的标签、展开的折叠面板……同时还要让页面切换有丝滑的过渡动画。
一、问题场景:状态丢失的痛
1.1 典型的用户体验灾难
场景:用户在电商后台的商品列表页
↓
1. 滚动到了第 50 条数据(滚动了 2000px)
2. 筛选条件设为:分类=电子产品,状态=上架,排序=价格降序
3. 当前在第 3 页,每页 20 条
4. 选中了第 15、18、21 行(多选操作)
5. 点击其中一行进入商品详情
↓ (导航到详情页)
6. 在详情页浏览了一会儿
7. 点击浏览器后退按钮
↓ 💀💀💀
8. 回到列表页,但:
- 滚动条回到了顶部 ❌
- 筛选条件全部重置 ❌
- 回到第 1 页 ❌
- 多选状态丢失 ❌
- 用户心态炸裂 💢
1.2 为什么会这样?
默认的路由切换行为:
离开 /list → 进入 /detail
↓
List 组件执行 beforeUnmount → 所有响应式变量被销毁
→ DOM 节点被移除
→ 事件监听器被移除
→ 定时器/网络请求被清除
→ 一切归零 🧹
从 /detail 后退回 /list
↓
List 组件重新执行 setup() / created()
→ 所有变量回到初始值
→ 重新发起 API 请求获取数据
→ 渲染出全新的页面(第 1 页、无筛选、滚动位置 = 0)
二、滚动位置还原 —— scrollBehavior
2.1 基础用法
⚠️ 前提条件:
scrollBehavior需要浏览器支持history.pushState(官方文档)。这意味着:
- History 模式(
createWebHistory()):✅ 完全支持- Hash 模式(
createWebHashHistory()):⚠️ 行为可能不一致,因为 hash 变化不会产生真正的浏览器历史记录条目,savedPosition可能为空
Vue Router 提供了 scrollBehavior 配置项来处理滚动位置:
const router = createRouter({
history: createWebHistory(),
routes: [...],
scrollBehavior(to, from, savedPosition) {
// to: 即将进入的目标路由
// from: 当前正要离开的路由
// savedPosition: 只有在通过 popstate(前进/后退)时才有值
// 格式为 { top: number, left: number } 或 null
// (仅在 History 模式且浏览器支持 pushState 时可用)
if (savedPosition) {
// 用户点了后退/前进 → 恢复之前的滚动位置
return savedPosition
} else {
// 其他情况(点击链接、编程式导航)→ 滚到顶部
return { top: 0 }
}
}
})
📌 Vue Router 3 → 4 迁移注意:返回值的属性名发生了变化!
- v3:
{ x: 0, y: 100 }- v4:
{ left: 0, top: 100 }如果从老项目迁移,记得改属性名。
### 2.2 进阶用法:锚点滚动 + 平滑动画
```js
scrollBehavior(to, from, savedPosition) {
// 场景 1:浏览器后退/前进 → 恢复位置
if (savedPosition) {
// 如果目标路由有 hash 锚点,优先锚点
if (to.hash) {
return {
el: to.hash,
behavior: 'smooth' // CSS scroll-behavior: smooth 的 JS 版本
}
}
return savedPosition
}
// 场景 2:目标有 hash 锚点 → 滚动到锚点位置
if (to.hash) {
// 延迟等待 DOM 更新完成后再滚动
return new Promise((resolve) => {
setTimeout(() => {
resolve({
el: to.hash,
behavior: 'smooth'
})
}, 500) // 给异步组件足够的渲染时间
})
}
// 场景 3:普通导航 → 滚到顶部
return { top: 0, behavior: 'smooth' }
}
2.3 ⚠️ savedPosition 只在 popstate 时有效
这是一个关键细节:
// 这些操作 savedPosition 有值:
history.back() // ✅ 有 savedPosition
history.forward() // ✅ 有 savedPosition
点击浏览器后退按钮 // ✅ 有 savedPosition
// 因为这些都会触发 popstate 事件
// 这些操作 savedPosition 为 null:
router.push('/about') // ❌ null
router.replace('/home') // ❌ null
点击 <router-link> // ❌ null
// 因为这些都是 pushState 操作,不是"回到历史记录"
2.4 我面试时的回答 vs 完整答案
我在面试中说:
"用 beforeRouteLeave + meta 做状态缓存"
这个回答的问题:
- 方向偏了——
beforeRouteLeave离开时确实可以保存状态,但滚动位置不需要你手动管,scrollBehavior已经内置了这个能力 - 过度工程化——用
meta手动存滚动位置是重复造轮子
更准确的回答应该分层说:
滚动位置分两层处理:第一层,Vue Router 内置的
scrollBehavior配置项已经能自动处理后退/前进时的滚动恢复(通过浏览器原生的savedPosition);第二层,对于应用内部的状态(如表单数据、筛选条件、分页信息),我使用keep-alive包裹router-view来避免组件销毁重建,或者对不适合 keep-alive 的大列表场景,手动将状态持久化到sessionStorage。
三、组件级状态保持 —— <keep-alive>
3.1 什么是 keep-alive
<keep-alive> 是 Vue 的内置组件,它能在组件切换时保留组件实例在内存中,而不是销毁它们:
<!-- 普通 router-view:每次切换都销毁+重建 -->
<router-view />
<!-- keep-alive 包裹:切换时不销毁,只是 deactivated -->
<keep-alive>
<router-view />
</keep-alive>
生命周期变化对比:
不使用 keep-alive:
首次进入: created → mounted → ...
离开: beforeUnmount → unmounted(销毁!)
再次进入: created → mounted → ...(全新实例)
使用 keep-alive:
首次进入: created → mounted → activated
离开: deactivated(不销毁!实例保存在内存中)
再次进入: activated(直接复用旧实例,跳过 created/mounted)
注意两个特殊钩子:
activated():keep-alive 包裹的组件每次被激活(显示)时调用deactivated():keep-alive 包裹的组件每次被停用(隐藏)时调用
3.2 基础用法
<template>
<router-view v-slot="{ Component }">
<keep-alive :include="cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</router-view>
</template>
<script setup>
import { ref } from 'vue'
// 缓存白名单:只缓存这些组件
const cachedViews = ref(['UserList', 'OrderList', 'Dashboard'])
</script>
3.3 include 和 exclude —— 精确控制缓存哪些页面
| 属性 | 类型 | 说明 |
|---|---|---|
| include | 字符串/正则/数组 | 只有名称匹配的组件会被缓存 |
| exclude | 字符串/正则/数组 | 名称匹配的组件不会被缓存 |
| max | 数字 | 最大缓存实例数(LRU 淘汰策略) |
<template>
<keep-alive
:include="/^List/" <!-- 正则:所有以 List 开头的组件 -->
:exclude="['Detail', 'Edit']" <!-- 数组:排除 Detail 和 Edit -->
:max="10" <!-- 最多缓存 10 个组件实例 -->
>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</keep-alive>
</template>
⚠️ 关键坑点:组件名称必须显式定义!
// 必须定义 name 屄件名,否则 include/exclude 无法匹配!
export default {
name: 'UserList', // ← 这个名字要和 include 里的字符串一致
setup() { ... }
}
3.4 动态控制缓存列表
实际项目中,缓存列表通常是动态的:
<!-- App.vue 或 Layout.vue -->
<template>
<router-view v-slot="{ Component, route }">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViewNames">
<component :is="Component" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup>
import { computed } from 'vue'
import { useTagsViewStore } from '@/stores/tagsView'
const tagsViewStore = useTagsViewStore()
// 从 store 中读取需要缓存的视图名称
const cachedViewNames = computed(() => tagsViewStore.cachedViews)
</script>
// stores/tagsView.js
import { defineStore } from 'pinia'
export const useTagsViewStore = defineStore('tagsView', {
state: () => ({
visitedViews: [], // 访问过的页面标签
cachedViews: [] // 需要缓存的组件名称列表
}),
actions: {
addView(view) {
this.addVisitedView(view)
// 如果该页面设置了 keepAlive 且还没缓存过,加入缓存列表
if (view.meta?.keepAlive && !this.cachedViews.includes(view.name)) {
this.cachedViews.push(view.name)
}
},
delView(view) {
return new Promise(resolve => {
this.delVisitedView(view)
this.delCachedView(view)
resolve({
visitedViews: [...this.visitedViews],
cachedViews: [...this.cachedViews]
})
})
},
delCachedView(view) {
const index = this.cachedViews.indexOf(view.name)
if (index > -1) {
this.cachedViews.splice(index, 1)
}
},
delOthersViews(view) {
this.visitedViews = this.visistedViews.filter(v => v.path === view.path)
this.cachedViews = this.cachedViews.filter(name => name === view.name)
},
delAllViews() {
this.visitedViews = []
this.cachedViews = []
}
}
})
3.5 结合路由 meta 控制缓存
// router/index.js
const routes = [
{
path: '/user/list',
component: UserList,
meta: {
title: '用户列表',
keepAlive: true // ← 标记这个页面需要缓存
}
},
{
path: '/user/detail/:id',
component: UserDetail,
meta: {
title: '用户详情',
keepAlive: false // ← 详情页不需要缓存(每次看不同的用户)
}
},
]
3.6 keep-alive 的陷阱和注意事项
⚠️ 陷阱 1:缓存的组件不会重新请求接口
export default {
name: 'UserList',
async created() {
// ⚠️ 使用 keep-alive 后:
// 首次进入:created 执行 ✅
// 再次进入(从缓存恢复):created 不执行 ❌(直接走 activated)
const res = await api.getUsers()
this.list = res.data
},
activated() {
// ✅ 每次激活都会执行
// 适合做:刷新列表数据、重置某些临时状态
this.fetchData()
}
}
⚠️ 陷阱 2:内存泄漏风险
keep-alive 会把组件实例一直保存在内存里。如果组件里有以下资源没有清理:
export default {
name: 'HeavyPage',
created() {
// ⚠️ 这些资源在 keep-alive 下不会被自动清理:
this.timer = setInterval(() => this.updateChart(), 1000) // 定时器
window.addEventListener('resize', this.handleResize) // 事件监听
this.eventSource = new EventSource('/api/stream') // SSE 连接
},
// 正确做法:在 deactivate 中清理
deactivated() {
clearInterval(this.timer) // 清定时器
window.removeEventListener('resize') // 移除事件
this.eventSource.close() // 关闭连接
},
activated() {
// 重新激活时恢复
this.timer = setInterval(() => this.updateChart(), 1000)
window.addEventListener('resize', this.handleResize)
}
}
⚠️ 陷阱 3:max 属性的 LRU 淘汰
<!-- max=3 表示最多缓存 3 个组件 -->
<keep-alive :max="3">
<router-view />
</keep-alive>
<!-- LRU(最近最少使用)淘汰策略 -->
<!-- 当第 4 个组件进来时,最久没被访问的那个会被销毁 -->
四、进阶:手动状态管理(不用 keep-alive)
有些场景不适合用 keep-alive(如大数据量列表占用内存太多),此时可以用其他方式保存状态:
4.1 方案一:Pinia/Vuex Store 持久化
// stores/listPage.js
import { defineStore } from 'pinia'
export const useListPageStore = defineStore('listPage', {
state: () => ({
// 列表数据
list: [],
pagination: { page: 1, pageSize: 20, total: 0 },
// 筛选条件
filters: {
category: '',
status: '',
keyword: '',
sortBy: 'createdAt',
sortOrder: 'desc'
},
// UI 状态
selectedRows: [],
expandedRows: [],
scrollTop: 0,
}),
actions: {
saveState() {
// 保存到 sessionStorage(页面关闭后自动清除)
sessionStorage.setItem(
'listPage_state',
JSON.stringify(this.$state)
)
},
restoreState() {
const saved = sessionStorage.getItem('listPage_state')
if (saved) {
Object.assign(this.$state, JSON.parse(saved))
}
},
clearState() {
this.list = []
this.pagination = { page: 1, pageSize: 20, total: 0 }
this.filters = { category: '', status: '', keyword: '', sortBy: 'createdAt', sortOrder: 'desc' }
this.selectedRows = []
this.expandedRows = []
this.scrollTop = 0
}
}
})
4.2 方案二:onBeforeRouteLeave 保存 + onBeforeRouteEnter 恢复
<script setup>
import { onBeforeRouteLeave, onBeforeRouteEnter } from 'vue-router'
import { useListPageStore } from '@/stores/listPage'
const store = useListPageStore()
const route = useRoute()
// 离开前保存状态
onBeforeRouteLeave((to) => {
// 只在进入详情页时保存(返回首页则不保存)
if (to.name === 'user-detail') {
store.saveState()
} else if (to.name === 'home') {
// 从其他地方回来,清空状态
store.clearState()
}
})
// 进入时恢复
onBeforeRouteEnter((to) => {
// 从详情页回来才恢复
if (store.hasSavedState()) {
store.restoreState()
}
})
</script>
五、三种方案的对比和选择指南
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| scrollBehavior | 仅滚动位置还原 | Vue Router 内置、零配置 | 只管滚动位置,不管其他状态 |
| keep-alive | 中小规模页面缓存 | 保留完整组件实例、简单易用 | 占用内存、需管理缓存列表、不清理会泄漏 |
| Store + SessionStorage | 大列表/重型页面 | 内存友好、可精确控制 | 需要手动编写保存/恢复逻辑 |
选择决策树
你需要保持什么状态?
│
├─ 只需要滚动位置?
│ └─ → 用 scrollBehavior 就够了
│
├─ 还需要表单/筛选/选中状态?
│ ├─ 页面组件不大?(< 几百行代码)
│ │ └─ → 用 keep-alive + include 白名单
│ │
│ └─ 页面很大?(大表格、复杂图表)
│ └─ → 用 Store + sessionStorage 手动管理
│
└─ 需要多标签页同时存在不同状态?(如 A 标签打开 /list?page=1,B 标签打开 /list?page=3)
└─ → keep-alive 不适合(只能有一个实例)
→ 用 Map<key, State> 按 key 分别存储状态
六、<transition> 与 keep-alive 的配合动画
6.1 为什么需要过渡动画?
没有过渡动画的路由切换是这样的:
页面A → 突然消失 → 白屏/空白 → 页面B突然出现
用户体验:生硬、像 PPT 翻页、感觉"廉价"。
有了过渡动画:
页面A → 淡出/左滑消失 → 页面B淡入/右滑出现
用户体验:流畅、原生 App 般的顺滑、专业感。
6.1 完整写法:transition + keep-alive + router-view 三件套
<!-- App.vue 或 Layout.vue -->
<template>
<router-view v-slot="{ Component, route }">
<!-- 外层:过渡动画 -->
<transition :name="transitionName" mode="out-in">
<!-- 内层:缓存控制 -->
<keep-alive :include="cachedViewNames">
<!-- 组件渲染 -->
<component :is="Component" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const transitionName = ref('fade-transform')
// 根据路由 meta 或导航方向动态切换动画
watch(() => route.path, (to, from) => {
// 可以根据路由层级判断是前进还是后退
// 前进:slide-left(新页从右边滑入)
// 后退:slide-right(旧页从左边回来)
if (to && from) {
const toDepth = route.meta.depth || 0
const fromDepth = (/* 上一个路由的 depth */)
transitionName.value = toDepth > fromDepth ? 'slide-left' : 'slide-right'
}
})
</script>
6.2 CSS 过渡动画模板
/* ========== 淡入淡出(通用) ========== */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ========== 缩放 + 淡入(推荐) ========== */
.fade-transform-enter-active {
transition: all 0.3s ease;
}
.fade-transform-leave-active {
transition: all 0.2s ease;
}
.fade-transform-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.fade-transform-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* ========== 左右滑动(App 风格) ========== */
.slide-left-enter-active {
transition: all 0.3s ease-out;
}
.slide-left-leave-active {
transition: all 0.3s ease-in;
}
.slide-left-enter-from {
transform: translateX(100%); /* 从右侧进来 */
}
.slide-left-leave-to {
transform: translateX(-30%); /* 向左侧退 */
}
.slide-right-enter-active {
transition: all 0.3s ease-out;
}
.slide-right-leave-active {
transition: all 0.3s ease-in;
}
.slide-right-enter-from {
transform: translateX(-100%); /* 从左侧回来(后退) */
}
.slide-right-leave-to {
transform: translateX(30%); /* 向右侧离开 */
}
6.3 ⚠️ mode="out-in" 的重要性
<!-- ❌ 没有 mode:两个组件同时存在一瞬间 -->
<transition name="fade">
<component :is="Component" />
</transition>
<!-- 问题:新组件进入时旧组件还没完全消失,可能出现布局闪烁 -->
<!-- ✅ 使用 out-in:旧组件先离开,新组件再进入 -->
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
| mode | 行为 | 适用场景 |
|---|---|---|
| 无 | 同时执行 enter 和 leave | 一般不用 |
out-in | 当前元素先 leave 完成 → 新元素再 enter | 路由切换最常用 |
in-out | 新元素先 enter → 当前元素再 leave | 极少使用 |
七、多标签页状态隔离方案
7.1 keep-alive 的局限性
前面提到 keep-alive 可以缓存组件状态,但它有一个致命局限:
场景:多标签页系统
标签A 打开 /user/list?page=1&status=active
用户滚动到了第 50 条,筛选了条件
然后打开标签B → /user/list?page=2&status=inactive
这是同一个 UserList 组件!但参数不同!
问题:
keep-alive 只缓存一个 UserList 实例
标签B 会复用标签A的缓存实例
→ 标签B看到的是标签A的状态!(page=1, status=active)
→ 切回标签A后,状态又被标签B覆盖了 💀
根因:keep-alive 是按组件定义缓存的(通过 name 匹配),不是按路由实例缓存的。
7.2 方案一:Map<key, State> 手动状态管理(适合大列表)
核心思路:不依赖 keep-alive,而是手动将每个"视图"的状态存到一个 Map 中:
// stores/viewState.js
import { defineStore } from 'pinia'
export const useStateStore = defineStore('viewState', {
state: () => ({
// key = 视图唯一标识(通常是 path + query 组合)
// value = 该视图的状态快照
stateMap: new Map(),
// 记录当前激活的 viewKey
activeViewKey: null,
}),
actions: {
// 生成唯一的 viewKey
getViewKey(route) {
// 方式 A:用完整路径 + query 作为 key
return route.fullPath
// 方式 B:用路径 + 关键 query 参数作为 key(更灵活)
// const { path, query } = route
// return `${path}?${Object.entries(query).sort().map(([k,v])=>`${k}=${v}`).join('&')}`
},
// 保存当前视图状态
saveViewState(route, state) {
const key = this.getViewKey(route)
this.stateMap.set(key, JSON.parse(JSON.stringify(state)))
},
// 恢复指定视图状态
getViewState(route) {
const key = this.getViewKey(route)
return this.stateMap.get(key) || null
},
// 清除指定视图状态
clearViewState(route) {
const key = this.getViewKey(route)
this.stateMap.delete(key)
},
// 清除所有状态(关闭所有标签时)
clearAll() {
this.stateMap.clear()
}
}
})
在列表页中使用:
<script setup>
import { onBeforeRouteLeave } from 'vue-router'
import { reactive } from 'vue'
import { useStateStore } from '@/stores/viewState'
const stateStore = useStateStore()
const route = useRoute()
// 页面状态
const pageState = reactive({
currentPage: 1,
pageSize: 20,
filters: { category: '', status: '' },
selectedRows: [],
scrollTop: 0,
})
// 进入时恢复状态(如果有)
onMounted(() => {
const saved = stateStore.getViewState(route)
if (saved) {
Object.assign(pageState, saved)
// 恢复滚动位置
nextTick(() => window.scrollTo(0, saved.scrollTop))
}
})
// 离开时保存状态
onBeforeRouteLeave((to) => {
pageState.scrollTop = window.scrollY || document.documentElement.scrollTop
stateStore.saveViewState(route, { ...pageState })
})
</script>
7.3 方案二:动态 key 强制多实例(适合中小型页面)
利用 Vue 的 key 属性——当 key 变化时,Vue 会销毁旧实例并创建新实例:
<template>
<router-view v-slot="{ Component, route }">
<!-- 用 route.fullPath 作为 key -->
<!-- 不同 fullPath → 不同 key → 不同实例 → 各自独立状态 -->
<keep-alive :include="cachedViews">
<component
:is="Component"
:key="route.fullPath" <!-- ⭐ 关键! -->
/>
</keep-alive>
</router-view>
</template>
效果:
标签A: /user/list?page=1 → key="/user/list?page=1" → 实例 #1
标签B: /user/list?page=2 → key="/user/list?page=2" → 实例 #2(不同!)
✅ 各自独立的滚动位置、筛选条件、选中行
✅ keep-alive 分别缓存两个实例
⚠️ 注意事项:
| 问题 | 说明 |
|---|---|
| 内存占用 | 每个标签一个实例,10个标签=10份缓存 |
| max 限制 | 务必设置 <keep-alive :max="10"> 做 LRU 淘汰 |
| key 设计 | 用 fullPath 最简单;也可以用自定义的 tabId |
| 数据过期 | activated 时刷新数据(因为缓存可能过时) |
7.4 方案选择对比
| 维度 | keep-alive 单实例 | Map 手动管理 | key 多实例 |
|---|---|---|---|
| 实现难度 | 简单 | 复杂 | 简单 |
| 内存占用 | 低(1份) | 低(只存数据) | 高(N个实例) |
| 适用页面规模 | 小(表单、详情) | 大(复杂列表) | 中(普通CRUD) |
| 滚动位置保持 | ✅ 自动 | 需手动保存恢复 | ✅ 自动 |
| 状态隔离 | ❌ 不支持 | ✅ 完美支持 | ✅ 支持 |
八、history.state —— 浏览器原生的状态存储能力
6.1 它是什么
pushState 的第一个参数就是一个状态对象,它会关联到当前历史记录条目上:
// 存储自定义状态
const state = {
scrollTop: document.documentElement.scrollTop,
filters: { category: 'electronics', status: 'active' },
page: 3
}
history.pushState(state, '', '/user/list')
// 在 popstate 事件中取回
window.addEventListener('popstate', (e) => {
console.log(e.state)
// { scrollTop: 1200, filters: {...}, page: 3 }
// 可以用它来恢复页面状态!
})
6.2 与 Vue Router 整合
// 自定义一个 composable
function useHistoryState() {
const route = useRoute()
const router = useRouter()
function savePageState(state) {
// 将页面状态写入 history.state
history.replaceState(
{ ...history.state, pageState: state },
''
)
}
function getPageState() {
return history.state?.pageState || null
}
return { savePageState, getPageState }
}
// 使用
const { savePageState, getPageState } = useHistoryState()
// 离开前保存
savePageState({ scrollTop: window.scrollY, page: currentPage })
// 激活后恢复
onActivated(() => {
const state = getPageState()
if (state) {
window.scrollTo(0, state.scrollTop)
currentPage.value = state.page
}
})
6.3 注意事项
| 特性 | 说明 |
|---|---|
| 存储大小限制 | 通常 640KB~1MB(因浏览器而异),超出会报错 |
| 安全性 | 同域下任何 JS 都能读取,不要存敏感信息(token 等) |
| F5 刷新后 | popstate 不会触发,但 history.state 仍然可读(浏览器保留了) |
| 与 Vue Router 冲突 | Vue Router 也用 history.state 内部存储路由信息,覆盖可能冲突 |
七、面试高频问题速查
Q1:用户从详情页后退回列表页,如何保持之前的滚动位置和筛选条件?
分两层回答。第一层是滚动位置:Vue Router 的
scrollBehavior配置项可以通过savedPosition参数自动恢复浏览器后退时的滚动位置。第二层是业务状态(筛选条件、分页、选中行等):推荐使用<keep-alive>包裹router-view并配合include白名单精确控制需要缓存的页面;或者对大型列表页面,在beforeRouteLeave中将状态序列化到sessionStorage,在activated或beforeRouteEnter中反序列化恢复。选择依据是页面规模和内存预算。
Q2:keep-alive 有什么缺点?怎么解决?
主要有三个问题:(1)内存占用——缓存的组件实例一直驻留内存,可通过设置
max限制数量或动态维护include列表来解决;(2)数据过期——缓存的组件不会重新触发created和mounted,需要在activated钩子里刷新数据;(3)资源泄漏——setInterval、addEventListener等在deactivated时不会自动清理,必须在deactivated中手动释放,并在activated中重新注册。如果页面特别大(如含 ECharts 图表的重型仪表盘),建议改用手动状态管理方案而非 keep-alive。
Q3:scrollBehavior 的 savedPosition 什么时候有值?
只有在触发
popstate事件时有值,即用户点击浏览器的前进/后退按钮、或调用history.back()/history.forward()/history.go()时。普通的router.push()、router.replace()、点击<router-link>等操作都是pushState行为,savedPosition为 null。这是因为在 pushState 时浏览器会自动记录当前滚动位置到历史状态中,等到后续 popstate 触发时才会把那个位置传给 scrollBehavior。
十一、本篇小结
| 概念 | 一句话记忆 |
|---|---|
scrollBehavior | Vue Router 内置配置,savedPosition 自动处理后退时的滚动恢复 |
savedPosition 有效时机 | 仅 popstate 时有值(后退/前进),push 操作时为 null |
keep-alive | 包裹 router-view 使组件切换时不销毁而是 deactivate |
activated/deactivated | keep-alive 特有钩子,替代 created/beforeUnmount |
include/exclude | 精确控制缓存哪些组件(必须配组件 name 属性) |
| keep-alive 三大坑 | 内存占用 + 数据不过期 + 资源需在 deactivated 手动清理 |
| transition 配合 | <transition mode="out-in"> + <keep-alive> + CSS 动画 = 丝滑路由切换 |
| mode="out-in" | 旧组件先离开再进入新组件,避免路由闪烁(路由场景最常用) |
| 多标签页状态隔离 | keep-alive 单实例不够用 → 方案A:Map<key,State>手动管理;方案B::key="route.fullPath" 强制多实例 |
| key 多实例法 | 用 route.fullPath 做 key 让同组件不同参数各自独立缓存,注意设置 max 限制内存 |
history.pushState 第一个参数 | 可存自定义状态对象(~640KB 上限),popstate 时取回 |
| 方案选择 | 只要滚动→scrollBehavior;中小页面→keep-alive;大页面→Store+sessionStorage |
下一篇预告:最后一篇——SPA 内存泄漏与性能排查。SPA 不像传统页面那样刷新即重置,长时间运行后泄漏会累积到浏览器卡死。十大泄漏场景是什么?Chrome DevTools 怎么排查?除了泄漏之外,路由切换本身还有哪些性能优化手段?
👉 Vue 路由系列 06:内存泄漏与性能排查 —— 十大泄漏场景 + DevTools 实战
🔗 回顾前篇:Vue 路由系列 04:懒加载与打包优化 —— import() 原理 + Webpack/Vite 分组策略
参考来源:Gemini 3.1 Pro 模拟面试记录(路由与单页应用章节)、Vue Router 4 官方文档