结构
起因是接手了一个第三方团队的 Vue 项目,但是它的路由 active/expand 状态渲染的实现居然是靠 watch $route,然后再写入 sessionStroage,最后再在页面组件中需获取 sessionStroage 的 route 来实现。最后出线了 route 更新了,组件也更新了,但是路由的 active 状态不对。
我一看这种代码,哪里要得,要重写。菜单明明要支持自动 active 和自动 展开/收起 子菜单才对,但是这个bug又比较有趣,于是顺路去把源码读了一遍,之前也读过,但今时不同往日,温故知新。
主要类及方法:
- VueRouter
- history
-
HTML5History
BaseHistory
-
HashHistory
BaseHistory
-
AbstractHistory
BaseHistory
-
- history
BaseHistory
定义了一部分接口,也实现了一些方法:
- BaseHistory
- properties
- router: Router
- base: string
- current: Route
- pending: ?Route
- cb: (r: Route) => void
- ready: boolean
- readyCbs: Array
- readyErrorCbs: Array
- errorCbs: Array
- listeners: Array
- cleanupListeners: Function
- setupListeners: Function
- Interface(implemented by sub-classes)
- go: (n: number) => void
- push: (loc: RawLocation, onComplete?: Function, onAbort?: Function) => void
- replace: (loc: RawLocation, onComplete?: Function, onAbort?: Function) => void
- ensureURL: (push?: boolean) => void
- getCurrentLocation: () => string
- setupListeners: Function
- Implementation
- onReady()
- onError(errorCb)
- transitionTo(location: Location, onComplete?: Function, onAbort?: Function)
- confirmTransition(route: Route, onComplete?: Function, onAbort?: Function)
- runQueue(queue, iterator, () ⇒ {})
- updateRoute(route: Route)
- teardown()
- properties
HashHistory
继承自 BaseHistory
,实现了和重写了一部分方法:
- class HashHistory extends History
-
constructor
实例化父类
super
,同时执行checkFallback
和ensureSlash
方法 -
setupListeners: Function
- 设置事件监听
- 在
this.listeners
中添加滚动监听函数setupScroll
,用以监听popstate
事件并以getStateKey
为 key 保存滚动位置saveScrollPosition
- 在 window 拦截
supportsPushState ? 'popstate' : 'hashchange'
事件,并执行handleRoutingEvent
函数 handleRoutingEvent
函数,执行this.transitionTo(getHash, router => {})
函数。- 而在
transitionTo
函数内部执行handleScroll
用以滚动到已保存到 scrollPosition: { x: number, y: number }. 且如果不支持supportsPushState
就执行replaceHash(route.fullPath)
函数走hashchange
.
- 在
- 然后在
this.listeners
中添加removeEventListener
注销事件监听
- 设置事件监听
-
push: (location: RawLocation, onComplete?: Function, onAbort?: Function)
内部实现就是调用
this.transitionTo
函数,在完成之后replaceHash
,handleScroll
, 执行onComplete
回调函数 -
go(n: number) { window.history.go(n) }
直接执行 window.history.go 走浏览器 history 的原生行为。
-
ensureURL (push?: boolean)
确保浏览器的 url 与 router 行为一致,所有内部做了判断
this.current.fullPath !== gethash()
则push ? pushHash(current) : replaceHash(current)
,就是history.pushState({ key: getStateKey() }, '', url)
与history.replaceState({ key: getStateKey() }, '', url)
-
getCurrentLocation()
返回当前位置,内部直接返回了
getHash()
-
看到核心的函数是 transitionTo
,所以去看看 BaseHistory 的 transitionTo
实现。
-
第一步是通过 VueRouter 的 match 取到在 路由注册表 中的 route 定义
this.router.match(location, this.current)
-
而 VueRouter 的 match 方法则是调用内部的 matcher.match 方法,
this.matcher.match(raw, current, redirectedFrom)
- 而 matcher 则是 VueRouter 的实例属性,由工厂函数
createMatcher(options.routes || [], this)
创建 createMatcher
函数内部的定义及实现:-
createRouteMap 函数
createRouteMap ( routes: Array<RouteConfig>, oldPathList?: Array<string>, oldPathMap?: Dictionary<RouteRecord>, oldNameMap?: Dictionary<RouteRecord> ): { pathList: Array<string>, pathMap: Dictionary<RouteRecord>, nameMap: Dictionary<RouteRecord> }
接收 routes 返回各种映射,
pathList
,pahtMap
,nameMap
。这些映射都是为了支持通过 pathMap or nameMap 快速找到 route 声明。 -
所以需要循环一下
routes
数组routes.forEach(route => { addRouteRecord(pathList, pathMap, nameMap, route) })
-
addRouteRecord
函数生成RouteRecord
数据结构,递归 route.children,并记录到pathList
,pathMap
,nameMap
中。最终RouteRecord
对象的数据结构:record = { // 组装后的路径 path: normalizedPath, // 通过 pathToRegexpOptions 选项将路由的 normalizedPath 编译成正则表达式 regex: compileRouteRegex(normalizedPath, pathToRegexpOptions), // 路由组件 components: route.components || { default: route.component }, // 实例缓存,用于 keepalive instances: {}, // 路由名称 name: name, // 理由 parent 引用,即 parentRoute parent: parent, // 用于 alias 匹配的 matchAs matchAs: matchAs, // 重定向 redirect: route.redirect, // route 声名处的 beforeEnter hook beforeEnter: route.beforeEnter, meta: route.meta || {}, props: route.props == null ? {} : route.components ? route.props : { default: route.props } }
-
- 而 matcher 则是 VueRouter 的实例属性,由工厂函数
-
最终返回的是
return *createRoute(record, location, redirectedFrom)
,而在_*createRoute
方法内部包含了对redirect
,alias
的操作,最终返回是return createRoute(record, location, redirectedFrom, router)
, 即最终的经过处理后的Route
对象function createRoute ( record, location, redirectedFrom, router ) { var stringifyQuery = router && router.options.stringifyQuery; var query = location.query || {}; try { query = clone(query); } catch (e) {} var route = { name: location.name || (record && record.name), meta: (record && record.meta) || {}, path: location.path || '/', hash: location.hash || '', query: query, params: location.params || {}, fullPath: getFullPath(location, stringifyQuery), matched: record ? formatMatch(record) : [] }; if (redirectedFrom) { route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery); } return Object.freeze(route) }
-
而
formatMatch
方法中则包含了所有匹配的RouteRecord
[] 路由记录:function formatMatch (record) { var res = []; while (record) { res.unshift(record); record = record.parent; } return res }
-
-
所以这里拿到的 route 就是 hook 中的
to
, 因此prev = this.current
就是 from. -
而后函数执行了
confirmTransition (route: Route, onComplete: Function, onAbort?: Function)
方法,同时在 onComplete 和 onAbort 回调中做了许多拦截。
所以往下先看完 confirmTransition
函数的实现,再来看 transitionTo
中的回调。
-
设置 route 为 pending 状态
-
声明 abort 函数处理错误,并在函数内部出发
errorCbs
forEach(error) 回调 -
判断 current(即 from) 和 route(即 to)是否是
isSameRoute(route, current)
相同路由,同时两者 matched 的 length 长度一致,且route.matched[lastRouteIndex] === current.matched[lastCurrentIndex]
,则中止并抛出重复导航错误 -
执行
resolveQueue
找到分别updated, deactivated, activated
的 matched 组件function resolveQueue ( current, next ) { var i; var max = Math.max(current.length, next.length); for (i = 0; i < max; i++) { if (current[i] !== next[i]) { break } } return { updated: next.slice(0, i), activated: next.slice(i), deactivated: current.slice(i) } }
-
声明
queue: Array<?NavigationGuard>
,目的是分别取到updated, deactivated, activated
的对应功能 hooks 函数,并迭代执行。而这就对应着 VueRouter 提供的各种 hooks 实现,router hooks & component route hooks.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) )
-
迭代执行函数的声明
iterator = (hook: NavigationGuard, next) => {}
,说明每次调用 hook 该函数都会被执行- 判断了如果
this.pending !== route
则抛出abort(createNavigationCancelledError(current, route))
错误 - 接着执行传入的参数
hook(route, current, (to) => {})
函数 - 第三个参数是个函数,接收参数
to
,这里的 to 的值有多种类型:- 一种是上一个 hook 执行完成后返回的 boolean 值,如果是为
false
则意味着中止导航,并抛出abort(createNavigationAbortedError(current, route))
错误 - 第二个 else if 就是 to 是个
error
- 另一种可能就是真正的
to: Route
即下一个路由。
- 一种是上一个 hook 执行完成后返回的 boolean 值,如果是为
(to: any) => { if (to === false) { // next(false) -> abort navigation, ensure current URL this.ensureURL(true) abort(createNavigationAbortedError(current, route)) } else if (isError(to)) { 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(createNavigationRedirectedError(current, route)) if (typeof to === 'object' && to.replace) { this.replace(to) } else { this.push(to) } } else { // confirm transition and pass on the value next(to) } })
- 判断了如果
-
最后是
runQueue
函数,序列化(串行)运行迭代函数。先看下runQueue
函数本身的实现:- 三个参数,分别是 queue 队列, fn 函数(可以理解为 next), cb 执行完成后的回调
- 关键是这个还需要支持 async 异步执行,比如应用初始化的时候需要先异步拉取用户是否登录再决定渲染页面或者重定向登录页。
// 事实上这个函数非常的经常,甚至在面试中也高频率出现 // 现在回看,年初面阿里菜鸟时有道题目完全可以用这个实现 export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) { // step by step ~ const step = index => { // index 大于等于 queue.length 表示 queue 已执行完成,执行 cb if (index >= queue.length) { cb() } else { // 判断 queue[index] 是否有有效值 if (queue[index]) { // 给 fn 函数传入参数 // 第一个参数为当前项 queue[index] // 第二个函数即是进入 next step,就是 () => step(index + 1) fn(queue[index], () => { step(index + 1) }) } else { // queue[index] 不存在有效值,则直接继续进入下一步: step(index + 1) step(index + 1) } } } step(0) }
-
然后此处的 runQueue 分别执行了两次,第一次是 run beforeHook queue,在 beforeHook queue 执行完成后,第二次 run 的是合并完成后的
enterGuards.concat(resolveHooks)
.。resolveHooks 是新 API?在文档中找到了 Global Resolve Guards 如下:You can register a global guard with
router.beforeResolve
. This is similar torouter.beforeEach
, with the difference that resolve guards will be called right before the navigation is confirmed, after all in-component guards and async route components are resolved.它解释了该 hook 是在所有组件内守卫和异步路由组件被解析之后调用。
runQueue(queue, iterator, () => { // wait until async components are resolved before // extracting in-component enter guards const enterGuards = extractEnterGuards(activated) const queue = enterGuards.concat(this.router.resolveHooks) runQueue(queue, iterator, () => { if (this.pending !== route) { return abort(createNavigationCancelledError(current, route)) } this.pending = null onComplete(route) if (this.router.app) { this.router.app.$nextTick(() => { handleRouteEntered(route) }) } }) })
-
The Full Navigation Resolution Flow
https://router.vuejs.org/guide/advanced/navigation-guards.html#in-component-guards
- Navigation triggered.
- Call
beforeRouteLeave
guards in deactivated components. - Call global
beforeEach
guards. - Call
beforeRouteUpdate
guards in reused components. - Call
beforeEnter
in route configs. - Resolve async route components.
- Call
beforeRouteEnter
in activated components. - Call global
beforeResolve
guards. - Navigation confirmed.
- Call global
afterEach
hooks. - DOM updates triggered.
- Call callbacks passed to
next
inbeforeRouteEnter
guards with instantiated instances.
核心类型
- Location
- RouteRecord
核心功能
- 滚动监听和记录 - scrollPostion
- 监听浏览器 history - 'hashchange' | 'popState'
- 路由监听和渲染 - 'history.current ⇒ _route ⇒ $route' | '_routerRoot ⇒ $router' | RouterLink | RouterView
- 哨兵钩子函数(router & component NavigationGuard hooks)
路由的状态
有点 this.history.current
变化,维护的几个地方需要变化:
- 路由 _route 和 $route 被更新
- 对应 route.matched 的所有组件将被 resolved 然后 render
- 浏览器地址栏 path 被更新
- VueRouter
- this.$router/this.$route
- RouterView/RouterLink
- Window.history
- pushState/replaceState(getUrl(path)) - 当支持 supportsPushState 时
- window.location.hash = path
生命周期
当使用 Vue.use(VueRouter)
时候,VueRouter 为 Vue 注入了全局 mixin,用于给实例写入 router
和 route
相关属性。
var registerInstance = function (vm, callVal) {
var i = vm.$options._parentVnode;
if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
i(vm, callVal);
}
}
Vue.mixin({
beforeCreate: function beforeCreate () {
// 调用 nwe Vue({ router }) 时传入 router 属性的 Vue 实例
// 通常是我们的 RootApp, 就是 ./main.js 下声明 Vue 入口实例
if (isDef(this.$options.router)) {
// 挂载 _routerRoot 属性
this._routerRoot = this;
// 挂载 this._routerRoot._router 属性 => 见下方声明 this.$router
this._router = this.$options.router;
// 执行 router.init 初始化方法,监听浏览器 history
this._router.init(this);
// 挂载 this._routerRoot._route 属性 => 见下方声明 this.$route
// 这里使用了 defineReactive 定义 reactive 状态
Vue.util.defineReactive(this, '_route', this._router.history.current);
} else {
// 未传入 router 实例的 Vue 初始化
this._routerRoot = (this.$parent && this.$parent._routerRoot) || this;
`
// 注册实例,通常是 RouterView 才会用到这里?
// 这个方法主要用于记录 route.matched[depth].instances[name] =
registerInstance(this, this);
},
destroyed: function destroyed () {
registerInstance(this);
}
})
// 声明全局属性 $router 时返回 _routerRoot._router
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
// 声明全局属性 $route 时返回 _routerRoot._route
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})
在执行 init
的时赋值了 router.app
实例属性, transitionTo
current location 并 setupListeners
。这里有个很重要的是添加了 listener
更新 apps 数组中每个 app 实例的 _route
属性:
history.listen(route => {
this.apps.forEach(app => {
app._route = route
})
})
而 history.listen
对应着 BaseHistory.listen
方法:
listen (cb: Function) {
this.cb = cb
}
而 this.cb
方法又仅在每次的 updateRoute
时候调用:
updateRoute (route: Route) {
this.current = route
this.cb && this.cb(route)
}
而 updateRoute
是在每次confirmTransition
之后被执行:
this.confirmTransition(
route,
() => {
this.updateRoute(route)
onComplete && onComplete(route)
// ...
},
err => {
// ...
}
)
所以,在每次 路由 发生变化之后,$route
属性对应的 _route
被更新。所以在 install.js 中的 beforeCreate
钩子中实例访问的是 init 之后最新的 history.current
。而后每次,则访问的是直接被赋值的 app._route = route
。
然后因为之前使用 Vue.util.defineReactive
为 _route
属性添加了 reactive 特性,所以在重新赋值的时候触发了 dep.notify()
,通知所有对 $route
的 subs: Watcher[]
.
调用对应组建的 _update
方法 rerender 重新渲染,更新整体视图。