背景:一组看起来很吓人的报错
Vue 2 + vue-router 3.x 的项目里,你可能见过这样一幕:业务代码一行没改,控制台先红了一片,页面却照样跳到了目标页。点一下菜单,偶发报错;再点一下,又好了。
这类报错有三个常驻面孔:
Navigation cancelled from "/" to "/manageWorkbench" with a new navigationNavigationDuplicated: Avoided redundant navigation to current location: "/manageWorkbench"Uncaught (in promise),堆栈顶部还是前两条
自 vue-router 3.1 起,push / replace 开始返回 Promise:导航成功 resolve 到目标 Route,失败 reject 一个 Error。如果调用处不接这个 rejection,控制台就给你挂一条 Uncaught (in promise)。也就是说,报错的「音量」是版本升级带来的,但内容几乎都指向同一件事——并发导航竞态:一次导航还没完成,就被下一次导航顶掉了。
我先把一条真实堆栈贴出来,后面所有讨论都围绕它:
[Vue Router warn]: uncaught error during route navigation:
Error: Navigation cancelled from "/" to "/manageWorkbench" with a new navigation.
at createNavigationCancelledError (vue-router.esm.js:...)
at ... (vue-router.esm.js: completeNavigation / abortPending)
注意一个反直觉的点:抛错的那条导航被取消了,但页面最终停在了目标路由,一切看起来正常。这基本就是竞态的指纹——逻辑没错,只是两条导航打架,赢的那条把页面带到了正确的地方,输的那条在控制台留了具尸体。也正因为「功能没坏」,这类报错特别容易被当成无关噪音放着不管,直到某天真正的导航失败混在里面,没人再从满屏红字里认出它。所以我后来要求自己:每一条 router 报错都要归因,归不清就先复现,不许用「反正页面能打开」糊弄过去。
为什么是竞态,而不是逻辑错
vue-router 内部同一时刻只允许一个 pending 导航:新导航发起时,旧的 pending 导航会被 abort,并以 NavigationCancelled reject。这是设计特性,不是 bug。
所以看到这类报错,第一个问题永远不是「我哪里写错了」,而是:谁在同一拍里发起了两次导航?
区分竞态和真逻辑错,看三个信号:
- 报错里的
from和to,和另一处代码里几乎同时出现的push/replace目标重叠; - 两条导航触发时机相同(同一帧、同一批生命周期钩子里);
- 错误是从框架层抛的,而不是你业务调用处抛的。
第 3 点最能迷惑人:配置里的 redirect 是一次「框架导航」,它的 rejection 不会回传到组件里那个 replace(...).catch(...)——你的 catch 根本没挂在那条 Promise 上。于是业务代码明明写了兜底,Uncaught (in promise) 照样满屏。
如果真是路由表写错(路径不存在、守卫 next() 没调),失败会是稳定的、每次必现的;而竞态是时序敏感的——偶发、快慢环境表现不一致、加了个 await 就复现。这是排查时最值钱的手感。
下面按三个最常见的碰撞源逐个拆。
坑一:路由级 redirect + 组件内跳转,同一拍双导航
现象
一进首页就吃一发 cancelled:
Navigation cancelled from "/" to "/manageWorkbench" with a new navigation.
Uncaught (in promise) Error: Navigation cancelled from "/" to "/manageWorkbench"
排查
首页路由通常长这样:
// router/index.js
{ path: '/', redirect: '/workbench' }
进入 / 时,router 命中 redirect,发起框架级导航去 /workbench。巧的是,同一个页面树里的布局组件在 mounted() / created() 里还有一段「按条件自动跳转」的逻辑:
// App.vue(简化)
mounted() {
checkDeviceType().then(isMobile => {
if (isMobile) {
this.$router.replace('/manageWorkbench'); // 第二次导航
}
});
}
redirect 的导航还 pending,组件的 replace 就把它顶掉了——前者被 cancelled。而这条 cancelled 是从框架层抛的,组件里的 .catch 够不着它,于是 Uncaught (in promise)。
排查动作我固定三条:
# 1. 找出所有自动跳转点
grep -rn "\$router.replace\|\$router.push" src/
# 2. 看根路由(path: '/')有没有配置级 redirect
# 3. 确认两者是否同一拍触发(同一批 created/mounted + '/' redirect)
第 2 步里如果发现目标路径(比如 /manageWorkbench)在源码里 grep 不到,先别慌:那多半是登录后按权限动态注入的路由(addRoutes),不在静态路由表里。这类情况下焦点应放在「谁和谁撞了」,而不是「路由定义在哪」。
解法
原则:redirect 目标与组件内跳转二选一。路由表能表达的,就别在组件里再 push 一次。如果跳转是条件性的(按设备、按角色),把它中央化到全局守卫里,用 next(...) 认领这次导航,让它成为唯一发起方:
// router/index.js
router.beforeEach((to, from, next) => {
const isMobile = /iPhone|iPad|iPod|Android|Mobile|Windows Phone/i.test(navigator.userAgent);
if (isMobile && (to.path === '/' || to.path === '/workbench')) {
next('/manageWorkbench');
return;
}
next();
});
然后把组件 created() 里那段自动 replace 删掉。碰撞源从两个变成一个,报错自然消失——注意这里是消灭了竞争,不是掩盖了报错。
复盘
框架发起的导航(redirect、守卫重定向)和业务发起的导航(push / replace)走的是同一条串行通道。同一拍里出现两次导航意图,必有一次被吃掉。自动跳转这种「组件顺手就写」的逻辑,放进全局守卫永远比散落在生命周期钩子里可控:守卫里一条 next('/target') 就是一次被 router 正式认领的导航,它会按顺序接上、取消旧的重定向,而不是从外部插队。组件钩子里的跳转做不到这一点——它不知道此刻框架层还有一次 redirect 在飞,也没有任何接口能询问「现在有没有 pending 导航」。把条件跳转都收进 beforeEach,本质上是在给导航建立单一事实源:路由去向只看一处代码就能说全,出问题时也只需要在一处加日志。
坑二:登录态异步回写后二次跳转
现象
Navigation cancelled from "/login" to "/workbench" with a new navigation.
排查
守卫里 await 了一个接口——拉用户信息、回写登录态、初始化权限菜单。网络慢的几百毫秒里,用户已经点了别的入口。等 await 回来,守卫继续导航去 /workbench,和用户点击发起的导航撞车。两次导航打架,输的一方报 cancelled。
这个坑比坑一隐蔽,因为两条导航之间隔了一个 await,肉眼看不出「同一拍」;但对 router 来说它们依然是先后进入同一条串行通道的竞争者。时序敏感的表现也更典型:本地 mock 秒回时从不复现,线上接口稍慢就偶发。判断是不是这个坑,一个便宜的验证方法是在守卫里 await 之后打一行日志,对比「守卫想去的 to」和「当前 router.history.current 实际已在的路由」——如果两者已经不一致,说明用户在等待窗口里完成了别的导航,旧跳转不该再发。
解法
先想清楚:导航到底该由谁发起?
- 如果登录完成本身就意味着该去
/workbench,那这一步跳转应该由守卫或登录成功回调独家发起,别在登录页组件里再写一个「成功后 push」; - 如果
await之后用户的意图可能已经变了,就尊重后到的导航,让旧跳转作废,而不是两边都发; - 确实需要在组件里发起异步跳转时,用
this.$nextTick或基于状态标志的watch错开时机,保证同一时刻只有一个导航在飞。
复盘
守卫里放异步逻辑是常见做法,但 await 之后的世界可能已经不是 before 时的那个世界了。异步回来前先确认目标路径是否仍是本次导航的意图,能避免一半的打架。
坑三:重复点同一个菜单——NavigationDuplicated
现象
连续两次点同一个菜单:
Error: Avoided redundant navigation to current location: "/manageWorkbench".
at createNavigationDuplicatedError (vue-router.esm.js:...)
Uncaught (in promise) Error: Avoided redundant navigation to current location
排查
push 的目标和当前路由完全相同,vue-router 判定为冗余跳转,直接 reject 一个 NavigationDuplicated。这不是 bug,是 3.1+ 的预期行为;报错之所以刺眼,只是没人接这个 rejection。排查时顺着菜单组件看一眼即可:push(this.$route.path) 这种写法几乎必然踩中。
解法
这类属于高频、benign 的报错,优先用低成本手段消灭,而不是给每次跳转都包 catch——后者只会把噪音藏进代码里。
先查重再跳:
if (this.$route.path === target) {
return; // 同路由重复跳转,直接放弃这次导航意图
}
this.$router.push(target);
或者用 replace 替代同路由的 push,按场景选择语义。
最后才上兜底,且 catch 要精准过滤错误名,别裸吞:
this.$router.push('/x').catch(err => {
const benign = ['NavigationDuplicated', 'NavigationCancelled', 'NavigationRedirected'];
if (!benign.includes(err && err.name)) {
throw err; // 不是竞态类错误,是别的问题,继续抛
}
});
复盘
NavigationDuplicated 的教训在于修法排序:先去重、再错开时机,最后才是过滤式 catch。顺序反过来,代码里就长满消音器——每个调用点都包一层 .catch(() => {}),短期看控制台干净了,长期看真正的导航失败(守卫里抛的业务异常、异步 chunk 加载失败)也被一并吞掉,等到用户反馈「点了没反应」时已经无从定位。这就是为什么兜底 catch 必须按 err.name 白名单过滤:三类竞态错误放行,其余继续抛出,保留报错的鉴别力。
排查清单与复盘
把全文收敛成一张可抄的清单:
- 看到 cancelled / duplicated 连环报错,先画「谁在同一拍里发起了两次导航」,别急着加 catch 吞错——吞掉只是消音;
grep -rn "\$router.replace\|\$router.push" src/找出全部自动跳转点,对照路由表里path: '/'的 redirect 和beforeEach守卫;- redirect 目标与组件内跳转二选一:路由表能表达的就别在组件里 push,条件跳转中央化到守卫里用
next(...)认领; - 同路由重复跳用
replace,或先判this.$route.path === target再跳; - 兜底 catch 只包
NavigationDuplicated/NavigationCancelled/NavigationRedirected三种err.name,其余一律重新抛出; - 目标路由在源码里 grep 不到 ≠ 路由不存在,优先怀疑权限下发的动态注入路由,把力气花在碰撞而不是路由定义上。
回头看,vue-router 3.1 把导航变成 Promise 之后,这些报错的数量并没有变多——竞态一直都在,只是以前安静地赢了就算,现在输的一方会被打印出来。把「逻辑错」和「竞态」分开,先把竞争者找全、只留一个发起方,比给每一处跳转套 catch 更接近问题的本质。
评论