[vue-router]03-动态路由与权限控制

4 阅读9分钟

【Vue 路由系列 03】动态路由与权限控制:addRoute 与 404 问题

"先跳到 404,带 query 参数记录原路径,再 replace 回来。" —— 这是我在模拟面试中给出的方案。

这是一个能工作的 Hack,但不是标准做法。面试官想听到的答案是 next({ ...to, replace: true })。在进入 addRoute 这个高级话题之前,我们需要先把 Vue Router 的完整路由体系梳理清楚——嵌套路由怎么配?编程式导航有哪些 API?命名路由和路径路由怎么选?动态参数匹配的通配符语法是什么?搞懂这些基础,再看动态路由就会豁然开朗。


零、Vue Router 路由体系全貌(基础铺垫)

在上一篇中,我们讲了守卫系统("谁能进来")。这一节我们讲路由体系本身("路怎么走")。很多同学直接跳到 addRoute 却连基础路由配置都没搞透,面试时自然答不完整。

0.1 基础路由配置

import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import About from '@/views/About.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    // 最简单的静态路由
    { path: '/', component: Home },
    { path: '/about', component: About },
  ]
})

这是最基础的写法——每个路径对应一个组件。但实际项目中,只有这种配置远远不够。

0.2 嵌套路由(Children)—— ⚠️ 面试高频

嵌套路由是 Vue Router 最核心的概念之一,也是企业级项目布局的基础:

const routes = [
  {
    path: '/',
    component: Layout,        // 父组件(通常是整体布局框架)
    children: [
      // 子路由的 path 不要加前导斜杠!
      // 最终路径是 /dashboard(父路径 + 子路径拼接)
      {
        path: '',              // 空字符串表示默认子路由
        name: 'home',
        component: Dashboard   // 访问 / 时渲染在 Layout 的 <router-view> 中
      },
      {
        path: 'user',          // 最终路径:/user
        name: 'user-list',
        component: UserList,
      },
      {
        path: 'user/:id',      // 最终路径:/user/123
        name: 'user-detail',
        component: UserDetail
      },
      {
        path: 'settings',      // 最终路径:/settings
        name: 'settings',
        component: Settings,
        // 子路由还可以继续嵌套!
        children: [
          {
            path: 'profile',   // 最终路径:/settings/profile
            component: ProfileSettings
          },
          {
            path: 'security',  // 最终路径:/settings/security
            component: SecuritySettings
          }
        ]
      }
    ]
  }
]

Layout 组件的结构

<!-- Layout.vue -->
<template>
  <div class="layout">
    <aside class="sidebar">侧边菜单</aside>
    
    <main class="content">
      <!-- 这里是关键!子路由渲染的位置 -->
      <router-view />
      <!-- 
        访问 /         → router-view 渲染 Dashboard
        访问 /user     → router-view 渲染 UserList
        访问 /user/123 → router-view 渲染 UserDetail
        访问 /settings → router-view 渲染 Settings
      -->
    </main>
  </div>
</template>

⚠️ 嵌套 <router-view> 的坑

<!-- 如果 Settings 也有自己的子路由,Settings 内部也需要 <router-view> -->
<!-- Settings.vue -->
<template>
  <div>
    <h1>设置页</h1>
    <nav>
      <router-link to="/settings/profile">个人资料</router-link>
      <router-link to="/settings/security">安全设置</router-link>
    </nav>
    
    <!-- Settings 的子路由在这里渲染 -->
    <router-view />
    <!--
      访问 /settings/profile → 这里渲染 ProfileSettings
      访问 /settings/security → 这里渲染 SecuritySettings
    -->
  </div>
</template>

记忆法:N 层嵌套路由 = N 个 <router-view>,每个组件负责自己那一层的视图出口。

0.3 动态路径参数

基本用法
const routes = [
  // :id 是动态参数,匹配 /user/123、/user/abc 等
  { path: '/user/:id', component: UserDetail, name: 'user-detail' },
  
  // 多个参数
  { path: '/post/:postId/comment/:commentId', component: CommentDetail },
  
  // 可选参数(用 ? 标记)
  { path: '/category/:categoryId?', component: CategoryPage },
]

在组件内获取参数

<script setup>
import { useRoute } from 'vue-router'

const route = useRoute()

// 获取路径参数
console.log(route.params.id)        // /user/123 → "123"
console.log(route.params.postId)    // /post/42/comment/7 → "42"

// 获取 query 参数(URL 中 ? 后面的部分)
// URL: /search?keyword=vue&page=2
console.log(route.query.keyword)    // "vue"
console.log(route.query.page)       // "2"
</script>

⚠️ 参数变化但组件复用的陷阱

// 从 /user/1 导航到 /user/2
// 默认情况下,Vue Router 会**复用同一个组件实例**
// (因为路由配置没变,只是参数变了)

// 这意味着:
// ❌ created() 和 mounted() 不会重新执行!
// ❌ setup() 不会重新执行!

// 解决方案一:监听路由变化
import { watch } from 'vue'
watch(() => route.params.id, (newId) => {
  fetchUserData(newId)
}, { immediate: true })

// 解决方案二:用 onBeforeRouteUpdate(第02篇学过的)
import { onBeforeRouteUpdate } from 'vue-router'
onBeforeRouteUpdate((to) => {
  fetchUserData(to.params.id)
})
通配符语法(Vue Router 4)
模式匹配示例不匹配
/user/:id/user/123, /user/abc/user, /user/12/34
/user/:id*/user/123, /user/12/34, /user
/user/:pathMatch(.*)*/user/123, /user/a/b/c/user
/:pathMatch(.*)*所有路径(用于 404)
/:pathMatch(.*)/abc, /a/b 但不含尾部斜杠后空段
const routes = [
  // 始终放在最后的 404 兜底路由
  {
    path: '/:pathMatch(.*)*',  // Vue Router 4 推荐写法
    name: 'not-found',
    component: NotFound
  }
]

⚠️ Vue Router 3 用的是 path: '*',Vue Router 4 改为 path: '/:pathMatch(.*)*'。面试时提到这个区别能体现你对版本差异的了解。

0.4 编程式导航(Programmatic Navigation)

除了用 <router-link> 点击跳转,你还可以在 JS 代码中主动导航:

import { useRouter } from 'vue-router'

const router = useRouter()

// ===== 字符串路径 =====
router.push('/users')                    // → /users
router.push({ path: '/user/123' })       // → /user/123

// ===== 命名路由 + params(推荐!)=====
router.push({ name: 'user-detail', params: { id: '123' } })
// 如果路由配置是 /user/:id,则导航到 /user/123

// ===== 带 query 参数 =====
router.push({ path: '/search', query: { keyword: 'vue', page: 1 } })
// → /search?keyword=vue&page=1

// ===== 替换当前记录(不留后退历史)=====
router.replace('/login')
// 等同于
router.push({ path: '/login', replace: true })

// ===== 前进/后退 =====
router.go(1)     // 前进一步(等同于 history.forward())
router.go(-1)    // 后退一步(等同于 history.back())
router.go(-3)    // 后退三步

push vs replace vs go 对比

方法历史栈变化适用场景
router.push()新增一条记录正常页面跳转
router.replace()替换当前记录登录后替换登录页、重定向
router.go(n)移动 n 步前进/后退操作

0.5 命名路由 vs 路径路由

const routes = [
  // 路径路由
  { path: '/user/:id', component: UserDetail },
  
  // 命名路由(多了 name 属性)
  { path: '/user/:id', name: 'user-detail', component: UserDetail },
]

为什么推荐使用命名路由?

// ❌ 路径硬编码——如果路径变了,所有地方都要改
router.push('/user/' + userId)
// <router-link to="/user/123">用户详情</router-link>

// ✅ 命名路由——路径变了只需改一处(路由配置表)
router.push({ name: 'user-detail', params: { id: userId } })
// <router-link :to="{ name: 'user-detail', params: { id: user.id } }">用户详情</router-link>

面试加分项

当被问到"为什么用命名路由",可以回答:(1)解耦——组件不需要知道目标的具体路径,只通过名字引用;(2)可维护性——路径变更只改路由配置一处;(3)params 自动编码——命名路由配合 params 会自动进行 URL 编码,而字符串拼接容易出错。

0.6 路由懒加载(提前铺垫,下一篇细讲)

// ❌ 静态导入——打包到一个文件里
import UserList from '@/views/UserList.vue'
{ path: '/user', component: UserList }

// ✅ 动态导入(懒加载)——按需加载,单独打包
{ path: '/user', component: () => import('@/views/UserList.vue') }

这里只需要知道:() => import() 返回一个 Promise 函数,Vue Router 在导航到该路由时才真正执行 import 加载组件代码。完整的懒加载原理和打包优化策略将在下一篇(04篇)深入展开。

0.7 路由元信息(meta)

{
  path: '/admin/settings',
  component: AdminSettings,
  meta: {
    requiresAuth: true,           // 是否需要登录
    requiredPermissions: ['system:settings:view'],  // 所需权限
    title: '系统设置',             // 页面标题
    keepAlive: false,             // 是否缓存(配合 keep-alive)
    breadcrumb: ['首页', '系统管理', '系统设置']  // 面包屑
  }
}

// 在守卫中读取
router.beforeEach((to) => {
  if (to.meta.requiresAuth && !isLogin()) {
    return { name: 'login' }
  }
  if (to.meta.requiredPermissions) {
    checkPermission(to.meta.requiredPermissions)
  }
})

一、什么是动态路由

1.1 静态路由 vs 动态路由

静态路由:在项目启动时就确定好所有路由配置:

// router/index.js
const routes = [
  { path: '/', component: Home },
  { path: '/login', component: Login },
  { path: '/about', component: About },
  { path: '/404', component: NotFound },
]

动态路由:根据用户权限、后端返回数据等运行时条件,在应用启动后动态添加路由

// 用户登录后,根据后端返回的菜单列表动态添加路由
const menuList = await api.getUserMenu()
menuList.forEach(menu => {
  router.addRoute({
    path: menu.path,
    component: () => import(`@/views/${menu.component}.vue`),
    meta: { title: menu.title, requiresAuth: true }
  })
})

1.2 为什么需要动态路由

场景说明
RBAC 权限系统不同角色看到不同的菜单和页面(管理员看后台、普通用户看首页)
多租户 SaaS每个租户有不同的功能模块
后端驱动路由菜单结构由后端数据库管理,前端不硬编码

现在,结合前面 0.X 节的基础知识,你应该理解了:动态路由本质上就是运行时调用 addRoute 往路由表里塞新规则,而那些新规则的写法(children、name、meta、component 懒加载)和静态路由完全一样。


二、addRoute API 详解

2.0 ⚠️ 版本迁移:从 Vue Router 3 到 4

如果你维护过老项目或被问到版本差异,这个知识点很重要:

Vue Router 3 (Vue 2)Vue Router 4 (Vue 3)
创建实例new Router({ ... })createRouter({ ... })
历史模式mode: 'history'history: createWebHistory()
动态添加路由router.addRoutes(routes) ❌ 已移除router.addRoute(route)
添加方式批量传入数组逐个添加(支持嵌套路由)
通配符语法path: '*'path: '/:pathMatch(.*)*'

📌 关键变化addRoutes() 在 Vue Router 4 中已被完全移除。原因:新 API 支持更细粒度的控制——可以单独添加顶级路由,也可以通过 router.addRoute('parentName', route) 添加子路由到指定父路由下。如果需要批量添加,只需用 forEach 循环调用 addRoute() 即可。

参考:官方迁移指南

2.1 基本用法

const router = createRouter({
  history: createWebHistory(),
  // 初始只有基础路由(登录页、错误页等)
  routes: [
    {
      path: '/login',
      name: 'login',
      component: Login,
      meta: { public: true }
    },
    {
      path: '/:pathMatch(.*)*',
      name: 'not-found',
      component: NotFound
    }
  ]
})

// 登录成功后动态添加业务路由
async function initRoutes() {
  const { data } = await getUserRoutes()  // 从后端获取
  
  data.routes.forEach(route => {
    router.addRoute(route)  // 动态添加
  })
  
  console.log('当前路由表:', router.getRoutes())
}

2.2 addRoute vs addRoute(parentName, route) —— 嵌套路由的关键

这是很多人忽略的重要细节:

// 方式一:添加顶级路由
router.addRoute({
  path: '/dashboard',
  component: Dashboard
})

// 方式二:添加子路由到已有的父路由下
router.addRoute('layout', {
  path: 'settings',       // 注意:没有前导斜杠!
  component: Settings,
  children: [...]
})

// 效果等同于:
// {
//   path: '/layout',
//   component: Layout,
//   children: [
//     { path: 'settings', component: Settings }  // 最终路径是 /layout/settings
//   ]
// }

2.3 removeRoute —— 清理不再需要的路由

// 通过名称移除
router.removeRoute('admin')

// addRoute 返回的函数可以用来移除(更优雅)
const removeSettings = router.addRoute('layout', {
  path: 'settings',
  component: Settings
})

// 需要时调用
removeSettings()  // 移除 settings 路由

三、⚠️ 刷新 404 问题 —— 核心考点

3.1 问题复现

这是我在面试中回答得最差的问题之一。让我们完整还原这个场景:

// 初始路由配置
const staticRoutes = [
  { path: '/login', component: Login },
  { path: '/:pathMatch(.*)*', component: NotFound }  // 通配符 404
]

// 用户流程:
// 1. 访问 /login → 输入账号密码 → 登录成功
// 2. 后端返回菜单 → 调用 addRoute 添加 /dashboard, /users 等路由
// 3. 导航到 /dashboard → 正常显示 ✓
// 4. 用户按 F5 刷新……
// 5. 💀 404 页面出现了!

为什么?

F5 刷新之前的状态:
┌─────────────────────────────┐
│ 路由表 (内存中):            │
│  - /login                   │
│  - /dashboard  ← addRoute加的│
│  - /users      ← addRoute加的│
│  - /:pathMatch(.*)* (404)   │
└─────────────────────────────┘
✅ 能匹配 /dashboard

F5 刷新之后:
┌─────────────────────────────┐
│ JS 执行上下文被完全销毁!     │
│ 内存清空!Store 清空!       │
│                              │
│ 路由表回到初始状态:          │
│  - /login                   │
│  - /:pathMatch(.*)* (404)   │
│                              │
│ /dashboard 不存在了!        │
│ 只能被通配符捕获 → 404 💀    │
└─────────────────────────────┘

根因addRoute 添加的路由只存在于内存中,F5 刷新后一切重来。

3.2 我面试时的方案 vs 标准解法

❌ 我的方案(Hack)

"先跳到 404,带 query 参数记录原路径,再 replace 回来"

问题:

  1. 用户会闪一下 404 页面(体验极差)
  2. 触发了不必要的组件生命周期
  3. 历史记录可能混乱
  4. 本质上是在用错误的方式绕过问题
✅ 标准解法(Vue Router 官方推荐)

核心思路:在 beforeEach 中拦截 → 拉取路由 → 重新导航

// ✅ 正确写法
router.beforeEach(async (to) => {
  // 白名单直接放行
  if (isPublicPage(to)) return true
  
  // 检查是否已经初始化过动态路由
  if (!isRouteInitialized()) {
    // 1. 从后端获取路由配置
    const routeConfig = await fetchUserRoutes()
    
    // 2. 动态添加所有业务路由
    routeConfig.forEach(route => {
      router.addRoute(route)
    })
    
    // 3. ⭐⭐⭐ 关键一步:重新触发导航 ⭐⭐⭐
    
    // 写法A(本文采用):返回路由对象 + replace
    return { ...to, replace: true }
    
    // 写法B([官方文档](https://router.vuejs.org/guide/advanced/dynamic-routing.html)推荐):返回完整路径字符串
    // return to.fullPath  ← 等效!更简洁
    
    // 核心原理:addRoute() 只注册路由到路由表,**不会触发重新导航**。
    // 所以必须手动触发一次导航,让 Router 用最新的路由表重新匹配当前路径。
    // 在守卫中,return 一个新位置 = 触发重定向(等价于 router.replace(to.fullPath))
  }
  
  return true
})

时间线推演

用户在 /dashboard 页面按 F5
    ↓
浏览器请求 /dashboard → 服务端返回 index.html(fallback 配置)
    ↓
SPA 启动,创建 Router,初始路由表中没有 /dashboard
    ↓
beforeEach 触发,to = { path: '/dashboard' }
    ↓
isRouteInitialized() === false(还没初始化)
    ↓
fetchUserRoutes() → 获取路由配置
    ↓
router.addRoute(...) × N → 路由表现在有 /dashboard 了
    ↓
return { ...to, replace: true } 
→ 中断当前导航,用新路由表重新导航到 /dashboard
    ↓
这次 /dashboard 能匹配到了 ✓
→ 渲染 Dashboard 组件

3.3 关于 404 通配符的一个关键细节

这是另一个容易踩坑的地方——404 通配符应该在什么时候注册?

// ❌ 错误:把 404 写在静态路由里
const staticRoutes = [
  { path: '/login', component: Login },
  { path: '/:pathMatch(.*)*', name: 'not-found', component: NotFound }  // ❌ 太早了!
]
// 问题:addRoute 添加的路由都在通配符后面,
// 但通配符优先级最高,会先捕获所有路径!

// ✅ 正确:最后才注册 404
const staticRoutes = [
  { path: '/login', component: Login },
  // 注意:初始路由表里不放 404 通配符!
]

async function initDynamicRoutes() {
  // 1. 添加所有业务路由
  const routes = await fetchUserRoutes()
  routes.forEach(r => router.addRoute(r))
  
  // 2. ⭐ 所有动态路由添加完毕后,最后挂载 404
  router.addRoute({
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: NotFound
  })
}

为什么顺序很重要?

Vue Router 匹配路由的规则是:按照路由配置数组中的定义顺序依次匹配,先定义的路由优先级更高官方文档)。

💡 官方原文:"When defining routes with dynamic segments, the order of the routes matters: the first route that matches the URL is used."

如果你先把 404 通配符注册了,那后面的任何路由都永远不会被匹配到——因为通配符会"吞掉"所有路径。这也是为什么官方建议将通配符路由放在最后


四、完整的动态路由实现模板

综合以上要点,一份生产级别的动态路由方案:

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'

// 创建路由实例(初始只包含基础路由)
const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/login',
      name: 'login',
      component: () => import('@/views/Login.vue'),
      meta: { public: true }
    },
    {
      path: '/404',
      name: '404',
      component: () => import('@/views/NotFound.vue'),
      meta: { public: true }
    }
    // ⚠️ 这里不放 404 通配符!等动态路由加载完再加
  ]
})

// ========== 状态标记 ==========
let isInitialized = false
let initPromise = null

// ========== 核心初始化函数 ==========
async function initializeRoutes() {
  if (isInitialized) return true
  
  // 防止并发重复初始化
  if (initPromise) return initPromise
  
  initPromise = (async () => {
    try {
      const token = localStorage.getItem('token')
      
      if (!token) {
        isInitialized = true
        return false  // 未登录,不需要加载动态路由
      }
      
      // 1. 获取用户菜单/路由数据
      const { data: menuList } = await api.getUserMenus()
      
      if (!menuList || menuList.length === 0) {
        isInitialized = true
        return true
      }
      
      // 2. 将后端数据转换为 Vue Router 格式
      const dynamicRoutes = transformMenuToRoutes(menuList)
      
      // 3. 逐个添加路由
      dynamicRoutes.forEach(route => {
        router.addRoute(route)
      })
      
      // 4. 最后挂载 404 通配符(必须在所有动态路由之后!)
      router.addRoute({
        path: '/:pathMatch(.*)*',
        redirect: '/404'
      })
      
      isInitialized = true
      return true
      
    } catch (error) {
      console.error('路由初始化失败:', error)
      throw error
    } finally {
      initPromise = null
    }
  })()
  
  return initPromise
}

// ========== 全局前置守卫 ==========
router.beforeEach(async (to) => {
  // 公开页面直接放行
  if (to.meta.public) return true
  
  // 初始化动态路由
  const initialized = await initializeRoutes()
  
  // 如果未登录且访问非公开页面
  if (!initialized && to.name !== 'login') {
    return {
      name: 'login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 如果路由刚刚完成初始化,需要重新导航
  // (因为 addRoute 之后需要让 Router 用最新的路由表重新匹配)
  if (initialized && !isInitialized) {
    // isInitialized 还没被设为 true 说明是首次初始化完成
    return { ...to, replace: true }
  }
  
  return true
})

export default router


// ========== 工具函数:将后端菜单转为路由配置 ==========
function transformMenuToRoutes(menus) {
  return menus.map(menu => ({
    path: menu.path,
    name: menu.name,
    component: loadComponent(menu.component),
    meta: {
      title: menu.title,
      icon: menu.icon,
      permission: menu.permission
    },
    // 递归处理子菜单
    children: menu.children ? transformMenuToRoutes(menu.children) : undefined
  }))
}

// 动态导入组件
function loadComponent(componentPath) {
  // 使用 Vite/Webpack 的动态导入
  // componentPath 可能是 "system/User" → import("@/views/system/User.vue")
  return () => import(`@/views/${componentPath}.vue`)
}

五、动态路由 + 权限粒度控制

5.1 三级权限模型

第一层:路由级别(能否进入这个页面)
       ↓ 手段:beforeEach 白名单 + Token 校验
       
第二层:按钮级别(页面内的操作按钮显隐)
       ↓ 手段:自定义指令 v-permission
       
第三层:接口级别(API 请求鉴权)
       ↓ 手法:请求拦截器携带 Token + 后端校验

5.2 按钮级权限的自定义指令

// directives/permission.js
import { useUserStore } from '@/stores/user'

/**
 * 用法:
 * <button v-permission="['user:create']">新增用户</button>
 * <button v-permission="['user:delete']">删除用户</button>
 */
export default {
  mounted(el, binding) {
    const { value: requiredPermissions } = binding
    const userStore = useUserStore()
    const userPermissions = userStore.permissions  // 当前用户的权限码列表
    
    // 检查用户是否拥有所需权限
    const hasPermission = requiredPermissions.some(
      perm => userPermissions.includes(perm)
    )
    
    if (!hasPermission) {
      // 没有权限 → 从 DOM 中移除该元素
      el.parentNode?.removeChild(el)
    }
  }
}

// main.js 注册
app.directive('permission', permissionDirective)

5.3 路由元信息配合权限

// 在路由配置中使用 meta 存储权限要求
{
  path: '/admin/settings',
  component: AdminSettings,
  meta: {
    requiresAuth: true,
    requiredPermissions: ['system:settings:view'],
    title: '系统设置'
  }
}

// beforeEach 中检查
router.beforeEach((to) => {
  if (to.meta.requiredPermissions) {
    const hasPermission = checkPermission(to.meta.requiredPermissions)
    if (!hasPermission) {
      // 无权访问 → 403 页面
      return { name: 'forbidden' }
    }
  }
})

六、常见问题排查清单

问题现象可能原因解决方法
F5 刷新后 404addRoute 的路由只在内存中在 beforeEach 里重新拉取路由 + return { ...to, replace: true }
动态路由添加后还是 404404 通配符注册太早/:pathMatch(.*)* 放到最后
新增的路由无法匹配忘记 replace: true 重新导航addRoute 后必须重新触发导航
路由闪烁(白屏)异步初始化阻塞渲染配合 loading 状态或骨架屏使用
多次请求路由数据并发导航导致重复初始化使用单例 Promise(类似第02篇的 authCheckPromise)

七、本篇小结

概念一句话记忆
嵌套路由父组件 + children 配置 + <router-view> 渲染出口;N层嵌套=N个router-view
动态参数:id 捕获路径片段;参数变但组件复用时用 watchonBeforeRouteUpdate
通配符Vue Router 4 用 /:pathMatch(.*)* 兜底 404(Vue Router 3 是 *
编程式导航push(新增记录) / replace(替换) / go(n)(前进后退)
命名路由推荐!解耦路径、改一处生效、params 自动编码
meta路由的"注释字段",存权限/标题/缓存等自定义信息,守卫中通过 to.meta 读取
动态路由运行时通过 addRoute() 添加路由,适用于 RBAC 权限系统
F5 刷新 404 根因addRoute 的路由存在内存中,刷新后被清除
❌ Hack 方案先跳 404 再 replace 回来 → 会闪 404 页面,体验差
✅ 标准解法beforeEach 中拦截 → fetchUserRoutesaddRoutereturn { ...to, replace: true }
404 通配符时机必须放在所有 addRoute 之后,否则通配符会吞掉所有路径
replace: true让浏览器用新路由表重新匹配当前 URL,不留多余历史记录
并发去重单例 Promise:let initPromise = null,避免快速导航时重复请求
三级权限路由级(beforeEach) → 按钮级(v-permission 指令) → 接口级(请求拦截器)

下一篇预告:搞懂了路由怎么配(静态/动态)和怎么守(权限控制),下一篇我们关注性能——路由懒加载与打包优化() => import() 背后发生了什么?Webpack 和 Vite 分别怎么分组?HTTP/2 时代还需要路由分组吗?这些直接影响你的首屏加载速度和用户体验。

👉 Vue 路由系列 04:路由懒加载与打包优化 —— 懒加载原理 + Webpack/Vite 分组策略 + HTTP/2 思考

🔗 回顾前篇:Vue 路由系列 02:导航系统与路由守卫 —— 守卫执行顺序 + 死循环 + 数据预取


参考来源:Gemini 3.1 Pro 模拟面试记录(路由与单页应用章节)、Vue Router 4 官方文档