背景

最近在写的项目遇到这样一个问题:

页面在登录后,查询一个接口获取租户 ID,然后异步刷新页面,把获取到的租户 ID 挂载在路由路径里

在获取到租户 ID 后的这次刷新页面的路由守卫(Router Guard)的钩子里,代码会动态下载一段脚本并执行,该脚本会调用 Vue Router 的 addRoute() 方法动态注册路由

之后执行路由守卫的 next() 放行本次跳转

问题

问题来了,由于该次刷新页面是异步操作,而且动态下载脚本也是异步操作,有一定的几率,addRoute()next()在同一个 Vue 的刷新任务队列里执行,然后就会发生路由跳转停止,反映到页面上,就是

页面点击登录后不再跳转到主页面

解析

之前有同事跟我说,这是因为 addRoute() 会打断路由跳转,所以我们写了一些代码来规避这件事

总的思路是,在这次异步 next() 之前发送事件通知脚本延后执行 addRoute(),这样做有一个问题,即延后多久执行 addRoute(),我们原先设定的是200ms,但结果是仍然有概率与 next() 运行在同一个任务队列里,再延长这个时间其实也不是最好的办法,故这个问题搁置了很久

直到有客户的自动化测试因为这个问题而打断运行,我终于不得不仔细研究这个问题

查看 Vue Router 3 的源码,我发现在 addRoute() 的定义如下:

1
2
3
4
5
6
addRoute (parentOrRoute: string | RouteConfig, route?: RouteConfig) {
this.matcher.addRoute(parentOrRoute, route)
if (this.history.current !== START) {
this.history.transitionTo(this.history.getCurrentLocation())
}
}

其中的 this.matcher.addRoute(parentOrRoute, route) 这一段只是在对路由信息进行维护,并不会有打断路由跳转的风险

问题出在下面的 if 判断中,对于我们的登录后异步加载脚本并执行路由注册的代码逻辑,this.history.current 一定不会与 START 相等(因为 this.history.current 是登录页,而 START 是根路由),那么根据这段代码,Vue Router 会尝试路由到当前页面,即登录页

history.transitionTo() 方法里,根据传来的路由,设置目标跳转对象 pending,简化后的源码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
transitionTo (
location: RawLocation,
onComplete?: Function,
onAbort?: Function
) {
let route
// catch redirect option https://github.com/vuejs/vue-router/issues/3201
try {
route = this.router.match(location, this.current)
} catch (e) {
this.errorCbs.forEach(cb => {
cb(e)
})
// Exception should still be thrown
throw e
}
const prev = this.current
this.confirmTransition(route, /* ...more params */)
}

confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
const current = this.current

/* 这一行设置了 pending */
this.pending = route
/* ================== */

const abort = () => {/* some code */}
const lastRouteIndex = route.matched.length - 1
const lastCurrentIndex = current.matched.length - 1
if (
isSameRoute(route, current) &&
// in the case the route map has been dynamically appended to
lastRouteIndex === lastCurrentIndex &&
route.matched[lastRouteIndex] === current.matched[lastCurrentIndex]
) {
// some code
return abort(createNavigationDuplicatedError(current, route))
}

// some code
runQueue(/* params */)
}

由上面的代码可知,此时的 pending 已经由根路由改为了登录页路由

好了,当异步的 next() 在这个任务队列里被调用,就会发现,目标跳转路由是登录页,当前路由也是登录页,路由跳转停止

解决

了解了打断路由跳转的原因,兼容代码就好写了,我翻看了 github 的 issues,发现有人提出,这里其实不做那次 history.transitionTo() 也是可以正常注册成功的,于是我调整了注册路由的代码,不再调用 router.addRoute(),而是调用 router.matcher.addRoute() 绕过了多余的 history.transitionTo()

1
2
3
4
5
const addRoutes = function(routes) {
routes.forEach((route) => {
this.$router.matcher.addRoute(route)
})
}

深入

那么,代价呢?

其实我还没看明白 Vue Router 在这里刻意这么做的目的是什么,但是由此确实带来一个问题,即当登录后跳转的页面是新注册的路由时,由于该路由在跳转时正在注册,于是 next() 时便找不到该路由信息,于是页面会停止渲染,变成空白页面

我现在的解决方案是,当识别到 pending 是准备注册的路由时,在执行 addRoute() 的200ms后,使用 router.replace() 刷新当前页面,从而使得已注册的路由被正确地渲染