vue-router 原始碼分析-history | 掘金技術徵文

滴滴出行·DDFE發表於2019-03-03

作者:滴滴公共前端團隊 - dolymood

上篇中介紹了 vue-router 的整體流程,但是具體的 history 部分沒有具體分析,本文就具體分析下和 history 相關的細節。

初始化 Router

通過整體流程可以知道在路由例項化的時候會根據當前 mode 模式來選擇例項化對應的History類,這裡再來回顧下,在 src/index.js 中:

// ...
import { HashHistory, getHash } from './history/hash'
import { HTML5History, getLocation } from './history/html5'
import { AbstractHistory } from './history/abstract'
// ...
export default class VueRouter {
// ...
  constructor (options: RouterOptions = {}) {
// ...
    // 預設模式是 hash
    let mode = options.mode || 'hash'
    // 如果設定的是 history 但是如果瀏覽器不支援的話 
    // 強制退回到 hash
    this.fallback = mode === 'history' && !supportsHistory
    if (this.fallback) {
      mode = 'hash'
    }
    // 不在瀏覽器中 強制 abstract 模式
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode
    // 根據不同模式選擇例項化對應的 History 類
    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        // 細節 傳入了 fallback
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this)
        break
      default:
        assert(false, `invalid mode: ${mode}`)
    }
  }
// ...複製程式碼

可以看到 vue-router 提供了三種模式:hash(預設)、history 以及 abstract 模式,還不瞭解具體區別的可以在文件 中檢視,有很詳細的解釋。下面就這三種模式初始化一一來進行分析。

HashHistory

首先就看預設的 hash 模式,也應該是用的最多的模式,對應的原始碼在 src/history/hash.js 中:

// ...
import { History } from './base'
import { getLocation } from './html5'
import { cleanPath } from '../util/path'

// 繼承 History 基類
export class HashHistory extends History {
  constructor (router: VueRouter, base: ?string, fallback: boolean) {
    // 呼叫基類構造器
    super(router, base)

    // 如果說是從 history 模式降級來的
    // 需要做降級檢查
    if (fallback && this.checkFallback()) {
      // 如果降級 且 做了降級處理 則什麼也不需要做
      return
    }
    // 保證 hash 是以 / 開頭
    ensureSlash()
  }

  checkFallback () {
    // 得到除去 base 的真正的 location 值
    const location = getLocation(this.base)
    if (!/^\/#/.test(location)) {
      // 如果說此時的地址不是以 /# 開頭的
      // 需要做一次降級處理 降級為 hash 模式下應有的 /# 開頭
      window.location.replace(
        cleanPath(this.base + '/#' + location)
      )
      return true
    }
  }
// ...
}

// 保證 hash 以 / 開頭
function ensureSlash (): boolean {
  // 得到 hash 值
  const path = getHash()
  // 如果說是以 / 開頭的 直接返回即可
  if (path.charAt(0) === '/') {
    return true
  }
  // 不是的話 需要手工保證一次 替換 hash 值
  replaceHash('/' + path)
  return false
}

export function getHash (): string {
  // 因為相容性問題 這裡沒有直接使用 window.location.hash
  // 因為 Firefox decode hash 值
  const href = window.location.href
  const index = href.indexOf('#')
  // 如果此時沒有 # 則返回 ''
  // 否則 取得 # 後的所有內容
  return index === -1 ? '' : href.slice(index + 1)
}複製程式碼

可以看到在例項化過程中主要做兩件事情:針對於不支援 history api 的降級處理,以及保證預設進入的時候對應的 hash 值是以 / 開頭的,如果不是則替換。值得注意的是這裡並沒有監聽 hashchange 事件來響應對應的邏輯,這部分邏輯在上篇router.init 中包含的,主要是為了解決 github.com/vuejs/vue-r…,在對應的回撥中則呼叫了 onHashChange 方法,後邊具體分析。

友善高階的 HTML5History

HTML5History 則是利用 history.pushState/repaceState API 來完成 URL 跳轉而無須重新載入頁面,頁面地址和正常地址無異;原始碼在 src/history/html5.js 中:

// ...
import { cleanPath } from '../util/path'
import { History } from './base'
// 記錄滾動位置工具函式
import {
  saveScrollPosition,
  getScrollPosition,
  isValidPosition,
  normalizePosition,
  getElementPosition
} from '../util/scroll-position'

// 生成唯一 key 作為位置相關快取 key
const genKey = () => String(Date.now())
let _key: string = genKey()

export class HTML5History extends History {
  constructor (router: VueRouter, base: ?string) {
    // 基類建構函式
    super(router, base)

    // 定義滾動行為 option
    const expectScroll = router.options.scrollBehavior
    // 監聽 popstate 事件 也就是
    // 瀏覽器歷史記錄發生改變的時候(點選瀏覽器前進後退 或者呼叫 history api )
    window.addEventListener('popstate', e => {
// ...
    })

    if (expectScroll) {
      // 需要記錄滾動行為 監聽滾動事件 記錄位置
      window.addEventListener('scroll', () => {
        saveScrollPosition(_key)
      })
    }
  }
// ...
}
// ...複製程式碼

可以看到在這種模式下,初始化作的工作相比 hash 模式少了很多,只是呼叫基類建構函式以及初始化監聽事件,不需要再做額外的工作。

AbstractHistory

理論上來說這種模式是用於 Node.js 環境的,一般場景也就是在做測試的時候。但是在實際專案中其實還可以使用的,利用這種特性還是可以很方便的做很多事情的。由於它和瀏覽器無關,所以程式碼上來說也是最簡單的,在 src/history/abstract.js 中:

// ...
import { History } from './base'

export class AbstractHistory extends History {
  index: number;
  stack: Array<Route>;

  constructor (router: VueRouter) {
    super(router)
    // 初始化模擬記錄棧
    this.stack = []
    // 當前活動的棧的位置
    this.index = -1
  }
// ...
}複製程式碼

可以看出在抽象模式下,所做的僅僅是用一個陣列當做棧來模擬瀏覽器歷史記錄,拿一個變數來標示當前處於哪個位置。

三種模式的初始化的部分已經完成了,但是這只是剛剛開始,繼續往後看。

history 改變

history 改變可以有兩種,一種是使用者點選連結元素,一種是更新瀏覽器本身的前進後退導航來更新。

先來說瀏覽器導航發生變化的時候會觸發對應的事件:對於 hash 模式而言觸發 windowhashchange 事件,對於 history 模式而言則觸發 windowpopstate 事件。

先說 hash 模式,當觸發改變的時候會呼叫 HashHistory 例項的 onHashChange

  onHashChange () {
    // 不是 / 開頭
    if (!ensureSlash()) {
      return
    }
    // 呼叫 transitionTo
    this.transitionTo(getHash(), route => {
      // 替換 hash
      replaceHash(route.fullPath)
    })
  }複製程式碼

對於 history 模式則是:

window.addEventListener('popstate', e => {
  // 取得 state 中儲存的 key
  _key = e.state && e.state.key
  // 儲存當前的先
  const current = this.current
  // 呼叫 transitionTo
  this.transitionTo(getLocation(this.base), next => {
    if (expectScroll) {
      // 處理滾動
      this.handleScroll(next, current, true)
    }
  })
})複製程式碼

上邊的 transitionTo 以及 replaceHashgetLocationhandleScroll 後邊統一分析。

再看使用者點選連結互動,即點選了 <router-link>,回顧下這個元件在渲染的時候做的事情:

// ...
  render (h: Function) {
// ...

    // 事件繫結
    const on = {
      click: (e) => {
        // 忽略帶有功能鍵的點選
        if (e.metaKey || e.ctrlKey || e.shiftKey) return
        // 已阻止的返回
        if (e.defaultPrevented) return
        // 右擊
        if (e.button !== 0) return
        // `target="_blank"` 忽略
        const target = e.target.getAttribute('target')
        if (/\b_blank\b/i.test(target)) return
        // 阻止預設行為 防止跳轉
        e.preventDefault()
        if (this.replace) {
          // replace 邏輯
          router.replace(to)
        } else {
          // push 邏輯
          router.push(to)
        }
      }
    }
    // 建立元素需要附加的資料們
    const data: any = {
      class: classes
    }

    if (this.tag === 'a') {
      data.on = on
      data.attrs = { href }
    } else {
      // 找到第一個 <a> 給予這個元素事件繫結和href屬性
      const a = findAnchor(this.$slots.default)
      if (a) {
        // in case the <a> is a static node
        a.isStatic = false
        const extend = _Vue.util.extend
        const aData = a.data = extend({}, a.data)
        aData.on = on
        const aAttrs = a.data.attrs = extend({}, a.data.attrs)
        aAttrs.href = href
      } else {
        // 沒有 <a> 的話就給當前元素自身繫結時間
        data.on = on
      }
    }
    // 建立元素
    return h(this.tag, data, this.$slots.default)
  }
// ...複製程式碼

這裡一個關鍵就是繫結了元素的 click 事件,當使用者觸發後,會呼叫 routerpushreplace 方法來更新路由。下邊就來看看這兩個方法定義,在 src/index.js 中:

  push (location: RawLocation) {
    this.history.push(location)
  }

  replace (location: RawLocation) {
    this.history.replace(location)
  }複製程式碼

可以看到其實他們只是代理而已,真正做事情的還是 history 來做,下面就分別把 history 的三種模式下的這兩個方法進行分析。

HashHistory

直接看程式碼:

// ...
  push (location: RawLocation) {
    // 呼叫 transitionTo
    this.transitionTo(location, route => {
// ...
    })
  }

  replace (location: RawLocation) {
    // 呼叫 transitionTo
    this.transitionTo(location, route => {
// ...
    })
  }
// ...複製程式碼

操作是類似的,主要就是呼叫基類的 transitionTo 方法來過渡這次歷史的變化,在完成後更新當前瀏覽器的 hash 值。上篇中大概分析了 transitionTo 方法,但是一些細節並沒細說,這裡來看下遺漏的細節:

  transitionTo (location: RawLocation, cb?: Function) {
    // 呼叫 match 得到匹配的 route 物件
    const route = this.router.match(location, this.current)
    // 確認過渡
    this.confirmTransition(route, () => {
      // 更新當前 route 物件
      this.updateRoute(route)
      cb && cb(route)
      // 子類實現的更新url地址
      // 對於 hash 模式的話 就是更新 hash 的值
      // 對於 history 模式的話 就是利用 pushstate / replacestate 來更新
      // 瀏覽器地址
      this.ensureURL()
    })
  }
  // 確認過渡
  confirmTransition (route: Route, cb: Function) {
    const current = this.current
    // 如果是相同 直接返回
    if (isSameRoute(route, current)) {
      this.ensureURL()
      return
    }
    const {
      deactivated,
      activated
    } = resolveQueue(this.current.matched, route.matched)

    // 整個切換週期的佇列
    const queue: Array<?NavigationGuard> = [].concat(
      // leave 的鉤子
      extractLeaveGuards(deactivated),
      // 全域性 router before hooks
      this.router.beforeHooks,
      // 將要更新的路由的 beforeEnter 鉤子
      activated.map(m => m.beforeEnter),
      // 非同步元件
      resolveAsyncComponents(activated)
    )

    this.pending = route
    // 每一個佇列執行的 iterator 函式
    const iterator = (hook: NavigationGuard, next) => {
// ...
    }
    // 執行佇列 leave 和 beforeEnter 相關鉤子
    runQueue(queue, iterator, () => {
//...
    })
  }複製程式碼

這裡有一個很關鍵的路由物件的 matched 例項,從上次的分析中可以知道它就是匹配到的路由記錄的合集;這裡從執行順序上來看有這些 resolveQueueextractLeaveGuardsresolveAsyncComponentsrunQueue 關鍵方法。

首先來看 resolveQueue

function resolveQueue (
  current: Array<RouteRecord>,
  next: Array<RouteRecord>
): {
  activated: Array<RouteRecord>,
  deactivated: Array<RouteRecord>
} {
  let i
  // 取得最大深度
  const max = Math.max(current.length, next.length)
  // 從根開始對比 一旦不一樣的話 就可以停止了
  for (i = 0; i < max; i++) {
    if (current[i] !== next[i]) {
      break
    }
  }
  // 舍掉相同的部分 只保留不同的
  return {
    activated: next.slice(i),
    deactivated: current.slice(i)
  }
}複製程式碼

可以看出 resolveQueue 就是交叉比對當前路由的路由記錄和現在的這個路由的路由記錄來決定呼叫哪些路由記錄的鉤子函式。

繼續來看 extractLeaveGuards

// 取得 leave 的元件的 beforeRouteLeave 鉤子函式們
function extractLeaveGuards (matched: Array<RouteRecord>): Array<?Function> {
  // 打平元件的 beforeRouteLeave 鉤子函式們 按照順序得到 然後再 reverse
  // 因為 leave 的過程是從內層元件到外層元件的過程
  return flatten(flatMapComponents(matched, (def, instance) => {
    const guard = extractGuard(def, 'beforeRouteLeave')
    if (guard) {
      return Array.isArray(guard)
        ? guard.map(guard => wrapLeaveGuard(guard, instance))
        : wrapLeaveGuard(guard, instance)
    }
  }).reverse())
}
// ...
// 將一個二維陣列(偽)轉換成按順序轉換成一維陣列
// [[1], [2, 3], 4] -> [1, 2, 3, 4]
function flatten (arr) {
  return Array.prototype.concat.apply([], arr)
}複製程式碼

可以看到在執行 extractLeaveGuards 的時候首先需要呼叫 flatMapComponents 函式,下面來看看這個函式具體定義:

// 將匹配到的元件們根據fn得到的鉤子函式們打平
function flatMapComponents (
  matched: Array<RouteRecord>,
  fn: Function
): Array<?Function> {
  // 遍歷匹配到的路由記錄
  return flatten(matched.map(m => {
    // 遍歷 components 配置的元件們
    //// 對於預設檢視模式下,會包含 default (也就是例項化路由的時候傳入的 component 的值)
    //// 如果說多個命名檢視的話 就是配置的對應的 components 的值
    // 呼叫 fn 得到 guard 鉤子函式的值
    // 注意此時傳入的值分別是:檢視對應的元件類,對應的元件例項,路由記錄,當前 key 值 (命名檢視 name 值)
    return Object.keys(m.components).map(key => fn(
      m.components[key],
      m.instances[key],
      m, key
    ))
  }))
}複製程式碼

此時需要仔細看下呼叫 flatMapComponents 時傳入的 fn

flatMapComponents(matched, (def, instance) => {
  // 元件配置的 beforeRouteLeave 鉤子
  const guard = extractGuard(def, 'beforeRouteLeave')
  // 存在的話 返回
  if (guard) {
    // 每一個鉤子函式需要再包裹一次
    return Array.isArray(guard)
      ? guard.map(guard => wrapLeaveGuard(guard, instance))
      : wrapLeaveGuard(guard, instance)
  }
  // 這裡沒有返回值 預設呼叫的結果是 undefined
})複製程式碼

先來看 extractGuard 的定義:

// 取得指定元件的 key 值
function extractGuard (
  def: Object | Function,
  key: string
): NavigationGuard | Array<NavigationGuard> {
  if (typeof def !== 'function') {
    // 物件的話 為了應用上全域性的 mixins 這裡 extend 下
    // 賦值 def 為 Vue “子類”
    def = _Vue.extend(def)
  }
  // 取得 options 上的 key 值
  return def.options[key]
}複製程式碼

很簡答就是取得元件定義時的 key 配置項的值。

再來看看具體的 wrapLeaveGuard 是幹啥用的:

function wrapLeaveGuard (
  guard: NavigationGuard,
  instance: _Vue
): NavigationGuard {
  // 返回函式 執行的時候 用於保證上下文 是當前的元件例項 instance
  return function routeLeaveGuard () {
    return guard.apply(instance, arguments)
  }
}複製程式碼

其實這個函式還可以這樣寫:

function wrapLeaveGuard (
  guard: NavigationGuard,
  instance: _Vue
): NavigationGuard {
  return _Vue.util.bind(guard, instance)
}複製程式碼

這樣整個的 extractLeaveGuards 就分析完了,這部分還是比較繞的,需要好好理解下。但是目的是明確的就是得到將要離開的元件們按照由深到淺的順序組合的 beforeRouteLeave 鉤子函式們。

再來看一個關鍵的函式 resolveAsyncComponents,一看名字就知道這個是用來解決非同步元件問題的:

function resolveAsyncComponents (matched: Array<RouteRecord>): Array<?Function> {
  // 依舊呼叫 flatMapComponents 只是此時傳入的 fn 是這樣的:
  return flatMapComponents(matched, (def, _, match, key) => {
    // 這裡假定說路由上定義的元件 是函式 但是沒有 options
    // 就認為他是一個非同步元件。
    // 這裡並沒有使用 Vue 預設的非同步機制的原因是我們希望在得到真正的非同步元件之前
    // 整個的路由導航是一直處於掛起狀態
    if (typeof def === 'function' && !def.options) {
      // 返回“非同步”鉤子函式
      return (to, from, next) => {
// ...
      }
    }
  })
}複製程式碼

下面繼續看,最後一個關鍵的 runQueue 函式,它的定義在 src/util/async.js 中:

// 執行佇列
export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
  // 內部迭代函式
  const step = index => {
    // 如果說當前的 index 值和整個佇列的長度值齊平了 說明佇列已經執行完成
    if (index >= queue.length) {
      // 執行佇列執行完成的回撥函式
      cb()
    } else {
      if (queue[index]) {
        // 如果存在的話 呼叫傳入的迭代函式執行
        fn(queue[index], () => {
          // 第二個引數是一個函式 當呼叫的時候才繼續處理佇列的下一個位置
          step(index + 1)
        })
      } else {
        // 當前佇列位置的值為假 繼續佇列下一個位置
        step(index + 1)
      }
    }
  }
  // 從佇列起始位置開始迭代
  step(0)
}複製程式碼

可以看出就是一個執行一個函式佇列中的每一項,但是考慮了非同步場景,只有上一個佇列中的項顯式呼叫回撥的時候才會繼續呼叫佇列的下一個函式。

在切換路由過程中呼叫的邏輯是這樣的:

// 每一個佇列執行的 iterator 函式
const iterator = (hook: NavigationGuard, next) => {
  // 確保期間還是當前路由
  if (this.pending !== route) return
  // 呼叫鉤子
  hook(route, current, (to: any) => {
    // 如果說鉤子函式在呼叫第三個引數(函式)` 時傳入了 false
    // 則意味著要終止本次的路由切換
    if (to === false) {
      // next(false) -> abort navigation, ensure current URL
      // 重新保證當前 url 是正確的
      this.ensureURL(true)
    } else if (typeof to === 'string' || typeof to === 'object') {
      // next('/') or next({ path: '/' }) -> redirect
      // 如果傳入的是字串 或者物件的話 認為是一個重定向操作
      // 直接呼叫 push 走你
      this.push(to)
    } else {
      // confirm transition and pass on the value
      // 其他情況 意味著此次路由切換沒有問題 繼續佇列下一個
      // 且把值傳入了
      // 傳入的這個值 在此時的 leave 的情況下是沒用的
      // 注意:這是為了後邊 enter 的時候在處理 beforeRouteEnter 鉤子的時候
      // 可以傳入一個函式 用於獲得元件例項
      next(to)
    }
  })
}
// 執行佇列 leave 和 beforeEnter 相關鉤子
runQueue(queue, iterator, () => {
// ...
})複製程式碼

queue 是上邊定義的一個切換週期的各種鉤子函式以及處理非同步元件的“非同步”鉤子函式所組成佇列,在執行完後就會呼叫佇列執行完成後毀掉函式,下面來看這個函式做的事情:

runQueue(queue, iterator, () => {
  // enter 後的回撥函式們 用於元件例項化後需要執行的一些回撥
  const postEnterCbs = []
  // leave 完了後 就要進入 enter 階段了
  const enterGuards = extractEnterGuards(activated, postEnterCbs, () => {
    return this.current === route
  })
  // enter 的回撥鉤子們依舊有可能是非同步的 不僅僅是非同步元件場景
  runQueue(enterGuards, iterator, () => {
// ...
  })
})複製程式碼

仔細看看這個 extractEnterGuards,從呼叫引數上來看還是和之前的 extractLeaveGuards 是不同的:

function extractEnterGuards (
  matched: Array<RouteRecord>,
  cbs: Array<Function>,
  isValid: () => boolean
): Array<?Function> {
  // 依舊是呼叫 flatMapComponents
  return flatten(flatMapComponents(matched, (def, _, match, key) => {
    // 呼叫 extractGuard 得到元件上的 beforeRouteEnter 鉤子
    const guard = extractGuard(def, 'beforeRouteEnter')
    if (guard) {
      // 特殊處理 依舊進行包裝
      return Array.isArray(guard)
        ? guard.map(guard => wrapEnterGuard(guard, cbs, match, key, isValid))
        : wrapEnterGuard(guard, cbs, match, key, isValid)
    }
  }))
}
function wrapEnterGuard (
  guard: NavigationGuard,
  cbs: Array<Function>,
  match: RouteRecord,
  key: string,
  isValid: () => boolean
): NavigationGuard {
  // 代理 路由 enter 的鉤子函式
  return function routeEnterGuard (to, from, next) {
// ...
  }
}複製程式碼

可以看出此時整體的思路還是和 extractLeaveGuards 的差不多的,只是多了 cbs 回撥陣列 和 isValid 校驗函式,截止到現在還不知道他們的具體作用,繼續往下看此時呼叫的 runQueue

// enter 的鉤子們
runQueue(enterGuards, iterator, () => {
// ...
})複製程式碼

可以看到此時執行 enterGuards 佇列的迭代函式依舊是上邊定義的 iterator,在迭代過程中就會呼叫 wrapEnterGuard 返回的 routeEnterGuard 函式:

function wrapEnterGuard (
  guard: NavigationGuard,
  cbs: Array<Function>,
  match: RouteRecord,
  key: string,
  isValid: () => boolean
): NavigationGuard {
  // 代理 路由 enter 的鉤子函式
  return function routeEnterGuard (to, from, next) {
    // 呼叫使用者設定的鉤子函式
    return guard(to, from, cb => {
      // 此時如果說呼叫第三個引數的時候傳入了回撥函式
      // 認為是在元件 enter 後有了元件例項物件之後執行的回撥函式
      // 依舊把引數傳遞過去 因為有可能傳入的是
      // false 或者 字串 或者 物件
      // 繼續走原有邏輯
      next(cb)
      if (typeof cb === 'function') {
        // 加入到 cbs 陣列中
        // 只是這裡沒有直接 push 進去 而是做了額外處理
        cbs.push(() => {
          // 主要是為了修復 #750 的bug
          // 如果說 router-view 被一個 out-in transition 過渡包含的話
          // 此時的例項不一定是註冊了的(因為需要做完動畫) 所以需要輪訓判斷
          // 直至 current route 的值不再有效
          poll(cb, match.instances, key, isValid)
        })
      }
    })
  }
}複製程式碼

這個 poll 又是做什麼事情呢?

function poll (
  cb: any, // somehow flow cannot infer this is a function
  instances: Object,
  key: string,
  isValid: () => boolean
) {
  // 如果例項上有 key
  // 也就意味著有 key 為名的命名檢視例項了
  if (instances[key]) {
    // 執行回撥
    cb(instances[key])
  } else if (isValid()) {
    // 輪訓的前提是當前 cuurent route 是有效的
    setTimeout(() => {
      poll(cb, instances, key, isValid)
    }, 16)
  }
}複製程式碼

isValid 的定義就是很簡單了,通過在呼叫 extractEnterGuards 的時候傳入的:

const enterGuards = extractEnterGuards(activated, postEnterCbs, () => {
  // 判斷當前 route 是和 enter 的 route 是同一個
  return this.current === route
})複製程式碼

回到執行 enter 進入時的鉤子函式佇列的地方,在執行完所有佇列中函式後會呼叫傳入 runQueue 的回撥:

runQueue(enterGuards, iterator, () => {
  // 確保當前的 pending 中的路由是和要啟用的是同一個路由物件
  // 以防在執行鉤子過程中又一次的切換路由
  if (this.pending === route) {
    this.pending = null
    // 執行傳入 confirmTransition 的回撥
    cb(route)
    // 在 nextTick 時執行 postEnterCbs 中儲存的回撥
    this.router.app.$nextTick(() => {
      postEnterCbs.forEach(cb => cb())
    })
  }
})複製程式碼

通過上篇分析可以知道 confirmTransition 的回撥做的事情:

this.confirmTransition(route, () => {
  // 更新當前 route 物件
  this.updateRoute(route)
  // 執行回撥 也就是 transitionTo 傳入的回撥
  cb && cb(route)
  // 子類實現的更新url地址
  // 對於 hash 模式的話 就是更新 hash 的值
  // 對於 history 模式的話 就是利用 pushstate / replacestate 來更新
  // 瀏覽器地址
  this.ensureURL()
})複製程式碼

針對於 HashHistory 來說,呼叫 transitionTo 的回撥就是:

// ...
  push (location: RawLocation) {
    // 呼叫 transitionTo
    this.transitionTo(location, route => {
      // 完成後 pushHash
      pushHash(route.fullPath)
    })
  }

  replace (location: RawLocation) {
    // 呼叫 transitionTo
    this.transitionTo(location, route => {
      // 完成後 replaceHash
      replaceHash(route.fullPath)
    })
  }
// ...
function pushHash (path) {
  window.location.hash = path
}

function replaceHash (path) {
  const i = window.location.href.indexOf('#')
  // 直接呼叫 replace 強制替換 以避免產生“多餘”的歷史記錄
  // 主要是使用者初次跳入 且hash值不是以 / 開頭的時候直接替換
  // 其餘時候和push沒啥區別 瀏覽器總是記錄hash記錄
  window.location.replace(
    window.location.href.slice(0, i >= 0 ? i : 0) + '#' + path
  )
}複製程式碼

其實就是更新瀏覽器的 hash 值,pushreplace 的場景下都是一個效果。

回到 confirmTransition 的回撥,最後還做了一件事情 ensureURL

ensureURL (push?: boolean) {
  const current = this.current.fullPath
  if (getHash() !== current) {
    push ? pushHash(current) : replaceHash(current)
  }
}複製程式碼

此時 pushundefined,所以呼叫 replaceHash 更新瀏覽器 hash 值。

HTML5History

整個的流程和 HashHistory 是類似的,不同的只是一些具體的邏輯處理以及特性,所以這裡呢就直接來看整個的 HTML5History

export class HTML5History extends History {
// ...
  go (n: number) {
    window.history.go(n)
  }

  push (location: RawLocation) {
    const current = this.current
    // 依舊呼叫基類 transitionTo
    this.transitionTo(location, route => {
      // 呼叫 pushState 但是 url 是 base 值加上當前 fullPath
      // 因為 fullPath 是不帶 base 部分得
      pushState(cleanPath(this.base + route.fullPath))
      // 處理滾動
      this.handleScroll(route, current, false)
    })
  }

  replace (location: RawLocation) {
    const current = this.current
    // 依舊呼叫基類 transitionTo
    this.transitionTo(location, route => {
      // 呼叫 replaceState
      replaceState(cleanPath(this.base + route.fullPath))
      // 滾動
      this.handleScroll(route, current, false)
    })
  }
  // 保證 location 地址是同步的
  ensureURL (push?: boolean) {
    if (getLocation(this.base) !== this.current.fullPath) {
      const current = cleanPath(this.base + this.current.fullPath)
      push ? pushState(current) : replaceState(current)
    }
  }
  // 處理滾動
  handleScroll (to: Route, from: Route, isPop: boolean) {
    const router = this.router
    if (!router.app) {
      return
    }
    // 自定義滾動行為
    const behavior = router.options.scrollBehavior
    if (!behavior) {
      // 不存在直接返回了
      return
    }
    assert(typeof behavior === 'function', `scrollBehavior must be a function`)

    // 等待下重新渲染邏輯
    router.app.$nextTick(() => {
      // 得到key對應位置
      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') {
        // 帶有 selector 得到該元素
        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)
      }
    })
  }
}

// 得到 不帶 base 值的 location
export function getLocation (base: string): string {
  let path = window.location.pathname
  if (base && path.indexOf(base) === 0) {
    path = path.slice(base.length)
  }
  // 是包含 search 和 hash 的
  return (path || '/') + window.location.search + window.location.hash
}

function pushState (url: string, replace?: boolean) {
  // 加了 try...catch 是因為 Safari 有呼叫 pushState 100 次限制
  // 一旦達到就會丟擲 DOM Exception 18 錯誤
  const history = window.history
  try {
    // 如果是 replace 則呼叫 history 的 replaceState 操作
    // 否則則呼叫 pushState
    if (replace) {
      // replace 的話 key 還是當前的 key 沒必要生成新的
      // 因為被替換的頁面是進入不了的
      history.replaceState({ key: _key }, '', url)
    } else {
      // 重新生成 key
      _key = genKey()
      // 帶入新的 key 值
      history.pushState({ key: _key }, '', url)
    }
    // 儲存 key 對應的位置
    saveScrollPosition(_key)
  } catch (e) {
    // 達到限制了 則重新指定新的地址
    window.location[replace ? 'assign' : 'replace'](url)
  }
}
// 直接呼叫 pushState 傳入 replace 為 true
function replaceState (url: string) {
  pushState(url, true)
}複製程式碼

這樣可以看出和 HashHistory 中不同的是這裡增加了滾動位置特性以及當歷史發生變化時改變瀏覽器地址的行為是不一樣的,這裡使用了新的 history api 來更新。

AbstractHistory

抽象模式是屬於最簡單的處理了,因為不涉及和瀏覽器地址相關記錄關聯在一起;整體流程依舊和 HashHistory 是一樣的,只是這裡通過陣列來模擬瀏覽器歷史記錄堆疊資訊。

// ...
import { History } from './base'

export class AbstractHistory extends History {
  index: number;
  stack: Array<Route>;
// ...

  push (location: RawLocation) {
    this.transitionTo(location, route => {
      // 更新歷史堆疊資訊
      this.stack = this.stack.slice(0, this.index + 1).concat(route)
      // 更新當前所處位置
      this.index++
    })
  }

  replace (location: RawLocation) {
    this.transitionTo(location, route => {
      // 更新歷史堆疊資訊 位置則不用更新 因為是 replace 操作
      // 在堆疊中也是直接 replace 掉的
      this.stack = this.stack.slice(0, this.index).concat(route)
    })
  }
  // 對於 go 的模擬
  go (n: number) {
    // 新的歷史記錄位置
    const targetIndex = this.index + n
    // 超出返回了
    if (targetIndex < 0 || targetIndex >= this.stack.length) {
      return
    }
    // 取得新的 route 物件
    // 因為是和瀏覽器無關的 這裡得到的一定是已經訪問過的
    const route = this.stack[targetIndex]
    // 所以這裡直接呼叫 confirmTransition 了
    // 而不是呼叫 transitionTo 還要走一遍 match 邏輯
    this.confirmTransition(route, () => {
      // 更新
      this.index = targetIndex
      this.updateRoute(route)
    })
  }

  ensureURL () {
    // noop
  }
}複製程式碼

小結

整個的和 history 相關的程式碼到這裡已經分析完畢了,雖然有三種模式,但是整體執行過程還是一樣的,唯一差異的就是在處理location更新時的具體邏輯不同。

歡迎拍磚。

Vuex 2.0 原始碼分析知乎地址:zhuanlan.zhihu.com/p/23921964


歡迎關注DDFE
GITHUB:github.com/DDFE
微信公眾號:微信搜尋公眾號“DDFE”或掃描下面的二維碼

vue-router 原始碼分析-history | 掘金技術徵文

相關文章