從路由到 vue-router 源碼,帶你吃透前端路由

原由是由於咱們團隊內部在進行發佈系統遷移的時候,遇到了個路由相關的基礎問題。當時隱約知道是爲何,可是對於路由 由於咱們平時過於熟悉,以致於忘了其不少基礎特性,並無第一時間快速的排查問題。對此深感慚愧。javascript

因而就找個時間補一補路由相關的基礎知識,而且查看了下 vue-router 的源碼,從大致上先講一下 vue-router 是怎麼和 vue 結合作路由的管理的。後續還會有更詳細的源碼分析~~~html

本篇文章主要內容以下前端

  • 什麼是路由
  • 後端路由
  • 前端路由
  • vue-router
    • 如何在 vue 中注入 vueRouter(插件的安裝)
    • init VueRouter
    • VueRouter 的 constructor
    • 如何更改 url
    • url 更改後如何渲染
    • go, forward, back 是怎麼處理的
    • setupListeners
    • hash 路由
    • history 路由

路由

咱們既然是要聊一下前端路由,那麼首先應該知道什麼是路由。vue

路由這個概念原本是後端提出來的。很早的時候,都是服務端渲染,那時候先後端尚未分離,服務端將整個頁面返回,響應過程基本都是這樣的:java

  1. 瀏覽器發出請求
  2. 服務器的 80 或者 443 接口監聽到瀏覽器發過來的請求,解析 URL 路徑
  3. 服務端根據 URL 的內容,查詢相應的資源,多是 html 資源,多是圖片資源....,而後將對應的資源處理並返回給瀏覽器
  4. 瀏覽器接收到數據,而後根據 content-type 來判斷如何解析資源

那麼什麼是路由呢?咱們能夠簡單理解爲和服務器交互的一種方式,經過不一樣的路由咱們去請求不一樣的資源(HTML 資源只是其中的一種方式)node

後端路由

咱們上面介紹的其實就是後端路由。ajax

後端路由又可稱之爲服務器端路由,由於對於服務器來講,當接收到客戶端發來的HTTP請求,就會根據所請求的URL,來找到相應的映射函數,而後執行該函數,並將函數的返回值發送給客戶端。vue-router

對於最簡單的靜態資源服務器,能夠認爲,全部URL的映射函數就是一個文件讀取操做。 對於動態資源,映射函數多是一個數據庫讀取操做,也多是進行一些數據的處理,等等。數據庫

而後根據這些讀取的數據,在服務器端就使用相應的模板來對頁面進行渲染後,再返回渲染完畢的頁面。後端

前端路由

前端路由是因爲 ajax 的崛起而誕生的,咱們你們都知道 ajax 是瀏覽器爲了實現異步加載的一種技術方案,剛剛也介紹了,在先後端沒有分離的時候,服務端都是直接將整個 HTML 返回,用戶每次一個很小的操做都會引發頁面的整個刷新(再加上以前的網速還很慢,因此用戶體驗可想而知)

在 90年代末的時候,微軟首先實現了 ajax(Asynchronous JavaScript And XML) 這個技術,這樣用戶每次的操做就能夠不用刷新整個頁面了,用戶體驗就大大提高了。

又隨着技術的發展,慢慢三大框架稱霸了前端圈,成爲前端開發的主力軍。前端也能夠作更多的事情了,陸陸續續也有了模塊化和組件化的概念。

固然還有單頁應用、MVVM也陸陸續續出如今了前端er的視野。

至此,前端開發者可以開發出更加大型的應用,職能也變得更增強大了,那麼這和前端路由有什麼關係呢?

異步交互體驗的更高級版本就是 SPA —— 單頁應用。單頁應用不只僅是在頁面交互是無刷新的,連頁面跳轉都是無刷新的。既然頁面的跳轉是無刷新的,也就是再也不向後端請求返回 html。

那麼,一個大型應用一般會有幾十個頁面(url 地址)相互跳轉,怎麼前端怎麼知道 url 對應展現什麼內容呢?

答案就是 —— 前端路由

能夠理解爲,前端路由就是將以前服務端根據 url 的不一樣返回不一樣的頁面的任務交給前端來作。

image.png 優勢:用戶體驗好,不須要每次都從服務器所有獲取,快速展示給用戶 缺點:使用瀏覽器的前進,後退鍵的時候會從新發送請求,沒有合理地利用緩存,單頁面沒法記住以前滾動的位置,沒法在前進,後退的時候記住滾動的位置。

前端路由解決了什麼問題

  • 前端路由可讓前端本身維護路由和頁面展現的邏輯。每次頁面的改動不須要通知服務端。
  • 更好的交互體驗:不用每次都從服務端拉取資源,快速展示給用戶

前端路由有哪些缺點?

  • 最讓人詬病的就是不利於 SEO
  • 使用瀏覽器的前進,後退鍵時會從新發送請求,來獲取數據,沒有合理地利用緩存。

前端路由實現的原理是什麼

在瞭解了什麼是前端路由和前端路由解決了什麼問題以後,咱們再來深刻了解下前端路由實現的原理

前端路由的實現原理其實很簡單,本質上就是檢測 URL 的變化,經過攔截 URL而後解析匹配路由規則。

hash 路由

以前,你們都是經過 hash 來實現實現路由的,hash 路由的方式就和 <a> 連接的錨點是同樣的,在地址後面增長 # ,例如個人我的博客 https://cherryblog.site/#/  # 及後面的內容,咱們稱之爲 location 的 hash image.png

而後咱們再點開其餘的 tab 頁面,發現雖然瀏覽器地址欄的 url 改變了,可是頁面卻沒有刷新。打開控制檯,咱們能夠看到切換 tab 只是向服務端發送了請求接口數據的接口,並無從新請求 html 的資源。 image.png 這是由於 hash 的變化不會致使瀏覽器向服務端發送請求,因此也就不會刷新頁面。可是每次 hash 的變化,都會觸發 haschange 事件。因此咱們就能夠經過監聽 haschange 的變化來作出響應。

在咱們如今(2021)的前端開發中,一般都是會有一個根節點 <div id="root"></div> ,而後將所要展現的內容插入到這個根節點之中。而後根據路由的不一樣,更換插入的內容組件。 image.png

history 路由

hash 路由有一個問題就是由於有 #  因此不是那麼「好看」

14年後,由於 HTML5 標準發佈。多了兩個 API, pushState  和 replaceState ,經過這兩個 API 能夠改變 url 地址且不會發送請求。同時還有 onpopstate  事件。經過這些就能用另外一種方式來實現前端路由了,但原理都是跟 hash 實現相同的。

用了 HTML5 的實現,單頁路由的 url 就不會多出一個 # ,變得更加美觀。但由於沒有 # 號,因此當用戶刷新頁面之類的操做時,瀏覽器仍是會給服務器發送請求。爲了不出現這種狀況,因此這個實現須要服務器的支持,須要把全部路由都重定向到根頁面。具體能夠見:[HTML5 histroy 模式](HTML5 History 模式)

注意,直接調用 history.popState() 和 history.poshState() 並不會觸發 popState 。只有在作出瀏覽器的行爲纔會調用 popState ,好比點擊瀏覽器的前進後退按鈕或者JS調用 history.back() 或者 history.forward()  image.png

vue-router

那咱們來看一下 vue-router 是怎麼結合 vue 一塊兒實現前端路由的。

總的來講就是使用 Vue.util.defineReactive 將實例的 _route 設置爲響應式對象。而 push, replace 方法會主動更新屬性 _route。而 go,back,或者點擊前進後退的按鈕則會在 onhashchange 或者 onpopstate 的回調中更新 _route。_route 的更新會觸發 RoterView 的從新渲染。

而後咱們就在具體的看下是怎麼實現的

如何在 vue 中注入 vueRouter(插件的安裝)

Vue提供了插件註冊機制是,每一個插件都須要實現一個靜態的 install方法,當執行 Vue.use 註冊插件的時候,就會執行 install 方法,該方法執行的時候第一個參數強制是 Vue對象。

在 vue-router 中,install 方法以下。

import View from './components/view'
import Link from './components/link'

// 導出 vue 實例
export let _Vue

// install 方法 當 Vue.use(vueRouter)時 至關於 Vue.use(vueRouter.install())
export function install (Vue) {
  // 若是已經註冊過了而且已經有了 vue 實例,那麼直接返回
  if (install.installed && _Vue === Vue) return
  install.installed = true

  // 保存Vue實例,方便其它插件文件使用
  _Vue = Vue

  const isDef = v => v !== undefined

  // 遞歸註冊實例的方法
  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }

  /** * 遞歸的將全部的 vue 組件混入兩個生命週期 beforeCreate 和 destroyed * 在 beforeCreated 中初始化 vue-router,並將_route響應式 */
  Vue.mixin({
    beforeCreate () {
      // 初始化 vue-router
      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 {
        // 將 _route 變成響應式對象
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      registerInstance(this, this)
    },
    destroyed () {
      registerInstance(this)
    }
  })
  
  /** * 給Vue添加實例對象 $router 和 $route * $router爲router實例 * $route爲當前的route */
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })

  /** * 注入兩個全局組件 * <router-view> * <router-link> */
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)

  /** * Vue.config 是一個對象,包含了Vue的全局配置 * 將vue-router的hook進行Vue的合併策略 */
  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}
複製代碼

爲了保證 VueRouter 只執行一次,當執行 install 邏輯的時候添加一個標識 installed。用一個全局變量保存 Vue,方便插件對 Vue 的使用。

VueRouter 安裝的核心是經過 mixin,向 Vue app 的全部組件混入 beforeCreatedestroyed鉤子函數。

而且還在 Vue 添加實例對象

  • _routerRoot: 指向 vue 實例
  • _router:指向 vueRouter 實例

在 Vue 的 prototype 上初始化了一些 getter

  • $router, 當前Router的實例
  • $route, 當前Router的信息

Vue.util.defineReactive, 這是Vue裏面觀察者劫持數據的方法,劫持 _route,當 _route 觸發 setter 方法的時候,則會通知到依賴的組件。

後面經過 Vue.component 方法定義了全局的 <router-link><router-view> 兩個組件。<router-link>相似於a標籤,<router-view> 是路由出口,在 <router-view> 切換路由渲染不一樣Vue組件。 最後定義了路由守衛的合併策略,採用了Vue的合併策略。

init VueRouter

剛剛咱們提到了在 install 的時候會執行 VueRouter 的 init 方法( this._router.init(this) ),那麼接下來咱們就來看一下 init 方法作了什麼。簡單來講就是將 Vue 實例掛載到當前 router 的實例上。

而後 install 的時候會執行執行 VueRouter 的 init 方法( this._router.init(this) )。init 執行的時候經過 history.transitionTo 作路由過渡。matcher 路由匹配器是後面路由切換,路由和組件匹配的核心函數。

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

    // main app previously initialized
    // return as we don't need to set up new history listener 
    if (this.app) {
      return
    }

    // 在 VueRouter 上掛載 Vue 實例
    this.app = app

    const history = this.history

    // setupListeners 裏會對 hashchange 事件進行監聽
    // transitionTo 是進行路由導航的函數
    if (history instanceof HTML5History || history instanceof HashHistory) {
      const setupListeners = routeOrError => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupListeners,
        setupListeners
      )
    }

    // 路由全局監聽,維護當前的route
    // 由於 _route 在 install 執行時定義爲響應式屬性,
    // 當 route 變動時 _route 更新,後面的視圖更新渲染就是依賴於 _route
    history.listen(route => {
      this.apps.forEach(app => {
        app._route = route
      })
    })
  }
複製代碼

VueRouter 的 constructor

VueRouter 的 constructor 相對而言比較簡單

  • 定義了一些屬性和方法。
  • 建立 matcher 匹配函數,這個函數函數很重要,能夠查找 route
  • 設置默認值和作不支持 H5 history 的降級處理
  • 根據不一樣的 mode 實例化不一樣的 History 對象
constructor (options: RouterOptions = {}) {
    this.app = null
    this.apps = []
    this.options = options
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    // 建立 matcher 匹配函數
    this.matcher = createMatcher(options.routes || [], this)

    // 默認使用 哈希路由
    let mode = options.mode || 'hash'
    
    // h5的history有兼容性 對history作降級處理
    this.fallback =
      mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    
    if (!inBrowser) {
      mode = 'abstract'
    }
   
    this.mode = mode

    // 分發處理
    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}`)
        }
    }
  }
複製代碼

在實例化 vueRouter 的時候,vueRouter 仿照 history 定義了一些api:pushreplacebackgoforward,還定義了路由匹配器、添加router動態更新方法等。

如何更改 url

那麼 VueRouter 是如何作路由的跳轉的呢?也就是說咱們在使用 _this_.$router.push('/foo', increment) 的時候,怎麼讓渲染的視圖展現 Foo 組件。

const router = new VueRouter({
  mode: 'history',
  base: __dirname,
  routes: [
    { path: '/', component: Home },
    { path: '/foo', component: Foo },
    { path: '/bar', component: Bar },
    { path: encodeURI('/é'), component: Unicode },
    { path: '/query/:q', component: Query }
  ]
})
複製代碼

還記得咱們剛剛在 vue-router 的 constructor 中作了什麼嗎?我來幫你們回憶一下。在 constructor 中,咱們根據不一樣的 mode 選擇不一樣類型的 history 進行實例化(h5 history 仍是 hash history 仍是 abstract ),而後在 init 的時候調用 history.transitionTo 進行路由初始化匹配,也就是完成第一次路由導航。

咱們在 history/base.js 文件中能夠找到 transitionTo 方法。transitionTo 能夠接收三個參數 locationonCompleteonAbort,分別是目標路徑、路經切換成功的回調、路徑切換失敗的回調。

首先在 router 中找到傳入的 location ,而後更新當前的 route,接着就執行路經切換成功的回調函數(在這個函數中,不一樣模式的 history 的實現是不同的)。

回調中會調用 replaceHash 或者 pushHash 方法。它們會更新 location 的 hash 值。若是兼容 historyAPI,會使用 history.replaceState 或者 history.pushState。若是不兼容 historyAPI 會使用 window.location.replace 或者window.location.hash。

而handleScroll方法則是會更新咱們的滾動條的位置。

transitionTo (
    location: RawLocation,
    onComplete?: Function,
    onAbort?: Function
) {
    // 調用 match方法獲得匹配的 route對象
    const route = this.router.match(location, this.current)
    
    // 過渡處理
    this.confirmTransition(
        route,
        () => {
            // 更新當前的 route 對象
            this.updateRoute(route)
          
            // 更新url地址 hash模式更新hash值 history模式經過pushState/replaceState來更新
            onComplete && onComplete(route)
           
            this.ensureURL()
    
            // fire ready cbs once
            if (!this.ready) {
                this.ready = true
                this.readyCbs.forEach(cb => {
                  cb(route)
                })
            }
        },
        err => {
            if (onAbort) {
                onAbort(err)
            }
            if (err && !this.ready) {
                this.ready = true
                this.readyErrorCbs.forEach(cb => {
                cb(err)
                })
            }
        }
    )
}
複製代碼

url 更改後怎麼進行組件的渲染

到此爲止,已經可讓不一樣模式下的 history 對象擁有了表現相同的 push  replace 功能(詳細能夠看下面的實現部分)

那麼路由更換以後怎麼進行正確的渲染呢。

記得咱們前面說過的 vue 的響應式原理了嗎?咱們在 install 的時候已經將 _router 設置爲響應式的了。只要 _router 進行了改變,那麼就會觸發 RouterView 的渲染。(咱們在 transitionTo 的回調中更新了 _route)

go, forward, back

在 VueRouter 上定義的 go,forward,back方法都是調用 history 的屬性的 go 方法。

而hash上go方法調用的是history.go,它是如何更新RouteView的呢?答案是hash對象在setupListeners方法中添加了對popstate或者hashchange事件的監聽。在事件的回調中會觸發RoterView的更新

setupListeners

咱們在經過點擊後退, 前進按鈕或者調用 back, forward, go 方法的時候。咱們沒有主動更新 _app.route 和current。咱們該如何觸發 RouterView 的更新呢?經過在 window 上監聽 popstate,或者 hashchange 事件。在事件的回調中,調用 transitionTo 方法完成對 _route 和 current 的更新。

或者能夠這樣說,在使用 push,replace 方法的時候,hash的更新在 _route 更新的後面。而使用 go, back 時,hash 的更新在 _route 更新的前面。

setupListeners () {
  const router = this.router
  const expectScroll = router.options.scrollBehavior
  const supportsScroll = supportsPushState && expectScroll
  if (supportsScroll) {
    setupScroll()
  }
  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)
      }
    })
  })
}
複製代碼

hash 路由

export class HashHistory extends History {
  constructor (router: Router, base: ?string, fallback: boolean) {
    super(router, base)
    // check history fallback deeplinking
    if (fallback && checkFallback(this.base)) {
      return
    }
    ensureSlash()
  }

  // this is delayed until the app mounts
  // to avoid the hashchange listener being fired too early
  setupListeners () {
    if (this.listeners.length > 0) {
      return
    }

    const router = this.router
    const expectScroll = router.options.scrollBehavior
    const supportsScroll = supportsPushState && expectScroll

    if (supportsScroll) {
      this.listeners.push(setupScroll())
    }

    // 添加 hashchange 事件監聽
    window.addEventListener(
      hashchange,
      () => {
        const current = this.current
        // 獲取 hash 的內容並經過路由配置,把新的頁面 render 到 ui-view 的節點
        this.transitionTo(getHash(), route => {
          if (supportsScroll) {
            handleScroll(this.router, route, current, true)
          }
          if (!supportsPushState) {
            replaceHash(route.fullPath)
          }
        })
      }
    )
    this.listeners.push(() => {
      window.removeEventListener(eventType, handleRoutingEvent)
    })
  }
    push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(
      location,
      route => {
        pushHash(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 => {
        replaceHash(route.fullPath)
        handleScroll(this.router, route, fromRoute, false)
        onComplete && onComplete(route)
      },
      onAbort
    )
  }

  go (n: number) {
    window.history.go(n)
  }
}

function pushHash (path) {
  if (supportsPushState) {
    pushState(getUrl(path))
  } else {
    window.location.hash = path
  }
}

function replaceHash (path) {
  if (supportsPushState) {
    replaceState(getUrl(path))
  } else {
    window.location.replace(getUrl(path))
  }
}
複製代碼

H5 history 路由

其實和 hash 的實現方式是基本相似的,區別點主要在於

  • 監聽的事件不同
  • push 和 replace 方法的實現不同
export class HTML5History extends History {
  _startLocation: string

  constructor (router: Router, base: ?string) {
    super(router, base)

    this._startLocation = getLocation(this.base)
  }

  setupListeners () {
    if (this.listeners.length > 0) {
      return
    }

    const router = this.router
    const expectScroll = router.options.scrollBehavior
    const supportsScroll = supportsPushState && expectScroll

    if (supportsScroll) {
      this.listeners.push(setupScroll())
    }

    // 經過監聽 popstate 事件
    window.addEventListener('popstate', () => {
      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 === this._startLocation) {
        return
      }

      this.transitionTo(location, route => {
        if (supportsScroll) {
          handleScroll(router, route, current, true)
        }
      })
    })
    this.listeners.push(() => {
      window.removeEventListener('popstate', handleRoutingEvent)
    })
  }

  go (n: number) {
    window.history.go(n)
  }

  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      // 使用 pushState 更新 url,不會致使瀏覽器發送請求,從而不會刷新頁面
      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 跟 pushState 的區別在於,不會記錄到歷史棧
      replaceState(cleanPath(this.base + route.fullPath))
      handleScroll(this.router, route, fromRoute, false)
      onComplete && onComplete(route)
    }, onAbort)
  }

  ensureURL (push?: boolean) {
    if (getLocation(this.base) !== this.current.fullPath) {
      const current = cleanPath(this.base + this.current.fullPath)
      push ? pushState(current) : replaceState(current)
    }
  }

  getCurrentLocation (): string {
    return getLocation(this.base)
  }
}

複製代碼

能讀到這裏的同窗真的很感謝你們~~ 這是我第一次寫源碼相關的內容,尚未研究的很透徹,其中難免會有一些錯誤的地方,但願你們多多指正~~~

相關文章
相關標籤/搜索