vue-router 原始碼:路由模式

cobish發表於2018-08-15

前言

前端的路由模式包括了 Hash 模式和 History 模式。

vue-router 在初始化的時候,會根據 mode 來判斷使用不同的路由模式,從而 new 出了不同的物件例項。例如 history 模式就用 HTML5History,hash 模式就用 HashHistory

init (app: any /* Vue component instance */) {
  this.app = app

  const { mode, options, fallback } = this
  switch (mode) {
    case 'history':
      this.history = new HTML5History(this, options.base)
      break
    case 'hash':
      this.history = new HashHistory(this, options.base, fallback)
      break
    case 'abstract':
      this.history = new AbstractHistory(this)
      break
    default:
      assert(false, `invalid mode: ${mode}`)
  }

  this.history.listen(route => {
    this.app._route = route
  })
}
複製程式碼

本次重點來了解一下 HTML5HistoryHashHistory 的實現。

HashHistory

vue-router 通過 new 一個 HashHistory 來實現 Hash 模式路由。

this.history = new HashHistory(this, options.base, fallback)
複製程式碼

三個引數分別代表:

  • this:Router 例項
  • base:應用的基路徑
  • fallback:History 模式,但不支援 History 而被轉成 Hash 模式

HashHistory 繼承 History 類,有一些屬性與方法都來自於 History 類。先來看下 HashHistory 的建構函式 constructor。

constructor

建構函式主要做了四件事情。

  1. 通過 super 呼叫父類建構函式,這個先放一邊。
  2. 處理 History 模式,但不支援 History 而被轉成 Hash 模式的情況。
  3. 確保 # 後面有斜槓,沒有則加上。
  4. 實現跳轉到 hash 頁面,並監聽 hash 變化事件。
constructor (router: VueRouter, base: ?string, fallback: boolean) {
  super(router, base)

  // check history fallback deeplinking
  if (fallback && this.checkFallback()) {
    return
  }

  ensureSlash()
  this.transitionTo(getHash(), () => {
    window.addEventListener('hashchange', () => {
      this.onHashChange()
    })
  })
}
複製程式碼

下面細講一下這幾件事情的細節。

checkFallback

先來看建構函式做的第二件事情,fallback 為 true 的情況,一般是低版本的瀏覽器(IE9)不支援 History 模式,所以會被降級為 Hash 模式。

同時需要通過 checkFallback 方法來檢測 url。

checkFallback () {
  // 去掉 base 字首
  const location = getLocation(this.base)

  // 如果不是以 /# 開頭
  if (!/^\/#/.test(location)) {
    window.location.replace(
      cleanPath(this.base + '/#' + location)
    )
    return true
  }
}
複製程式碼

先通過 getLocation 方法來去掉 base 字首,接著正則判斷 url 是否以 /# 為開頭。如果不是,則將 url 替換成以 /# 為開頭。最後跳出 constructor,因為在 IE9 下以 Hash 方式的 url 切換路由,它會使得整個頁面進行重新整理,後面的監聽 hashchange 不會起作用,所以直接 return 跳出。

再來看看 checkFallback 裡面呼叫的 getLocationcleanPath 方法的實現。

getLocation 方法主要是去掉 base 字首。在 vue-router 官方文件裡搜尋 base,可以知道它是應用的基路徑

export function getLocation (base: string): string {
  let path = window.location.pathname
  if (base && path.indexOf(base) === 0) {
    path = path.slice(base.length)
  }
  return (path || '/') + window.location.search + window.location.hash
}
複製程式碼

cleanPath 方法則是將雙斜槓替換成單斜槓,保證 url 路徑正確。

export function cleanPath (path: string): string {
  return path.replace(/\/\//g, '/')
}
複製程式碼

ensureSlash

接下來來看看建構函式做的第三件事情。

ensureSlash 方法做的事情就是確保 url 根路徑帶上斜槓,沒有的話則加上。

function ensureSlash (): boolean {
  const path = getHash()
  if (path.charAt(0) === '/') {
    return true
  }
  replaceHash('/' + path)
  return false
}
複製程式碼

ensureSlash 通過 getHash 來獲取 url 的 # 符號後面的路徑,再通過 replaceHash 來替換路由。

function getHash (): string {
  // We can't use window.location.hash here because it's not
  // consistent across browsers - Firefox will pre-decode it!
  const href = window.location.href
  const index = href.indexOf('#')
  return index === -1 ? '' : href.slice(index + 1)
}
複製程式碼

由於 Firefox 瀏覽器的原因(原始碼註釋裡已經寫出來了),所以不能通過 window.location.hash 來獲取,而是通過 window.location.href 來獲取。

function replaceHash (path) {
  const i = window.location.href.indexOf('#')
  window.location.replace(
    window.location.href.slice(0, i >= 0 ? i : 0) + '#' + path
  )
}
複製程式碼

replaceHash 方法做的事情則是更換 # 符號後面的 hash 路由。

onHashChange

最後看看建構函式做的第四件事情。

this.transitionTo(getHash(), () => {
  window.addEventListener('hashchange', () => {
    this.onHashChange()
  })
})
複製程式碼

transitionTo 是父類 History 的一個方法,比較的複雜,主要是實現了 守衛導航 的功能。這裡也暫時先放一放,以後再深入瞭解。

接下來的是監聽 hashchange 事件,當 hash 路由發生的變化,會呼叫 onHashChange 方法。

onHashChange () {
  if (!ensureSlash()) {
    return
  }
  this.transitionTo(getHash(), route => {
    replaceHash(route.fullPath)
  })
}
複製程式碼

當 hash 路由發生的變化,即頁面發生了跳轉時,首先取保路由是以斜槓開頭的,然後觸發守衛導航,最後更換新的 hash 路由。

HashHistory 還分別實現了 pushreplacego 等程式設計式導航,有興趣可以直接看原始碼,這裡就不一一講解了,主要也是運用了上面的方法來實現。

HTML5History

vue-router 通過 new 一個 HTML5History 來實現 History 模式路由。

this.history = new HTML5History(this, options.base)
複製程式碼

HTML5History 也是繼承與 History 類。

constructor

HTML5History 的建構函式做了這麼幾件事情:

  1. 呼叫父類 transitionTo 方法,觸發守衛導航,以後細講。
  2. 監聽 popstate 事件。
  3. 如果有滾動行為,則監聽滾動條滾動。
constructor (router: VueRouter, base: ?string) {
  super(router, base)

  this.transitionTo(getLocation(this.base))

  const expectScroll = router.options.scrollBehavior
  window.addEventListener('popstate', e => {
    _key = e.state && e.state.key
    const current = this.current
    this.transitionTo(getLocation(this.base), next => {
      if (expectScroll) {
        this.handleScroll(next, current, true)
      }
    })
  })

  if (expectScroll) {
    window.addEventListener('scroll', () => {
      saveScrollPosition(_key)
    })
  }
}
複製程式碼

下面細講一下這幾件事情的細節。

scroll

先從監聽滾動條滾動事件說起吧。

window.addEventListener('scroll', () => {
  saveScrollPosition(_key)
})
複製程式碼

滾動條滾動後,vue-router 就會儲存滾動條的位置。這裡有兩個要了解的,一個是 saveScrollPosition 方法,一個是 _key

const genKey = () => String(Date.now())
let _key: string = genKey()
複製程式碼

_key 是一個當前時間戳,每次瀏覽器的前進或後退,_key 都將作為引數傳入,從而跳轉的頁面也能獲取到。那麼 _key 是做什麼用呢。

來看看 saveScrollPosition 的實現就知道了:

export function saveScrollPosition (key: string) {
  if (!key) return
  window.sessionStorage.setItem(key, JSON.stringify({
    x: window.pageXOffset,
    y: window.pageYOffset
  }))
}
複製程式碼

vue-router 將滾動條位置儲存在 sessionStorage,其中的鍵就是 _key 了。

所以每一次的瀏覽器滾動,滾動條的位置將會被儲存在 sessionStorage 中,以便後面的取出使用。

popstate

瀏覽器的前進與後退會觸發 popstate 事件。這時同樣會呼叫 transitionTo 觸發守衛導航,如果有滾動行為,則呼叫 handleScroll 方法。

handleScroll 方法程式碼比較多,我們先來看看是怎麼使用滾動行為的。

scrollBehavior (to, from, savedPosition) {
  if (savedPosition) {
    return savedPosition
  } else {
    return { x: 0, y: 0 }
  }
}
複製程式碼

如果要模擬“滾動到錨點”的行為:

scrollBehavior (to, from, savedPosition) {
  if (to.hash) {
    return {
      selector: to.hash
    }
  }
}
複製程式碼

所以至少有三個要判斷,一個是 savedPosition(即儲存的滾動條位置),一個是 selector,還有一個就是 xy 座標。

再來看 handleScroll(刪掉一些判斷):

handleScroll (to: Route, from: Route, isPop: boolean) {
  const router = this.router
  const behavior = router.options.scrollBehavior

  // wait until re-render finishes before scrolling
  router.app.$nextTick(() => {
    let position = getScrollPosition(_key)
    const shouldScroll = behavior(to, from, isPop ? position : null)
    if (!shouldScroll) {
      return
    }
    const isObject = typeof shouldScroll === 'object'
    if (isObject && typeof shouldScroll.selector === 'string') {
      const el = document.querySelector(shouldScroll.selector)
      if (el) {
        position = getElementPosition(el)
      } else if (isValidPosition(shouldScroll)) {
        position = normalizePosition(shouldScroll)
      }
    } else if (isObject && isValidPosition(shouldScroll)) {
      position = normalizePosition(shouldScroll)
    }

    if (position) {
      window.scrollTo(position.x, position.y)
    }
  })
}
複製程式碼

從 if 判斷開始,如果有 selector,則獲取對應的元素的座標。

否則,則使用 scrollBehavior 返回的值作為座標,其中有可能是 savedPosition 的座標,也有可能是自定義的 xy 座標。

通過一系列校驗後,最終呼叫 window.scrollTo 方法來設定滾動條位置。

其中有三個方法用來對座標進行處理的,分別是:

  • getElementPosition:獲取元素座標
  • isValidPosition:驗證座標是否有效
  • normalizePosition:格式化座標

程式碼量不大,具體的程式碼細節感興趣的可以看一下。

同樣,HTML5History 也分別實現了 pushreplacego 等程式設計式導航。

最後

至此,HashHistory 和 HTML5History 的實現就大致瞭解了。在閱讀的過程中,我們不斷地遇到了父類 History 與其 transitionTo 方法,下一篇就來對其進行深入瞭解吧。

相關文章