Vue Router 进阶:导航守卫、动态路由与懒加载,源码级理解

路由守卫的执行顺序总是记不住?动态路由的 addRoute 到底做了什么?懒加载的 chunk 是怎么拆的?今天从用法到源码,一次性讲透。


前言

Vue Router 是 Vue 项目里几乎必用的库,但说实话,大部分同学对它的理解还停留在"能跑就行"的阶段。

今天从使用方法源码实现两个维度,把导航守卫、动态路由和路由懒加载彻底讲清楚。源码基于 Vue Router 4.x,顺便提一下跟 3.x 的核心差异。


一、导航守卫

1.1 三种级别的守卫

导航守卫分三个层级:全局、路由独享、组件内。先有个整体印象:

javascript 复制代码
// ① 全局前置守卫 ------ 最常用,权限校验就靠它
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next('/login')  // 没登录?去登录页
  } else {
    next()          // 放行
  }
})

// ② 全局解析守卫 ------ 组件解析完后调用,用的不多
router.beforeResolve(async (to, from) => {
  // 适合在这里获取数据,组件已经准备就绪了
  await fetchUserData(to.params.id)
})

// ③ 全局后置钩子 ------ 没有 next,不能中断导航
router.afterEach((to, from) => {
  document.title = to.meta.title || '默认标题'
})

路由独享守卫写在路由配置里,只对当前路由生效:

javascript 复制代码
const routes = [
  {
    path: '/admin',
    component: Admin,
    beforeEnter: (to, from, next) => {
      // 只在进入 /admin 时校验
      if (!isAdmin()) next('/403')
      else next()
    }
  }
]

组件内守卫写在组件里,有三个钩子:

javascript 复制代码
export default {
  beforeRouteEnter(to, from, next) {
    // 注意!此时组件还没创建,this 不可用
    // 如果需要访问组件实例,通过回调拿到
    next(vm => {
      vm.fetchData()  // vm 就是组件实例
    })
  },
  beforeRouteUpdate(to, from) {
    // /user/1 → /user/2 时,同一个组件被复用,走这个钩子
    this.fetchData(to.params.id)
  },
  beforeRouteLeave(to, from) {
    // 离开前的确认,可以阻止导航
    if (this.hasUnsavedChanges) {
      return window.confirm('有未保存的更改,确定离开吗?')
    }
  }
}

1.2 完整执行顺序(面试必背!)

这个顺序我之前总是记混,后来发现只要理解了"管道模型"就很好记:

markdown 复制代码
1. 导航被触发
2. 失活组件的 beforeRouteLeave     ← 组件内
3. 全局 beforeEach                  ← 全局
4. 重用组件的 beforeRouteUpdate     ← 组件内
5. 路由配置里的 beforeEnter         ← 路由独享
6. 解析异步路由组件                  ← 自动
7. 激活组件的 beforeRouteEnter     ← 组件内
8. 全局 beforeResolve               ← 全局
9. 导航确认,URL 更新
10. 全局 afterEach                  ← 全局

记忆技巧:从"离开"到"进入",先组件后全局,中间穿插路由配置。

1.3 next() 的那些坑

Vue Router 3 里必须调用 next(),不调就会卡住。Vue Router 4 改成了返回值控制,next 保留但不强制。

javascript 复制代码
// Vue Router 4 推荐写法
router.beforeEach((to) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    return '/login'     // 返回路径 = 重定向
  }
  return true           // 放行
  // return false        // 中断导航
})

// Vue Router 3 写法(仍兼容)
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next('/login')
  } else {
    next()
  }
})

千万别混用!要么用返回值,要么用 next,别两个都用,不然可能触发两次导航。

1.4 源码是怎么实现的?

源码的核心在 navigate 方法和 runGuardQueue 函数。

第一步:收集守卫队列

导航触发后,Router 会按顺序把所有守卫收集到一个数组里:

typescript 复制代码
// 简化后的源码逻辑
const guards: NavigationGuard[] = []

// 收集失活组件的 beforeRouteLeave
for (const record of from.matched) {
  guards.push(...extractGuards(record, 'beforeRouteLeave'))
}
// 收集全局 beforeEach
guards.push(...this.beforeGuards)
// 收集重用组件的 beforeRouteUpdate
for (const record of to.matched) {
  guards.push(...extractGuards(record, 'beforeRouteUpdate'))
}
// 收集路由独享 beforeEnter
for (const record of to.matched) {
  if (record.beforeEnter) guards.push(record.beforeEnter)
}
// 收集激活组件的 beforeRouteEnter
for (const record of to.matched) {
  guards.push(...extractGuards(record, 'beforeRouteEnter'))
}
// 收集全局 beforeResolve
guards.push(...this.resolveGuards)

第二步:用 Promise 链串行执行

typescript 复制代码
function runGuardQueue(guards): Promise<void> {
  return guards.reduce(
    (promise, guard) => promise.then(() => guard(to, from, next)),
    Promise.resolve()
  )
}

本质上就是一个 reduce,把守卫数组串成 Promise 链,一个接一个执行。任何一个守卫返回 false 或抛错,链条就中断。

next 的内部实现也很有意思:

typescript 复制代码
const next = (provided) => {
  if (provided === false) {
    abortNavigation()  // 中断,回退到 from
    return
  }
  if (typeof provided === 'string' || isRouteLocation(provided)) {
    return this.push(provided)  // 重定向,启动新导航
  }
  if (provided instanceof Error) {
    abortNavigation()
    throw provided  // 抛错
  }
  resolve()  // 正常放行
}

所以 next('/login') 不是直接跳转,而是中断当前管道,启动一个全新的导航。这也是为什么不能混用 next 和返回值的原因 ------ 两个控制流会打架。


二、动态路由匹配

2.1 基本用法

javascript 复制代码
const routes = [
  { path: '/user/:id', component: User }
]
// 访问 /user/123 → route.params.id === '123'

多参数与正则约束

javascript 复制代码
// 只匹配数字
{ path: '/user/:id(\\d+)', component: User }

// 可选参数
{ path: '/user/:id?', component: User }

// 通配符(404 页面常用)
{ path: '/:pathMatch(.*)*', component: NotFound }
// 访问 /foo/bar → params.pathMatch === ['foo', 'bar']

参数变化时组件复用问题

/user/1 跳到 /user/2,默认情况下同一个组件实例会被复用,生命周期钩子不会重新执行。

javascript 复制代码
// 方式一:watch 监听
watch(() => route.params.id, (newId) => {
  fetchData(newId)
})

// 方式二:组件内守卫(推荐)
beforeRouteUpdate(to) {
  this.fetchData(to.params.id)
}

动态添加路由

javascript 复制代码
// 添加顶级路由
router.addRoute({
  path: '/posts',
  component: Posts
})

// 添加子路由
router.addRoute('parentName', {
  path: 'child',
  component: Child
})

// 删除路由(通过替换整个路由表)
router.removeRoute('routeName')

动态添加路由最常见的场景就是权限控制:根据用户角色动态注册可访问的路由。

2.2 源码:路径是怎么匹配的?

path-to-regexp

Vue Router 用 path-to-regexp 库把路径字符串编译成正则表达式:

typescript 复制代码
// 简化逻辑
function normalizeRouteRecord(route) {
  const keys = []  // 保存参数名,如 ['id']
  const regexp = pathToRegexp(route.path, keys)
  
  return {
    ...route,
    re: regexp,     // 编译后的正则
    keys,           // 参数名数组
    score: computeScore(route.path)  // 路径权重,用于排序
  }
}

比如 /user/:id 会被编译成类似 /^\/user\/([^/]+)\/?$/i 的正则,keys 保存 ['id']

匹配过程

URL 变化时,Router 遍历所有路由记录,用正则匹配:

typescript 复制代码
function matchRoute(location, route) {
  const match = route.re.exec(location.path)
  if (!match) return null
  
  // 从正则匹配结果中提取参数
  const params = {}
  for (let i = 0; i < route.keys.length; i++) {
    params[route.keys[i].name] = match[i + 1] || ''
  }
  
  return { route, params }
}

匹配到的路由按 score 排序,形成 matched 数组。这就是嵌套路由能正确匹配的原因 ------ 父路由和子路由都会被匹配到,按顺序排列。

addRoute 的实现

addRoute 的核心是重新构建路由匹配表

typescript 复制代码
function addRoute(parentNameOrRoute, route) {
  if (route) {
    // 添加到指定父路由的 children
    const parent = matcher.getRecordMatcher(parentNameOrRoute)
    parent.children.push(normalizeRecord(route))
  } else {
    // 添加顶级路由
    matcher.addRoute(normalizeRecord(parentNameOrRoute))
  }
  // 内部会更新匹配表,确保新路由参与后续导航
}

所以每次 addRoute 后,新的路由就能立即参与匹配了。


三、路由懒加载

3.1 为什么需要懒加载?

如果不做懒加载,所有页面组件会打包到一个 JS 文件里。项目大了之后,首屏加载那个文件可能好几 MB,用户等半天白屏。

懒加载的核心思想:访问到某个路由时,才加载对应的组件代码

3.2 基本用法

javascript 复制代码
// 最简单的写法
const UserDetails = () => import('./views/UserDetails.vue')

// 带 webpack chunk 命名(Vite 也支持)
const UserProfile = () => import(
  /* webpackChunkName: "user-group" */ './views/UserProfile.vue'
)

const routes = [
  { path: '/user/:id', component: UserDetails },
  { path: '/user/:id/profile', component: UserProfile }
]

构建工具(Webpack/Vite)看到动态 import() 就会自动把对应的模块拆成独立的 chunk 文件。

3.3 加载状态与错误处理

组件加载需要时间,这段时间用户看到什么?加载失败怎么办?

javascript 复制代码
const AsyncComp = defineAsyncComponent({
  loader: () => import('./Heavy.vue'),
  loadingComponent: LoadingSpinner,  // 加载中显示
  errorComponent: ErrorPage,         // 加载失败显示
  delay: 200,                        // 200ms 后才显示 loading(避免闪烁)
  timeout: 3000                      // 超过 3s 算加载失败
})

这个在加载大型组件(比如富文本编辑器、图表库)时特别有用。

3.4 源码:懒加载是在什么时候触发的?

关键在于导航守卫管道中的 resolveAsyncComponents 这一步。

回顾前面的执行顺序,第 6 步是"解析异步路由组件",就在 beforeRouteEnter 之前:

typescript 复制代码
// 简化后的源码
function resolveAsyncComponents(to) {
  const matched = to.matched
  
  for (const record of matched) {
    for (const key in record.components) {
      const comp = record.components[key]
      // 如果 component 是一个函数(工厂函数),说明是异步组件
      if (typeof comp === 'function') {
        // 包装成 defineAsyncComponent
        record.components[key] = defineAsyncComponent(comp)
      }
    }
  }
  
  // 等待所有异步组件加载完成
  return Promise.all(
    matched.flatMap(record =>
      Object.values(record.components)
        .filter(c => c?.__asyncLoader)
        .map(c => c.__asyncLoader())
    )
  )
}

做了两件事:

  1. 识别异步组件,包装成 defineAsyncComponent
  2. Promise.all 等待所有异步组件加载完成

只有所有异步组件都加载完了,导航才会继续。这保证了组件在渲染时已经就绪,不会出现布局抖动。

3.5 代码分割的底层原理

Vue Router 本身不负责代码分割,这是构建工具干的活:

  1. 编译阶段 :Webpack/Vite 扫描到 import() 语法,把对应的模块标记为独立入口
  2. 打包阶段 :生成独立的 chunk 文件(比如 UserDetails.[hash].js
  3. 运行阶段import() 被调用时,通过 JSONP 或动态 <script> 加载对应 chunk

Vue Router 只负责在合适的时机(导航确认前)调用工厂函数,剩下的网络请求、模块解析和缓存全交给浏览器和构建工具。

3.6 跟普通异步组件的区别

对比项 路由懒加载 普通异步组件
触发时机 导航确认之前 组件渲染时
加载方式 resolveAsyncComponents 统一处理 defineAsyncComponent 单独处理
用户体验 加载完再渲染,无闪烁 可能先渲染 loading 骨架再替换
代码分割 构建工具自动处理 同样支持

路由懒加载把加载时机提前了,避免了渲染阶段的异步等待。


四、总结速查

特性 核心方法 源码关键点
导航守卫 beforeEach / afterEach / beforeEnter runGuardQueue Promise 链串行执行,next 控制流程
动态路由 :id 参数 / addRoute / removeRoute path-to-regexp 编译正则,addRoute 重建匹配表
路由懒加载 () => import('./Comp.vue') resolveAsyncComponents 在导航前加载,构建工具负责代码分割

面试高频问题

  1. 导航守卫的完整执行顺序? → 离开 → 全局 → 重用 → 路由配置 → 解析异步组件 → 进入 → 解析 → 后置
  2. next() 和返回值能混用吗? → 不能,会触发两次导航
  3. /user/1 跳到 /user/2 组件为什么不更新? → 组件复用了,用 watch 或 beforeRouteUpdate 处理
  4. addRoute 后需要刷新页面吗? → 不需要,内部会更新匹配表
  5. 懒加载的 chunk 是什么时候加载的? → 导航确认之前,resolveAsyncComponents 阶段

写在最后

Vue Router 看起来简单,但内部的管道模型、正则匹配和异步加载机制都设计得很精巧。理解了这些原理,遇到路由相关的 bug 就不会一头雾水了。

特别是导航守卫的执行顺序和 next 的控制流,建议自己写个 demo 跑一遍,比背十遍文档都有用。

有问题评论区见 👋

相关推荐
ricardo19735 小时前
# Tree Shaking 深度解析:为什么你的代码没被摇掉?
前端·面试
前端流一5 小时前
踩坑实录:Vite打包AntD5报错 rc-picker/es/generate/dayjs 模块找不到
前端
_按键伤人_5 小时前
三、手把手教你从零写一个本地 RAG
前端·llm·ai编程
008爬虫实战录5 小时前
【码上爬】 题十二:如来神掌 困难, JSVMP加密,使用代理补环境
前端·javascript·node.js
008爬虫实战录5 小时前
【码上爬】 题十:魔改算法 堆栈分析,找加密值过程详解
前端·python·算法
无人装备硬件开发爱好者5 小时前
深度解析GPS天线设计:从贴片天线到LNA前端的完整硬件方案
前端
卷帘依旧6 小时前
React Hook采用环形链表的原因
前端
lichenyang4536 小时前
从 HarmonyOS AI 聊天模块理解工程化架构:MVVM、Controller、Provider、请求封装与 SSE
前端
卷帘依旧6 小时前
为什么React Hooks不能用在if/for等条件/循环语句中
前端