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


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

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

官網vue-router文檔

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>

  

具體實現請看如下代碼:

  1.  
    //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中“留坑”

    1.  
      //app.vue中
       
      <template>
       
      <div>
       
      <!-- 留坑,非常重要 -->
       
      <router-view></router-view>
       
      </div>
       
      </template>
       
      <script>
       
      export default {
       
      data(){
       
       return {}
       
        }
       
      }
       
      </script>
       
      復制代碼
      

        

vue-router源碼分析

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

 

 

 

在入口文件中需要實例化一個 VueRouter 的實例對象 ,然后將其傳入 Vue 實例的 options 中。

  1.   1 export default class VueRouter {
      2   static install: () => void;
      3   static version: string;
      4  
      5   app: any;
      6   apps: Array<any>;
      7   ready: boolean;
      8   readyCbs: Array<Function>;
      9   options: RouterOptions;
     10   mode: string;
     11   history: HashHistory | HTML5History | AbstractHistory;
     12   matcher: Matcher;
     13   fallback: boolean;
     14   beforeHooks: Array<?NavigationGuard>;
     15   resolveHooks: Array<?NavigationGuard>;
     16   afterHooks: Array<?AfterNavigationHook>;
     17  
     18   constructor (options: RouterOptions = {}) {
     19     this.app = null
     20     this.apps = []
     21     this.options = options
     22     this.beforeHooks = []
     23     this.resolveHooks = []
     24     this.afterHooks = []
     25     // 創建 matcher 匹配函數
     26     this.matcher = createMatcher(options.routes || [], this)
     27     // 根據 mode 實例化具體的 History,默認為'hash'模式
     28     let mode = options.mode || 'hash'
     29     // 通過 supportsPushState 判斷瀏覽器是否支持'history'模式
     30     // 如果設置的是'history'但是如果瀏覽器不支持的話,'history'模式會退回到'hash'模式
     31     // fallback 是當瀏覽器不支持 history.pushState 控制路由是否應該回退到 hash 模式。默認值為 true。
     32     this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
     33     if (this.fallback) {
     34       mode = 'hash'
     35     }
     36     // 不在瀏覽器內部的話,就會變成'abstract'模式
     37     if (!inBrowser) {
     38       mode = 'abstract'
     39     }
     40     this.mode = mode
     41      // 根據不同模式選擇實例化對應的 History 類
     42     switch (mode) {
     43       case 'history':
     44         this.history = new HTML5History(this, options.base)
     45         break
     46       case 'hash':
     47         this.history = new HashHistory(this, options.base, this.fallback)
     48         break
     49       case 'abstract':
     50         this.history = new AbstractHistory(this, options.base)
     51         break
     52       default:
     53         if (process.env.NODE_ENV !== 'production') {
     54           assert(false, `invalid mode: ${mode}`)
     55         }
     56     }
     57   }
     58  
     59   match (
     60     raw: RawLocation,
     61     current?: Route,
     62     redirectedFrom?: Location
     63   ): Route {
     64     return this.matcher.match(raw, current, redirectedFrom)
     65   }
     66  
     67   get currentRoute (): ?Route {
     68     return this.history && this.history.current
     69   }
     70  
     71   init (app: any /* Vue component instance */) {
     72     process.env.NODE_ENV !== 'production' && assert(
     73       install.installed,
     74       `not installed. Make sure to call \`Vue.use(VueRouter)\` ` +
     75       `before creating root instance.`
     76     )
     77  
     78     this.apps.push(app)
     79  
     80     // main app already initialized.
     81     if (this.app) {
     82       return
     83     }
     84  
     85     this.app = app
     86  
     87     const history = this.history
     88     // 根據history的類別執行相應的初始化操作和監聽
     89     if (history instanceof HTML5History) {
     90       history.transitionTo(history.getCurrentLocation())
     91     } else if (history instanceof HashHistory) {
     92       const setupHashListener = () => {
     93         history.setupListeners()
     94       }
     95       history.transitionTo(
     96         history.getCurrentLocation(),
     97         setupHashListener,
     98         setupHashListener
     99       )
    100     }
    101  
    102     history.listen(route => {
    103       this.apps.forEach((app) => {
    104         app._route = route
    105       })
    106     })
    107   }
    108   // 路由跳轉之前
    109   beforeEach (fn: Function): Function {
    110     return registerHook(this.beforeHooks, fn)
    111   }
    112   // 路由導航被確認之間前
    113   beforeResolve (fn: Function): Function {
    114     return registerHook(this.resolveHooks, fn)
    115   }
    116   // 路由跳轉之后
    117   afterEach (fn: Function): Function {
    118     return registerHook(this.afterHooks, fn)
    119   }
    120   // 第一次路由跳轉完成時被調用的回調函數
    121   onReady (cb: Function, errorCb?: Function) {
    122     this.history.onReady(cb, errorCb)
    123   }
    124   // 路由報錯
    125   onError (errorCb: Function) {
    126     this.history.onError(errorCb)
    127   }
    128   // 路由添加,這個方法會向history棧添加一個記錄,點擊后退會返回到上一個頁面。
    129   push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    130     this.history.push(location, onComplete, onAbort)
    131   }
    132   // 這個方法不會向history里面添加新的記錄,點擊返回,會跳轉到上上一個頁面。上一個記錄是不存在的。
    133   replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    134     this.history.replace(location, onComplete, onAbort)
    135   }
    136   // 相對於當前頁面向前或向后跳轉多少個頁面,類似 window.history.go(n)。n可為正數可為負數。正數返回上一個頁面
    137   go (n: number) {
    138     this.history.go(n)
    139   }
    140   // 后退到上一個頁面
    141   back () {
    142     this.go(-1)
    143   }
    144   // 前進到下一個頁面
    145   forward () {
    146     this.go(1)
    147   }
    148  
    149   getMatchedComponents (to?: RawLocation | Route): Array<any> {
    150     const route: any = to
    151       ? to.matched
    152         ? to
    153         : this.resolve(to).route
    154       : this.currentRoute
    155     if (!route) {
    156       return []
    157     }
    158     return [].concat.apply([], route.matched.map(m => {
    159       return Object.keys(m.components).map(key => {
    160         return m.components[key]
    161       })
    162     }))
    163   }
    164  
    165   resolve (
    166     to: RawLocation,
    167     current?: Route,
    168     append?: boolean
    169   ): {
    170     location: Location,
    171     route: Route,
    172     href: string,
    173     // for backwards compat
    174     normalizedTo: Location,
    175     resolved: Route
    176   } {
    177     const location = normalizeLocation(
    178       to,
    179       current || this.history.current,
    180       append,
    181       this
    182     )
    183     const route = this.match(location, current)
    184     const fullPath = route.redirectedFrom || route.fullPath
    185     const base = this.history.base
    186     const href = createHref(base, fullPath, this.mode)
    187     return {
    188       location,
    189       route,
    190       href,
    191       // for backwards compat
    192       normalizedTo: location,
    193       resolved: route
    194     }
    195   }
    196  
    197   addRoutes (routes: Array<RouteConfig>) {
    198     this.matcher.addRoutes(routes)
    199     if (this.history.current !== START) {
    200       this.history.transitionTo(this.history.getCurrentLocation())
    201     }
    202   }
    203 }

     

      

HashHistory

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

• 可以為hash的改變添加監聽事件:

  1.  
    window.addEventListener("hashchange",funcRef,false)
  2.  
    復制代碼

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

  1. 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()方法:

  1. 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

  1. 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

  1.  
    // 直接調用 pushState 傳入 replace 為 true
     
    export function replaceState (url?: string) {
     
        pushState(url, true)
     
    }
     
    復制代碼
    

      

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

supportsPushState

  1. 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()等方法,我們可以讀取瀏覽器歷史記錄棧的信息,進行各種跳轉操作。

  1.  
    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'是一樣的,其原理是通過數組模擬瀏覽器歷史記錄棧的功能

  1.  

    //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兩位老師提供的思路。


作者:DIVI
鏈接:https://juejin.im/post/5bc6eb875188255c9c755df2
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM