路由守卫的执行顺序总是记不住?动态路由的 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())
)
)
}
做了两件事:
- 识别异步组件,包装成
defineAsyncComponent - 用
Promise.all等待所有异步组件加载完成
只有所有异步组件都加载完了,导航才会继续。这保证了组件在渲染时已经就绪,不会出现布局抖动。
3.5 代码分割的底层原理
Vue Router 本身不负责代码分割,这是构建工具干的活:
- 编译阶段 :Webpack/Vite 扫描到
import()语法,把对应的模块标记为独立入口 - 打包阶段 :生成独立的 chunk 文件(比如
UserDetails.[hash].js) - 运行阶段 :
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 在导航前加载,构建工具负责代码分割 |
面试高频问题
- 导航守卫的完整执行顺序? → 离开 → 全局 → 重用 → 路由配置 → 解析异步组件 → 进入 → 解析 → 后置
- next() 和返回值能混用吗? → 不能,会触发两次导航
- /user/1 跳到 /user/2 组件为什么不更新? → 组件复用了,用 watch 或 beforeRouteUpdate 处理
- addRoute 后需要刷新页面吗? → 不需要,内部会更新匹配表
- 懒加载的 chunk 是什么时候加载的? → 导航确认之前,resolveAsyncComponents 阶段
写在最后
Vue Router 看起来简单,但内部的管道模型、正则匹配和异步加载机制都设计得很精巧。理解了这些原理,遇到路由相关的 bug 就不会一头雾水了。
特别是导航守卫的执行顺序和 next 的控制流,建议自己写个 demo 跑一遍,比背十遍文档都有用。
有问题评论区见 👋