阅读视图

发现新文章,点击刷新页面。

为什么你的动态路由 “初始化了却没用”?揭秘 Vue Router 快照时机坑

提到 Vue Router,相信每一位前端开发者都不陌生。在日常开发中,尤其是搭建权限管控型后台管理系统时,很多同学应该都遇到过这样一个棘手的问题:我们需要通过前端实现权限菜单的动态渲染与路由控制。

先来看一段常见的业务代码:在路由前置守卫中,我们做了菜单路由的初始化处理 —— 登录成功后,前端会请求接口获取当前用户对应的权限菜单列表,将其持久化存储后,在路由跳转前调用 initRouter 方法,实现动态路由的添加。

但此时会出现一个令人困惑的问题:当我们成功进入某个动态添加的路由页面(例如 /dashboard)后,一旦刷新页面,该路由就会 “丢失”,页面无法正常渲染。这背后的原因究竟是什么呢?

router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  if (to.path !== '/login' && !userStore.token) {
    return next('/login')
  }
  if (!isInitRouter) {
    console.log('初始化菜单Menu')
    initRouter()
    isInitRouter = true
  }
  return next() 
})
// 一开始的默认静态路由
const router = createRouter({
  history: createWebHistory(), // history模式
  routes: [
    {
      path: '/',
      name: 'layout',
      redirect: '/dashboard',
      children: [],
      component: () => import('../views/layout/index.vue')
    },
    {
      path: '/login',
      component: () => import('../views/login/index.vue')
    }
  ]
})

快照拍的是 “启动瞬间路由表的全貌”,默认是静态路由表(因为动态路由还没机会注册),但如果动态路由提前注册了,快照就会包含它。快照是「导航周期启动瞬间」拍的,而是「导航周期启动瞬间」拍一次,整个导航周期(包括所有守卫、匹配、渲染环节)都共用这份快照。
先拍快照,后初始化动态路由,初始化改不了已拍好的快照。

  1. 先拍快照:导航周期一启动,就立刻给当前的路由表拍一张 “只读照片”(副本),这张照片里只有提前定义的静态路由(比如 /login),没有动态路由(比如 /dashboard);
  2. 后初始化:快照拍好之后,才会进入 beforeEach 守卫,执行你的 initRouter() 方法(动态添加 /dashboard 等路由,更新原路由表);
  3. 初始化改不了已拍好的快照:你通过 initRouter() 确实更新了「原路由表」(新增了动态路由),但那张提前拍好的「快照照片」不会同步更新 —— 它是一张独立的、固定不变的副本,导航周期全程只会用这张快照做事,不会再去读取更新后的原路由表。
next() 的调用方式 行为描述 是否开启新导航周期?
next()(无参数) 继续当前导航周期,用当前周期的快照匹配路由 ❌ 不开启,沿用旧周期
next('/login')(带路径字符串) 终止当前导航周期,发起一个新导航周期(目标:/login ✅ 开启新周期
next({ ...to, replace: true })(带路由对象) 终止当前导航周期,发起一个新导航周期(目标:路由对象指定的路径) ✅ 开启新周期

注:开启新的导航周期会重新走一次路由守卫

什么是导航周期?

导航周期的启动时机是刷新 / 进入页面的一瞬间」,但「导航周期本身是一整套连贯的流程」—— 不是 “瞬间结束”,而是从 “启动瞬间” 开始,依次执行 “拍快照、守卫、匹配、渲染” 等步骤,直到页面显示完成才结束(只是整个流程很快,体感上像 “一瞬间”)。

解决方案

一、要解决动态路由生效的问题,核心思路就是在添加路由后,通过 next(retryPath)等方式主动开启一个新的导航周期。在新的周期里,路由器就会基于包含新路由的、更新后的映射表来拍“快照”了

二、在跳转之前初始化路由表。正确的顺序是:先动态添加路由(让路由表变成最新),再执行跳转(启动新导航周期,拍新快照),新快照会包含最新路由表,跳转必然能找到对应路由,不会白屏。

快照触发时机

每次有效跳转都会启动一个新的导航周期—— 快照和 “导航周期” 是「一一对应」的:一个新导航周期,必然对应一次新快照;没有新导航周期,就不会有新快照。导航周期开始->新的快照->跳转

有效跳转:必然启动新导航周期,拍新快照

只要操作能引发「路由路径变化」或「路由查询参数 / 哈希值变化」(即路由状态改变),都属于有效跳转,一定会启动新导航周期,进而拍摄新快照。

  1. 路径完全变化(最常见)
    • 示例:/login/dashboard/dashboard/profile、页面刷新 /dashboard、点击 <router-link to="/setting">、浏览器前进 / 后退(路径变化);
    • 结果:启动新导航周期,拍新快照(快照为当前路由表全貌)。
  1. 路径不变,查询参数变化
    • 示例:/list?page=1/list?page=2(路径都是 /list,仅查询参数 page 变化)、/detail?id=1/detail?id=2
    • 结果:同样启动新导航周期,拍新快照(哪怕路径不变,查询参数变化也属于路由状态变化,会触发新周期)。
  1. 路径不变,哈希值变化
    • 示例:/home#top/home#bottom(路径 /home 不变,仅哈希值 # 后面的内容变化);
    • 结果:启动新导航周期,拍新快照。

无效跳转:不启动新导航周期,不拍新快照

只有一种情况属于无效跳转:跳转的目标路由与当前路由完全一致(路径、查询参数、哈希值均无变化) ,此时 Vue Router 会直接忽略该跳转请求,不会启动新导航周期,自然也不会拍摄新快照。

  1. 当前路由是 /dashboard,执行 router.push('/dashboard')(路径、参数、哈希均一致);
  2. 当前路由是 /list?page=1,执行 router.push('/list?page=1')(查询参数无变化);
  3. 当前路由是 /home#top,点击 <router-link to="/home#top">(哈希值无变化)。

注意

router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  if (to.path !== '/login' && !userStore.token) {
    return next('/login')
  }
  if (!isInitRouter) {
    console.log('初始化菜单Menu')
    initRouter()
    isInitRouter = true
  }
  return next({ ...to }) // 这样写会循环卡死
})

原因:触发了无限循环的导航周期——return next({ ...to }) 会持续终止当前导航周期,同时启动一个和当前目标完全一致的新导航周期,而新周期又会重复执行守卫逻辑,再次触发 return next({ ...to }),如此往复没有尽头,最终导致浏览器主线程被占用,页面卡死。

解决方案:仅在动态路由初始化后,按需执行 next({ ...to });其他场景执行 next(),同时添加 replace: true 优化体验。

步骤演示

一、正常进入(从 /login 跳转 /dashboard):没问题的流程

正常进入是「先访问 /login,登录后再跳转 /dashboard」,两步走,初始化时机提前了:

步骤 1:访问 /login(第一个导航周期,提前完成初始化)

  1. 触发导航:用户输入网址访问 /login → 启动「导航周期 A」;

  2. 拍快照 A:启动瞬间拍快照,此时路由表只有静态路由 /login(快照 A = 静态路由表);

  3. 执行 beforeEach 守卫:

    • 登录校验:to.path === '/login',条件不成立,跳过;
    • !isInitRouter === true → 执行 initRouter()(注册 /dashboard 等动态路由,路由表更新为「静态 + 动态」);
    • isInitRouter 设为 true
    • return next() → 继续导航周期 A,用快照 A 匹配 /login,匹配成功 → 显示 /login 页面;
  4. 关键结果:此时「路由表已经更新」(有 /dashboard),只是导航周期 A 的快照 A 是旧的,但不影响 /login 显示。

步骤 2:登录成功,跳转 /dashboard(第二个导航周期,快照拍到新路由表)

  1. 触发导航:登录成功后执行 router.push('/dashboard') → 启动「导航周期 B」;

  2. 拍快照 B:启动瞬间拍快照,此时路由表已经是「静态 + 动态」(步骤 1 已初始化),快照 B = 完整路由表(含 /dashboard

  3. 执行 beforeEach 守卫:

    • 登录校验:to.path === '/dashboard' 且有 token,跳过;
    • !isInitRouter === false → 跳过 initRouter()
    • return next() → 继续导航周期 B,用快照 B 匹配 /dashboard,匹配成功 → 正常显示页面;

正常进入的核心:初始化提前完成

initRouter() 在第一个导航周期(/login)就执行了,路由表提前更新;后续跳转 /dashboard 时,新导航周期的快照能拍到完整路由表,自然没问题。


二、刷新 /dashboard:不行的流程

刷新是「直接访问 /dashboard」,一步到位,初始化时机滞后了:

步骤 1:刷新 /dashboard(唯一导航周期,先拍快照后初始化)

  1. 触发导航:用户刷新 /dashboard 网址 → 启动「导航周期 C」;

  2. 拍快照 C:启动瞬间拍快照,此时路由表还是「初始静态路由」(无 /dashboardinitRouter() 还没执行),快照 C = 旧静态路由表

  3. 执行 beforeEach 守卫:

    • 登录校验:to.path === '/dashboard' 且有 token,跳过;
    • !isInitRouter === true → 执行 initRouter()(注册 /dashboard路由表更新为「静态 + 动态」);
    • isInitRouter 设为 true
    • return next() → 继续导航周期 C,用快照 C(旧静态路由表)匹配 /dashboard
  4. 关键结果:快照 C 中没有 /dashboard,匹配失败 → 页面白屏(无组件可渲染)。

刷新不行的核心:顺序反了

「导航周期 C 启动(拍旧快照)」在前,「initRouter() 初始化(更新路由表)」在后;旧导航周期只能用已拍好的快照 C,哪怕后续路由表更新了,也无法改变快照 C 的内容,导致匹配失败。

扩展

vue-router@3 和 vue-router@4 路由守卫的return和next的区别是否触发请看下一篇文章《动态路由跳转失效?原来是 next() 与 return 的用法搞反了!》

❌