vue-router.js源码学习 —— 路由匹配

在上一篇文章vue-router.js源码学习 —— 初识Vue Router我们大致看了 Vue Router 运行的主要流程,以及<router-link><router-view>的实现,几乎是把 Vue Router 的 API 都过了一遍。今天我们来看一下核心部分路由与组件匹配的实现。

matcher这一部分代码相对独立,不依赖其他部分,代码位于文件src/create-matcher.js中。我们先来缕清楚matcher被创建和使用的地方,这样再去分析会目标明确一些。

之前在讲VueRouter的构造函数时,曾遇到过创建matcher

1
2
3
4
5
6
7
8
9
export default class VueRouter {
// ...
constructor (options: RouterOptions = {}) {
// ...
this.matcher = createMatcher(options.routes || [], this)
// ...
}
// ...
}

是在这里调用createMatcher函数创建了matcher对象,并传了两个参数:开发者传入的routes选项,和router本身。

那我们到src/create-matcher.js中看一下createMatcher函数,它的构成如下:

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
export function createMatcher (
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
// ...

function addRoutes (routes) {
// ...
}

function match (raw: RawLocation, currentRoute?: Route, redirectedFrom?: Location): Route {
// ...
}

function redirect (record: RouteRecord, location: Location): Route {
// ...
}

function alias (record: RouteRecord, location: Location, matchAs: string): Route {
// ...
}

function _createRoute (record: ?RouteRecord, location: Location, redirectedFrom?: Location): Route {
// ...
}

return {
match,
addRoutes
}
}

可以看到createMatcher函数最后返回了一个对象,包裹着matchaddRoutes两个函数。路由的重定向和别名的处理也是在这里的。既然公开出去的是matchaddRoutes,那再来看一下它们是如何被调用的。这又得回到VueRouter类,其中有下面两个方法:

1
2
3
4
5
6
7
8
9
10
11
export default class VueRouter {
// ...
match (raw: RawLocation, current?: Route, redirectedFrom?: Location): Route {
return this.matcher.match(raw, current, redirectedFrom)
}

addRoutes (routes: Array<RouteConfig>) {
this.matcher.addRoutes(routes)
// ...
}
}

VueRouter对其进行了一次包装,提供给外部使用。

先了解到这,我们现在开始看createMatcher函数的具体实现。

createRouteMap

createMatcher函数的开始就看到createRouteMap函数被调用了两次:

1
2
3
4
5
6
7
// 初始化时只传了一个参数 routes
const { pathList, pathMap, nameMap } = createRouteMap(routes)

// 当外部调用 addRoutes 时,除了传routes,还传了前面得到的pathList, pathMap, nameMap
function addRoutes (routes) {
createRouteMap(routes, pathList, pathMap, nameMap)
}

所以在createRouteMap中主要做了这么几件事:

  • 初始化pathListpathMapnameMap这几个集合变量。

  • 循环routes,调用addRouteRecord向上面几个集合中添加内容,一会看addRouteRecord的实现就知道每个集合中具体放的什么。

  • 确保在pathList中的路径有使用通配符*的,将这个路径放在最后。

  • 在开发环境下判断外部传入的path如果没有使用通配符的话,是否以/开头,发现有没有的path会给出提示,非嵌套的路径需要以/开头。

  • 最后返回pathListpathMapnameMap

所以这里关键是addRouteRecord函数中做的事情。

addRouteRecord

addRouteRecord的函数签名如下:

1
2
3
4
5
6
7
8
function addRouteRecord (
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>,
route: RouteConfig,
parent?: RouteRecord, // 在内部进行递归时会传该参数
matchAs?: string // 在有别名时会传该参数
)
  • route中解构出pathname。开发环境下判断path是否传了,route.component是否传的是组件而不是字符串。

  • 规范path,同步pathToRegexpOptions.sensitiveroute.caseSensitive

    先说一下pathToRegexpOptions是用来干嘛的,举个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    const router = new VueRouter({
    routes: [
    // case 1
    { path: '/foo' },
    // case 2
    { path: '/optional-group/(foo/)?bar' }
    ]
    })
    1
    2
    3
    4
    5
    6
    7
    8
    <ul>
    <!-- match case 1 -->
    <li><router-link to="/foo">/foo</router-link></li>
    <!-- match case 2 -->
    <li><router-link to="/optional-group/bar">/optional-group/bar</router-link></li>
    <!-- match case 2 -->
    <li><router-link to="/optional-group/foo/bar">/optional-group/foo/bar</router-link></li>
    </ul>

    要想能成功跳转,首先to中传的路径要与routes中配置的其中一个匹配上。像 case 1 是比较简单的情况,直接是一个固定的字符串,但是Vue Router 还支持像 case 2 这样的高级配置模式,可以匹配多个路径。Vue Router 使用开源库path-to-regexp来将path转换成正则表达式。而pathToRegexpOptions实际上就是传给 path-to-regex 的参数,查阅它的文档可知pathToRegexpOptions中的一些选项,如下:

    • sensitive:当值为true时路径是大小写敏感的,默认为false
    • strict:当值为false时路径结尾的/是可选的,默认为false
    • end:当值为true时全局匹配,默认为true
    • delimiter:为重复的参数设置默认的分隔符,默认为/

    再来看一下是如何规范path的,normalizePath函数的内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    function normalizePath (
    path: string,
    parent?: RouteRecord,
    strict?: boolean
    ): string {
    // 非严格模式下去掉 path 结尾的 /
    if (!strict) path = path.replace(/\/$/, '')
    // 如果 path 以 / 开头,直接返回该 path
    if (path[0] === '/') return path
    // 如果 parent 为 null,也就是这不是一个嵌套的 path,直接返回 path
    if (parent == null) return path
    // 这个 path 是嵌套的,那 parent.path 和 path组合在一起后可能出现连续重复的 /,所以在 cleanPath 会将其替换为1个
    return cleanPath(`${parent.path}/${path}`)
    }

    这里要注意的是,在嵌套的path中,如果也是以/开头的,那就直接使用这个path了。

  • 创建 路由记录RouteRecord),其内容如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    const record: RouteRecord = {
    path: normalizedPath,
    // 创建匹配 path 的正则表达式
    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 }
    }

    可以看到,对于使用route.component的情况,会给默认名字default,比如下面的例子:

    1
    2
    3
    4
    5
    6
    7
    routes: [
    {
    path: '/home',
    component: Home,
    props: { userId: 10 }
    }
    ]

    会转换成:

    1
    2
    3
    4
    5
    6
    7
    8
    {
    components: {
    default: Home
    },
    props: {
    default: { userId: 10 }
    }
    }
  • 如果route中配置了children,也就是嵌套路由,会递归调用addRouteRecord

  • 将上面得到的record存入pathMappathList,对应下面这段代码:

    1
    2
    3
    4
    if (!pathMap[record.path]) {
    pathList.push(record.path)
    pathMap[record.path] = record
    }
  • 如果route中配置了alias,就以alias中的值为path再创建对应的RouteRecord对象。

    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
    )

    虽然aliasRoute中只提供了pathchildren两项,但是最后一个参数matchAs此时是有值的。

  • 最后如果在route中配置了name,也就是给路由起了个名字,会向nameMap中添加record

    1
    2
    3
    if (!nameMap[name]) {
    nameMap[name] = record
    }

可以看到addRouteRecord就是在处理创建VueRouter实例时传入的routes,其中的每一个route都会对应生成一个RouteRecord对象,同时匹配该RouteRecord对象的正则表达式也在其中,所有的RouteRecord对象最后都存在了pathListpathMapnameMap中。

match

前面处理完了routes的配置,match函数中要处理的就是通过<router-link>router.push传的目标位置信息了,最后会得到一个Route对象。

match函数的主要处理流程如下:

  • 先调用normalizeLocation得到包含了目标位置基本信息的Location对象。

    看一下normalizeLocation函数中对不同情况的传参的处理:

    • raw如果是一个已经规范过的Location对象就直接将其返回。

    • raw中如果有name字段,创建一个空对象将name放进去,再判断如果有params,再将params放进去,然后将这个对象返回。也就是说如果提供了name,那就只会关心params,其他的字段都会给忽略掉。此时对应的是下面这种情况:

      1
      2
      <!-- 此时 raw 是 { name: 'user', params: { id: 123 } } -->
      <router-link :to="{ name: 'user', params: { id: 123 } }">User</router-link>
  • 如果raw中没有name也没有path,但是提供了params,并且当前有路由信息的情况,像下面这个例子展示的,如果点击 User1 时,当前正处于 User2,则会取 User2 路由信息中的name,并将 User1 和 User2 的params进行合并。如果当前正处于 User3,则会取出 User3 路由信息中的matched中的最后一个路由记录的path,然后用params去填充该path,此时得到path/user/456

    1
    2
    3
    <router-link :to="{ params: { id: 456 } }">User1 (no name and path)</router-link>
    <router-link :to="{ name: 'user', params: { id: 123 } }">User2 (with name)</router-link>
    <router-link :to="{ path: '/user/123'}">User3 (with path)</router-link>
  • 最后就是raw中有path的情况,此时会先解析path。比如下面例子这样的path

    1
    <router-link :to="{ path: '/user/123?username=Ida#foo', query: { age: 18 }, hash: '#bar' }">User (with path)</router-link>

    解析后得到的是:

    1
    2
    3
    4
    5
    {
    hash: "#foo",
    path: "/user/123",
    query: "username=Ida"
    }

    然后还要处理appendtrue的情况。接着会将上面得到的queryto中传的query进行合并,得到最终的query

    1
    2
    3
    4
    {
    age: "18",
    username: "Ida"
    }

    最后确定hash的值,如果to中传了,就使用传的这个,如果没有传,就使用上面解析出来的。所以现在hash#bar

    这样经过normalizeLocation函数后,各种传参情况就都处理了。

  • 如果上面得到的Location对象中有name,用这个namenameMap中取出对应的路由记录。如果取到了,再接着获取location.path

  • 如果上面得到的Location对象中有path,就去pathMap中找到正则表达式能匹配该path的路由记录。

  • 最后都会调用_createRoute函数创建Route对象,如果没有找到路由记录,就传null过去。

_createRoute中又分别调用了redirectalias方法来处理重定向和别名的情况,但最终都是调用createRoute函数去创建路由对象。

createRoute函数中创建的路由对象内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
const route: Route = {
name: location.name || (record && record.name),
meta: (record && record.meta) || {},
path: location.path || '/',
hash: location.hash || '',
query,
params: location.params || {},
fullPath: getFullPath(location, stringifyQuery),
matched: record ? formatMatch(record) : []
}
if (redirectedFrom) {
route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery)
}

formatMatch函数中,遍历recordparent,依次放入数组的前面。所以如果是嵌套路由,matched存的是当前路由,及其外层的路由,最外层的排在最前面。

比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
routes: [
{
path: '/',
component: Home,
children: [
{
path: '/user/:id',
name: 'user',
component: User
}
]
}
]

最后得到的 User 的路由对象内容如下:

最终将在router.push<router-link>中传的路由信息,与在创建router时配置的routes中的路由匹配起来了。

我把分析history部分的代码新开了一篇文章,要不这篇文章实在太长了,可能看着看着就没耐心了-.-

本文作者:意林
本文链接:http://shinancao.cn/2020/03/10/Vue-Source-Code-18/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!