vue-router 原始碼概覽

清夜發表於2018-10-09

原始碼這個東西對於實際的工作其實沒有立竿見影的效果,不會像那些針對性極強的文章一樣看了之後就立馬可以運用到實際專案中,產生什麼樣的效果,原始碼的作用是一個潛移默化的過程,它的理念、設計模式、程式碼結構等看了之後可能不會立即知識變現(或者說變現很少),而是在日後的工作過程中悄無聲息地發揮出來,你甚至都感覺不到這個過程

另外,優秀的原始碼案例,例如 vuereact這種,內容量比較龐大,根本不是三篇五篇十篇八篇文章就能說完的,而且寫起來也很難寫得清楚,也挺浪費時間的,而如果只是分析其中一個點,例如 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-routerinstall方法位於 vue-router原始碼的src/install.js中 主要是通過 vue.minxin混入 beforeCreatedestroyed鉤子函式,並全域性註冊 router-viewrouter-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):hashhistoryabstract,其中 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 只會匹配 Bar1Bar2這一條配置會被忽略

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, () => {
  ...
}
複製程式碼

路由例項的pushreplace等路由切換方法,都是基於此方法實現路由切換的,例如 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函式執行非同步回撥,並在非同步回撥方法中執行相應的鉤子函式(即 導航守衛) beforeEachbeforeRouteUpdatebeforeRouteEnterbeforeRouteLeave

通過 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會呼叫 pushgo等方法實現檢視與地址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
  }
}
複製程式碼

上述程式碼,先檢測當前瀏覽器是否支援 html5History 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這個 urlvue-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-routeroptions

當路由初始化時,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-routergithub上的原始碼中,有相關的 example,原始碼位置在 vue-router/examples/scroll-behavior/app.js

router-view & router-link

router-viewrouter-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方法修改資料的 gettersetter

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-viewrender函式,此函式中訪問了 this._route這個資料,也就相當於是呼叫了 this._routegetter方法,觸發依賴收集,建立一個 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比較重要的原因,自己很難發現的問題,別人可能一眼就看出來了,此之謂當局者迷旁觀者清也

相關文章