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 的守卫支持两种语法:
- 调用
next()(兼容旧版) - 返回值(推荐):
return false(中止)、return '/redirect'(重定向)、return undefined(继续)
守卫执行流程图
beforeEach 守卫列表 [guard1, guard2, guard3]
串行执行:
guard1 调用
│
└─ next() / return undefined
│
▼
guard2 调用
│
└─ next('/login') / return '/login'
│
▼
中止当前导航,触发新导航 '/login'
guard3 不再执行
NavigationFailure:导航失败的类型
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()) // 首次进入时触发
})
}
章节小结
-
路由匹配是带评分的正则引擎:每条路由规则被编译成正则表达式和分数数组;多路由匹配时按分数排序,静态段优先于动态段,更具体的路径优先于通配符。评分系统是 Vue Router 4 相比 v3 的重要改进,完全消除了路由定义顺序的依赖性。
-
导航守卫是异步流水线:15 个步骤串行执行,每步异步 resolve 后才触发下一步。从
beforeRouteLeave到beforeResolve的每一步都可以中止、重定向或放行导航;afterEach在导航确认后触发,无法改变结果。 -
三种 History 模式适用不同场景:
createWebHistory生成干净 URL 但需要服务器配合;createWebHashHistory无需服务器配置;createMemoryHistory用于 SSR 和测试环境。 -
addRoute不会触发当前路由重新匹配:动态添加路由后,必须显式调用router.push()或router.replace()才能让当前页面匹配到新路由。这是 RBAC 动态权限路由实现中最容易忽略的一步。 -
beforeRouteEnter是唯一不能直接使用this的守卫:因为组件实例在beforeRouteEnter时尚未创建,必须通过next(vm => ...)回调访问组件实例。在 Vue Router 4 的 Composition API 中,使用onBeforeRouteEnter配合next回调是标准解法。