[vue-router]05-状态保持与滚动行为

2 阅读17分钟

【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 做状态缓存"

这个回答的问题

  1. 方向偏了——beforeRouteLeave 离开时确实可以保存状态,但滚动位置不需要你手动管,scrollBehavior 已经内置了这个能力
  2. 过度工程化——用 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 includeexclude —— 精确控制缓存哪些页面

属性类型说明
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,在 activatedbeforeRouteEnter 中反序列化恢复。选择依据是页面规模和内存预算。

Q2:keep-alive 有什么缺点?怎么解决?

主要有三个问题:(1)内存占用——缓存的组件实例一直驻留内存,可通过设置 max 限制数量或动态维护 include 列表来解决;(2)数据过期——缓存的组件不会重新触发 createdmounted,需要在 activated 钩子里刷新数据;(3)资源泄漏——setIntervaladdEventListener 等在 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。


十一、本篇小结

概念一句话记忆
scrollBehaviorVue Router 内置配置,savedPosition 自动处理后退时的滚动恢复
savedPosition 有效时机仅 popstate 时有值(后退/前进),push 操作时为 null
keep-alive包裹 router-view 使组件切换时不销毁而是 deactivate
activated/deactivatedkeep-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 官方文档