vue:路由實現原理

隨着前端應用的業務功能起來越複雜,用戶對於使用體驗的要求愈來愈高,單面(SPA)成爲前端應用的主流形式。大型單頁應用最顯著特色之一就是採用的前端路由系統,經過改變URL,在不從新請求頁面的狀況下,更新頁面視圖。html

更新視圖但不從新請求頁面,是前端路由原理的核心之一,目前在瀏覽器環境中這一功能的實現主要有2種方式:前端

  • 利用URL中的hash("#");
  • 利用History interfaceHTML5中新增的方法;

vue-routerVue.js框架的路由插件,它是經過mode這一參數控制路由的實現模式的:vue

const router=new VueRouter({
    mode:'history',
    routes:[...]
})

建立VueRouter的實例對象時,mode以構造參數的形式傳入。ajax

src/index.js

export default class VueRouter{
    mode: string; // 傳入的字符串參數,指示history類別
  history: HashHistory | HTML5History | AbstractHistory; // 實際起做用的對象屬性,必須是以上三個類的枚舉
  fallback: boolean; // 如瀏覽器不支持,'history'模式需回滾爲'hash'模式
  
  constructor (options: RouterOptions = {}) {
    
    let mode = options.mode || 'hash' // 默認爲'hash'模式
    this.fallback = mode === 'history' && !supportsPushState // 經過supportsPushState判斷瀏覽器是否支持'history'模式
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract' // 不在瀏覽器環境下運行需強制爲'abstract'模式
    }
    this.mode = mode

    // 根據mode肯定history實際的類並實例化
    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}`)
        }
    }
  }

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

    // 根據history的類別執行相應的初始化操做和監聽
    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }

    history.listen(route => {
      this.apps.forEach((app) => {
        app._route = route
      })
    })
  }

  // VueRouter類暴露的如下方法實際是調用具體history對象的方法
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.push(location, onComplete, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.replace(location, onComplete, onAbort)
  }
}
  1. 做爲參數傳入的字符串屬性mode只是一個標記,用來指示實際起做用的對象屬性history的實現類,二者對應關係:
modehistory:
        'history':HTML5History;
        'hash':HashHistory;
        'abstract':AbstractHistory;
  1. 在初始化對應的history以前,會對mode作一些校驗:若瀏覽器不支持HTML5History方式(經過supportsPushState變量判斷),則mode設爲hash;若不是在瀏覽器環境下運行,則mode設爲abstract;
  2. VueRouter類中的onReady(),push()等方法只是一個代理,實際是調用的具體history對象的對應方法,在init()方法中初始化時,也是根據history對象具體的類別執行不一樣操做

HashHistory

hash("#")符號的原本做用是加在URL指示網頁中的位置:vue-router

http://www.example.com/index.html#print

#自己以及它後面的字符稱之爲hash可經過window.location.hash屬性讀取.後端

  • hash雖然出如今url中,但不會被包括在http請求中,它是用來指導瀏覽器動做的,對服務器端徹底無用,所以,改變hash不會從新加載頁面。
  • 能夠爲hash的改變添加監聽事件:
window.addEventListener("hashchange",funcRef,false)
  • 每一次改變hash(window.location.hash),都會在瀏覽器訪問歷史中增長一個記錄。

利用hash的以上特色,就能夠來實現前端路由"更新視圖但不從新請求頁面"的功能了。數組

HashHistory.push()

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  this.transitionTo(location, route => {
    pushHash(route.fullPath)
    onComplete && onComplete(route)
  }, onAbort)
}

function pushHash (path) {
  window.location.hash = path
}

transitionTo()方法是父類中定義的是用來處理路由變化中的基礎邏輯的,push()方法最主要的是對windowhash進行了直接賦值:瀏覽器

window.location.hash=route.fullPath

hash的改變會自動添加到瀏覽器的訪問歷史記錄中。
那麼視圖的更新是怎麼實現的呢,咱們來看看父類History中的transitionTo()方法:前端框架

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  const route = this.router.match(location, this.current)
  this.confirmTransition(route, () => {
    this.updateRoute(route)
    ...
  })
}

updateRoute (route: Route) {
  
  this.cb && this.cb(route)
  
}

listen (cb: Function) {
  this.cb = cb
}

能夠看到,當路由變化時,調用了Hitory中的this.cb方法,而this.cb方法是經過History.listen(cb)進行設置的,回到VueRouter類定義中,找到了在init()中對其進行了設置:服務器

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

  history.listen(route => {
    this.apps.forEach((app) => {
      app._route = route
    })
  })
}

appVue組件實例,可是Vue做爲漸進式的前端框架,自己的組件定義中應該是沒有有關路由內置屬性_route,若是組件中要有這個屬性,應該是在插件加載的地方,即VueRouterinstall()方法中混入Vue對象的,install.js的源碼:

export function install (Vue) {
  
  Vue.mixin({
    beforeCreate () {
      if (isDef(this.$options.router)) {
        this._router = this.$options.router
        this._router.init(this)
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      }
      registerInstance(this, this)
    },
  })
}

經過Vue.mixin()方法,全局註冊一個混合,影響註冊以後全部建立的每一個Vue實例,該混合在beforeCreate鉤子中經過Vue.util.defineReactive()定義了響應式的_route屬性。所謂響應式屬性,即當_route值改變時,會自動調用Vue實例的render()方法,更新視圖。

$router.push()-->HashHistory.push()-->History.transitionTo()-->History.updateRoute()-->{app._route=route}-->vm.render()

HashHistory.replace()

replace()方法與push()方法不一樣之處在於,它並非將新路由添加到瀏覽器訪問歷史棧頂,而是替換掉當前的路由:

replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  this.transitionTo(location, route => {
    replaceHash(route.fullPath)
    onComplete && onComplete(route)
  }, onAbort)
}
  
function replaceHash (path) {
  const i = window.location.href.indexOf('#')
  window.location.replace(
    window.location.href.slice(0, i >= 0 ? i : 0) + '#' + path
  )
}

能夠看出,它與push()的實現結構基本類似,不一樣點它不是直接對window.location.hash進行賦值,而是調用window.location.replace方法將路由進行替換。

監聽地址欄

上面的VueRouter.push()VueRouter.replace()是能夠在vue組件的邏輯代碼中直接調用的,除此以外在瀏覽器中,用戶還能夠直接在瀏覽器地址欄中輸入改變路由,所以還須要監聽瀏覽器地址欄中路由的變化 ,並具備與經過代碼調用相同的響應行爲,在HashHistory中這一功能經過setupListeners監聽hashchange實現:

setupListeners () {
  window.addEventListener('hashchange', () => {
    if (!ensureSlash()) {
      return
    }
    this.transitionTo(getHash(), route => {
      replaceHash(route.fullPath)
    })
  })
}

該方法設置監聽了瀏覽器事件hashchange,調用的函數爲replaceHash,即在瀏覽器地址欄中直接輸入路由至關於代碼調用了replace()方法。

HTML5History

History interface是瀏覽器歷史記錄棧提供的接口,經過back(),forward(),go()等方法,咱們能夠讀取瀏覽器歷史記錄棧的信息,進行各類跳轉操做。
HTML5開始,History interface提供了2個新的方法:pushState(),replaceState()使得咱們能夠對瀏覽器歷史記錄棧進行修改:

window.history.pushState(stateObject,title,url)
window.history,replaceState(stateObject,title,url)
  • stateObject:當瀏覽器跳轉到新的狀態時,將觸發popState事件,該事件將攜帶這個stateObject參數的副本
  • title:所添加記錄的標題
  • url:所添加記錄的url

2個方法有個共同的特色:當調用他們修改瀏覽器歷史棧後,雖然當前url改變了,但瀏覽器不會當即發送請求該url,這就爲單頁應用前端路由,更新視圖但不從新請求頁面提供了基礎。

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  const { current: fromRoute } = this
  this.transitionTo(location, route => {
    pushState(cleanPath(this.base + route.fullPath))
    handleScroll(this.router, route, fromRoute, false)
    onComplete && onComplete(route)
  }, onAbort)
}

replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  const { current: fromRoute } = this
  this.transitionTo(location, route => {
    replaceState(cleanPath(this.base + route.fullPath))
    handleScroll(this.router, route, fromRoute, false)
    onComplete && onComplete(route)
  }, onAbort)
}

// src/util/push-state.js
export function pushState (url?: string, replace?: boolean) {
  saveScrollPosition()
  // try...catch the pushState call to get around Safari
  // DOM Exception 18 where it limits to 100 pushState calls
  const history = window.history
  try {
    if (replace) {
      history.replaceState({ key: _key }, '', url)
    } else {
      _key = genKey()
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
    window.location[replace ? 'replace' : 'assign'](url)
  }
}

export function replaceState (url?: string) {
  pushState(url, true)
}

代碼結構以及更新視圖的邏輯與hash模式基本相似,只不過將對window.location.hash()直接進行賦值window.location.replace()改成了調用history.pushState()history.replaceState()方法。

HTML5History中添加對修改瀏覽器地址欄URL的監聽popstate是直接在構造函數中執行的:

constructor (router: Router, base: ?string) {
  
  window.addEventListener('popstate', e => {
    const current = this.current
    this.transitionTo(getLocation(this.base), route => {
      if (expectScroll) {
        handleScroll(router, route, current, true)
      }
    })
  })
}

HTML5History用到了HTML5的新特性,須要瀏版本的支持,經過supportsPushState來檢查:

src/util/push-state.js

export const supportsPushState = inBrowser && (function () {
  const ua = window.navigator.userAgent

  if (
    (ua.indexOf('Android 2.') !== -1 || ua.indexOf('Android 4.0') !== -1) &&
    ua.indexOf('Mobile Safari') !== -1 &&
    ua.indexOf('Chrome') === -1 &&
    ua.indexOf('Windows Phone') === -1
  ) {
    return false
  }

  return window.history && 'pushState' in window.history
})()

以上就是hash模式與history模式源碼導讀,這2種模式都是經過瀏覽器接口實現的,除此以外,vue-router還爲非瀏覽器環境準備了一個abstract模式,其原理爲用一個數組stack模擬出瀏覽器歷史記錄棧的功能。

兩種模式比較

通常的需求場景中,hash模式與history模式是差很少的,根據MDN的介紹,調用history.pushState()相比於直接修改hash主要有如下優點:

  • pushState設置的新url能夠是與當前url同源的任意url,而hash只可修改#後面的部分,故只可設置與當前同文檔的url
  • pushState設置的新url能夠與當前url如出一轍,這樣也會把記錄添加到棧中,而hash設置的新值必須與原來不同纔會觸發記錄添加到棧中
  • pushState經過stateObject能夠添加任意類型的數據記錄中,而hash只可添加短字符串
  • pushState可額外設置title屬性供後續使用

history模式的問題

對於單頁應用來講,理想的使用場景是僅在進入應用時加載index.html,後續在的網絡操做經過ajax完成,不會根據url從新請求頁面,可是若是用戶直接在地址欄中輸入並回車,瀏覽器重啓從新加載等特殊狀況。

hash模式僅改變hash部分的內容,而hash部分是不會包含在http請求中的(hash#):

http://oursite.com/#/user/id //如請求,只會發送http://oursite.com/

因此hash模式下遇到根據url請求頁面不會有問題

history模式則將url修改的就和正常請求後端的url同樣(history不帶#)

http://oursite.com/user/id

若是這種向後端發送請求的話,後端沒有配置對應/user/idget路由處理,會返回404錯誤。

官方推薦的解決辦法是在服務端增長一個覆蓋全部狀況的候選資源:若是 URL 匹配不到任何靜態資源,則應該返回同一個 index.html 頁面,這個頁面就是你 app 依賴的頁面。同時這麼作之後,服務器就再也不返回 404 錯誤頁面,由於對於全部路徑都會返回 index.html 文件。爲了不這種狀況,在 Vue 應用裏面覆蓋全部的路由狀況,而後在給出一個 404 頁面。或者,若是是用 Node.js 做後臺,可使用服務端的路由來匹配 URL,當沒有匹配到路由的時候返回 404,從而實現 fallback

相關文章
相關標籤/搜索