第 24 章

Vue Router 4:路由匹配算法与导航守卫的异步流水线

Vue Router 4 的路由匹配不是简单的字符串比较,而是一个带评分系统的正则引擎。每条路由规则被编译成正则表达式,每次导航都要经历 15 个异步步骤的流水线——其中任何一步调用 next(false) 或抛出异常,导航都会被中止。理解这个流水线,你就理解了为什么有些守卫"不生效",以及动态权限路由的正确实现方式。

Level 1 · 你需要知道的(1-3年经验)

Vue Router 4 的三种 History 模式

HTML5 History API(createWebHistory)

// 生成 URL:https://example.com/user/profile
// 需要服务器配置:所有路径返回 index.html
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [...]
})

服务器配置(Nginx):

location / {
  try_files $uri $uri/ /index.html;
}

Hash 模式(createWebHashHistory)

// 生成 URL:https://example.com/#/user/profile
// 不需要服务器配置(hash 不发送到服务器)
const router = createRouter({
  history: createWebHashHistory(),
  routes: [...]
})

内存模式(createMemoryHistory)

// URL 不变,适合 SSR 和单元测试
const router = createRouter({
  history: createMemoryHistory(),
  routes: [...]
})

路由定义:动态参数与嵌套路由

const routes = [
  // 静态路由
  { path: '/', component: Home },
  
  // 动态参数
  { path: '/user/:id', component: User, props: true },
  
  // 可选参数(Vue Router 4 语法)
  { path: '/user/:id?', component: User },
  
  // 通配符(必须放最后)
  { path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound },
  
  // 嵌套路由
  {
    path: '/admin',
    component: AdminLayout,
    children: [
      { path: '', component: AdminDashboard },         // /admin
      { path: 'users', component: AdminUsers },        // /admin/users
      { path: 'users/:id', component: AdminUserEdit }, // /admin/users/123
    ]
  }
]

导航守卫的三个层级

// 全局守卫
router.beforeEach((to, from, next) => {
  if (!isAuthenticated && to.meta.requiresAuth) {
    next('/login')
  } else {
    next()
  }
})

router.afterEach((to, from) => {
  document.title = to.meta.title as string || 'My App'
})

// 路由独享守卫
const routes = [
  {
    path: '/admin',
    component: Admin,
    beforeEnter: (to, from) => {
      if (!isAdmin()) return '/403'
    }
  }
]

// 组件内守卫
const MyComponent = {
  beforeRouteEnter(to, from, next) {
    // 注意:此时组件实例尚未创建,无法访问 this
    next(vm => {
      // vm 是组件实例
      vm.loadData(to.params.id)
    })
  },
  beforeRouteLeave(to, from) {
    if (hasUnsavedChanges) {
      return false // 阻止导航
    }
  }
}

动态路由:RBAC 权限控制

// 登录后根据角色动态添加路由
async function setupRoutes(userRole: string) {
  const permittedRoutes = await fetchRoutesByRole(userRole)
  
  permittedRoutes.forEach(route => {
    router.addRoute(route)
  })
  
  // 重要:addRoute 后需要重新导航,让当前路径匹配新路由
  router.push(router.currentRoute.value.fullPath)
}

// 删除路由
router.removeRoute('admin-route-name')

路由懒加载

// Vite 自动代码分割
const routes = [
  {
    path: '/user',
    component: () => import('./views/User.vue') // 懒加载
  },
  {
    path: '/admin',
    component: () => import('./views/Admin.vue'),
    // 添加 webpackChunkName 控制 chunk 名称(Webpack)
    // Vite 中用 rollupOptions 配置
  }
]

Level 2 · 它是怎么运行的(3-5年经验)

路由匹配算法:正则编译

每条路由在注册时都会被编译成正则表达式:

'/user/:id'   → /^\/user\/([^\/]+?)\/?$/i
'/:pathMatch(.*)*' → /^(?:\/(.*))?\/?$/i

路径参数提取示例:

路由:/user/:id/posts/:postId
URL:/user/42/posts/100

正则:/^\/user\/([^\/]+?)\/posts\/([^\/]+?)\/?$/i
捕获:[42, 100]
映射:{ id: '42', postId: '100' }

路由评分系统(Rank Score)

当多条路由都能匹配同一 URL 时,Vue Router 用评分系统决定优先级:

评分维度(每段路径独立评分):

静态段:得分最高(如 /user)
  → score: 40

动态参数:中等得分(如 /:id)
  → score: 20

可选参数:较低得分(如 /:id?)
  → score: 10

通配符:最低得分(如 /:pathMatch(.*))
  → score: 1
示例场景:

路由 A:/user/profile(静态路径)
路由 B:/user/:id(动态参数)

URL:/user/profile

路由 A 匹配:静态段分数 40 + 静态段分数 40 = 80
路由 B 匹配:静态段分数 40 + 动态段分数 20 = 60

路由 A 优先(score: 80 > 60)

段数更多的路由同等条件下优先于段数少的路由:

路由 A:/user/:id        → score: 60
路由 B:/user/:id/posts  → score: 80

URL:/user/42/posts
路由 B 优先(更具体的匹配)

导航守卫的完整执行顺序(15步)

导航触发(router.push('/new-path'))
        │
        ▼
1. 组件内守卫:beforeRouteLeave(即将离开的组件)
        │
        ▼
2. 全局守卫:router.beforeEach
        │
        ▼
3. 路由重用检测:
   └─ 如果组件复用(同路由不同参数):
      4. 组件内守卫:beforeRouteUpdate
        │
        ▼
5. 路由独享守卫:beforeEnter
        │
        ▼
6. 解析异步路由组件(懒加载)
        │
        ▼
7. 组件内守卫:beforeRouteEnter(进入新组件,this 不可用)
        │
        ▼
8. 全局守卫:router.beforeResolve
        │
        ▼
--- 导航确认 ---
        │
        ▼
9. 全局守卫:router.afterEach(不接受 next,不影响导航)
        │
        ▼
10. DOM 更新(组件渲染)
        │
        ▼
11. beforeRouteEnter 的 next(vm => ...) 回调执行
    (此时组件实例已可用)

关键规则:步骤 1-8 每一步都是异步的,前一步 resolve 后才执行下一步(串行)。任何一步返回 false 或导航目标(字符串/Location 对象),都会中止当前导航。

守卫流水线的异步执行机制

// 内部实现(简化版 runGuardQueue)
async function runGuardQueue(guards: NavigationGuard[]) {
  for (const guard of guards) {
    await new Promise<void>((resolve, reject) => {
      const next = (value?: unknown) => {
        if (value === false) {
          reject(new NavigationFailure(NavigationFailureType.aborted))
        } else if (isRouteLocation(value)) {
          reject(value) // 重定向
        } else {
          resolve()
        }
      }
      
      // 调用守卫
      const result = guard.call(null, to, from, next)
      
      // 支持返回值语法(不使用 next)
      if (result !== undefined) {
        if (result === false) {
          reject(new NavigationFailure(NavigationFailureType.aborted))
        } else if (isRouteLocation(result)) {
          reject(result) // 重定向
        } else if (result === true || result === undefined) {
          resolve()
        } else if (result instanceof Promise) {
          result.then(resolve, reject)
        }
      }
    })
  }
}

Vue Router 4 的守卫支持两种语法:

  1. 调用 next()(兼容旧版)
  2. 返回值(推荐):return false(中止)、return '/redirect'(重定向)、return undefined(继续)

守卫执行流程图

beforeEach 守卫列表 [guard1, guard2, guard3]

串行执行:
guard1 调用
  │
  └─ next() / return undefined
        │
        ▼
guard2 调用
  │
  └─ next('/login') / return '/login'
        │
        ▼
中止当前导航,触发新导航 '/login'

guard3 不再执行
import { isNavigationFailure, NavigationFailureType } from 'vue-router'

router.push('/user/123').then(failure => {
  if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
    // 守卫返回 false 中止导航
  }
  if (isNavigationFailure(failure, NavigationFailureType.cancelled)) {
    // 新导航在前一导航完成前触发(竞态)
  }
  if (isNavigationFailure(failure, NavigationFailureType.duplicated)) {
    // 导航到当前路由(重复导航)
  }
})

Level 3 · 设计文档与源码(资深开发者)

路由记录的内部数据结构

// packages/router/src/types/index.ts
interface RouteRecordNormalized {
  path: string                        // 规范化路径
  redirect?: RouteRecordRedirectOption
  name?: RouteRecordName
  components?: Record<string, RawRouteComponent>
  children: RouteRecordRaw[]
  meta: RouteMeta
  props: Record<string, _RouteProps>
  aliasOf?: RouteRecordNormalized
  beforeEnter?: NavigationGuard | NavigationGuard[]
  
  // 匹配用
  regex: RegExp                       // 编译后的正则
  score: number[][]                   // 每段的分数数组
  keys: PathParserParamKey[]          // 参数 key 列表
}

路径解析器的编译实现

// packages/router/src/matcher/pathParserRanker.ts
export function tokenizePath(path: string): Token[][] {
  // 将路径字符串分割成 token 数组
  // '/user/:id/posts' → [
  //   [{ type: 'Static', value: 'user' }],
  //   [{ type: 'Param', value: 'id' }],
  //   [{ type: 'Static', value: 'posts' }],
  // ]
}

export function tokensToParser(segments: Token[][]): PathParser {
  // 将 token 编译成正则表达式
  let pattern = '^'
  const keys: PathParserParamKey[] = []
  const score: number[][] = []
  
  for (const segment of segments) {
    const segmentScore: number[] = []
    
    for (const token of segment) {
      if (token.type === TokenType.Static) {
        pattern += '/' + escapeRegex(token.value)
        segmentScore.push(PathScore.Static) // 40 分
      } else if (token.type === TokenType.Param) {
        const { value, repeatable, optional } = token
        keys.push({ name: value, repeatable, optional })
        pattern += '/' + (optional ? '?' : '') + `([^/]+?)`
        segmentScore.push(
          optional ? PathScore.OptionalParam : PathScore.DynamicParam
        ) // 10 或 20 分
      }
    }
    
    score.push(segmentScore)
  }
  
  return { re: new RegExp(pattern), score, keys }
}

导航守卫的调度实现

// packages/router/src/router.ts(finalizeNavigation 的调用链)
async function navigate(to: RouteLocationNormalized, from: RouteLocationNormalized) {
  // 收集所有需要执行的守卫
  const guards: Lazy<any>[] = []
  
  // 1. 离开守卫:从旧组件收集 beforeRouteLeave
  extractComponentsGuards(leavingRecords, 'beforeRouteLeave', to, from)
  
  // 2. 全局 beforeEach
  guards.push(...router.beforeEachGuards)
  
  // 3. beforeRouteUpdate(路由复用的组件)
  extractComponentsGuards(updatingRecords, 'beforeRouteUpdate', to, from)
  
  // 4. 新路由的 beforeEnter
  enteringRecords.forEach(record => {
    if (record.beforeEnter) guards.push(...toArray(record.beforeEnter))
  })
  
  // 串行执行上述守卫
  await runGuardQueue(guards)
  
  // 5. 解析组件(触发懒加载)
  await Promise.all(
    enteringRecords.map(async record => {
      if (!record.components) return
      await Promise.all(
        Object.values(record.components).map(component => {
          if (isRouteComponent(component)) return
          return component() // 执行 () => import(...)
        })
      )
    })
  )
  
  // 6. 组件内 beforeRouteEnter
  extractComponentsGuards(enteringRecords, 'beforeRouteEnter', to, from)
  
  // 7. 全局 beforeResolve
  guards.push(...router.beforeResolveGuards)
  
  await runGuardQueue(guards)
  
  // 所有守卫通过后,确认导航
}

scroll behavior 的实现

// 自定义滚动行为
const router = createRouter({
  history: createWebHistory(),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 返回上一页时恢复滚动位置
    if (savedPosition) {
      return savedPosition
    }
    
    // 有 hash 时滚动到锚点
    if (to.hash) {
      return { el: to.hash, behavior: 'smooth' }
    }
    
    // 默认滚动到顶部
    return { top: 0 }
  }
})

savedPosition 由浏览器的 popstate 事件提供,Vue Router 在 popstate 时记录当前滚动位置,在恢复导航时传入 scrollBehavior

路由的懒加载与 Vite 代码分割

// Vite 将每个动态 import 编译成独立 chunk
const User = () => import('./views/User.vue')
// 编译为:__vite_dynamic_import('./views/User.vue')
// 实际生成:/assets/User-[hash].js

// 路由懒加载的加载状态处理
const router = createRouter({
  routes: [{
    path: '/user',
    component: () => import('./views/User.vue')
  }]
})

// 全局加载提示
router.beforeEach(() => { showLoading() })
router.afterEach(() => { hideLoading() })

Level 4 · 边界与陷阱(全体适用)

陷阱 1:beforeRouteEnter 中无法访问 this

// ❌ 错误:组件实例还未创建
beforeRouteEnter(to, from, next) {
  console.log(this) // undefined(严格模式)
  this.loadData()   // TypeError!
  next()
}

// ✅ 正确:通过 next 的回调访问实例
beforeRouteEnter(to, from, next) {
  next(vm => {
    // 组件挂载完成后调用
    vm.loadData(to.params.id)
  })
}

// ✅ 在 <script setup> 中使用 onBeforeRouteEnter(Vue Router 4.x)
import { onBeforeRouteEnter } from 'vue-router'

onBeforeRouteEnter((to, from, next) => {
  next(vm => {
    // vm 是组件实例
  })
})

陷阱 2:addRoute 后当前路由不自动刷新

// ❌ addRoute 不会触发当前路由重新匹配
router.addRoute({ path: '/new', component: NewPage })
// 如果当前在 /new,什么都不会发生

// ✅ 需要手动重新导航
router.addRoute({ path: '/new', component: NewPage })
// 检查当前路由是否需要刷新
if (router.currentRoute.value.path === '/new') {
  router.replace('/new') // 重新导航
}
// 或者更通用的方式:
router.push(router.currentRoute.value.fullPath)

实际场景:登录后动态加载权限路由,需要在 addRoute 后调用 router.push()router.replace() 触发重新匹配。

陷阱 3:全局 beforeEach 中忘记处理无限重定向

// ❌ 可能导致无限重定向循环
router.beforeEach((to, from) => {
  if (!isAuthenticated) {
    return '/login' // 如果在 /login 时也触发,会导致循环
  }
})

// ✅ 正确:检查当前路由,避免循环
router.beforeEach((to, from) => {
  if (!isAuthenticated && to.name !== 'Login') {
    return { name: 'Login' }
  }
  // 或者检查 meta 标记
  if (!isAuthenticated && to.meta.requiresAuth) {
    return { name: 'Login', query: { redirect: to.fullPath } }
  }
})

陷阱 4:动态路由与 404 通配符的顺序问题

// ❌ 如果 404 路由先于动态路由注册,动态路由可能被捕获为 404
const routes = [
  { path: '/:pathMatch(.*)*', component: NotFound }, // 放在开头
  { path: '/user/:id', component: User }, // 这条路由永远不会被匹配!
]

// ✅ 正确:通配符放最后,或使用 router.addRoute 的父路由参数
const routes = [
  { path: '/user/:id', component: User },
  { path: '/:pathMatch(.*)*', component: NotFound }, // 放最后
]

router.addRoute() 动态添加的路由会自动进行排名,但静态定义的路由数组中顺序很重要——特别是通配符路由。

陷阱 5:router.push 返回的 Promise 不会 reject

Vue Router 4 中,导航失败(被守卫阻止、重复导航)不会导致 Promise reject,而是 resolve 一个 NavigationFailure

// ❌ 错误的错误处理方式
try {
  await router.push('/protected')
} catch (e) {
  // 守卫阻止导航不会走到这里!
}

// ✅ 正确处理
const failure = await router.push('/protected')
if (failure) {
  if (isNavigationFailure(failure, NavigationFailureType.aborted)) {
    console.log('Navigation was blocked by a guard')
  }
}

只有编程错误(如传入无效的路由对象)才会让 Promise reject。

陷阱 6:keep-alive 与路由守卫的交互

<keep-alive> 缓存的组件在再次激活时不会触发 beforeRouteEnter,而是触发 activated 生命周期:

// ❌ 依赖 beforeRouteEnter 加载数据,在 keep-alive 下会失效
beforeRouteEnter(to, from, next) {
  next(vm => vm.loadData()) // 组件缓存后,再次进入不触发
}

// ✅ 同时监听 activated 钩子
setup() {
  onActivated(() => {
    loadData() // keep-alive 激活时触发
  })
  
  onBeforeRouteEnter((to, from, next) => {
    next(vm => vm.loadData()) // 首次进入时触发
  })
}

章节小结

  1. 路由匹配是带评分的正则引擎:每条路由规则被编译成正则表达式和分数数组;多路由匹配时按分数排序,静态段优先于动态段,更具体的路径优先于通配符。评分系统是 Vue Router 4 相比 v3 的重要改进,完全消除了路由定义顺序的依赖性。

  2. 导航守卫是异步流水线:15 个步骤串行执行,每步异步 resolve 后才触发下一步。从 beforeRouteLeavebeforeResolve 的每一步都可以中止、重定向或放行导航;afterEach 在导航确认后触发,无法改变结果。

  3. 三种 History 模式适用不同场景createWebHistory 生成干净 URL 但需要服务器配合;createWebHashHistory 无需服务器配置;createMemoryHistory 用于 SSR 和测试环境。

  4. addRoute 不会触发当前路由重新匹配:动态添加路由后,必须显式调用 router.push()router.replace() 才能让当前页面匹配到新路由。这是 RBAC 动态权限路由实现中最容易忽略的一步。

  5. beforeRouteEnter 是唯一不能直接使用 this 的守卫:因为组件实例在 beforeRouteEnter 时尚未创建,必须通过 next(vm => ...) 回调访问组件实例。在 Vue Router 4 的 Composition API 中,使用 onBeforeRouteEnter 配合 next 回调是标准解法。

本章评分
4.6  / 5  (5 评分)

💬 留言讨论