vue-router源码:create-route-map

vue-router源码解析系列。这是第二篇。本篇介绍源码中的create-route-map.js,它在vue-router中的作用是将路由配置数据routes解析为路由匹配时需要的数据,了解它的源码之后,能够加强对于routes配置的理解和使用。本系列解析的是官方git库中3.1.6的版本源码。

源码链接:create-route-map.js。源码里面用的是typescript,但是不影响阅读。

预备知识

首先我们要知道,vue-router实例是通常在router/index.js这个文件中进行实例化的,如:

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
import Vue from 'vue'
import Router from 'vue-router'

const Index = r => require.ensure([], () => r(require('../pages/Index.vue')), 'Index')
const List = r => require.ensure([], () => r(require('../pages/List.vue')), 'List')
const Detail = r => require.ensure([], () => r(require('../pages/Detail.vue')), 'Detail')

Vue.use(Router)

const router = new Router({
mode: 'history',
routes: [
{
path: '/',
component: Index
},
{
path: '/index',
redirect: '/'
},
{
name: 'list',
path: '/list',
component: List
},
{
name: 'detail',
path: '/detail/:id',
component: Detail,
alias: ['/query/:id'],
children: [
{
name: 'detail_more',
path: 'more',
components: {
bar: () => import(/* webpackChunkName: "group-bar" */ '../pages/Bar.vue')
}
}
]
}
]
})
export default router

vue-router的实例所对应的源码是:index.js,在这个文件内,可以看到Router的构造函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export default class VueRouter {
// 省略了一部分代码

constructor (options: RouterOptions = {}) {
this.app = null
this.apps = []
this.options = options
this.beforeHooks = []
this.resolveHooks = []
this.afterHooks = []
this.matcher = createMatcher(options.routes || [], this)
// 省略了一部分代码
}

// 省略了一部分代码
}

本篇还不涉及到学习index.js,所以不过多介绍。此处引入,只是为了说明create-route-map这个文件的生效入口。在router构造函数内,这行代码是create-route-map文件生效的入口:

1
this.matcher = createMatcher(options.routes || [], this)

这个createMatcher对应的源码文件是:create-matcher.js,这个文件是下一步学习的内容,所以本篇也不深入。它的作用,顾名思义,应该跟路由匹配有关系,在它的代码里,有引用create-route-map的地方:

1
2
3
4
5
6
7
8
9
10
import { createRouteMap } from './create-route-map'

// 省略了很多代码
export function createMatcher (
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
const { pathList, pathMap, nameMap } = createRouteMap(routes)
// 省略了很多代码
}

由此可见,create-route-map会返回一个对象,并且包含了pathList pathMap nameMap这三份数据。 这三个数据是什么呢,在分析之前,我们先看下结果:

  • pathList实际上是根据route/index.js文件内routes数组的配置,解析出的所有路径
  • pathMap实际上是路径与route记录的映射表,路径作为键名
  • nameMap实际上是路由名称(route配置上的name属性)与route记录的映射表,name作为键名

createRouteMap

先看第一段代码,重要的后面单独拆分解析,不重要的直接写注释:

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
44
45
46
47
48
49
50
51
52
53
54
55
// 这是一个库,做路径解析的库,github搜“path-to-regexp”
import Regexp from 'path-to-regexp'
// cleanPath是个很简单的函数,就是将字符串双斜线替换为单斜线,如//asd 替换为 /asd
import { cleanPath } from './util/path'
// assert warn都是与调试、日志有关
import { assert, warn } from './util/warn'

export function createRouteMap (
routes: Array<RouteConfig>,
oldPathList?: Array<string>,
oldPathMap?: Dictionary<RouteRecord>,
oldNameMap?: Dictionary<RouteRecord>
): {
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>
} {
// the path list is used to control path matching priority
const pathList: Array<string> = oldPathList || []
// $flow-disable-line
const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
// $flow-disable-line
const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)

routes.forEach(route => {
addRouteRecord(pathList, pathMap, nameMap, route)
})

// ensure wildcard routes are always at the end
for (let i = 0, l = pathList.length; i < l; i++) {
if (pathList[i] === '*') {
pathList.push(pathList.splice(i, 1)[0])
l--
i--
}
}

if (process.env.NODE_ENV === 'development') {
// warn if routes do not include leading slashes
const found = pathList
// check for missing leading slash
.filter(path => path && path.charAt(0) !== '*' && path.charAt(0) !== '/')

if (found.length > 0) {
const pathNames = found.map(path => `- ${path}`).join('\n')
warn(false, `Non-nested routes must include a leading slash character. Fix the following routes: \n${pathNames}`)
}
}

return {
pathList,
pathMap,
nameMap
}
}

Array<RouteConfig> Dictionary<RouteRecord>这些都是参数的类型声明,这是typescript语言特有的写法。createRouteMap函数有四个参数: routes oldPathList oldPathMap oldNameMap,它的返回值是一个包含pathList pathMap nameMap的对象,这些都是从这个函数的声明部分可以读出来的信息。

oldPathList oldPathMap oldNameMap三个参数都是可选的,它们有什么作用呢?一般情况下它们没什么用,但如果在app中需要动态添加route配置,它们就有用了。因为createRouteMap一旦被调用过一次,那么就已经创建好了pathList pathMap nameMap并且返回去了,当app需要动态添加路由时,意味着就要做新的路由配置解析,也就是重新调用createRouteMap,那一个app实例,肯定只需要一份pathList pathMap nameMap,所以再次调用createRouteMap时,再把app里面已经持有的数据,带回来就行了。

1
2
3
routes.forEach(route => {
addRouteRecord(pathList, pathMap, nameMap, route)
})

这是核心逻辑,遍历routes的配置,然后依次调用addRouteRecord,进行配置解析,把配置记录,解析为route record,并关联存储到pathList pathMap nameMap。后面介绍。

1
2
3
4
5
6
7
8
// ensure wildcard routes are always at the end
for (let i = 0, l = pathList.length; i < l; i++) {
if (pathList[i] === '*') {
pathList.push(pathList.splice(i, 1)[0])
l--
i--
}
}

这段注释写清楚了,就是把pathList里面,通配符的记录,移动到数组最后,应该是vue-router路由匹配时的优先级有关。 vue-router的路由匹配,是按照定义的先后顺序来匹配的,谁先匹配到就用谁,所以把通配符的移动到最后,就保证了只有在通配符路由,前面所有的路由都没有匹配到,才会匹配到通配符路由。

1
2
3
4
5
6
7
8
9
10
11
if (process.env.NODE_ENV === 'development') {
// warn if routes do not include leading slashes
const found = pathList
// check for missing leading slash
.filter(path => path && path.charAt(0) !== '*' && path.charAt(0) !== '/')

if (found.length > 0) {
const pathNames = found.map(path => `- ${path}`).join('\n')
warn(false, `Non-nested routes must include a leading slash character. Fix the following routes: \n${pathNames}`)
}
}

这段是对pathList进行一遍数据检查,要求我们在配置routes的时候,非children内的配置,在配置path的时候,除了*开头的path,其它path都要有/开头。

addRouteRecord

代码是这些:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
function addRouteRecord (
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>,
route: RouteConfig,
parent?: RouteRecord,
matchAs?: string
) {

// 解构出route里面的path和name数据
const { path, name } = route

// 下面做了些断言检测
// 可以没有name,但是不能没有path
// 而且component不能是字符串
if (process.env.NODE_ENV !== 'production') {
assert(path != null, `"path" is required in a route configuration.`)
assert(
typeof route.component !== 'string',
`route config "component" for path: ${String(
path || name
)} cannot be a ` + `string id. Use an actual component instead.`
)
}

const pathToRegexpOptions: PathToRegexpOptions =
route.pathToRegexpOptions || {}
const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)

if (typeof route.caseSensitive === 'boolean') {
pathToRegexpOptions.sensitive = route.caseSensitive
}

const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props:
route.props == null
? {}
: route.components
? route.props
: { default: route.props }
}

if (route.children) {
// Warn if route is named, does not redirect and has a default child route.
// If users navigate to this route by name, the default child will
// not be rendered (GH Issue #629)
if (process.env.NODE_ENV !== 'production') {
if (
route.name &&
!route.redirect &&
route.children.some(child => /^\/?$/.test(child.path))
) {
warn(
false,
`Named Route '${route.name}' has a default child route. ` +
`When navigating to this named route (:to="{name: '${
route.name
}'"), ` +
`the default child route will not be rendered. Remove the name from ` +
`this route and use the name of the default child route for named ` +
`links instead.`
)
}
}
route.children.forEach(child => {
const childMatchAs = matchAs
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
}

if (!pathMap[record.path]) {
pathList.push(record.path)
pathMap[record.path] = record
}

if (route.alias !== undefined) {
const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]
for (let i = 0; i < aliases.length; ++i) {
const alias = aliases[i]
if (process.env.NODE_ENV !== 'production' && alias === path) {
warn(
false,
`Found an alias with the same value as the path: "${path}". You have to remove that alias. It will be ignored in development.`
)
// skip in dev to make it work
continue
}

const aliasRoute = {
path: alias,
children: route.children
}
addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute,
parent,
record.path || '/' // matchAs
)
}
}

if (name) {
if (!nameMap[name]) {
nameMap[name] = record
} else if (process.env.NODE_ENV !== 'production' && !matchAs) {
warn(
false,
`Duplicate named routes definition: ` +
`{ name: "${name}", path: "${record.path}" }`
)
}
}
}

先来看它的参数:

1
2
3
4
5
6
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>,
route: RouteConfig,
parent?: RouteRecord,
matchAs?: string

前三个不用解释了,第四个route表示当前要处理的那条配置对象,parent则代表的是一个route recordaddRouteRecord就是创建route record的函数,如果某个route record被创建后,发现它的route配置里面有children,则会对children也进行addRouteRecord的处理,而它自身会作为一个parent参数传入到childrenaddRouteRecord处理;matchAs跟路由别名有关,后面用实例来解析。

下面这段代码是创建route record的过程:

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
const pathToRegexpOptions: PathToRegexpOptions =
route.pathToRegexpOptions || {}
const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)

if (typeof route.caseSensitive === 'boolean') {
pathToRegexpOptions.sensitive = route.caseSensitive
}

const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props:
route.props == null
? {}
: route.components
? route.props
: { default: route.props }
}

从这里可以看到在routes配置中,还可以添加pathToRegexpOptions caseSensitive这些option,这些options是干什么的呢?它与path-to-regexp有关,在vue-router的官方文档有介绍过:

pathToRegexpOptions就是给path-to-regexp这个库用的。这个库怎么用呢,可以去看github,也可以去看我写的另外一篇博客:

path-to-regexp使用小结

这里有一行代码,对path进行了一些处理:

1
const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)

它调用了这个函数:

1
2
3
4
5
6
7
8
9
10
function normalizePath (
path: string,
parent?: RouteRecord,
strict?: boolean
): string {
if (!strict) path = path.replace(/\/$/, '')
if (path[0] === '/') return path
if (parent == null) return path
return cleanPath(`${parent.path}/${path}`)
}

其实也比较简单,在js中normalize这个词的含义都是正规化,比如ES6开始有了对于字符串的正规化处理,所以normalizePath也就是一个对path进行正规化处理的作用。注意最后那个${parent.path}/${path},假如你有一个这个route:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
name: 'detail',
path: '/detail/:id',
component: Detail,
alias: ['/query/:id'],
children: [
{
name: 'detail_more',
path: 'more',
components: {
bar: () => import(/* webpackChunkName: "group-bar" */ '../pages/Bar.vue')
}
}
]
}

children中,path不需要把parentpath加上,因为在normalizePath里通过${parent.path}/${path},为你处理好了。

接下来看RouteRecord的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const record: RouteRecord = {
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props:
route.props == null
? {}
: route.components
? route.props
: { default: route.props }
}

先看这个regex,它是调用compileRouteRegex返回的一个正则表达式,这个进行路由匹配时,肯定是要用到的,compileRouteRegex内部其实就是使用path-to-regexp的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function compileRouteRegex (
path: string,
pathToRegexpOptions: PathToRegexpOptions
): RouteRegExp {
// Regexp就是path-to-regexp 顶部有import
const regex = Regexp(path, [], pathToRegexpOptions)
if (process.env.NODE_ENV !== 'production') {
const keys: any = Object.create(null)
regex.keys.forEach(key => {
warn(
!keys[key.name],
`Duplicate param keys in route with path: "${path}"`
)
keys[key.name] = true
})
}
return regex
}

它加了一个命名参数不允许重复的处理,比如这样的path:/some/:name/:name,是会触发警告的。其它的属性作用如下:

  • path是当前路由正规化处理之后的路径
  • component是当前路由配置的组件定义,从route.components || { default: route.component }可以看到,路由视图默认的名字之所以是default的原因
  • instances是最终要用来存放当前渲染的节点实例的,理解完上一篇博客,对这个instances的数据就不会陌生了
  • name是路由配置的名字
  • parent是父级的route record引用,只有当前是children中的路由才会有值
  • matchAs是传递进来的参数,只有别名路由才会有
  • redirect beforeEnter meta都是从route上直接读取的配置数据
  • props本质上也是要从route上读取的配置数据,但是做了些额外的处理,也比较好懂

后面还有些处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if (!pathMap[record.path]) {
pathList.push(record.path)
pathMap[record.path] = record
}

if (name) {
if (!nameMap[name]) {
nameMap[name] = record
} else if (process.env.NODE_ENV !== 'production' && !matchAs) {
warn(
false,
`Duplicate named routes definition: ` +
`{ name: "${name}", path: "${record.path}" }`
)
}
}

这两段实际上就是完成了pathList nameMap pathMap这三个数据的关联和填充。在nameMap那里,额外做了一些开发提示,最终的作用就是为了检查是否有name相同的路由配置,如果有,就给出警告提醒开发者。

以上的一些代码,在一些基础路由配置处理的时候,就已经够了,比如这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[
{
path: '/',
component: Index
},
{
path: '/index',
redirect: '/'
},
{
name: 'list',
path: '/list',
component: List
}
]

只要路由配置里面,没有别名和childrenaddRouteRecord的其它代码就不会执行。

下面就来看有别名和有children的代码处理。先看别名的:

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
if (route.alias !== undefined) {
const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]
for (let i = 0; i < aliases.length; ++i) {
const alias = aliases[i]
if (process.env.NODE_ENV !== 'production' && alias === path) {
warn(
false,
`Found an alias with the same value as the path: "${path}". You have to remove that alias. It will be ignored in development.`
)
// skip in dev to make it work
continue
}

const aliasRoute = {
path: alias,
children: route.children
}
addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute,
parent,
record.path || '/' // matchAs
)
}
}

如果route.alias不为undefined,以上代码就会执行。从代码看到,原来route.alias是可以配置数组的,并且别名不能与path配置相同,而且别名跟redirect不一样,别名必须跟path一样,是路径字符串,不能写配置为alis: {name: 'some'}这种。

最关键就是这段处理:

1
2
3
4
5
6
7
8
9
10
11
12
const aliasRoute = {
path: alias,
children: route.children
}
addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute,
parent,
record.path || '/' // matchAs
)

当路由配置了别名,就会把所有的别名遍历一遍,挨个调用addRouteRecord,也就是把每个别名,都创建额外一条的route record。只不过这个创建过程,有两个特殊的地方:

  1. aliasRoute是这里单独定义的,不是routes里面那种配置,只保留了原有的children配置,以便children也能全部支持别名
  2. record.path || '/'也就是当前route record的路径,被设置为了别名的route recordmatch as,猜想一下,在后续路由匹配的时候,如果匹配到了某个路由,发现它有matchAs,接下来只要拿matchAs,重新做一次路由匹配,就能找到原始的路由了。

正因为以上代码的作用,如果你有这个routes:

1
2
3
4
5
6
7
[
{
path: '/',
component: Index,
alias: '/index'
}
]

则会生成两条route record:

如果路由配置chidlren,则下面的代码会运行:

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
if (route.children) {
// Warn if route is named, does not redirect and has a default child route.
// If users navigate to this route by name, the default child will
// not be rendered (GH Issue #629)
if (process.env.NODE_ENV !== 'production') {
if (
route.name &&
!route.redirect &&
route.children.some(child => /^\/?$/.test(child.path))
) {
warn(
false,
`Named Route '${route.name}' has a default child route. ` +
`When navigating to this named route (:to="{name: '${
route.name
}'"), ` +
`the default child route will not be rendered. Remove the name from ` +
`this route and use the name of the default child route for named ` +
`links instead.`
)
}
}
route.children.forEach(child => {
const childMatchAs = matchAs
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
}

中间那段是为了应对下面这种配置场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
name: 'detail',
path: '/detail/:id',
component: Detail,
children: [
{
name: 'detail_more',
path: '',
components: {
bar: () => import(/* webpackChunkName: "group-bar" */ '../pages/Bar.vue')
}
}
]
}

detail_more是一个path为空的子路由,可被看成是detail的默认路由,当在app中使用{name: 'detail'}的方式访问时,这个情形下,尽管detail_moredetail的默认路由,它对应的component也不会被渲染,所以vue-router给出了开发期间的警告提醒。可以改用{name: 'detail_more'}的方式访问。

接下来这段:

1
2
3
4
5
6
route.children.forEach(child => {
const childMatchAs = matchAs
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})

主要是那个childMatchAs的处理,当matchAs为空的时候,上面的代码是很好理解的,就是遍历children,挨个创建route record的过程。假如有matchAs,说明当前是正在进行别名的route record的创建过程,而且这个别名路由,还是有一个有children的别名路由。 假如这个route是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
name: 'detail',
path: '/detail/:id',
component: Detail,
alias: ['/query/:id'],
children: [
{
name: 'detail_more',
path: 'more',
components: {
bar: () => import(/* webpackChunkName: "group-bar" */ '../pages/Bar.vue')
}
}
]
}

最终一共会添加4条路由,非别名的路由两条,其中1条是另外1条的子路由;别名的路由也是2条,其中1条是另外1条的子路由:

childMatchAs的作用,就是让children里的路由,也有了别名,比如/detail/:id/more会有一条/query/:id/more

(完)