共计 10544 个字符,预计需要花费 27 分钟才能阅读完成。
开始
首先是碎碎念,换了工作之后,终于有些闲暇时间,突然才发现自己竟然有一年多没有写博客,回头想想这段时间似乎都没有多少新的技术积累,感觉好惭愧,无法吐槽自己了。
好吧,还是立马进入主题,今天的主角就是 Vue-Router,作为 Vue 全家桶的一员,肯定是再熟悉不过了,但是自己却没有去阅读过源码,有些地方还不是很了解,终于在最近的项目还是遇到坑了(不遇到坑可能猴年马月都不会去看一下源码吧,哈哈),所以还是花了一些时间去学习了一下源码吧。
路由
路由在一个 app 开发中是不可缺少的,理论上在一个 app 开发中,首先就是要定义各个路由:哪些页面需要鉴权才能打开,什么时候需要重定向指定的页面,页面切换的时候怎么传递数据,等等。可以想象当应用越是庞大,路由的重要性就会发凸显,路由可以说是整个应用的骨架。但是 web 的路由功能相对原生应用开发是很弱的,控制不好,就会出现一些莫名其妙的跳转和交互。
构建 Router
直接进入 VueRouter 构建流程:
- 调用 createMatcher 方法,创建 route map
- 默认是 hash 模式路由
- 如果选择了 history 模式,但是刚好浏览器又不支持 history,fallback 选项就会设置为 true,fallback 选项会影响 hash 模式下 url 的处理,后面会分析到。
- 根据当前模式选择对应的 history(hash,history,abstract)
先看第一步 createMatcher 方法:
export function createMatcher (
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {const { pathList, pathMap, nameMap} = createRouteMap(routes)
...
return {
match,
addRoutes
}
}
主要通过 createRouteMap 方法创建出 pathList,pathMap 和 nameMap,pathMap 和 nameMap 里面都是 RouteRcord 对象;Vue-Router 会为每个路由配置项生成一个 RouteRecord 对象,然后就返回 match 和 addRoutes 方法;
这里 match 方法,就是通过当前的路由路径去返回匹配的 RouteRecord 对象,而 addRoutes 则能够动态添加新的路由配置信息,正与官方文档描述一样。
再看 createRouteMap 方法:
function createRouteMap (
routes: Array<RouteConfig>,
oldPathList?: Array<string>,
oldPathMap?: Dictionary<RouteRecord>,
oldNameMap?: Dictionary<RouteRecord>
): {
...
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--
}
}
...
}
遍历所有的路由配置信息,创建 RouteRecord 对象并添加到 pathMap,nameMap 中;最后调整 pathList 中通配符的位置,所以最后无法准确找匹配的路由,都会返回最后通配符的 RouteRecord。
那么 RouteRecord 究竟是个什么样的对象尼:
const record: RouteRecord = {
path,
regex,
components,
instances, // 路由创建的组件实例
name,
parent, // 父级 RouteRecord
matchAs, //alias
redirect,
beforeEnter,
meta,
props, // 后面会分析
}
这样一看 RouteRcord 其实有很多属性跟我们初始配置的路由属性是一致的。
RouteRecord 的关键属性是 parent 会指向父级的 RouteRecord 对象,在嵌套的 router-view 场景下,当我们找到匹配的的 RouteRecord 就可以顺带把父级的 RouteRecord 找出来直接匹配到同样 depth 的 router-view 上;
instances 属性保存了路由创建的组件实例,因为当路由切换的是,需要调起这些实例 beforeRouteLeave 等钩子;那么这些组件实例是什么时候添加到 instances 上的尼?主要有两个途径:
-
组件的 beforeCreate 钩子
Vue.mixin({beforeCreate () { ... registerInstance(this, this) }, destroyed () {registerInstance(this) } })
-
vnode 的钩子函数
;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {matched.instances[name] = vnode.componentInstance } data.hook.init = (vnode) => { if (vnode.data.keepAlive && vnode.componentInstance && vnode.componentInstance !== matched.instances[name] ) {matched.instances[name] = vnode.componentInstance } }
单纯依赖第一种组件的 beforeCreate 钩子,在某些场景下是无法正确的把组件的实例放到 RouteRecord 的 instances 属性中,那么再讨论一下哪些场景会需要第二种方法。
需要 prepatch 钩子的场景,当有两个路由:
new VueRouter({
mode: 'hash',
routes: [{ path: '/a', component: Foo},
{path: '/b', component: Foo}
]
})
‘/a’, ‘/b’ 它们的组件都是 Foo,如果初始路由是 ’/a’, 那么虚拟 dom 树上就已经有一个 Foo 实例生成,当路由切换到 ’/b’ 的时候,因为 virtual dom 的算法,Foo 实例会被重用,并不会重新创建新的实例,也就是 Foo 的 beforeCreate 钩子是不会调起,这样的话,Foo 的实例也就没办法通过 beforeCreate 钩子添加到 ’/b’ 的 RouteRecord 上。但是 vnode 的 prepatch 钩子这时可以调起,所以可以在这里把 Foo 的实例放到‘/b’的 RouteRecord 上。
需要 init 钩子的场景:
<keep-alive>
<router-view><router-view>
</keep-alive>
首先在 router-view 上套一个 keep-alive 组件,接着路由的定义如下:
new VueRouter({
mode: 'hash',
routes: [{ path: '/a', component: Foo},
{path: '/b', component: Other}
{path: '/c', component: Foo}
]
})
当初始路由是‘/a’的时候,Foo 的实例会被创建并且被 keep-alive 组件保存了,当切到‘/b’后,Other 的实例跟 Foo 的实例同样一样情况,然后再切换路由到‘/c’的时候,由于 keep-alive 的组件作用,会直接重用之前‘/a’保存的 Foo 实例,在 virtual dom 对比的时候,重用的 Foo 实例和 Other 实例的虚拟 dom 节点完全是不同类型是无法调起 prepatch 钩子,但是可以调起 init 钩子。
以上就是相关的一些场景讨论。其实个人感觉有些情况 Vue-Router 这种处理并不是很好,因为 RouteRcord 相对整个 Router 实例是唯一的,对应的 instances 也是唯一,如果同一深度(不用层级)情况下,有两个多个 router-view:
<div>
<div>
<router-view></router-view>
</div>
<div>
<router-view></router-view>
</div>
</div>
很明显现在它们都会指向同一个 RouteRecord,但是它们会创建出不同的组件实例,但是只有当中的一个会成功注册到 RouteRecord 的 instances 属性上,当切换路由的时候,另外一个组件实例是应该不会接收到相关的路由钩子调用的,虽然这种使用场景可能几乎没有。
另外一个场景可能只是我们使用的时候需要注意一下,因为我们可以改变 router-view 上的 name 属性也可以切换 router-view 展示,而这种切换并不是路由切换引起的,所以组件的实例上是不会有路由钩子调起的;另外当 instances 上有多个实例的时候,路由一旦切换,就算没有在 router-view 展示的实例,都会调起路由的钩子。
matchAs 是在路由有 alias 的时候,会创建 AliasRouteRecord,它的 matchAs 就会指向原本的路由路径。
props 这个属性有点特别,在官方文档上也没有多少说明,只有在源码有相关的例子;
- 它如果是对象可以在 router-view 创建组件实例时,把 props 传给实例;
- 但是如果是布尔值为 true 时,它就会把当前的 route 的 params 对象作为 props 传给组件实例;
- 如果是一个 function,就会把当前 route 作为参数传入然后调起,并把返回结果当作 props 传给实例。
所以我们可以很轻松的把 props 设置为 true,就可以把 route 的 params 当成 props 传给组件实例。
现在分析完 RouteRecord 对象,接着创建的主流程,先需要创建对应的 HashHistory;但是如果我们是选择了 history 模式只是浏览器不支持回退到 hash 模式的话,url 需要额外处理一下,例如如果 history 模式下,URL 路径是这样的:
http://www.baidu.com/a/b
在 hash 模式下就会被替换成
http://www.baidu.com/#/a/b
到此 Vue-Router 实例构建完成。
监听路由
在 Vue-Router 提供的全局 mixin 里,router 的 init 方法会在 beforeCreate 钩子里面调起,正式开始监听路由。
router 的 init 方法:
init(app) {
...
if (history instanceof HTML5History) {history.transitionTo(history.getCurrentLocation())
} else if (history instanceof HashHistory) {const setupHashListener = () => {history.setupListeners()
}
history.transitionTo(history.getCurrentLocation(),
setupHashListener,
setupHashListener
)
}
history.listen(route => {this.apps.forEach((app) => {app._route = route})
})
}
这里 history 模式会直接切换路由(history 模式在构建 HTML5History 时就已经挂载了 popstate 监听器),而 hash 模式会先设置 popstate 或者 hashchange 监听器,才切换到当前的路由(因为 hash 模式可能是 history 模式降级而来的,要调整 url,所以延迟到这里设置监听器)。
到这里可以发现,可能跟我们想象的不一样,其实 hash 模式也是优先使用 html5 的 pushstate/popstate 的,并不是直接使用 hash/hashchange 驱动路由切换的(因为 pushstate/popstate 可以方便记录页面滚动位置信息)
直接到最核心部分 transitionTo 方法,看 Vue-Router 如何驱动路由切换:
transitionTo (
location: RawLocation,
onComplete?: Function,
onAbort?: Function
) {const route = this.router.match(location, this.current) //1. 找到匹配的路由
this.confirmTransition( //2. 执行切换路由的钩子函数
route,
() => {this.updateRoute(route) //3. 更新当前路由
onComplete && onComplete(route)
this.ensureURL() //4. 确保当前 URL 跟当前路由一致
// fire ready cbs once
if (!this.ready) {
this.ready = true
this.readyCbs.forEach(cb => {cb(route)
})
}
},
err => {if (onAbort) {onAbort(err)
}
if (err && !this.ready) {
this.ready = true
this.readyErrorCbs.forEach(cb => {cb(err)
})
}
}
)
}
粗略来看总共 4 步:
- 先找到匹配的路由
- 执行切换路由的钩子函数(beforeRouteLevave, beforeRouteUpdate 等等)
- 更新当前路由
- 确保当前 URL 跟当前路由一致
接着分析
第 1 步到 match 方法:
function match (
raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location
): Route {const location = normalizeLocation(raw, currentRoute, false, router)
const {name} = location
if (name) {const record = nameMap[name]
...
location.path = fillParams(record.path, location.params, `named route "${name}"`)
return _createRoute(record, location, redirectedFrom)
} else if (location.path) {location.params = {}
for (let i = 0; i < pathList.length; i++) {const path = pathList[i]
const record = pathMap[path]
if (matchRoute(record.regex, location.path, location.params)) {return _createRoute(record, location, redirectedFrom)
}
}
}
// no match
return _createRoute(null, location)
}
match 方法有两个分支,如果跳转路由时提供 name,会从 nameMap 直接查找对应的 RouteRecord,否则就遍历 pathList 找出所有的 RouteRecord,逐个尝试匹配当前的路由。
当找到匹配的 RouteRecord,接着进入_createRoute 方法,创建路由对象:
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) : []}
关键看 matched 属性,formatMatch 会从匹配的 RouteRecord 一直从父级往上查找,返回一个匹配的 RouteRecord 数组,这个数组在嵌套 router-view 场景,会根据嵌套的深度选择对应的 RouteRecord。
接着第 2 步,确认路由切换,调起各个钩子函数,在这一步里面你可以中止路由切换,或者改变切换的路由等
confirmTransition 方法如下:
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
const current = this.current
const abort = err => {...}
if (isSameRoute(route, current) &&
// in the case the route map has been dynamically appended to
route.matched.length === current.matched.length
) {this.ensureURL()
return abort(new NavigationDuplicated(route))
}
// 匹配的路由跟当前路由对比,找出哪些 RouteRecrod 是需要 deactivated,哪些是需要 activated
const {updated, deactivated, activated} = resolveQueue(
this.current.matched,
route.matched
)
// 跟官方文档描述一致
const queue: Array<?NavigationGuard> = [].concat(
// in-component leave guards
extractLeaveGuards(deactivated),
// global before hooks
this.router.beforeHooks,
// in-component update hooks
extractUpdateHooks(updated),
// in-config enter guards
activated.map(m => m.beforeEnter),
// async components
resolveAsyncComponents(activated)
)
this.pending = route
const iterator = (hook: NavigationGuard, next) => {if (this.pending !== route) {return abort()
}
try {hook(route, current, (to: any) => {if (to === false || isError(to)) { // 如果为 false 或者出错就中止路由
// next(false) -> abort navigation, ensure current URL
this.ensureURL(true)
abort(to)
} else if (
typeof to === 'string' ||
(typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string'))
) {// next('/') or next({path: '/'}) -> redirect
abort()
if (typeof to === 'object' && to.replace) {this.replace(to)
} else {this.push(to)
}
} else {
// confirm transition and pass on the value
next(to)
}
})
} catch (e) {abort(e)
}
}
runQueue(queue, iterator, () => {const postEnterCbs = [] // 专门为了处理 next((vm)=> {})情况
const isValid = () => this.current === route
// wait until async components are resolved before
// extracting in-component enter guards
const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
const queue = enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, () => {if (this.pending !== route) {return abort()
}
this.pending = null
onComplete(route)
if (this.router.app) {this.router.app.$nextTick(() => {
postEnterCbs.forEach(cb => {cb()
})
})
}
})
})
}
这里一开始先判断匹配的路由跟当前路由是否一致,如果一致就直接中断了。
然后就是匹配的路由跟当前的路由对比,找出需要 updated, deactivated, activated 的 RouteRecord 对象,紧接着就是从 RouteRecord 的 instances(之前收集的)里面抽出组件实例的跟路由相关的钩子函数(beforeRouteLeave 等)组成一个钩子函数队列,这里队列的顺序跟官网对路由导航的解析流程完全一致的。
可以看到最后会执行两次 runQueue,第一次钩子函数队列会先执行 leave,update 相关的钩子函数,最后是加载 activated 的异步组件,当所有异步组件加载成功后,继续抽取 beforeRouteEnter 的钩子函数,对于 enter 相关的钩子函数处理是有点不一样的,正如文档说的,beforeRouteEnter 方法里面是没办法使用组件实例的,因为第二次 runQueue 时,明显组件的都还没有被构建出来。所以文档也提供另外一种方法获取组件的实例:
beforeRouterEnter(from, to, next) {next((vm) => {});
}
那么 Vue-Router 是怎么把 vm 传给方法的尼,主要是在抽取 enter 相关钩子的时候处理了:
function bindEnterGuard (
guard: NavigationGuard,
match: RouteRecord,
key: string,
cbs: Array<Function>,
isValid: () => boolean): NavigationGuard {return function routeEnterGuard (to, from, next) {
return guard(to, from, cb => {if (typeof cb === 'function') {cbs.push(() => {
// #750
// if a router-view is wrapped with an out-in transition,
// the instance may not have been registered at this time.
// we will need to poll for registration until current route
// is no longer valid.
poll(cb, match.instances, key, isValid)
})
}
next(cb)
})
}
}
function poll (
cb: any, // somehow flow cannot infer this is a function
instances: Object,
key: string,
isValid: () => boolean) {
if (instances[key] &&
!instances[key]._isBeingDestroyed // do not reuse being destroyed instance
) {cb(instances[key])
} else if (isValid()) {setTimeout(() => {poll(cb, instances, key, isValid)
}, 16)
}
}
当判断 next 传入的是一个 function 时,它就把这个 function 放到 postEnterCbs 数组上,然后在 $nextTick 等组件挂载上去的时候调用这个 function,就能顺利获取到创建的组件实例了;但是还有一种情况需要处理的,就是存在 out-in transtion 的时候,组件会延时挂载,所以 Vue-Router 在 poll 方法上直接用了一个 16 毫秒的 setTimeout 去轮询获取组件的实例(真是简单粗暴)。
最后一步,当所有钩子函数毫无意外都回调完毕,就是更新当前路由,确保当前的 url 跟更新的路由一致了。
在一般情况下,我们代码触发的路由切换,当我们使用 next(false)中断,我们完全可以直接中止对 URL 操作,避免不一致的情况发生。
另外一种情况我们使用浏览器后退的时候,URL 会立即改变,然后我们使用 next(false)去中断路由切换,这个时候 URL 就会跟当前路由不一致了,这个时候 ensureURL 是怎么保证路由一致的尼,其实也很简单:
ensureURL (push?: boolean) {
const current = this.current.fullPath
if (getHash() !== current) {push ? pushHash(current) : replaceHash(current)
}
}
先判断路由路径是否一致,不一致的话,按照刚刚说的场景就会把当前路由重新 push 进去(解开多年的疑惑,哈哈)。
总结
虽然最后发现我遇到的坑,跟 Vue-Router 好像没多大关系,但是阅读源码也是收获良多,了解到文档上很多没有介绍到的细节;噢,对了,Vue3.0 源码也开放了,又得加班加点啃源码了,今天先到这里,如有错漏,还望指正。