1.1 现象
场景:点击“新客户跳转”,进入“第2个页面”
我们从小视频中可以看见,路由变成了:http://localhost:8080/#/?isFromScan=true&scanCarInfo=%7B%22brandId%22%3A%2230%22,%22carBrandName%22%3A%22%E5%A5%94%E9%A9%B0%22%7D
而正常情况下,这个路由应该是:http://localhost:8080/#/newMaintain/guide?isFromScan=true&...
1.2 部分代码
“第2个页面”的路由是:
-- 代码1 --
...
} ,{
path: '/newMaintain/guide/:idCar/:idCustomer',
name: 'newMaintainGuide',
component: () => import('@/pages/newMaintain/pages/Guide')
},
...
点击后跳转的代码:
-- 代码2 --
this.$router.push({
name: 'newMaintainGuide',
query: {
isFromScan: true,
scanCarInfo: JSON.stringify({
brandId: "30",
carBrandName: "奔驰"
})
},
params: {
mileage: 0
}
})
这个小视频是模拟项目中的场景,“第2个页面”的路由是老早之前写的,当时只会出现老客户进入“第2个页面”,即肯定有idCar和idCustomer。而出问题的场景是新增的,需要新客户也进入“第2个页面”,没有idCar和idCustomer。
1.3 原因
其实看到1.2中的代码,就可以猜到出现这个现象的原因:路由匹配问题!
这种说法其实并不精准,在从“主页“通过 this.$router.push({name:'... 由于通过“命名路由”进行跳转的,因此可以找到正确的route对象,渲染了正确的vue组件。然而,虽然渲染没有问题,但在更新地址栏的url的时候却出了问题(部分url缺失)。
1.3.1 vue-router 中的重要概念
在深入vue-router源码之前,我们先了解下源码中的几个核心对象:
【1】路由配置数组(routes: Array)
如图1所示,就是我们在new VueRouter的时候传入的routes。在注册路由的时候,会将这些配置数组进行解析。
【2】路由对象(Route)
Route对象的数据结构如图2所示,我们经常使用的this.$route就是它。
【3】路由记录(RouteRecord)
它的定义如下所示,与Route还蛮像的,但是路由记录中包含了vue组件,在执行组件内的导航守卫时起到了很大的作用。
RouteRecord里面有个重点的东西:regex,它内部存储了解析出的动态路径参数,本例中要跳转的路由path为:'/newMaintain/guide/:idCar/:idCustomer' 。regex的内容如图4所示,重要的是regex.keys。它有什么用?后面可以通过 regex 和 router.params填充 path
【4】路由匹配器(matcher)
它的定义如下所示:
export type Matcher = {
match: (raw: RawLocation, current?: Route, redirectedFrom?: Location) => Route;
addRoutes: (routes: Array<RouteConfig>) => void;
};
而matcher是通过 createMatcher 方法生成的(见下图5),其中有三个非常重要的变量 pathList / pathMap / nameMap,下面分别介绍下它们:
pathList:是个数组,存储着routes中所有的path(见下图6)
pathMap:是个对象,key是路由地址path,value是RouteRecord(长的如下图7所示)nameMap:也是个对象,key是路由名称name,value是RouteRecord(长的如下图8所示)
以上三个对象是通过解析传入的routes配置生成的。具体源代码点击此处(在createMatcher方法 => 调用createMatcher方法后生成这三个对象,其实createMatcher方法里面还是遍历传入的routes + 调用addRoutes方法,从图9中可知)
而matcher的两个核心方法的作用分别为:
addRoutes方法:往核心对象pathList pathMap nameMap中添加记录
match方法:按照给定的路由地址,根据pathList pathMap nameMap这三个变量,匹配出要跳转的路由对象route。
1.3.2 router.push跳转经历的过程
1.2 中的【代码2】通过命名路由调用router.push进行跳转,深入源码大概经历以下几个过程:
【1】解析路由配置数组routes
在new VueRouter的时候传入的routes数组,在createMatcher方法中解析routes(见图10),生成路由匹配器matcher。createMatcher方法内部又调用createRouteMap(见图11)来解析。
我们继续深入createRouteMap内部,发现它是遍历routes数组,调用 addRouteRecord 来维护 pathList, pathMap, nameMap这几个对象(见图12)。 addRouteRecord 的完整源码戳这里。
- 使用路径匹配引擎 path-to-regexp 解析动态路径参数(compileRouteRegex方法内部处理的),解析出RouteRecord中的 regex(1.3.1 => 【4】路由匹配器中有regex的描述)
- 生成 RouteRecord 对象
- 若route里面还定义了children,则继续对children 调用 addRouteRecord
- 更新 pathList, pathMap, nameMap这几个对象
【2】push路由跳转
使用this.$router.push 进行路由跳转,而push方法的源代码点击这里。我们通过图11(push方法的源码)可发现,它其实就是调用的transitionTo方法,并且给它传入了成功后的回调(见图13的红框)。
transitionTo方法的源码点击这里,从图14可知,它主要做了以下几件事情:
(1)调用router.match匹配到正确的路由对象route(第89行)
(2)调用 confirmTransition执行导航钩子函数,解析异步路由,更新当前路由,更新url。
这两步都很重要下面会详细描述match 和 confirmTransition 内部的实现。
【3】match路由匹配
上一步分析了this.$router.push内部的实现是调用transitionTo方法,该方法先是调用match方法(其实就是路由匹配器matcher的match方法),确定下一步要跳转的路由记录RouteRecord。match方法的源码见图15,确定路由有两种方式:
**1)按路由名称name进行匹配。**若传入了路由名称,则会走这种方式,本次案例就是。
- 调用 normalizeLocation方法 处理传入的路由,本例中raw 为
{name: 'newMaintainGuide', query:{isFromScan: true ... }, params: {mileage: 0}},调用方法后获得的location也是它,location.params = {mileage: 0}; - 通过nameMap + name,直接匹配到路由记录record(1.3.1 =>【4】路由匹配器matcher 中已解释了什么是nameMap);
- paramNames中存储的是动态路径参数名称,并且是必填的动态路径参数(动态路径参数以及regex.keys在**1.3.1 =>【3】路由记录RouteRecord **中已讲到了)。本次案例中 paramNames = ['idCar', 'idCustomer'](第40 - 42行);
- 看下当前路由中的params值是否可以复用到即将跳转的路由中的params中去(第48-54行)。本案例中当前路由current.params中并没有 idCar/idCustomer,因此location.params = {mileage: 0};
- 调用 fillParams方法 将路由地址path
'/newMaintain/guide/:idCar/:idCustomer'用location.params 进行填充,获得完整的路由地址lcoation.path。 - 生成路由对象route,在本案例中它是调用 createRoute方法 生成的,这部分的源码戳这里,为方便查看留个图16。它内部主要是组成了路由对象route的各种属性:name/meta/path/hash/query/ params/matched/fullPath。并且把route通过Object.freeze设置为不可更改的。其中需要特别注意的是fullPath,它是后面拼接成完整的url的关键。fullPath是通过调用 getFullPath方法 形成的,代码截图如图17所示。stringifyQuery是将query对象转化为字符串的方法,默认的方式是以&拼接,以?开头,也可以传入自定义的转化方式,本例是采用默认的方式,然后将 lcoation.path 与转化后的query字符串拼接形成 fullPath。
**2)按路由地址path进行匹配。**本例中未涉及到这部分的内容,现在先不详细展开。
- 遍历路由地址数组pathList ,通过pathMap(存储着key:path -> value:RouteRecord)进行路由的匹配获得RouteRecord。
- 获得路由记录RouteRecord后,创建路由对象Route
【4】confirmTransition确认切换
上一节详细描述了match方法内部的实现,那么本节和下一节将展开confirmTransition方法的实现,本节先简述下 confirmTransition方法内部大概做了什么,这部分的源代码戳这里。
confirmTransition方法的定义如下所示,它传入的参数分别是:【2】路由匹配中解析出的路由route,完成后执行的函数 onComplete(下一节重点讲),以及失败后执行的函数 onAbort。
confirmTransition (route: Route, onComplete: Function, onAbort?: Function)
confirmTransition``方法 里面主要是执行各导航守卫钩子,解析异步组件。我们在vue router官网中看到完整的导航解析流程如下所示,看起来下面的这些流程都是在confirmTransition方法中做的(它非常重要,但是不是本次案例的问题所在就不详细描述啦~)。并且在完成后执行onComplete方法(这个是本案例的重点,下面还会单独拿出来)
- 导航被触发。
- 在失活的组件里调用 beforeRouteLeave 守卫。
- 调用全局的 beforeEach 守卫。
- 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
- 在路由配置里调用 beforeEnter。
- 解析异步路由组件。
- 在被激活的组件里调用 beforeRouteEnter。
- 调用全局的 beforeResolve 守卫 (2.5+)。
- 导航被确认。
- 调用全局的 afterEach 钩子。
- 触发 DOM 更新。
- 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。
【5】更新当前路由,更新url
本节是上一节的延续,由于confirmTransition方法中与本案例紧密相关的是onComplete方法,所以单独拿出来讲下。在该方法中主要做了以下几件事:
- 第101行:执行updateRoute方法,将要跳转的路由对象route更新为当前路由current;
- 第102行:若有onComplete方法,则执行onComplete,这里的omComplete方法要与前面说的
confirmTransition方法的onComplete方法 区分开,这里的是在调用transitionTo方法的时候传入的(可以翻到 1.3.2 =>【2】push路由跳转 见图 13)。
该回调在此时才开始执行,为了方便对照着看再留个图19,transitionTo的onComplete回调中做了3件事情:
- pushHash:该方法的实现如图20,调用了这个方法后浏览器的地址栏就会发生更新。该方法首先判断当前浏览器是否支持history.pushState() 方法,若支持则调用该方法,则往浏览器会话的历史堆栈中添加一个记录,即可形成浏览器中的url。那么这个url的值来源于:当前url截取‘#’之前的字符串 + route中的fullPath。
1.3.3 router.push后url丢失的原因
既然是url中的问题,那么首先从 **1.3.2 => 【5】更新当前路由,更新url **这边出发,问题出现在 pushHash方法 更新url的时候,这个url的值来源于:当前url截取‘#’之前的字符串 + route中的fullPath。那么问题就在于 route中的fullPath,果然打断点发现 fullPath如图21所示,完整的地址应该是:'/newMaintain/guide?isFromScan=true...' ,即path + query里拼接的值,而现在只有query里拼接的值。
而fullPath的形成,我们可以看到 **1.3.2 => 【3】match路由匹配 => vi 生成路由对象route **,fullPath是将lcoation.path 与字符串化后的query字符串拼接形成。既然query里拼接的值是存在的,那么path应该是‘’,我们在此处打断点,发现path果然是空字符串,见图22的红框。
我们发现这个path取自于location.path,那么它的赋值在 match方法 ,还是回到 **1.3.2 => 【3】match路由匹配 => v 调用 fillParams方法,获得完成路由地址location.path **之前的章节没有展开 fillParams方法,它调用的地方见图23,我们可知入参分别为:record.path(地址),location.params(params对象),报错时候的message提示。在本例中它们的值分别为:"/newMaintain/guide/:idCar/:idCustomer",{ mileage: 0 },"named route "newMaintainGuide"。
那我们这边看下该方法的实现,见图24。这边主要是通过 路径匹配引擎path-to-regexp 想要将 动态路由地址:"/newMaintain/guide/:idCar/:idCustomer"填充完。而且这边还对于 路径匹配引擎path-to-regexp 做了缓存将解析过的存储在 regexpCompileCache 中,若缓存中没有再调用pathToRegexp_1.compile(path)) 进行解析,并且把解析的结果缓存下来(第 938 - 940行)。然后在调用 filter方法 获得path。
案例中首次解析 "/newMaintain/guide/:idCar/:idCustomer" 则走pathToRegexp_1.compile(path)),先来看下它的实现,见图25,这边使用了函数柯里化返回了个模板函数。compile的入参str就是 path,值为 "/newMaintain/guide/:idCar/:idCustomer",并且先调用 parse方法 将传入的地址进行转化,转化后的结果如图26所示。然后在调用 tokensToFunction方法 返回一个处理该path的函数。
我们看下 tokensToFunction方法 内部(见图27),(1)将tokens(也就是图26的结果)转化为正则表达式存储在matches数组中。因此,本例中的matches 为 [empty, /^(?:[^\/]+?)$/i, /^(?:[^\/]+?)$/i ](2)返回一个模板函数(现在还未执行,先不讲),这个函数就是 图25 compile方法 返回的函数,对照 图24 中的 第936行 可知,filter这个变量中存储的也是这个函数。
图24 在获得了 filter变量 后,在第946行开始执行它,传入的参数是:params: { mileage: 0 }和{ pretty: true } 那么我们来看下 tokensToFunction方法 返回的模板函数的全貌,见图28。该方法就是遍历tokens(值可见图26)生成真正的path,而我们的例子中最终得到的path是空字符串在这个方法中就可以找到答案。
- 值初始化:path='',data={mileage: 0}(该值来源于路由的params),options={ pretty: true }
- 先取出token[0],即"/newMaintain/guide"。由于它是string,直接拼接到path中(line: 655 - 659),此时path = "/newMaintain/guide";
- 取出token[1],即{asterisk: false,delimiter: "/",name: "idCar",optional: false,partial: false,pattern: "[^/]+?",prefix: "/",repeat: false}。从这个对象我们可以发现,要填充的字段是 idCar,从optional: false可见,该字段是必填的。然而我们在 1.2 部分代码 => 代码2 中可知,传入的params 只有 mileage,没有idCar,因此从data中按照key为idCar取值时,value的结果是undefined。程序走入line 664,由于token.optional是false,直接走到line 673,抛出异常。
那么 filter方法 抛出异常之后,为啥还可以正常加载页面呢? 我们看到调用 filter方法 的地方 图24 => line 946,该方法被try/catch进行异常捕获了,当发现有异常时,会用 warn方法 在控制台上打印出异常,并且返回空字符串给path。控制台确实报了warning,因此 **图15 => line56 **中location.path的结果是空字符串
其实,在Vue Router 官网中的高级匹配模式 中有讲到可以配置为匹配0个或多个的。虽然官网中没写出如何配置在路径匹配引擎path-to-regexp文档中有写。参数可选的时候的配置如下:
var re = pathToRegexp('/:foo/:bar?', keys)
// keys = [{ name: 'foo', ... }, { name: 'bar', delimiter: '/', optional: true, repeat: false }]
那么,1.2 => 代码1 应更改为如下所示:
...
} ,{ // 在主页点击后,要跳转的路由
path: '/newMaintain/guide/:idCar?/:idCustomer?',
...