實現一個簡化版的vue-router

SilentPort發表於2018-10-19

本文旨在介紹vue-router的實現思路,並動手實現一個簡化版的vue-router。我們先來看一下一般專案中對vue-router最基本的一個使用,可以看到,這裡定義了四個路由元件,我們只要在根vue例項中注入該router物件就可以使用了.

import VueRouter from 'vue-router';
import Home from '@/components/Home';
import A from '@/components/A';
import B from '@/components/B'
import C from '@/components/C'

Vue.use(VueRouter)

export default new VueRouter.Router({
  // mode: 'history',
  routes: [
    {
      path: '/',
      component: Home
    },
    {
      path: '/a',
      component: A
    },
    {
      path: '/b',
      component: B
    },
    {
      path: '/c',
      component: C
    }
  ]
})
複製程式碼

vue-router提供兩個全域性元件,router-viewrouter-link,前者是用於路由元件的佔位,後者用於點選時跳轉到指定路由。此外元件內部可以通過this.$router.push,this.$rouer.replace等api實現路由跳轉。本文將實現上述兩個全域性元件以及pushreplace兩個api,呼叫的時候支援params傳參,並且支援hashhistory兩種模式,忽略其餘api、巢狀路由、非同步路由、abstract路由以及導航守衛等高階功能的實現,這樣有助於理解vue-router的核心原理。本文的最終程式碼不建議在生產環境使用,只做一個學習用途,下面我們就來一步步實現它。

install實現

任何一個vue外掛都要實現一個install方法,通過Vue.use呼叫外掛的時候就是在呼叫外掛的install方法,那麼路由的install要做哪些事情呢?首先我們知道 我們會用new關鍵字生成一個router例項,就像前面的程式碼例項一樣,然後將其掛載到根vue例項上,那麼作為一個全域性路由,我們當然需要在各個元件中都可以拿到這個router例項。另外我們使用了全域性元件router-viewrouter-link,由於install會接收到Vue建構函式作為實參,方便我們呼叫Vue.component來註冊全域性元件。因此,在install中主要就做兩件事,給各個元件都掛載router例項,以及實現router-viewrouter-link兩個全域性元件。下面是程式碼:

const install = (Vue) => {

  if (this._Vue) {
    return;
  };
  Vue.mixin({
    beforeCreate() {
      if (this.$options && this.$options.router) {
        this._routerRoot = this;
        this._router = this.$options.router;
        Vue.util.defineReactive(this, '_routeHistory', this._router.history)
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }

      Object.defineProperty(this, '$router', {
        get() {
          return this._routerRoot._router;
        }
      })

      Object.defineProperty(this, '$route', {
        get() {
          return {
            current: this._routerRoot._routeHistory.current,
            ...this._routerRoot._router.route
          };
        }
      })
    }
  });

  Vue.component('router-view', {
    render(h) { ... }
  })

  Vue.component('router-link', {    
    props: {
      to: String,
      tag: String,
    },
    render(h) { ... }
  })
  this._Vue = Vue;
}
複製程式碼

這裡的this代表的就是vue-router物件,它有兩個屬性暴露出來供外界呼叫,一個是install,一個是Router建構函式,這樣可以保證外掛的正確安裝以及路由例項化。我們先忽略Router建構函式,來看install,上面程式碼中的this._Vue是個開始沒有定義的屬性,他的目的是防止多次安裝。我們使用Vue.mixin對每個元件的beforeCreate鉤子做全域性混入,目的是讓每個元件例項共享router例項,即通過this.$router拿到路由例項,通過this.$route拿到路由狀態。需要重點關注的是這行程式碼:

Vue.util.defineReactive(this, '_routeHistory', this._router.history)
複製程式碼

這行程式碼利用vue的響應式原理,對根vue例項註冊了一個_routeHistory屬性,指向路由例項的history物件,這樣history也變成了響應式的。因此一旦路由的history發生變化,用到這個值的元件就會觸發render函式重新渲染,這裡的元件就是router-view。從這裡可以窺察到vue-router實現的一個基本思路。上述的程式碼中對於兩個全域性元件的render函式的實現,因為會依賴於router物件,我們先放一放,稍後再來實現它們,下面我們分析一下Router建構函式。

Router建構函式

經過剛才的分析,我們知道router例項需要有一個history物件,需要一個儲存當前路由狀態的物件route,另外很顯然還需要接受路由配置表routes,根據routes需要一個路由對映表routerMap來實現元件搜尋,還需要一個變數mode判斷是什麼模式下的路由,需要實現pushreplace兩個api,程式碼如下:

const Router = function (options) {
  this.routes = options.routes; // 存放路由配置
  this.mode = options.mode || 'hash';
  this.route = Object.create(null), // 生成路由狀態
  this.routerMap = createMap(this.routes) // 生成路由表
  this.history = new RouterHistory(); // 例項化路由歷史物件
  this.init(); // 初始化
}

Router.prototype.push = (options) => { ... }

Router.prototype.replace = (options) => { ... }

Router.prototype.init = () => { ... }
複製程式碼

我們看一下路由表routerMap的實現,由於不考慮巢狀等其他情況,實現很簡單,如下:

const createMap = (routes) => {
  let resMap = Object.create(null);
  routes.forEach(route => {
    resMap[route['path']] = route['component'];
  })
  return resMap;
}
複製程式碼

RouterHistory的實現也很簡單,根據前面分析,我們只需要一個current屬性就可以,如下:

const RouterHistory = function (mode) {
  this.current = null; 
}
複製程式碼

有了路由表和historyrouter-view的實現就很容易了,如下:

Vue.component('router-view', {
    render(h) {
      let routerMap = this._self.$router.routerMap;
      return h(routerMap[this._self.$route.current])
    }
  })
複製程式碼

這裡的this是一個renderProxy例項,他有一個屬性_self可以拿到當前的元件例項,進而訪問到routerMap,可以看到路由例項historycurrent本質上就是我們配置的路由表中的path

接下來我們看一下Router要做哪些初始化工作。對於hash路由而言,url上hash值的改變不會引起頁面重新整理,但是可以觸發一個hashchange事件。由於路由history.current初始為null,因此匹配不到任何一個路由,所以會導致頁面重新整理載入不出任何路由元件。基於這兩點,在init方法中,我們需要實現對頁面載入完成的監聽,以及hash變化的監聽。對於history路由,為了實現瀏覽器前進後退時準確渲染對應元件,還要監聽一個popstate事件。程式碼如下:

Router.prototype.init = function () {

  if (this.mode === 'hash') {
    fixHash()
    window.addEventListener('hashchange', () => {
      this.history.current = getHash();
    })
    window.addEventListener('load', () => {
      this.history.current = getHash();
    })
  }

  if (this.mode === 'history') {
    removeHash(this);
    window.addEventListener('load', () => {
      this.history.current = location.pathname;
    })
    window.addEventListener('popstate', (e) => {
      if (e.state) {
        this.history.current = e.state.path;
      }
    })
  }

}
複製程式碼

當啟用hash模式的時候,我們要檢測url上是否存在hash值,沒有的話強制賦值一個預設pathhash路由時會根據hash值作為key來查詢路由表。fixHashgetHash實現如下:

const fixHash = () => {
  if (!location.hash) {
    location.hash = '/';
  }
}
const getHash = () => {
  return location.hash.slice(1) || '/';
}
複製程式碼

這樣在重新整理頁面和hash改變的時候,current可以得到賦值和更新,頁面能根據hash值準確渲染路由。history模式也是一樣的道理,只是它通過location.pathname作為key搜尋路由元件,另外history模式需要去除url上可能存在的hash,removeHash實現如下:

const removeHash = (route) => {
  let url = location.href.split('#')[1]
  if (url) {
    route.current = url;
    history.replaceState({}, null, url)
  }
}
複製程式碼

我們可以看到當瀏覽器後退的時候,history模式會觸發popstate事件,這個時候是通過state狀態去獲取path的,那麼state狀態從哪裡來呢,答案是從window.history物件的pushStatereplaceState而來,這兩個方法正好可以用來實現routerpush方法和replace方法,我們看一下這裡它們的實現:

Router.prototype.push = function (options) {
  this.history.current = options.path;
  if (this.mode === 'history') {
    history.pushState({
      path: options.path
    }, null, options.path);
  } else if (this.mode === 'hash') {
    location.hash = options.path;
  }
  this.route.params = {
    ...options.params
  }
}

Router.prototype.replace = function (options) {
  this.history.current = options.path;
  if (this.mode === 'history') {
    history.replaceState({
      path: options.path
    }, null, options.path);
  } else if (this.mode === 'hash') {
    location.replace(`#${options.path}`)
  }
  this.route.params = {
    ...options.params
  }
}
複製程式碼

pushStatereplaceState能夠實現改變url的值但不引起頁面重新整理,從而不會導致新請求發生,pushState會生成一條歷史記錄而replaceState不會,後者只是替換當前url。在這兩個方法執行的時候將path存入state,這就使得popstate觸發的時候可以拿到路徑從而觸發元件渲染了。我們在元件內按照如下方式呼叫,會將params寫入router例項的route屬性中,從而在跳轉後的元件B內通過this.$route.params可以訪問到傳參。

 this.$router.push({
    path: '/b',
    params: {
      id: 55
    }
 });

複製程式碼

router-link實現

router-view的實現很簡單,前面已經說過。最後,我們來看一下router-link的實現,先放上程式碼:

Vue.component('router-link', {    
    props: {
      to: String,
      tag: String,
    },

    render(h) {
      let mode = this._self.$router.mode;
      let tag = this.tag || 'a';
      let routerHistory = this._self.$router.history;
      return h(tag, {
        attrs: tag === 'a' ? {
          href: mode === 'hash' ? '#' + this.to : this.to,

        } : {},
        on: {
          click: (e) => {
            if (this.to === routerHistory.current) {
              e.preventDefault();
              return;
            }
            routerHistory.current = this.to;
            switch (mode) {
              case 'hash':
                if (tag === 'a') return;
                location.hash = this.to;
                break;
              case 'history':
                history.pushState({
                  path: this.to
                }, null, this.to);
                break;
              default:
            }
            e.preventDefault();
          }
        },
        style: {
          cursor: 'pointer'
        }
      }, this.$slots.default)
    }
  })
複製程式碼

router-link可以接受兩個屬性,to表示要跳轉的路由路徑,tag表示router-link要渲染的標籤名,預設a為標籤。如果是a標籤,我們為其新增一個href屬性。我們給標籤繫結click事件,如果檢測到本次跳轉為當前路由的話什麼都不做直接返回,並且阻止預設行為,否則根據to更換路由。hash模式下並且是a標籤時候可以直接利用瀏覽器的預設行為完成url上hash的替換,否者重新為location.hash賦值。history模式下則利用pushState去更新url。

以上實現就是一個簡單的vue-router,完整程式碼參見vue-router-simple

相關文章