VueRouter 原始碼記錄 - 搞懂 router hooks 和 RouterView 更新的底層邏輯
結構
起因是接手了一個第三方團隊的 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 重新渲染,更新整體檢視。