vue-router 原始碼概覽
原始碼這個東西對於實際的工作其實沒有立竿見影的效果,不會像那些針對性極強的文章一樣看了之後就立馬可以運用到實際專案中,產生什麼樣的效果,原始碼的作用是一個潛移默化的過程,它的理念、設計模式、程式碼結構等看了之後可能不會立即知識變現(或者說變現很少),而是在日後的工作過程中悄無聲息地發揮出來,你甚至都感覺不到這個過程
另外,優秀的原始碼案例,例如vue
、react
這種,內容量比較龐大,根本不是三篇五篇十篇八篇文章就能說完的,而且寫起來也很難寫得清楚,也挺浪費時間的,而如果只是分析其中一個點,例如vue
的響應式,類似的文章也已經夠多了,沒必要再repeat
所以我之前沒專門寫過原始碼分析的文章,只是自己看看,不過最近閒來無事看了vue-router
的原始碼,發現這種外掛級別的東西,相比vue
這種框架級別的東西,邏輯簡單清晰,沒有那麼多道道,程式碼量也不多,但是其中包含的理念等東西卻很精煉,值得一寫,當然,文如其名,只是概覽,不會一行行程式碼分析過去,細節的東西還是要自己看看的
vue.use
vue
外掛必須通過vue.use
進行註冊,vue.use
的程式碼位於vue
原始碼的src/core/global-api/use.js
檔案中,此方法的主要作用有兩個:
- 對註冊的元件進行快取,避免多次註冊同一個外掛
if (installedPlugins.indexOf(plugin) > -1) { return this } 複製程式碼
-
呼叫外掛的
install
方法或者直接執行外掛,以實現外掛的install
if (typeof plugin.install === 'function') { plugin.install.apply(plugin, args) } else if (typeof plugin === 'function') { plugin.apply(null, args) } 複製程式碼
路由安裝
vue-router
的install
方法位於vue-router
原始碼的src/install.js
中
主要是通過vue.minxin
混入beforeCreate
和destroyed
鉤子函式,並全域性註冊router-view
和router-link
元件
// src/install.js Vue.mixin({ beforeCreate () { if (isDef(this.$options.router)) { this._routerRoot = this this._router = this.$options.router this._router.init(this) Vue.util.defineReactive(this, '_route', this._router.history.current) } else { this._routerRoot = (this.$parent && this.$parent._routerRoot) || this } registerInstance(this, this) }, destroyed () { registerInstance(this) } }) ... // 全域性註冊 `router-view` 和 `router-link`元件 Vue.component('RouterView', View) Vue.component('RouterLink', Link) 複製程式碼
路由模式
vue-router
支援三種路由模式(mode
):hash
、history
、abstract
,其中abstract
是在非瀏覽器環境下使用的路由模式,例如weex
路由內部會對外部指定傳入的路由模式進行判斷,例如當前環境是非瀏覽器環境,則無論傳入何種mode
,最後都會被強制指定為abstract
,如果判斷當前環境不支援HTML5 History
,則最終會被降級為hash
模式
// src/index.js let mode = options.mode || 'hash' this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false if (this.fallback) { mode = 'hash' } if (!inBrowser) { mode = 'abstract' } 複製程式碼
最後會對符合要求的mode
進行對應的初始化操作
// src/index.js switch (mode) { case 'history': this.history = new HTML5History(this, options.base) break case 'hash': this.history = new HashHistory(this, options.base, this.fallback) break case 'abstract': this.history = new AbstractHistory(this, options.base) break default: if (process.env.NODE_ENV !== 'production') { assert(false, `invalid mode: ${mode}`) } } 複製程式碼
路由解析
通過遞迴的方式來解析巢狀路由
// src/create-route-map.js function addRouteRecord ( pathList: Array<string>, pathMap: Dictionary<RouteRecord>, nameMap: Dictionary<RouteRecord>, route: RouteConfig, parent?: RouteRecord, matchAs?: string ) { ... route.children.forEach(child => { const childMatchAs = matchAs ? cleanPath(`${matchAs}/${child.path}`) : undefined addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs) }) ... } 複製程式碼
解析完畢之後,會通過key-value
對的形式對解析好的路由進行記錄,所以如果宣告多個相同路徑(path
)的路由對映,只有第一個會起作用,後面的會被忽略
// src/create-route-map.js if (!pathMap[record.path]) { pathList.push(record.path) pathMap[record.path] = record } 複製程式碼
例如如下路由配置,路由/bar
只會匹配Bar1
,Bar2
這一條配置會被忽略
const routes = [ { path: '/foo', component: Foo }, { path: '/bar', component: Bar1 }, { path: '/bar', component: Bar2 }, ]; 複製程式碼
路由切換
當訪問一個url
的時候,vue-router
會根據路徑進行匹配,創建出一個route
物件,可通過this.$route
進行訪問
// src/util/route.js 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) : [] } 複製程式碼
src/history/base.js
原始碼檔案中的transitionTo()
是路由切換的核心方法
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) { const route = this.router.match(location, this.current) this.confirmTransition(route, () => { ... } 複製程式碼
路由例項的push
和replace
等路由切換方法,都是基於此方法實現路由切換的,例如hash
模式的push
方法:
// src/history/hash.js push (location: RawLocation, onComplete?: Function, onAbort?: Function) { const { current: fromRoute } = this // 利用了 transitionTo 方法 this.transitionTo(location, route => { pushHash(route.fullPath) handleScroll(this.router, route, fromRoute, false) onComplete && onComplete(route) }, onAbort) } 複製程式碼
transitionTo
方法內部通過一種非同步函式佇列化執⾏的模式來更新切換路由,通過next
函式執行非同步回撥,並在非同步回撥方法中執行相應的鉤子函式(即 導航守衛)beforeEach
、beforeRouteUpdate
、beforeRouteEnter
、beforeRouteLeave
通過queue
這個陣列儲存相應的路由引數:
// src/history/base.js 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) ) 複製程式碼
通過runQueue
以一種遞歸回調的方式來啟動非同步函式佇列化的執⾏:
// src/history/base.js // 非同步回撥函式 runQueue(queue, iterator, () => { const postEnterCbs = [] 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() }) }) } }) }) 複製程式碼
通過next
進行導航守衛的回撥迭代,所以如果在程式碼中顯式聲明瞭導航鉤子函式,那麼就必須在最後呼叫next()
,否則回撥不執行,導航將無法繼續
// src/history/base.js const iterator = (hook: NavigationGuard, next) => { ... hook(route, current, (to: any) => { ... } else { // confirm transition and pass on the value next(to) } }) ... } 複製程式碼
路由同步
在路由切換的時候,vue-router
會呼叫push
、go
等方法實現檢視與地址url
的同步
位址列url
與檢視的同步
當進行點選頁面上按鈕等操作進行路由切換時,vue-router
會通過改變window.location.href
來保持檢視與url
的同步,例如hash
模式的路由切換:
// src/history/hash.js function pushHash (path) { if (supportsPushState) { pushState(getUrl(path)) } else { window.location.hash = path } } 複製程式碼
上述程式碼,先檢測當前瀏覽器是否支援html5
的History API
,如果支援則呼叫此API
進行href
的修改,否則直接對window.location.hash
進行賦值history
的原理與此相同,也是利用了History API
檢視與位址列url
的同步
當點選瀏覽器的前進後退按鈕時,同樣可以實現檢視的同步,這是因為在路由初始化的時候,設定了對瀏覽器前進後退的事件監聽器
下述是hash
模式的事件監聽:
// src/history/hash.js setupListeners () { ... window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => { const current = this.current if (!ensureSlash()) { return } this.transitionTo(getHash(), route => { if (supportsScroll) { handleScroll(this.router, route, current, true) } if (!supportsPushState) { replaceHash(route.fullPath) } }) }) } 複製程式碼
history
模式與此類似:
// src/history/html5.js window.addEventListener('popstate', e => { const current = this.current // Avoiding first `popstate` event dispatched in some browsers but first // history route not updated since async guard at the same time. const location = getLocation(this.base) if (this.current === START && location === initLocation) { return } this.transitionTo(location, route => { if (supportsScroll) { handleScroll(router, route, current, true) } }) }) 複製程式碼
無論是hash
還是history
,都是通過監聽事件最後來呼叫transitionTo
這個方法,從而實現路由與檢視的統一
另外,當第一次訪問頁面,路由進行初始化的時候,如果是hash
模式,則會對url
進行檢查,如果發現訪問的url
沒有帶#
字元,則會自動追加,例如初次訪問http://localhost:8080
這個url
,vue-router
會自動置換為http://localhost:8080/#/
,方便之後的路由管理:
// src/history/hash.js function ensureSlash (): boolean { const path = getHash() if (path.charAt(0) === '/') { return true } replaceHash('/' + path) return false } 複製程式碼
scrollBehavior
當從一個路由/a
跳轉到另外的路由/b
後,如果在路由/a
的頁面中進行了滾動條的滾動行為,那麼頁面跳轉到/b
時,會發現瀏覽器的滾動條位置和/a
的一樣(如果/b
也能滾動的話),或者重新整理當前頁面,瀏覽器的滾動條位置依舊不變,不會直接返回到頂部的
而如果是通過點選瀏覽器的前進、後退按鈕來控制路由切換時,則部門瀏覽器(例如微信)滾動條在路由切換時都會自動返回到頂部,即scrollTop=0
的位置
這些都是瀏覽器預設的行為,如果想要定製頁面切換時的滾動條位置,則可以藉助scrollBehavior
這個vue-router
的options
當路由初始化時,vue-router
會對路由的切換事件進行監聽,監聽邏輯的一部分就是用於控制瀏覽器滾動條的位置:
// src/history/hash.js setupListeners () { ... if (supportsScroll) { // 進行瀏覽器滾動條的事件控制 setupScroll() } ... } 複製程式碼
這個set
方法定義在src/util/scroll.js
,這個檔案就是專門用於控制滾動條位置的,通過監聽路由切換事件從而進行滾動條位置控制:
// src/util/scroll.js window.addEventListener('popstate', e => { saveScrollPosition() if (e.state && e.state.key) { setStateKey(e.state.key) } }) 複製程式碼
通過scrollBehavior
可以定製路由切換的滾動條位置,vue-router
的github上的原始碼中,有相關的example
,原始碼位置在vue-router/examples/scroll-behavior/app.js
router-view & router-link
router-view
和router-link
這兩個vue-router
的內建元件,原始碼位於src/components
下
router-view
router-view
是無狀態(沒有響應式資料)、無例項(沒有this
上下文)的函式式元件,其通過路由匹配獲取到對應的元件例項,通過h
函式動態生成元件,如果當前路由沒有匹配到任何元件,則渲染一個註釋節點
// vue-router/src/components/view.js ... const matched = route.matched[depth] // render empty node if no matched route if (!matched) { cache[name] = null return h() } const component = cache[name] = matched.components[name] ... return h(component, data, children) 複製程式碼
每次路由切換都會觸發router-view
重新render
從而渲染出新的檢視,這個觸發的動作是在vue-router
初始化init
的時候就聲明瞭的:
// src/install.js Vue.mixin({ beforeCreate () { if (isDef(this.$options.router)) { this._routerRoot = this this._router = this.$options.router this._router.init(this) // 觸發 router-view重渲染 Vue.util.defineReactive(this, '_route', this._router.history.current) ... }) 複製程式碼
將this._route
通過defineReactive
變成一個響應式的資料,這個defineReactive
就是vue
中定義的,用於將資料變成響應式的一個方法,原始碼在vue/src/core/observer/index.js
中,其核心就是通過Object.defineProperty
方法修改資料的getter
和setter
:
Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: function reactiveGetter () { const value = getter ? getter.call(obj) : val if (Dep.target) { // 進行依賴收集 dep.depend() if (childOb) { childOb.dep.depend() if (Array.isArray(value)) { dependArray(value) } } } return value }, set: function reactiveSetter (newVal) { ... // 通知訂閱當前資料 watcher的觀察者進行響應 dep.notify() } 複製程式碼
當路由發生變化時,將會呼叫router-view
的render
函式,此函式中訪問了this._route
這個資料,也就相當於是呼叫了this._route
的getter
方法,觸發依賴收集,建立一個Watcher
,執行_update
方法,從而讓頁面重新渲染
// vue-router/src/components/view.js render (_, { props, children, parent, data }) { // used by devtools to display a router-view badge data.routerView = true // directly use parent context's createElement() function // so that components rendered by router-view can resolve named slots const h = parent.$createElement const name = props.name // 觸發依賴收集,建立 render watcher const route = parent.$route ... } 複製程式碼
這個render watcher
的派發更新,也就是setter
的呼叫,位於src/index.js
:
history.listen(route => { this.apps.forEach((app) => { // 觸發 setter app._route = route }) }) 複製程式碼
router-link
router-link
在執行render
函式的時候,會根據當前的路由狀態,給渲染出來的active
元素新增class
,所以你可以藉助此給active
路由元素設定樣式等:
// src/components/link.js render (h: Function) { ... const globalActiveClass = router.options.linkActiveClass const globalExactActiveClass = router.options.linkExactActiveClass // Support global empty active class const activeClassFallback = globalActiveClass == null ? 'router-link-active' : globalActiveClass const exactActiveClassFallback = globalExactActiveClass == null ? 'router-link-exact-active' : globalExactActiveClass ... } 複製程式碼
router-link
預設渲染出來的元素是<a>
標籤,其會給這個<a>
新增href
屬性值,以及一些用於監聽能夠觸發路由切換的事件,預設是click
事件:
// src/components/link.js data.on = on data.attrs = { href } 複製程式碼
另外,你可以可以通過傳入tag
這個props
來定製router-link
渲染出來的元素標籤:
<router-link to="/foo" tag="div">Go to foo</router-link> 複製程式碼
如果tag
值不為a
,則會遞迴遍歷router-link
的子元素,直到找到一個a
標籤,則將事件和路由賦值到這個<a>
上,如果沒找到a
標籤,則將事件和路由放到router-link
渲染出的本身元素上:
if (this.tag === 'a') { data.on = on data.attrs = { href } } else { // find the first <a> child and apply listener and href // findAnchor即為遞迴遍歷子元素的方法 const a = findAnchor(this.$slots.default) ... } } 複製程式碼
當觸發這些路由切換事件時,會呼叫相應的方法來切換路由重新整理檢視:
// src/components/link.js const handler = e => { if (guardEvent(e)) { if (this.replace) { // replace路由 router.replace(location) } else { // push 路由 router.push(location) } } } 複製程式碼
總結
可以看到,vue-router
的原始碼是很簡單的,比較適合新手進行閱讀分析
原始碼這種東西,我的理解是沒必要非要專門騰出時間來看
,只要你熟讀文件,能正確而熟練地運用API
實現各種需求那就行了,輪子的出現本就是為實際開發所服務而不是用來折騰開發者的,注意,我不是說不要去看,有時間還是要看看的,就算弄不明白其中的道道,但看了一遍總會有收穫的,比如我在看vue
原始碼的時候,經常看到類似於這種的賦值寫法:
// vue/src/core/vdom/create-functional-component.js (clone.data || (clone.data = {})).slot = data.slot 複製程式碼
如果是之前,對於這段邏輯我通常會這麼寫:
if (clone.data) { clone.data.slot = data.slot } else { clone.data = { slot: data.slot } } 複製程式碼
也不是說第一種寫法有什麼難度或者看不明白,只是習慣了第二種寫法,平時寫程式碼的過程中自然而然不假思索地就寫出來了,習慣成自然了,但是當看到第一種寫法的時候才會一拍腦袋想著原來這麼寫也可以,以前白敲了那麼多次鍵盤,所以沒事要多看看別人優秀的原始碼,避免沉迷於自己的世界閉門造車,這樣才能查漏補缺,這同樣也是我認為程式碼review
比較重要的原因,自己很難發現的問題,別人可能一眼就看出來了,此之謂當局者迷旁觀者清也