Skip to content

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) => { /* ... */ })

beforeEachbeforeResolve 的区别:beforeResolve 在所有组件内守卫执行完毕、异步组件加载完成后触发。它比 beforeEach 更接近"导航确认"的时间点。如果需要在异步组件加载后再决定跳转逻辑,用 beforeResolve

afterEach 不接受 next,导航已经确认。第三个参数 failure 在导航被取消或出错时有值,正常完成时为 undefined

路由级守卫

js
const routes = [
  {
    path: '/admin',
    component: AdminLayout,
    beforeEnter: (to, from, next) => {
      // 只在进入 /admin 时触发,离开不触发
    }
  }
]

beforeEnterbeforeRouteUpdate 之后、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:旧路由有、新路由没有的组件记录 → 提取 beforeRouteLeave
  • updating:新旧路由共享的组件记录 → 提取 beforeRouteUpdate
  • entering:新路由有、旧路由没有的组件记录 → 提取 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 组件的 errorCapturedapp.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.tsnavigate() 函数:实际代码 ~300 行,涵盖了本文描述的完整管道逻辑、错误处理和 abort 控制
  • navigationGuards.tsextractComponentsGuardsrunGuardQueueguardToPromiseFn 的具体实现
  • HTML5 History API 规范:pushState / replaceState / popstate 的标准行为,vue-router 在这之上做了 thin wrapper,并未黑魔法