Vue番外篇 -- vue-router淺析原理

DIVI發表於2018-10-18

近期被問到一個問題,在你們專案中使用的是Vue的SPA(單頁面)還是Vue的多頁面設計?

這篇文章主要圍繞Vue的SPA單頁面設計展開。 關於如何展開Vue多頁面設計請點選檢視

vue-router是什麼?

首先我們需要知道vue-router是什麼,它是幹什麼的?

這裡指的路由並不是指我們平時所說的硬體路由器,這裡的路由就是SPA(單頁應用)的路徑管理器。 換句話說,vue-router就是WebApp的連結路徑管理系統。

vue-router是Vue.js官方的路由外掛,它和vue.js是深度整合的,適合用於構建單頁面應用。

那與傳統的頁面跳轉有什麼區別呢?

1.vue的單頁面應用是基於路由和元件的,路由用於設定訪問路徑,並將路徑和元件對映起來。

2.傳統的頁面應用,是用一些超連結來實現頁面切換和跳轉的。

在vue-router單頁面應用中,則是路徑之間的切換,也就是元件的切換。路由模組的本質 就是建立起url和頁面之間的對映關係。

至於為啥不能用a標籤,這是因為用Vue做的都是單頁應用,就相當於只有一個主的index.html頁面,所以你寫的標籤是不起作用的,必須使用vue-router來進行管理。

vue-router實現原理

SPA(single page application):單一頁面應用程式,有且只有一個完整的頁面;當它在載入頁面的時候,不會載入整個頁面的內容,而只更新某個指定的容器中內容。

單頁面應用(SPA)的核心之一是:

1.更新檢視而不重新請求頁面;

2.vue-router在實現單頁面前端路由時,提供了三種方式:Hash模式、History模式、abstract模式,根據mode引數來決定採用哪一種方式。

路由模式

vue-router 提供了三種執行模式:

● hash: 使用 URL hash 值來作路由。預設模式。

● history: 依賴 HTML5 History API 和伺服器配置。檢視 HTML5 History 模式。

● abstract: 支援所有 JavaScript 執行環境,如 Node.js 伺服器端。

Hash模式

vue-router 預設模式是 hash 模式 —— 使用 URL 的 hash 來模擬一個完整的 URL,當 URL 改變時,頁面不會去重新載入

hash(#)是URL 的錨點,代表的是網頁中的一個位置,單單改變#後的部分(/#/..),瀏覽器只會載入相應位置的內容,不會重新載入網頁,也就是說 #是用來指導瀏覽器動作的,對伺服器端完全無用,HTTP請求中不包括#;同時每一次改變#後的部分,都會在瀏覽器的訪問歷史中增加一個記錄,使用”後退”按鈕,就可以回到上一個位置;所以說Hash模式通過錨點值的改變,根據不同的值,渲染指定DOM位置的不同資料

History模式

HTML5 History API提供了一種功能,能讓開發人員在不重新整理整個頁面的情況下修改站點的URL,就是利用 history.pushState API 來完成 URL 跳轉而無須重新載入頁面;

由於hash模式會在url中自帶#,如果不想要很醜的 hash,我們可以用路由的 history 模式,只需要在配置路由規則時,加入"mode: 'history'",這種模式充分利用 history.pushState API 來完成 URL 跳轉而無須重新載入頁面。

//main.js檔案中
const router = new VueRouter({
  mode: 'history',
  routes: [...]
})
複製程式碼

當使用 history 模式時,URL 就像正常的 url,例如 yoursite.com/user/id,比較好… 不過這種模式要玩好,還需要後臺配置支援。因為我們的應用是個單頁客戶端應用,如果後臺沒有正確的配置,當使用者在瀏覽器直接訪問

所以呢,你要在服務端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態資源,則應該返回同一個 index.html 頁面,這個頁面就是你 app 依賴的頁面。

export const routes = [ 
  {path: "/", name: "homeLink", component:Home}
  {path: "/register", name: "registerLink", component: Register},
  {path: "/login", name: "loginLink", component: Login},
  {path: "*", redirect: "/"}]
  
複製程式碼

此處就設定如果URL輸入錯誤或者是URL 匹配不到任何靜態資源,就自動跳到到Home頁面。

abstract模式

abstract模式是使用一個不依賴於瀏覽器的瀏覽歷史虛擬管理後端。

根據平臺差異可以看出,在 Weex 環境中只支援使用 abstract 模式。 不過,vue-router 自身會對環境做校驗,如果發現沒有瀏覽器的 API,vue-router 會自動強制進入 abstract 模式,所以 在使用 vue-router 時只要不寫 mode 配置即可,預設會在瀏覽器環境中使用 hash 模式,在移動端原生環境中使用 abstract 模式。 (當然,你也可以明確指定在所有情況下都使用 abstract 模式)

vue-router使用方式

1:下載 npm i vue-router -S

**2:在main.js中引入 ** import VueRouter from 'vue-router';

3:安裝外掛 Vue.use(VueRouter);

4:建立路由物件並配置路由規則

let router = new VueRouter({routes:[{path:'/home',component:Home}]});

5:將其路由物件傳遞給Vue的例項,options中加入 router:router

6:在app.vue中留坑

<router-view></router-view>
複製程式碼

具體實現請看如下程式碼:

//main.js檔案中引入
import Vue from 'vue';
import VueRouter from 'vue-router';
//主體
import App from './components/app.vue';
import index from './components/index.vue'
//安裝外掛
Vue.use(VueRouter); //掛載屬性
//建立路由物件並配置路由規則
let router = new VueRouter({
    routes: [
        //一個個物件
        { path: '/index', component: index }
    ]
});
//new Vue 啟動
new Vue({
    el: '#app',
    //讓vue知道我們的路由規則
    router: router, //可以簡寫router
    render: c => c(App),
})
複製程式碼

最後記得在在app.vue中“留坑”

//app.vue中
<template>
    <div>
        <!-- 留坑,非常重要 -->
        <router-view></router-view>
    </div>
</template>
<script>
    export default {
        data(){
            return {}
        }
    }
</script>
複製程式碼

vue-router原始碼分析

我們先來看看vue的實現路徑。

Vue番外篇 -- vue-router淺析原理

在入口檔案中需要例項化一個 VueRouter 的例項物件 ,然後將其傳入 Vue 例項的 options 中。

export default class VueRouter {
  static install: () => void;
  static version: string;

  app: any;
  apps: Array<any>;
  ready: boolean;
  readyCbs: Array<Function>;
  options: RouterOptions;
  mode: string;
  history: HashHistory | HTML5History | AbstractHistory;
  matcher: Matcher;
  fallback: boolean;
  beforeHooks: Array<?NavigationGuard>;
  resolveHooks: Array<?NavigationGuard>;
  afterHooks: Array<?AfterNavigationHook>;

  constructor (options: RouterOptions = {}) {
    this.app = null
    this.apps = []
    this.options = options
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    // 建立 matcher 匹配函式
    this.matcher = createMatcher(options.routes || [], this)
    // 根據 mode 例項化具體的 History,預設為'hash'模式
    let mode = options.mode || 'hash'
    // 通過 supportsPushState 判斷瀏覽器是否支援'history'模式
    // 如果設定的是'history'但是如果瀏覽器不支援的話,'history'模式會退回到'hash'模式
    // fallback 是當瀏覽器不支援 history.pushState 控制路由是否應該回退到 hash 模式。預設值為 true。
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    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':
        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}`)
        }
    }
  }

  match (
    raw: RawLocation,
    current?: Route,
    redirectedFrom?: Location
  ): Route {
    return this.matcher.match(raw, current, redirectedFrom)
  }

  get currentRoute (): ?Route {
    return this.history && this.history.current
  }

  init (app: any /* Vue component instance */) {
    process.env.NODE_ENV !== 'production' && assert(
      install.installed,
      `not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
      `before creating root instance.`
    )

    this.apps.push(app)

    // main app already initialized.
    if (this.app) {
      return
    }

    this.app = app

    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
      })
    })
  }
  // 路由跳轉之前
  beforeEach (fn: Function): Function {
    return registerHook(this.beforeHooks, fn)
  }
  // 路由導航被確認之間前
  beforeResolve (fn: Function): Function {
    return registerHook(this.resolveHooks, fn)
  }
  // 路由跳轉之後
  afterEach (fn: Function): Function {
    return registerHook(this.afterHooks, fn)
  }
  // 第一次路由跳轉完成時被呼叫的回撥函式
  onReady (cb: Function, errorCb?: Function) {
    this.history.onReady(cb, errorCb)
  }
  // 路由報錯
  onError (errorCb: Function) {
    this.history.onError(errorCb)
  }
  // 路由新增,這個方法會向history棧新增一個記錄,點選後退會返回到上一個頁面。
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.push(location, onComplete, onAbort)
  }
  // 這個方法不會向history裡面新增新的記錄,點選返回,會跳轉到上上一個頁面。上一個記錄是不存在的。
  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    this.history.replace(location, onComplete, onAbort)
  }
  // 相對於當前頁面向前或向後跳轉多少個頁面,類似 window.history.go(n)。n可為正數可為負數。正數返回上一個頁面
  go (n: number) {
    this.history.go(n)
  }
  // 後退到上一個頁面
  back () {
    this.go(-1)
  }
  // 前進到下一個頁面
  forward () {
    this.go(1)
  }

  getMatchedComponents (to?: RawLocation | Route): Array<any> {
    const route: any = to
      ? to.matched
        ? to
        : this.resolve(to).route
      : this.currentRoute
    if (!route) {
      return []
    }
    return [].concat.apply([], route.matched.map(m => {
      return Object.keys(m.components).map(key => {
        return m.components[key]
      })
    }))
  }

  resolve (
    to: RawLocation,
    current?: Route,
    append?: boolean
  ): {
    location: Location,
    route: Route,
    href: string,
    // for backwards compat
    normalizedTo: Location,
    resolved: Route
  } {
    const location = normalizeLocation(
      to,
      current || this.history.current,
      append,
      this
    )
    const route = this.match(location, current)
    const fullPath = route.redirectedFrom || route.fullPath
    const base = this.history.base
    const href = createHref(base, fullPath, this.mode)
    return {
      location,
      route,
      href,
      // for backwards compat
      normalizedTo: location,
      resolved: route
    }
  }

  addRoutes (routes: Array<RouteConfig>) {
    this.matcher.addRoutes(routes)
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }
}
複製程式碼

HashHistory

• hash雖然出現在url中,但不會被包括在http請求中,它是用來指導瀏覽器動作的,對伺服器端沒影響,因此,改變hash不會重新載入頁面。

• 可以為hash的改變新增監聽事件:

window.addEventListener("hashchange",funcRef,false)
複製程式碼

• 每一次改變hash(window.location.hash),都會在瀏覽器訪問歷史中增加一個記錄。

export class HashHistory extends History {
  constructor (router: Router, base: ?string, fallback: boolean) {
    super(router, base)
    // check history fallback deeplinking
    // 如果是從history模式降級來的,需要做降級檢查
    if (fallback && checkFallback(this.base)) {
    // 如果降級且做了降級處理,則返回
      return
    }
    ensureSlash()
  }
  .......
複製程式碼
function checkFallback (base) {
  const location = getLocation(base)
  // 得到除去base的真正的 location 值
  if (!/^\/#/.test(location)) {
  // 如果此時地址不是以 /# 開頭的
  // 需要做一次降級處理,降為 hash 模式下應有的 /# 開頭
    window.location.replace(
      cleanPath(base + '/#' + location)
    )
    return true
  }
}

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

export function getHash (): string {
  // We can't use window.location.hash here because it's not
  // consistent across browsers - Firefox will pre-decode it!
  // 因為相容性的問題,這裡沒有直接使用 window.location.hash
  // 因為 Firefox decode hash 值
  const href = window.location.href
  const index = href.indexOf('#')
  return index === -1 ? '' : decodeURI(href.slice(index + 1))
}
// 得到hash之前的url地址
function getUrl (path) {
  const href = window.location.href
  const i = href.indexOf('#')
  const base = i >= 0 ? href.slice(0, i) : href
  return `${base}#${path}`
}
// 新增一個hash
function pushHash (path) {
  if (supportsPushState) {
    pushState(getUrl(path))
  } else {
    window.location.hash = path
  }
}
// 替代hash
function replaceHash (path) {
  if (supportsPushState) {
    replaceState(getUrl(path))
  } else {
    window.location.replace(getUrl(path))
  }
}
複製程式碼

hash的改變會自動新增到瀏覽器的訪問歷史記錄中。 那麼檢視的更新是怎麼實現的呢,看下 transitionTo()方法:

transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const route = this.router.match(location, this.current) //找到匹配路由
    this.confirmTransition(route, () => { //確認是否轉化
      this.updateRoute(route) //更新route
      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) })
      }
    })
  }
  
//更新路由
updateRoute (route: Route) {
    const prev = this.current // 跳轉前路由
    this.current = route // 裝備跳轉路由
    this.cb && this.cb(route) // 回撥函式,這一步很重要,這個回撥函式在index檔案中註冊,會更新被劫持的資料 _router
    this.router.afterHooks.forEach(hook => {
      hook && hook(route, prev)
    })
  }
}
複製程式碼

pushState

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
  // 加了 try...catch 是因為 Safari 有呼叫 pushState 100 次限制
  // 一旦達到就會丟擲 DOM Exception 18 錯誤
  const history = window.history
  try {
    if (replace) {
    // replace 的話 key 還是當前的 key 沒必要生成新的
      history.replaceState({ key: _key }, '', url)
    } else {
    // 重新生成 key
      _key = genKey()
       // 帶入新的 key 值
      history.pushState({ key: _key }, '', url)
    }
  } catch (e) {
  // 達到限制了 則重新指定新的地址
    window.location[replace ? 'replace' : 'assign'](url)
  }
}
複製程式碼

replaceState

// 直接呼叫 pushState 傳入 replace 為 true
export function replaceState (url?: string) {
  pushState(url, true)
}
複製程式碼

pushState和replaceState兩種方法的共同特點:當呼叫他們修改瀏覽器歷史棧後,雖然當前url改變了,但瀏覽器不會立即傳送請求該url,這就為單頁應用前端路由,更新檢視但不重新請求頁面提供了基礎。

supportsPushState

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

其實所謂響應式屬性,即當_route值改變時,會自動呼叫Vue例項的render()方法,更新檢視。 $router.push()-->HashHistory.push()-->History.transitionTo()-->History.updateRoute()-->{app._route=route}-->vm.render()

監聽位址列

在瀏覽器中,使用者可以直接在瀏覽器位址列中輸入改變路由,因此還需要監聽瀏覽器位址列中路由的變化 ,並具有與通過程式碼呼叫相同的響應行為,在HashHistory中這一功能通過setupListeners監聽hashchange實現:

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

HTML5History

History interface是瀏覽器歷史記錄棧提供的介面,通過back(),forward(),go()等方法,我們可以讀取瀏覽器歷史記錄棧的資訊,進行各種跳轉操作。

export class HTML5History extends History {
  constructor (router: Router, base: ?string) {
    super(router, base)

    const expectScroll = router.options.scrollBehavior //指回滾方式
    const supportsScroll = supportsPushState && expectScroll

    if (supportsScroll) {
      setupScroll()
    }

    const initLocation = getLocation(this.base)
    //監控popstate事件
    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.
      // 避免在某些瀏覽器中首次發出“popstate”事件
      // 由於同一時間非同步監聽,history路由沒有同時更新。
      const location = getLocation(this.base)
      if (this.current === START && location === initLocation) {
        return
      }

      this.transitionTo(location, route => {
        if (supportsScroll) {
          handleScroll(router, route, current, true)
        }
      })
    })
  }
複製程式碼

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

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

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

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

oursite.com/user/id

如果這種向後端傳送請求的話,後端沒有配置對應/user/id的get路由處理,會返回404錯誤。

官方推薦的解決辦法是在服務端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態資源,則應該返回同一個 index.html 頁面,這個頁面就是你 app 依賴的頁面。同時這麼做以後,伺服器就不再返回 404 錯誤頁面,因為對於所有路徑都會返回 index.html 檔案。為了避免這種情況,在 Vue 應用裡面覆蓋所有的路由情況,然後在給出一個 404 頁面。或者,如果是用 Node.js 作後臺,可以使用服務端的路由來匹配 URL,當沒有匹配到路由的時候返回 404,從而實現 fallback。

兩種模式比較

一般的需求場景中,hash模式與history模式是差不多的,根據MDN的介紹,呼叫history.pushState()相比於直接修改hash主要有以下優勢:

• pushState設定的新url可以是與當前url同源的任意url,而hash只可修改#後面的部分,故只可設定與當前同文件的url

• pushState設定的新url可以與當前url一模一樣,這樣也會把記錄新增到棧中,而hash設定的新值必須與原來不一樣才會觸發記錄新增到棧中

• pushState通過stateObject可以新增任意型別的資料記錄中,而hash只可新增短字串 pushState可額外設定title屬性供後續使用

AbstractHistory

'abstract'模式,不涉及和瀏覽器地址的相關記錄,流程跟'HashHistory'是一樣的,其原理是通過陣列模擬瀏覽器歷史記錄棧的功能

//abstract.js實現,這裡通過棧的資料結構來模擬路由路徑
export class AbstractHistory extends History {
  index: number;
  stack: Array<Route>;

  constructor (router: Router, base: ?string) {
    super(router, base)
    this.stack = []
    this.index = -1
  }
  
  // 對於 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)
    })
  }
複製程式碼
 //確認是否轉化路由
  confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    const abort = err => {
      if (isError(err)) {
        if (this.errorCbs.length) {
          this.errorCbs.forEach(cb => { cb(err) })
        } else {
          warn(false, 'uncaught error during route navigation:')
          console.error(err)
        }
      }
      onAbort && onAbort(err)
    }
    //判斷如果前後是同一個路由,不進行操作
    if (
      isSameRoute(route, current) &&
      route.matched.length === current.matched.length
    ) {
      this.ensureURL()
      return abort()
    }
    //下面是各類鉤子函式的處理
    //*********************
    })
  }
複製程式碼

看到這裡你已經對vue-router的路由基本掌握的差不多了,要是喜歡看原始碼可以點選查

要是喜歡可以給我一個star,github

感謝Aine_潔CaiBoBo兩位老師提供的思路。

相關文章