Appearance
Vue Router 导航守卫机制
先记结论
Vue Router 的导航守卫不是一个"事件系统",是一个异步串行管道。
每次路由跳转(push / replace / popstate / 手动改 hash)都会触发同一条 navigate() 调用链。所有守卫按固定顺序串行执行,任何一个拒绝(返回 false 或抛 error)都会中止整条管道,回到当前路由。
text
触发导航
→ 离开组件: beforeRouteLeave
→ 全局: beforeEach
→ 复用组件: beforeRouteUpdate
→ 路由配置: beforeEnter
→ 解析异步组件
→ 激活组件: beforeRouteEnter
→ 全局: beforeResolve
→ 导航确认, DOM 更新
→ 全局: afterEach
→ beforeRouteEnter 的 next 回调执行这个顺序记错一次,debug 可能浪费半天。
三类守卫的注册方式
全局守卫
js
const router = createRouter({ /* ... */ })
router.beforeEach((to, from, next) => { /* ... */ })
router.beforeResolve((to, from, next) => { /* ... */ })
router.afterEach((to, from, failure) => { /* ... */ })beforeEach 和 beforeResolve 的区别:beforeResolve 在所有组件内守卫执行完毕、异步组件加载完成后触发。它比 beforeEach 更接近"导航确认"的时间点。如果需要在异步组件加载后再决定跳转逻辑,用 beforeResolve。
afterEach 不接受 next,导航已经确认。第三个参数 failure 在导航被取消或出错时有值,正常完成时为 undefined。
路由级守卫
js
const routes = [
{
path: '/admin',
component: AdminLayout,
beforeEnter: (to, from, next) => {
// 只在进入 /admin 时触发,离开不触发
}
}
]beforeEnter 在 beforeRouteUpdate 之后、beforeRouteEnter 之前执行。只在进入该路由配置时触发一次。
组件内守卫
js
export default {
beforeRouteEnter(to, from, next) {
// ⚠️ 组件实例尚未创建,this 不可用
next(vm => { /* vm 是组件实例 */ })
},
beforeRouteUpdate(to, from, next) {
// 路由参数变化但组件复用时触发,this 可用
},
beforeRouteLeave(to, from, next) {
// 离开当前路由时触发,this 可用
// 常用于表单未保存拦截
}
}三个组件内守卫的关键点:
beforeRouteEnter的执行时机在组件实例创建之前,访问不到this。如果需要在组件创建后做操作,必须通过next(callback)。beforeRouteUpdate只在复用同一组件但路由参数变化时触发(如/user/1→/user/2)。从其他路由跳过来不会触发这个钩子。beforeRouteLeave是整条管道中第一个执行的钩子。用于"是否允许离开当前页"的判断,如表单未保存拦截。
源码入口:navigate()
vue-router 4 的 navigate() 函数是所有导航的入口。去掉错误处理和边界条件后,核心路径:
js
// 源码简化:router.ts → navigate()
function navigate(to, from) {
// 1. 提取被激活/失活/复用的组件
const [entering, leaving, updating] = extractChangingRecords(to, from)
// 2. 组装守卫队列:按固定顺序
const guards = [
...extractComponentsGuards(leaving, 'beforeRouteLeave'),
/* 将全局 beforeEach 注入为守卫对象 */
...extractGuards(globalBeforeGuards),
...extractComponentsGuards(updating, 'beforeRouteUpdate'),
/* 注入路由配置的 beforeEnter */
...extractGuards(routeBeforeEnterGuards),
/* 解析异步组件(resolve async components) */
...extractComponentsGuards(entering, 'beforeRouteEnter'),
/* 注入全局 beforeResolve */
...extractGuards(globalResolveGuards),
]
// 3. 串行执行守卫队列
return runGuardQueue(guards, to, from)
.then(() => {
// 守卫全部通过 → 确认导航
// 触发 afterEach
})
.catch(err => {
// 任何守卫拒绝 → 中止导航
})
}extractChangingRecords(to, from) 通过对比新旧 matched 数组,将记录分为三类:
leaving:旧路由有、新路由没有的组件记录 → 提取beforeRouteLeaveupdating:新旧路由共享的组件记录 → 提取beforeRouteUpdateentering:新路由有、旧路由没有的组件记录 → 提取beforeRouteEnter
这个分类在 diff 新旧路由匹配记录时完成,复杂度 O(n)。
runGuardQueue:promise 链的实现
runGuardQueue 是整个守卫机制的核心引擎。不是简单的 for 循环,是一个串行 promise 链:
js
// 源码简化:router.ts → runGuardQueue()
function runGuardQueue(guards, to, from) {
return guards.reduce(
(promise, guard) => promise.then(() => guard(to, from)),
Promise.resolve()
)
}用 reduce 将守卫数组折叠为 .then().then().then()... 链。每个 guard 的执行结果(return 值或 next() 参数)被 guardToPromiseFn 统一转换为 promise,决定下一环是继续、重定向还是中止。
js
// 源码简化:guardToPromiseFn —— 将 guard 返回值统一为 promise
function guardToPromiseFn(guard, to, from) {
return () => new Promise((resolve, reject) => {
const result = guard.call(null, to, from, (nextArg) => {
// next() 被调用
if (nextArg === false || isError(nextArg)) {
reject(nextArg)
} else if (nextArg && typeof nextArg === 'string') {
reject(createRouterError(16 /* redirected */, to, from, nextArg))
} else {
resolve()
}
})
// guard 返回了 promise(return 值模式)
if (isPromise(result)) {
result.then(resolve, reject)
} else if (result !== undefined) {
// 同步 return 值
resolve()
}
})
}几个实现细节:
next(false)和return false等价:导航中止,停留在当前路由。next('/login')和return '/login'等价:触发重定向新导航。next()(无参数)和return undefined等价:通过守卫。- 同一守卫内同时调了 next 和 return:以 next 为准(next 先执行到)。但别依赖这个行为,二选一。
为什么推荐 return 值而不是 next()?
next() 模式的问题:如果守卫内既调了 next() 又调了 return,或者异步逻辑中多次调 next(),行为难以预测。
js
// ❌ 危险:可能多次触发 next
router.beforeEach((to, from, next) => {
fetchUser().then(user => {
if (!user) next('/login')
})
next() // ← 这里会先执行,导航已经继续了
})
// ✅ return 值:promise 模式天然避免这个问题
router.beforeEach(async (to, from) => {
const user = await fetchUser()
if (!user) return '/login'
})return 值是单向的、只有一次。next() 可以被多次调用。Vue Router 4 保留了 next() 主要是向后兼容,新代码一律用 return 值。
导航取消和重定向的 abort 控制
当守卫返回 false 或重定向路径时,vue-router 内部不是简单地 reject promise。它抛出一个 NavigationFailure 对象:
js
// 导航取消的内部处理
class NavigationFailure {
constructor(type, from, to) {
this.type = type // 4=cancelled, 8=duplicated, 16=redirected
this.from = from
this.to = to
}
}router.push() 返回的 promise 会 reject 这个 NavigationFailure:
js
// 用户代码
const result = await router.push('/admin')
// 如果导航被 beforeEach 取消了,上面这行会抛错
// 正确做法:捕获并忽略导航取消
try {
await router.push('/admin')
} catch (e) {
if (isNavigationFailure(e, NavigationFailureType.cancelled)) {
// 守卫取消了导航,这是预期行为,不做处理
} else {
throw e // 真正的错误,往上抛
}
}vue-router 也提供 router.push().catch(() => {}) 作为一个简单兜底,但会吞掉所有异常(包括真正的 bug),不推荐在核心导航逻辑中用。
新导航中断旧导航
如果当前有一个正在执行的导航管道(守卫还在异步等待),用户触发了新的导航,旧管道会被 abort:
text
导航 A: 正在 beforeRouteEnter 的异步逻辑中...
导航 B: router.push('/other') 触发
→ 导航 A 的 promise reject(NavigationAborted)
→ 导航 B 的管道开始执行这个机制保证同一时间只有一条活跃的导航管道。关键在于 vue-router 内部维护了当前导航的 abort controller,新导航触发时调用 abort() → 旧导航在下一个守卫的 promise.then 中检测到 abort 信号后终止。
popstate 事件与浏览器历史栈
导航守卫不仅拦截 router.push() / router.replace(),也拦截浏览器的前进/后退:
text
用户点击浏览器后退
→ popstate 事件触发
→ router 内部监听 popstate → 将 popstate 的 state 转换为目标路由
→ 调用 navigate(to, from) ← 与 push/replace 走同一入口
→ 守卫管道正常执行
→ 守卫返回 false → popstate 无效,回到当前页(pushState 回补一条记录)router.push 调用 history.pushState 推入历史记录,router.replace 调用 history.replaceState 替换当前记录。导航守卫执行完后,最终调用 history.pushState / history.replaceState 更新浏览器 URL——如果守卫中途取消,浏览器 URL 不变。
js
// 源码简化:pushState + navigate 的协作
function pushWithRedirect(to) {
// 1. 先执行完整的 navigate() 管道
navigate(to, currentRoute)
.then(finalRoute => {
// 2. 守卫通过后,才更新浏览器 URL
history.pushState(finalRoute.state, '', finalRoute.fullPath)
// 3. 触发响应式更新 → 组件渲染
currentRoute.value = finalRoute
})
.catch(err => {
// 守卫取消 → URL 不变,currentRoute 不变
if (isNavigationFailure(err, cancelled)) {
// 不做任何事
}
})
}所以:浏览器 URL 的更新发生在守卫全部通过之后,不是 router.push() 被调用时。这意味着如果你在一个 beforeEach 中做异步操作(如权限校验),URL 不会提前变化。
scrollBehavior 与导航的交互
scrollBehavior 不是守卫的一部分,但它的执行时机和守卫管道紧密耦合:
text
守卫管道全部通过
→ DOM 更新完成
→ 等待 nextTick(Vue 的 DOM flush)
→ 执行 scrollBehavior()如果守卫中触发了重定向(return '/login'),scrollBehavior 不会在原路由上执行——它只在新导航首次成功时触发。多个连续重定向(A → B → C)只在最终的 C 路由上执行一次 scrollBehavior。
常见排查路径
守卫死循环
text
/login → beforeEach 检查无 token → return '/login' → 又进入 /login 的 beforeEach → ...vue-router 4 对此有检测:如果连续重定向到同一路由超过一定次数(默认 10 次),会 throw NavigationFailureType.redirected 的 maxRedirected 错误。
修复思路:检查守卫的 return 条件是否包含"目标路由已经是目标时不要重定向"的逻辑。
js
// ❌ 死循环
router.beforeEach((to) => {
if (!hasToken()) return '/login'
})
// ✅ 避免重定向到自己
router.beforeEach((to) => {
if (!hasToken() && to.path !== '/login') return '/login'
})守卫中没有 return 也没有 next
这是一个常见陷阱——守卫体执行完毕但没有返回任何值,也没有调用 next。路由会一直卡在 pending 状态。表现:URL 不变,页面不跳转,没有报错。
vue-router 4 的 guard 函数如果不 return 任何值、不调 next,promise 会一直 pending。在 async 函数中更容易出现:
js
router.beforeEach(async (to, from) => {
const permissions = await fetchPermissions()
// 忘记 return —— 导航永远不会完成
if (!permissions.includes(to.meta.requiredPermission)) {
return false
}
})修复:确保每个分支都有明确的 return 值。
异步组件加载失败
在 beforeEnter 之后、beforeRouteEnter 之前,vue-router 会解析所有异步组件(() => import('./Foo.vue'))。如果组件加载失败(网络错误、chunk 缺失),导航会中止。
失败的异步组件解析不会触发 afterEach。vue-router 的 onError 钩子可以捕获这类错误:
js
router.onError((error) => {
// error 可能是 chunk load failed
})一些实际判断
permission check 放 beforeEach 还是 beforeResolve?
如果权限校验依赖异步组件内的信息(如组件定义的 meta.roles),必须放在 beforeResolve——因为此时异步组件已加载。如果只需要路由配置的 meta(不需要组件加载),beforeEach 够用。
beforeRouteLeave 适合做未保存确认,但别在它里面做异步网络请求再决定是否离开。 浏览器对 beforeunload 的行为是同步的、用户可见的对话框。beforeRouteLeave 可以异步,但用户期望的是即时反馈——如果在 beforeRouteLeave 中做了 2s 的网络请求,用户会看到点击后页面卡住 2s 才跳转。更好的做法是维护一个 isDirty 状态,beforeRouteLeave 同步检查它,实际的保存操作在外层触发。
导航守卫中的错误不会被 Vue 的 errorHandler 捕获。 守卫执行是在 vue-router 内部独立的 promise 链中,Vue 组件的 errorCaptured 和 app.config.errorHandler 不会收到守卫中抛出的错误。需要 router.onError() 或在守卫内部 try/catch。
与 React Router 的关键差异
React Router 6 没有等价的"导航守卫"概念。React Router 的路由切换是声明式的——匹配到新组件就渲染,没有 pipe。权限控制通常通过高阶组件/布局路由内的 useEffect 重定向来实现:
jsx
// React Router:权限在组件层处理
function ProtectedRoute({ children }) {
if (!hasToken()) return <Navigate to="/login" replace />
return children
}这和 Vue Router 的 guard pipe 在时序上有一个关键区别:React Router 的 Navigate 是渲染阶段的重定向,组件树需要先渲染 ProtectedRoute,发现没有 token,再渲染 <Navigate>,再触发新的导航。Vue Router 的守卫在进入组件之前拦截,组件根本不会被实例化。
另外 React Router 没有等价的 beforeRouteLeave。表单未保存拦截通常通过 useBlocker(v6.4+)或 window.addEventListener('beforeunload') 实现。
补充阅读方向
- vue-router 4 源码中
router.ts的navigate()函数:实际代码 ~300 行,涵盖了本文描述的完整管道逻辑、错误处理和 abort 控制 navigationGuards.ts:extractComponentsGuards、runGuardQueue、guardToPromiseFn的具体实现- HTML5 History API 规范:
pushState/replaceState/popstate的标准行为,vue-router 在这之上做了 thin wrapper,并未黑魔法
