记一次 router.push 抛出未捕获的 NavigationDuplicated Promise Rejection 引发的思考
记一次 router.push 抛出未捕获的 NavigationDuplicated Promise Rejection 引发的思考
Section titled “记一次 router.push 抛出未捕获的 NavigationDuplicated Promise Rejection 引发的思考”vue-router@3.6.5
在 Vue Router 3.1.0 版本之后, 这个错误会以
Promise rejection的形式抛出。NavigationDuplicated: Avoided redundant navigation to current location
案例地址:https://stackblitz.com/edit/vitejs-vite-6r7qaeuj?file=src%2Fviews%2FJumpSameRoute.vue
代码示例:
<template>    <div>      <button @click="goSameRoute">        跳转相同路由 button (使用 $router.push)      </button>
      <router-link :to="currentPath">        跳转相同路由 link (使用 router-link)      </router-link>    </div></template>
<script>export default {  data() {    return {      currentPath: '',      error: null, // 使用 null 初始化错误,方便 v-if 判断    };  },  mounted() {    // 获取当前路由路径    this.currentPath = this.$route.path;    console.log('当前路径已设置为:', this.currentPath);  },  methods: {    async goSameRoute() {      this.error = null;
      try {        await this.$router.push(this.currentPath);      } catch (e) {        // 捕获 NavigationDuplicated 错误        if (e.name === 'NavigationDuplicated') {          this.error = e;          console.warn('✅ NavigationDuplicated 错误已成功捕获:', e.message);        } else {          // 捕获其他类型的错误          this.error = e;          console.error('⚠️ 发生了其他错误:', e);        }
        throw e;      }    },  },};</script>如果通过 router.push 跳转相同路由会触发如下错误:

但是通过 <router-link></router-link> 不会触发这个错误。
为什么 push 会产生这个问题
Section titled “为什么 push 会产生这个问题”当尝试跳转到相同路由时, Vue Router 会中断导航并抛出错误。这个判断发生在 confirmTransition 的开始阶段:
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {  // 如果 onComplete 和 onAbort 都没有传递, 那么 push 返回的就是一个 Promise  if (!onComplete && !onAbort && typeof Promise !== 'undefined') {    return new Promise((resolve, reject) => {      this.history.push(location, resolve, reject)    })  } else {    this.history.push(location, onComplete, onAbort)  }}
// src/history/base.jsconfirmTransition (route: Route, onComplete: Function, onAbort?: Function) {  const current = this.current  // ...
  // 这里的 onAbort 函数, router.push(location: RawLocation, onComplete?: Function, onAbort?: Function) 可以传递进来。如果没有传递的话,默认传进来的就是一个 reject 函数,这个函数会让 push 返回的 Promise 变为 rejected 状态  const abort = err => {    // changed after adding errors with    // https://github.com/vuejs/vue-router/pull/3047 before that change,    // redirect and aborted navigation would produce an err == null    if (!isNavigationFailure(err) && isError(err)) {      if (this.errorCbs.length) {        this.errorCbs.forEach(cb => {          cb(err)        })      } else {        if (process.env.NODE_ENV !== 'production') {          warn(false, 'uncaught error during route navigation:')        }        console.error(err)      }    }    onAbort && onAbort(err)  }
  // 1. 检查是否是相同路由  if (isSameRoute(route, current)) {    this.ensureURL()    return abort(createNavigationDuplicatedError(current, route))  }
  // ...}
// src/util/route.jsexport function isSameRoute (a: Route, b: ?Route): boolean {  if (b === START) {    return a === b  } else if (!b) {    return false  } else if (a.path && b.path) {    return (      a.path.replace(trailingSlashRE, '') === b.path.replace(trailingSlashRE, '') &&      a.hash === b.hash &&      isObjectEqual(a.query, b.query)    )  } else if (a.name && b.name) {    return (      a.name === b.name &&      a.hash === b.hash &&      isObjectEqual(a.query, b.query) &&      isObjectEqual(a.params, b.params)    )  } else {    return false  }}常见的处理方式是重写 push 和 replace 来 catch 异常:
const originalPush = VueRouter.prototype.pushconst originalReplace = VueRouter.prototype.replace
// 重写 push 方法VueRouter.prototype.push = function push(...args) {  return originalPush.apply(this, args).catch(err => {    // 如果是重复导航错误,则忽略    if (err.name === 'NavigationDuplicated') {      return Promise.resolve(err)    }    // 其他错误则继续抛出    return Promise.reject(err)  })}
// 重写 replace 方法VueRouter.prototype.replace = function replace(...args) {  return originalReplace.apply(this, args).catch(err => {    if (err.name === 'NavigationDuplicated') {      return Promise.resolve(err)    }    return Promise.reject(err)  })}为什么 RouterLink 没这个问题
Section titled “为什么 RouterLink 没这个问题”const noop = () => {}
// ...
export default {  name: 'RouterLink',  props: {    to: {      type: toTypes,      required: true    },    // ...  },  render (h: Function) {    const router = this.$router    const current = this.$route    const { location, route, href } = router.resolve(      this.to,      current,      this.append    )
   // ...
    // push (location: RawLocation, onComplete?: Function, onAbort?: Function)    // 这里的 replace 和 push 只传递了两个参数,也就是说传递了 onComplete,那么 push 就不会返回 Promise    // 因此即使跳转 same route,也不会抛出 NavigationDuplicated Error    const handler = e => {      if (guardEvent(e)) {        if (this.replace) {          router.replace(location, noop)        } else {          router.push(location, noop)        }      }    }
    const on = { click: guardEvent }    if (Array.isArray(this.event)) {      this.event.forEach(e => {        on[e] = handler      })    } else {      on[this.event] = handler    }
    // ...
    return h(this.tag, data, this.$slots.default)  }}push 跳转流程梳理
Section titled “push 跳转流程梳理”调用链路:
router.push() -> History.transitionTo() -> History.confirmTransition()router.push
Section titled “router.push”这里的 history instance 有不同的实现,具体是根据 new VueRouter({ mode: 'hash | history' }) 中的 mode 来决定
HashHistory 和 HTML5History 都继承 History(src\history\base.js)
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {  // 如果 onComplete 和 onAbort 都没有传递, 那么 push 返回的就是一个 Promise  if (!onComplete && !onAbort && typeof Promise !== 'undefined') {    return new Promise((resolve, reject) => {      this.history.push(location, resolve, reject)    })  } else {    this.history.push(location, onComplete, onAbort)  }}HashHistory.push
Section titled “HashHistory.push”export class HashHistory extends History {  constructor (router: Router, base: ?string, fallback: boolean) {    // ...  }
  // this is delayed until the app mounts  // to avoid the hashchange listener being fired too early  setupListeners () {    // ...  }
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {    const { current: fromRoute } = this    this.transitionTo(      location,      route => {        pushHash(route.fullPath)        handleScroll(this.router, route, fromRoute, false)        onComplete && onComplete(route)      },      onAbort    )  }
  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {    const { current: fromRoute } = this    this.transitionTo(      location,      route => {        replaceHash(route.fullPath)        handleScroll(this.router, route, fromRoute, false)        onComplete && onComplete(route)      },      onAbort    )  }
  go (n: number) {    window.history.go(n)  }
  ensureURL (push?: boolean) {    // ...  }
  getCurrentLocation () {    // ...  }}HTML5History.push
Section titled “HTML5History.push”export class HTML5History extends History {  constructor (router: Router, base: ?string) {    // ...  }
  setupListeners () {    // ...  }
  go (n: number) {    window.history.go(n)  }
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {    const { current: fromRoute } = this    this.transitionTo(location, route => {      pushState(cleanPath(this.base + route.fullPath))      handleScroll(this.router, route, fromRoute, false)      onComplete && onComplete(route)    }, onAbort)  }
  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {    const { current: fromRoute } = this    this.transitionTo(location, route => {      replaceState(cleanPath(this.base + route.fullPath))      handleScroll(this.router, route, fromRoute, false)      onComplete && onComplete(route)    }, onAbort)  }
  ensureURL (push?: boolean) {    // ...  }
  getCurrentLocation (): string {    // ...  }}History.transitionTo
Section titled “History.transitionTo”transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {  // 1. 匹配路由  const route = this.router.match(location, this.current)
  // 2. 确认转换  this.confirmTransition(    route,    () => {      // 3. 更新当前路由      this.updateRoute(route)      // 4. 执行 onComplete 回调      onComplete && onComplete(route)      // 5. 更新 URL      this.ensureURL()      // 6. 触发全局的 afterEach 钩子      this.router.afterHooks.forEach(hook => {        hook && hook(route, this.current)      })    },    err => {      onAbort && onAbort(err)    }  )}History.confirmTransition
Section titled “History.confirmTransition”confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {  const current = this.current
  // 1. 检查是否是相同路由  if (isSameRoute(route, current)) {    this.ensureURL()    return abort(createNavigationDuplicatedError(current, route))  }
  // 2. 解析需要更新的路由组件  const {    updated,    deactivated,    activated  } = resolveQueue(this.current.matched, route.matched)
  // 3. 按顺序执行路由守卫队列  const queue: Array<?NavigationGuard> = [].concat(    extractLeaveGuards(deactivated),    // 组件内离开守卫    this.router.beforeHooks,            // 全局 beforeEach 守卫    extractUpdateHooks(updated),        // 组件内更新守卫    activated.map(m => m.beforeEnter),  // 路由配置内的 beforeEnter    resolveAsyncComponents(activated)   // 解析异步路由组件  )
  // 4. 执行守卫队列  runQueue(queue, iterator, () => {    // 5. 执行 beforeRouteEnter 守卫    const enterGuards = extractEnterGuards(activated)    const queue = enterGuards.concat(this.router.resolveHooks)    runQueue(queue, iterator, () => {      onComplete(route)    })  })}