vue-router 源码探究——路由重置实现
在之前的一篇博文中主要阐述了前端权限控制的一种实现 —— 前端权限控制的基本实现。其中介绍了通过权限过滤实现动态地私有路由添加,那么在当前用户登出时,应该是要重置当前应用的用户数据的。那么全局的 vuex
状态可通过官方替换 store
的方法 replaceState 来实现。那么在没有官方实现的 feature
的情况下该如何删除(重置)通过 addRoutes 方法添加的动态路由?
TL;DR
起初,只有通过调用全局的原生 location.refresh 方法来实现整个页面的刷新,进而实现当前路由的重置。那么会有一种在不刷新当前页面的方法实现当前路由的重置功能么?在经过一系列的尝试,在官方源码存在这样一个 issue
—— feature request: replace routes dynamically,其中提到了一种 hack
的方法,通过替换路由实例的 matcher 对象来实现路由的 重置,即实现删除通过 addRoutes 添加的路由。
那么截至现在就有两种解决方案可实现路由的删除:
-
通过调用全局的 location.refresh 方法来实现应用刷新来实现前端路由的重置。
-
通过替换当前
vue-router
的matcher
属性对象来实现在不刷新页面的情况下重置当前路由实例。
下文将着重从与 matcher
相关的 vue-router 源码解读为什么替换 vue-router 的 matcher 对象可实现删除 addRoutes 添加的路由。另外截至本文写作日期,vue-router 的最新版本为 v3.0.6。后文所述的所有源码解读都是基于 此 v3.0.6 版本。
首先在 src/index.js 中可见 vue-router
类,其中包含了一系列我们熟知的 router API
。这里尤其要注意与本文相关联的 VueRouter
的 构造函数 constructor 和 原型方法 addRoutes。
后文的内容都是基于这个两个点展开直至解决我们的核心问题 —— 为什么替换实例的 matcher
可实现 删除 由 addRoutes 添加的路由。
先问是什么,再问为什么。
matcher 是什么
在源码的 src/index.js 入口文件中的 VueRouter
类的构造函数中可见,路由实例的 matcher
对象由 create-matcher
中的内部方法 createMatcher 创建 而来。
// ... other code
import { createMatcher } from './create-matcher'
// ... other code
export default class VueRouter {
// ...
constructor(options: RouterOptions = {}) {
// ...
this.matcher = createMatcher(options.routes || [], this)
let mode = options.mode || 'hash'
// ... other code
}
// ... other code
addRoutes(routes: Array<RouteConfig>) {
this.matcher.addRoutes(routes)
if (this.history.current !== START) {
this.history.transitionTo(this.history.getCurrentLocation())
}
}
}
通过对 VueRouter
类的代码抽象显而易见:
-
在实例化路由对象时,会创建一个与当前路由实例对应的
matcher
。并在实例化时,传入在实例化时的 routes 参数。这里 留心 这里传入的 routes 参数,后续的源码分析也会用到该 routes 参数。 -
在我们之前在 前端权限控制的基本实现 中用到的 addRoutes 方法中,本质上是调用了
matcher
的 addRoutes 方法。
不论是 VueRouter
实例化还是通过 addRoutes 方法,都绕不开 matcher
。那么再次深入 create-matcher.js 源码文件中,可见以下代码:
// {projectRoot}/src/create-matcher.js
export function createMatcher(
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
const { pathList, pathMap, nameMap } = createRouteMap(routes)
// ...
return {
match,
addRoutes
}
}
这里暂时性省略了其他无关代码,关注我们之前的问题 —— matcher
是什么。在这里可以很明显地看出,实例的 matcher
对象是由 match
属性和 addRoutes
属性组成。接下来我们进一步探究二者的本质。
这里先提一点,探究源码的时候最好是带着一个具体的问题来看源码,来理解其中的代码逻辑,这样才不至于在源码中迷失,以保持自己的初心。
全局路由的存储
在上一章节,已经提到不论是 路由实例化 还是调用动态添加路由的 API
—— addRoutes。都会调用到一个 createMatcher 函数。这里我们将分步讨论以下 createMatcher 函数 到底具有什么样的职责。
export default class VueRouter {
constructor(options: RouterOptions = {}) {
// ...
this.matcher = createMatcher(options.routes || [], this)
// ...
}
// ...
match(raw: RawLocation, current?: Route, redirectedFrom?: Location): Route {
return this.matcher.match(raw, current, redirectedFrom)
}
// ...
addRoutes(routes: Array<RouteConfig>) {
this.matcher.addRoutes(routes)
if (this.history.current !== START) {
this.history.transitionTo(this.history.getCurrentLocation())
}
}
// ...
}
在查看 create-matcher.js 时,我们可见第一句代码:
const { pathList, pathMap, nameMap } = createRouteMap(routes)
这句代码我们大致从调用的函数名称上 推测 一下,在执行 createMatcher 函数 时,首要任务时对当前开发者传入的 options.routes 进行路由 映射化处理,并得到了三个路由容器 pathList
、pathMap
、nameMap
。
在 create-matcher.js 的同级目录下我们可以找到 createRouteMap 所在的文件 create-route-map.js,我们同样可将 createRouteMap 的逻辑展示如下:
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 || []
/**
* @description Dictionary 泛型
* https://github.com/vuejs/vue-router/blob/v3.0.6/flow/declarations.js#L20
*/
// $flow-disable-line
const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
// $flow-disable-line
const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)
/**
* @description 将所有的 VueRouter 构造函数提供的 routes 分别存入 pathList,
* pathMap, nameMap 三个路由容器中。
*/
routes.forEach(route => {
addRouteRecord(pathList, pathMap, nameMap, route)
})
/**
* @description 因为上文代码注释已经说明 pathList 是用于保证路由匹配的优先级
* 那么,以下代码用于保证路由通配符始终被最后匹配
*/
// 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--
}
}
return {
pathList,
pathMap,
nameMap
}
}
同样可知,createRouteMap 函数返回三个属性 —— pathList
、pathMap
、nameMap
。
pathList
,作者已经给出了注释说明,pathList
是用来保证进行非命名路由时的path
匹配优先级的(具体可查看文档 —— 匹配优先级)。
在进一步探究 addRouteRecord 时,我们可以发现 addRouteRecord 就主要做了三件事,将所有之前实例化时传入的 options.routes 格式化
-
对其中每一个
route
做映射,该映射集合被称为pathMap
。同时在映射时,不断向pathList
列表加入当前path
记录以保证匹配路由时的优先级。 -
对提供了
name
字段的路由记录,加入到nameMap
映射中。因为name
具有唯一性,所以此时在nameMap
中就不用考虑匹配优先级了。 -
递归所有路由的子路由,并进行映射化处理。
具体代码解析如下:
function addRouteRecord(
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>,
route: RouteConfig,
parent?: RouteRecord,
matchAs?: string
) {
const { path, name } = route
// 断言传入的 route 中是否包含 path 属性
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.`
)
}
// 有没有提供客制化解析参数
// https://router.vuejs.org/zh/guide/essentials/dynamic-matching.html#高级匹配模式
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 }
}
// 当前 route 存在子路由时
if (route.children) {
/**
* @description 用来解决子路由为空字符串或为 '/' 时,将导致子路由无法渲染的 BUG
* 具体可见:https://github.com/vuejs/vue-router/issues/629
*/
// 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.`
)
}
}
/**
* @description 递归映射化当前路由的所有子路由
*/
route.children.forEach(child => {
const childMatchAs = matchAs
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
}
/**
* @description route.alias 表示路由别名。当存在 /a 别名为 /b 时,访问 /b 就像
* 访问 /a 一样,但路由还是保持为 /b。
* https://router.vuejs.org/guide/essentials/redirect-and-alias.html#alias
* https://router.vuejs.org/zh/guide/essentials/redirect-and-alias.html#别名
*/
if (route.alias !== undefined) {
const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]
aliases.forEach(alias => {
const aliasRoute = {
path: alias,
children: route.children
}
addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute,
parent,
record.path || '/' // matchAs
)
})
}
/**
* @description 若当前 pathMap 映射容器中不包含 path 路由记录,那么将该路由记录对
* 象添加到 pathMap 容器中。另外 pathList 用来保证匹配的优先级
*/
if (!pathMap[record.path]) {
pathList.push(record.path)
pathMap[record.path] = record
}
/**
* @description 若当前路由提供了 name 字段,即当前路由是命名路由时,将在 nameMap
* 映射容器中添加该命名路由的路由记录。
*/
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}" }`
)
}
}
}
现在我们可以大致 总结 一下,所有之前我们通过 VueRouter
构造函数所传递的参数 最终 都在 addRouteRecord 函数中得到解析处理。
-
所有的路由信息
path
字段都存储在pathMap
中,通过pathList
列表实现 匹配优先级。 -
若存在路由有
name
字段时,该命名路由将被存储在nameMap
映射容器中,因为文档中约定了name
字段具有唯一性,那么命名路由没有专门的nameList
来实现匹配优先级。 -
所有路由的每一项子路由都会递归进行解析并存储。
现在我们理解了所有路由信息的最终归宿之后,回溯之前的解析可以发现:
// {ProjectRoot}/src/index.js
import { createMatcher } from './create-matcher'
// ...
export default VueRouter {
constructor (options: RouterOptions = {}) {
this.matcher = createMatcher(options.routes || [], this)
}
}
// {ProjectRoot}/src/create-matcher.js
import { createRouteMap } from './create-route-map'
// ...
export function createMatcher(
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
const { pathList, pathMap, nameMap } = createRouteMap(routes)
// ...
}
// {ProjectRoot}/src/create-route-map.js
export function createRouteMap(
routes: Array<RouteConfig>,
oldPathList?: Array<string>,
oldPathMap?: Dictionary<RouteRecord>,
oldNameMap?: Dictionary<RouteRecord>
): {
pathList: Array<string>
pathMap: Dictionary<RouteRecord>
nameMap: Dictionary<RouteRecord>
} {
// ...
routes.forEach(route => {
addRouteRecord(pathList, pathMap, nameMap, route)
})
// ...
}
以上代码展示了传入的 options.routes 的格式化存储过程,最终所有的路由信息都在 addRouteRecord 被解析,并存储在 pathList
和 pathMap
中,若是命名路由,另外还会被存储在 nameMap
中。**在构造函数中可见,这一切的路由信息容器都是被挂载在路由实例的 matcher
对象上的。**这一点,对于后续的动态路由删除功能提供了契机。
matcher.match 函数
之前我们知道了 matcher
对象由 match
方法和 addRoutes
组成的。我们大致看一下 match
属性是指什么,在 create-matcher.js 中的 26 - 72 行就是我们找的 match
属性——它是一个函数。
function match(
raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location
): Route {
// 通过调用的函数名称可大致推断是格式化当前的路由对象
const location = normalizeLocation(raw, currentRoute, false, router)
// 抽离当前路由对象中的 name 字段,即命名路由中的名称
const { name } = location
// 若当前路由是命名路由时
if (name) {
// ... code
// 当前路由不是命名路由,那么直接进行路由的 path 字段路由匹配
} else if (location.path) {
// ... code
}
// no match
return _createRoute(null, location)
}
这里直接将 match
函数的大致脉络抽象为以上结构,经过抽象后的代码可轻易看出 match
函数的主要定位是 vue-router
的 路由匹配模块。一切路由的匹配都是依赖于实例的 matcher.match
函数。这里我们主要是要探究替换 matcher
为什么可以实现路由重置,将不对 路由匹配模块 做深入探究,如果读者感兴趣的话,可以从这个函数开始开起,自己可以尝试着探究 vue-router
的路由匹配模块。稍微提示一下,整个 vue-router
实例都是基于命名路由的 name
字段的 nameMap
映射 用于命名路由的路由匹配,使用非命名路由的 pathList
列表 保证路由匹配的优先级和使用 pathMap
映射 来实现路由的匹配的。这里的匹配搜索原理和作者之前的 前端权限控制的基本实现 数据搜索原理都是基于 映射 这种数据结构。
本文所述的映射是指的一种抽象化的 key-value 数据结构。每一个唯一个 key 都有一个唯一的 value 值与之对应。在 JS 中,一个朴素对象或一个 Map 实例对象都可称为映射。
动态添加路由的实现
在前文中已经提到我们外部调用路由实例的 addRoutes 方法 本质 上就是调用了 match.addRoutes 方法实现 路由的动态添加。
回到之前的 create-matcher.js 中的 create-matcher
函数,在 22 - 24 行可看到 addRoutes
同样是一个函数,并且我们还知道了 addRoutes
的本质就是调用了前文所述的 createRouteMap
函数,我们对之前全局静态路由的解析存储流程有了理解之后就不难理解,调用 createRouteMap 函数本质上就是 向当前路由实例的路由容器动态地添加路由。
// {ProjectRoot}/src/index.js
import { createMatcher } from './create-matcher'
// ...
export default VueRouter {
// ...
addRoutes (routes: Array<RouteConfig>) {
this.matcher.addRoutes(routes)
if (this.history.current !== START) {
this.history.transitionTo(this.history.getCurrentLocation())
}
}
}
// {ProjectRoot}/src/create-matcher.js
import { createRouteMap } from './create-route-map'
export function createMatcher(
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
const { pathList, pathMap, nameMap } = createRouteMap(routes)
function addRoutes(routes) {
createRouteMap(routes, pathList, pathMap, nameMap)
}
// ...
return {
match,
addRoutes
}
}
由上源码可知,调用路由实例的 addRoutes 方法本质上是调用了路由实例的 addRoutes
方法,该方法在其内部完成了传入的 addRoutes 的路由解析并进行映射化处理。
结论
在我们明白了 addRoutes 是如何向当前路由实例 动态地添加路由 后,我们再结合之前的路由实例化中的路由映射化处理流程可知:
替换当前路由实例的 matcher
之所以能实现删除动态添加的路由,是因为替换当前路由的 matcher
本质 上是 替换了现有的路由实例的路由映射容器。新的 matcher
始终 仅仅 包含路由实例化时的路由,而 不会包含 后期被 addRoutes 方法添加的路由,那么替换当前路由的 matcher
就可实现删除通过 addRoutes
添加的路由。