跟着來,你也能夠手寫VueRouter

聲明:本文爲掘金社區首發簽約文章,未獲受權禁止轉載。javascript

寫在前面

VueRouter,無疑是每一個 Vue 開發者時時刻刻都在使用的東西了,但對於它的源碼,你瞭解多少呢?html

相信大部分前端提及路由,均可以說出其核心有 hashhistory 兩種模式,hash 模式經過監聽 hashchange 事件實現,history 模式經過監聽 popstate 事件再使用 pushstate 修改 URL 來實現,你覺得這就懂了?仍是說你真的覺得懂這些就算接觸到 VueRouter 精髓了?No,far from it!!!前端

其實我和大多數人同樣,以前根本沒把 VueRouter 放在心上,認爲這是一個很簡單的東西。但當我開始讀 VueRouter 源碼時,並非像我想的那樣容易。VueRouter源碼的總體架構其實很簡單,但想讀懂細節仍是有難度的,各類謎同樣的函數分離以及一些細節實現都讓我想當無語,因而我就邊讀源碼邊照虎畫貓,想經過這種方式深度學習,沒成想直接淦了兩個大夜纔到預期目標。vue

本文重點

話很少說,咱們看下讀完這篇文章你能夠學到什麼?html5

介紹了關於 Router 的一些常識,並手寫了一個精簡版的 VueRouter(大部分核心特性),和絕大多數手擼文章不一樣的是,這裏的代碼是徹底以源碼爲標準一步一步實現的,包括總體架構、API等等都是一致的,跟着此文來一遍,除了能完全搞懂核心源碼以外,後期想看源碼細節可無縫接入,看起真正的源碼能夠絕不誇張的說:縱享絲滑!java

閱前提示

本文基於最新最穩定的 VueRouter V3.5.2 版本,4.0+ 仍是 next,因此不在本文討論範圍以內。node

源碼文章很枯燥也沒有多少人看是由於難理解以及沒有實踐樂趣,So,建議拿出編輯器跟着手敲比較快樂。git

關於本文對 VueRouter 的手寫實現,主要包括:github

  • hash/history模式路由
  • 嵌套路由
  • router-view/router-link組件
  • r o u t e r / router/ route
  • push/replace/go/back等方法
  • addRoute/addRoutes/getRouters
  • router hook

沒實現的部分,也會作大體介紹,而且我將一份剛 clone 下來的源碼作好了註釋,放到了手寫源碼項目的目錄裏(文末連接),你們手寫完以爲不過癮想磕細節就能夠直接去看源碼了,一套組合拳,不錯,come on~面試

開始前,你們能夠簡單看下整個 VueRouter 對應的三個流程圖解,看不懂也關係,有個大體印象便可,文末還會有此圖。

前端路由實現原理

前端路由,指由前端監聽 URL 改變從而控制頁面中組件渲染作到無刷新式頁面跳轉,用戶雖感受是一組不一樣的頁面,但其實都在一個頁面內。想要實現前端路由,咱們須要考慮兩個點:

  • URL 改變但頁面不刷新?
  • 監測 URL 改變?

接下來咱們分別看看 Hash 和 History 這兩種模式是怎麼解決的。

Hash路由簡單實現

Hash 模式其實就是經過改變 URL 中 # 號後面的 hash 值來切換路由,由於在 URL 中 hash 值的改變並不會引發頁面刷新,再經過 hashchange 事件來監聽 hash 的改變從而控制頁面組件渲染,看一個小例子:

<!DOCTYPE html>
<html lang="en">
<body>
  <a href="#/home">home</a>
  <a href="#/about">about</a>
  <!-- 渲染路由模塊 -->
  <div id="view"></div>
</body>
<script> let view = document.querySelector("#view") let cb = () => { let hash = location.hash || "#/home"; } window.addEventListener("hashchange", cb) window.addEventListener("load", cb) </script>
</html>
複製代碼

如上,經過兩個 a 標籤來改變路由 hash 值,至關於 router-link 組件,頁面中 id=view 的 div 咱們能夠把它理解爲 router-view 組件,頁面加載完畢先執行一下 cb 函數爲 hash 和路由模塊進行初始化賦值,點擊 a 標籤路由改變後,會被 hashchange 監聽到從而觸發路由模塊更新。

History路由簡單實現

還有一種不帶 # 號的方式,那就是 history,它提供了 pushState 和 replaceState 兩個方法,使用這兩個方法能夠改變 URL 的路徑還不會引發頁面刷新,同時它也提供了一個 popstate 事件來監控路由改變,可是 popstate 事件並不像 hashchange 那樣改變了就會觸發。

  • 經過瀏覽器前進後退時改變了 URL 會觸發 popstate 事件
  • js 調用 historyAPI 的 back、go、forward 等方法能夠觸發該事件

來看它怎麼實現路由監聽:

<!DOCTYPE html>
<html lang="en">
<body>
  <a href='/home'>home</a>
  <a href='/about'>about</a>
  <!-- 渲染路由模塊 -->
  <div id="view"></div>
</body>
<script> let view = document.querySelector("#view") // 路由跳轉 function push(path = "/home"){ window.history.pushState(null, '', path) update() } // 更新路由模塊視圖 function update(){ view.innerHTML = location.pathname } window.addEventListener('popstate', ()=>{ update() }) window.addEventListener('load', ()=>{ let links = document.querySelectorAll('a[href]') links.forEach(el => el.addEventListener('click', (e) => { // 阻止a標籤默認行爲 e.preventDefault() push(el.getAttribute('href')) })) push() }) </script>
</html>

複製代碼

如上,a 標籤爲 router-link 組件,div 爲 router-view 組件。

因爲 popstate 事件只能監聽瀏覽器前進回退和使用 history 前進後退 API,因此除了在事件監聽中要作更新操做,還要在跳轉時手動作路由模塊更新。

這樣就能夠作到和 hash 同樣的效果了,同時因爲 a 標籤存在默認點擊跳轉行爲,因此咱們阻止了此行爲。同時咱們能夠直接在瀏覽器中改變URL刷新,但在這個例子是不支持的,由於這就須要後端來配合了。

上面就是 hash模式和 history 模式的精簡原理了,知道這些基礎咱們就能夠開始寫 VueRouter 了

從使用分析VueRouter

手寫 VueRouter 以前,咱們要從它的使用層面分析,看它都有什麼,先回顧一下它的使用:

  • 路由配置文件中引入 VueRouter 並做爲一個插件 use 一下
  • 路由配置文件中配置路由對象生成路由實例並導出
  • 將配置文件導出的 router 實例掛載到 Vue 的根實例上

整個步驟以下所示:

// router/index.js
import Vue from "vue";
import VueRouter from "vue-router";

Vue.use(VueRouter);

const routes = [
  {
    path: "/",
    name: "Home",
    component,
  },
  {
    path: "/about",
    name: "About",
    component,
  }
];

const router = new VueRouter({
  mode: "hash",
  base: process.env.BASE_URL,
  routes,
});

export default router;
複製代碼

在項目 main.js 文件中:

// main.js
import Vue from "vue";
import App from "./App.vue";
import router from "./router";

new Vue({
  router,
  render: (h) => h(App),
}).$mount("#app");
複製代碼

可看出,VueRouter 做爲一個類能夠被實例化同時它也做爲一個 Vue 插件被加載。

實例化好理解,可是爲何要加載插件呢?

咱們在使用 VueRouter 時,常常會使用到 router-linkrouter-view 兩個組件,這兩個組件咱們沒有發現哪裏引入了,有沒有想過爲何能夠全局使用?其實就是在 VueRouter 做爲插件初始化時全局註冊的。

在使用過程當中,咱們可使用 this.$router 獲取路由實例,同時實例上還會有一些像 push/go/back 等方法,還能夠經過 this.$route 來獲取一個只讀的路由對象,其中包括咱們當前的路由以及一些參數等。

手寫前的準備

項目搭建

建立一個 Vue 項目,使用終端輸入下面命令構建一個 Vue 項目:

vue create hello-vue-router
複製代碼

注意構建時選上 VueRouter 哦!

構建完成直接 yarn serve 跑起來,以下,一個很是熟悉的界面:

接着咱們在 src/ 下新建一個文件夾 hello-vue-router/ ,此文件夾下就放咱們本身寫的 VueRouter 代碼。

先新建一個 index.js 文件,導出一個空 VueRouter 類:

/* * @path: src/hello-vue-router/index.js * @Description: 入口文件 VueRouter類 */

export default class VueRouter(){
  constructor(options){}
}
複製代碼

而後來到路由配置文件 src/router/index.js ,將引入的 VueRouter 換成咱們本身的,並將路由模式改成 hash,由於咱們要先實現 hash 模式,以下:

import Vue from 'vue'
import VueRouter from '@/hello-vue-router/index'
// import VueRouter from 'vue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [...]

const router = new VueRouter({
  mode: 'hash',
  base: process.env.BASE_URL,
  routes
})

export default router
複製代碼

那如今頁面就變成了空白,而且控制檯報着下面的錯:

Cannot call a class as a function
複製代碼

控制檯的錯誤說不能將 class 做爲函數調用!!!

誒,哪裏講 class 做爲函數調用了?

實際上是 Vue.use(VueRouter) 這,說到這,咱們就不得不介紹下這個 Vue 安裝插件的 API 了

Vue.use()源碼解析

以下,其實說白了,這個方法接收一個類型爲函數或對象的參數。若是參數是對象,那它就必須有一個 install 屬性方法。不論參數是函數仍是對象,在執行 install 方法或者函數自己的時候都會把構造函數 Vue 做爲第一個參數傳進去。

這樣咱們在寫插件時,寫一個函數或者一個有 install 函數屬性的對象,均可以接收到構造函數 Vue,也就可使用它來作一些事情了,很 easy 吧!

Vue.use = function (plugin: Function | Object) {
  // installedPlugins爲已安裝插件列表,若 Vue 構造函數不存在_installedPlugins屬性,初始化
  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
  // 判斷當前插件是否在已安裝插件列表,存在直接返回,避免重複安裝
  if (installedPlugins.indexOf(plugin) > -1) {
    return this
  }

	// toArray方法將Use方法的參數轉爲數組並刪除了第一個參數(第一個參數就是咱們的插件)
  const args = toArray(arguments, 1)
  // use是構造函數Vue的靜態方法,那這裏的this就是構造函數Vue自己
  // 把this即構造函數Vue放到參數數組args的第一項
  args.unshift(this)
  if (typeof plugin.install === 'function') {
    // 傳入參數存在install屬性且爲函數
    // 將構造函數Vue和剩餘參數組成的args數組做爲參數傳入install方法,將其this指向插件對象並執行install方法
    plugin.install.apply(plugin, args)
  } else if (typeof plugin === 'function') {
    // 傳入參數是個函數
    // 將構造函數Vue和剩餘參數組成的args數組做爲參數傳入插件函數並執行
    plugin.apply(null, args)
  }
  // 像已安裝插件列表中push當前插件
  installedPlugins.push(plugin)
  return this
}
複製代碼

初步構建install方法

接下來開始手寫代碼了!既然知道 Vue 如何加載插件,那就容易了,由於咱們導出的是一個 VueRouter 類,也是一個對象,因此爲其添加一個 install 方法就行。

稍微改變下 index.js ,爲 VueRouter 類添加靜態方法 install:

/* * @path: src/hello-vue-router/index.js * @Description: 入口文件 VueRouter類 */
import { install } from "./install";

export default class VueRouter(){
  constructor(options){}
}
VueRouter.install = install;
複製代碼

接着在 src/hello-vue-router/ 目錄下建立一個 instal.js ,導出一個 install 方法,咱們看過 Vue.use() 方法源碼了那確定曉得這個方法的第一個參數是構造函數 Vue,以下:

/* * @path: src/hello-vue-router/install.js * @Description: 插件安裝方法install */
export function install(Vue){}
複製代碼

上面也分析過,插件安裝時 install 方法會在 Vue 全局掛載兩個組件,router-viewrouter-link

要知道,咱們在 router 的配置文件中只作了初始化 VueRouter 插件和生成 VueRouter 實例 2 件事情,那咱們日常在項目中直接使用的 this.$router & this.$route 是哪來的呢?

首先 $router 是 VueRouter 的實例對象,$route 是當前路由對象,$route 其實也是 $router 的一個屬性,這兩個對象在 Vue 全部的組件中均可以使用。

可能有小夥伴還記得在項目的入口文件 main.js 中,咱們把導出的 router 實例掛載到了 Vue 根實例上,以下:

import Vue from 'vue'
import App from './App.vue'
import router from './router'

Vue.config.productionTip = false

new Vue({
  router,
  render: function (h) { return h(App) }
}).$mount('#app')
複製代碼

但問題又來了,咱們只是掛載到了根實例上,並無每一個組件都掛,何況直接在 Vue 實例上掛載的對象,Vue 都會給咱們放到當前實例的 $options 屬性上,結合咱們只掛載到了根實例上,那咱們想要訪問 router 實例對象只能採起 this.$root.$options.router 來獲取,這裏 this.$root 獲取到的即根實例。

顯然,外部並非這樣調用的。

因此,$router & $route 這兩個屬性只多是在 VueRouter 組件內掛載的,而且還須要在 Vue 項目開發過程當中能讓全部組件都使用。

細品,VueRouter 組件裏怎麼獲取它的實例對象(在這個類裏怎麼拿到new VueRouter對象)?

可能有小夥伴想到了,這個 router 實例在 Vue 根實例掛載了啊,沒錯,就是在 new Vue 的時候傳入的那個 router 。想辦法拿就能夠了,怎麼拿呢?

上面也說了,咱們能夠先獲取到 Vue 根實例,接着能夠用 $options.router 來獲取實例上掛載的 router 屬性,也就是說目前考慮的是如何在 VueRouter 中拿到 Vue 組件實例(有組件實例就能夠拿到根組件實例從而訪問它的 $options 屬性)

誒,好像又想到了, VueRouter 的 install 方法會傳進來一個 Vue 構造函數,它能搞事情嗎?

構造函數就是構造函數,它固然不是實例,可是構造函數 Vue 有 mixin 方法啊,沒錯就是 混入

Tips:Vue.mixin

估摸着不少人都知道這個方法,但仍是有必要介紹一下。

混入分爲全局混入和組件混入,咱們直接使用構造函數 Vue.mixin 這種是全局混入,它接收一個對象參數,在這個對象參數裏,咱們能夠寫任何 Vue 組件裏的東西,而後咱們寫的這堆東西會被混入(也能夠理解爲合併)到 Vue 每個組件上。

好比寫一個生命週期,裏面寫了個邏輯,那麼在全部的 Vue 組件中這個生命週期開始前都會先執行咱們混入的邏輯。還不懂?再好比,咱們寫了個 methods ,裏面寫了個函數,那這個函數會被混入到全部的 Vue 組件的 methods 中,全部組件均可直接調用。

Vue.mixin 能夠直接寫組件那套,這就簡單了,寫一個生命週期全局混入到組件就 OK 了。

那麼問題又又來了,在哪一個生命週期裏寫呢?其實也簡單,只要看在哪一個生命週期 $options 能夠構建好就好了,beforeCreate 這個週期 $options 就構建好了,也就是在這個生命週期後均可以使用 $options,還用問嗎?確定越早越好,就是 beforeCreate 這個生命週期了。

再捋一遍,install 方法能夠傳過來一個參數構造函數 Vue,使用構造函數 Vue 的靜態方法 mixin 爲咱們全部組件的 beforeCreate 生命週期混入一段邏輯,這段邏輯就是爲其掛載上 $router & $route 屬性

根據咱們上面的邏輯,先上完整代碼再逐步解釋:

/* * @path: src/hello-vue-router/install.js * @Description: 插件安裝方法install */

export let _Vue;

export function install(Vue){
  if (install.installed && _Vue === Vue) return;
  install.installed = true;

  _Vue = Vue;

  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        this._routerRoot = this;
        this._router = this.$options.router;
        this._route = {};
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
    },
  });

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

  Vue.component('RouterView', {});
  Vue.component('RouterLink', {});  
}
複製代碼

來逐塊解釋:

export _Vue;

export function install(Vue){
  if (install.installed && _Vue === Vue) return;
  install.installed = true;

  _Vue = Vue;
}
複製代碼

誒? install 文件中不止導出了一個 install 方法,還導出了一個 _Vue 變量,它是什麼?

在初始化插件的時候會執行 install 方法,在此方法裏會把行參也就是 Vue 的構造函數賦值給變量 _Vue 並導出,其實這個 _Vue 它有兩個做用:

第一就是經過它防止插件屢次註冊安裝,由於插件安裝方法 install 裏咱們給此方法添加了一個 installed 屬性,當此屬性存在且爲 true 且 _Vue 已被賦值爲構造函數 Vue 時 return,表明已經註冊過該插件,無需重複註冊。

第二個做用就是構造函數 Vue 上面掛載了不少實用 API 可供咱們在 VueRouter 類裏使用,固然也能夠經過引入 Vue 來使用它的 API,可是一旦引入包使用,打包的時候也會將整個 Vue 打包進去,即然 install 裏會把這個構造函數做爲參數傳過來,恰巧咱們寫 router 配置文件時,安裝插件(Vue.use)是寫在初始化 VueRouter 實例前面的,也就是 install 執行較早,這個時候咱們把構造函數參數賦值給一個變量在 VueRouter 類裏使用簡直完美,還不理解就看圖 ⬇️

接着來看混入這塊,其實說白了就是掛載 $router & $route

export function install(Vue){  
  // 全局註冊混入,每一個 Vue 實例都會被影響
  Vue.mixin({
    // Vue建立前鉤子,今生命週期$options已掛載完成
    beforeCreate() {
      // 經過判斷組件實例this.$options有無router屬性來判斷是否爲根實例
      // 只有根實例初始化時咱們掛載了VueRouter實例router(main.js中New Vue({router})時)
      if (this.$options.router) {
        this._routerRoot = this;
        // 在 Vue 根實例添加 _router 屬性( VueRouter 實例)
        this._router = this.$options.router;
        this._route = {};
      } else {
        // 爲每一個組件實例定義_routerRoot,回溯查找_routerRoot
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
    },
  });

  // 在 Vue 原型上添加 $router 屬性( VueRouter )並代理到 this._routerRoot._router
  Object.defineProperty(Vue.prototype, "$router", {
    get() {
      return this._routerRoot._router;
    },
  });
  
  // 在 Vue 原型上添加 $route 屬性( 當前路由對象 )並代理到 this._routerRoot._route
  Object.defineProperty(Vue.prototype, '$route', {
    get() {
      return this._routerRoot._route;
    }
  });
}
複製代碼

咱們看看都作了什麼:

首先寫一個mixin,全局註冊混入,讓每一個 Vue 實例都會被影響。混入裏寫一個 beforeCreate 鉤子,由於今生命週期 o p t i o n s 最先掛載完成。又因全局混入,因此 b e f o r e C r e a t e 鉤子裏咱們寫了一個經過組件實例中的 t h i s . options 最先掛載完成。又因全局混入,因此 beforeCreate 鉤子裏咱們寫了一個經過組件實例中的 this. ​options 有無 router 屬性來判斷是否爲根實例,只有根實例初始化時才掛載 VueRouter 實例 router(就是 main.js 中 New Vue({router}) 時)。

是根實例:

是根實例就爲其添加 _router 屬性,值爲 VueRouter 實例,同時添加一個 _routerRoot 屬性將 this 也就是根實例也掛載上去

上面分析過,這裏還應有 route 對象,因此最後還爲其添加了 _route 屬性,暫且將它設置成空對象,後面再完善

不是根實例:

不是根實例,那就是子組件實例了,找它的父實例判斷其父實例有沒有 _routerRoot 屬性,沒有就爲其加上引用,確保每個組件實例均可以有 _routerRoot 屬性,也就是讓每一個組件中均可以引用並訪問到根實例,注意並非反覆賦值,對象間的引用而已

最後爲了讓每一個組件均可以訪問到 $router $ $route 對象,咱們在 Vue 原型上添加了 r o u t e r 屬性並代理到 t h i s . r o u t e r R o o t . r o u t e r ,也在 V u e 原型上添加了 router 屬性並代理到 `this._routerRoot._router`,也在 Vue 原型上添加了 ` route屬性並代理到this._routerRoot._route`,剩下就是建立全局組件了:

// 全局註冊組件router-view
Vue.component('RouterView', {});
// 全局註冊組件router-link
Vue.component('RouterLink', {}); 
複製代碼

這塊暫時比較簡單,使用 Vue.component 全局註冊了兩個組件,配置對象都直接爲空。下面簡單的配置一下這兩個全局組件,讓項目跑起來,畢竟如今運行還在報錯。

初步構建RouterView、RouterLink組件

稍微分離一下,咱們在 src/hello-vue-router/ 目錄下新建一個 components/ 文件夾

components 文件夾下新建 view.jslink.js 兩個文件,隨後仍是要先改變一下 install 方法:

/* * @path: src/hello-vue-router/install.js * @Description: 插件安裝方法install */
import View from "./components/view";
import Link from "./components/link";

export function install(Vue){
  // 全局註冊組件router-view
  Vue.component('RouterView', view);

  // 全局註冊組件router-link
  Vue.component('RouterLink', link);  
}
複製代碼

能夠看到咱們把兩個組件的配置對象單獨拉出去了兩個文件來寫,其實就是每一個文件導出一個組件配置對象。

先看 link.js ,link 組件相似 a 標籤,其實它默認就會渲染一個 a 標籤,組件接收一個 to 參數,能夠爲對象,也能夠爲字符串,用做跳轉。

<router-link to="/home">
<router-link :to="{path: '/home'}">
複製代碼

看實現:

/* * @path: src/hello-vue-router/components/link.js * @Description: router-link */
export default {
  name: "RouterLink",
  props: {
    to: {
      type: [String, Object],
      require: true
    }
  },
  render(h) {
    const href = typeof this.to === 'string' ? this.to : this.to.path
    const router = this.$router
    let data = {
      attrs: {
        href: router.mode === "hash" ? "#" + href : href
      }
    };
    return h("a", data, this.$slots.default)
  }
}
複製代碼

首先是 props 接收參數 to,必選項,可爲對象或字符串類型,在 render 函數中首先判斷了參數 to 的類型,並把它統一作成了對象。

接着訪問了根實例中的 $router,這裏的 this 實際上是一個 Proxy,輸出一下就會知道,這個 Proxy 代理到了 VueComponent 實例,而咱們在 install 給每一個組件實例都加上了指向根實例的屬性 _routerRoot,這裏其實想要訪問 router 對象有好多種。

// this._self._routerRoot._router
// this._routerRoot._router
// this.$router
複製代碼

用啥均可以,可是源碼用的第三種,咱們也就用這個了,多是字符最少

接着就是返回一個 VNode 了,其實 render 的 h 參數就是 createElement 函數,做用就是建立一個 VNode,它的參數看官網描述:

// @returns {VNode}
createElement(
  // {String | Object | Function}
  // 一個 HTML 標籤名、組件選項對象,或者
  // resolve 了上述任何一種的一個 async 函數。必填項。
  'div',

  // {Object}
  // 一個與模板中 attribute 對應的數據對象。可選。
  {
    // (詳情見下一節)
  },

  // {String | Array}
  // 子級虛擬節點 (VNodes),由 `createElement()` 構建而成,
  // 也可使用字符串來生成「文本虛擬節點」。可選。
  [
    '先寫一些文字',
    createElement('h1', '一則頭條'),
    createElement(MyComponent, {
      props: {
        someProp: 'foobar'
      }
    })
  ]
)
複製代碼

這裏咱們想要返回一個 a 標籤,因此第一個參數就是字符串 a,第二個參數就是標籤 attribute 對應的數據對象,要給他帶上 href 屬性,屬性值就是 to 參數,須要注意的是模式問題,hash 模式下要給全部的跳轉路徑前加上一個 # 號,因此須要 router.mode 判斷一下模式,第三個參數就是子節點了,也就是 router-link 組件中包含的值,其實使用默認插槽便可拿到, this.$slots.default 獲取默認插槽。

OK,到這 router-link 組件就差很少完成了,只是在 history 模式下還有問題,咱們後面再說。

再來看 view.js ,其實咱們並不須要 RouterView 組件渲染什麼東西,它充其量就是一個佔位符,用來替換咱們的組件模塊UI,因此一不須要生命週期,二不須要狀態管理,三不須要各類監聽,通俗點就是不必創造一個實例,做爲一個三無組件,函數式組件最符合了。

/* * @path: src/hello-vue-router/components/view.js * @Description: router-view */
export default {
  name: "RouterView",
  functional: true, // 函數式組件
  render(h) {
    return h('div', 'This is RoutePage')
  }
}
複製代碼

如上,直接先設置成函數式組件,而後 render 函數直接返回一個 div,內容爲 'This is RoutePage'(h 函數即 createElement 函數沒有無第二個參數可省略),這裏只是初步搭建一下結構,邏輯後面再說,先讓頁面跑起來,如今你再打開瀏覽器會發現無報錯了,導航也有了,還能夠點擊切換路由,就是路由模塊組件即 router-view 永遠都只顯示 This is RoutePage ,以下:

初步構建VueRouter類

install 方法咱們暫時能夠告一段落,思考一下 VueRouter 類裏,咱們須要作什麼?

首先,接收到參數確定要對參數進行一個分析,傳進來的是一個對象,其中主要的就是兩個屬性:

  • mode 路由模式
  • routes 路由配置數組

其實 base 屬性也比較重要,不過能夠先不考慮這個,邏輯跑通後有時間再完善

思考 mode 配置,咱們須要根據 mode 傳入的路由模式來初始化對應模式的一些東西,從而實現對該模式下的路由監聽。

那再思考一下關於 routes 數組,咱們須要作什麼?

其實,此數組中配置的最重要的就是路由 path 以及 path 對應的路由組件,固然還有一些重定向、動態路由、路由名稱、路由別名的配置,這些也都暫時不考慮,後期逐步完善。

問題來了,監聽到路由發生了變化咱們須要作什麼?

固然是拿到改變的路由 path ,在 routes 數組中找到匹配的 path 配置,獲取它的組件,而後把拿到的組件渲染到對應的 router-view 中去。

對於 routes 配置,目的很明確了,由於這是一個樹結構的數組對象,咱們是基於 path 匹配的,很不放便,因此須要提早將此配置解析爲 {key : value} 這種結構,固然 key 就是咱們的 path ,而 value 則是此路由的配置項。分析完畢,開始敲代碼:

/* * @path: src/hello-vue-router/index.js * @Description: 入口文件 VueRouter類 */
import { install } from "./install";
import { createMatcher } from "./create-matcher";
import { HashHistory } from "./history/hash";
import { HTML5History } from "./history/html5";
import { AbstractHistory } from "./history/abstract";
const inBrowser = typeof window !== "undefined";

export default class VueRouter(){
  constructor(options) {
    // 路由配置
    this.options = options;
    // 建立路由matcher對象,傳入routes路由配置列表及VueRouter實例,主要負責url匹配
    this.matcher = createMatcher(options.routes);

    let mode = options.mode || "hash";

    // 支持全部 JavaScript 運行環境,非瀏覽器環境強制使用abstract模式,主要用於SSR
    if (!inBrowser) {
      mode = "abstract";
    }

    this.mode = mode;

    // 根據不一樣mode,實例化不一樣history實例
    switch (mode) {
      case "history":
        this.history = new HTML5History(this);
        break;
      case "hash":
        this.history = new HashHistory(this);
        break;
      case "abstract":
        this.history = new AbstractHistory(this);
        break;
      default:
        if (process.env.NODE_ENV !== "production") {
          throw new Error(`[vue-router] invalid mode: ${mode}`);
        }
    }
  }
}
VueRouter.install = install;
複製代碼

其實 VueRouter 這個類的 constructor 裏的邏輯很簡單,就是判斷傳入的 mode 模式隨後初始化不一樣類實例,雖然實例化的是不一樣的類,但實例方法包括屬性等都是同樣的

完整的 VueRouter 有三種模式:

  • hash 基本瀏覽器都支持,可是URL有 # 號,很差看
  • history URL好看,可是部分老版本瀏覽器不支持
  • abstract 支持全部環境,主要用於服務端 SSR

咱們不太清楚的多是 abstract 模式了,其實在官方中把這種模式定義爲支持任何環境的模式,由於這種模式是手動模擬一個路由環境,而源碼中也有一個和上面同樣的邏輯判斷(inBrowser),就是在當前環境沒有 window 對象也就是非瀏覽器環境狀況下,直接強制切換爲此模式,因此這種模式也主要用於 SSR,後面有精力就實現一下,至關簡單。

整個 constructor 其實沒有複雜邏輯。先判斷當前環境有無 window 對象也就是不是瀏覽器環境,是的話繼續走,不是則強制 mode 值爲 abstract;而後就是判斷一下 mode 屬性值,匹配三個模式分別使用對應類來初始化該路由模式實例,匹配不到直接拋出錯誤,這裏不管是哪一個模式,在對應的類中咱們都會實現一些相同的方法,而且將初始化的實例掛載到了 VueRouter 實例的 hisory 屬性上。

其實在作 mode 參數校驗前,還引入了一個 createMatcher 方法,這個方法的返回值掛載到了 VueRouter 實例的 matcher 屬性上,它是作什麼的呢?

你應該大體猜到了,上面也說過,大概就是構建 {key : value} 結構的對象(稱之爲 pathMap 對象)讓咱們更便捷的經過 path 路徑匹配到對應路由模塊。

那接下來咱們就一步步推導下 createMatcher 這個方法是怎麼封裝的。

createMatcher方法推導

你覺得 createMatcher 這個方法只是單純的構建一個 pathMap 映射對象?No,那樣的話函數名應該叫 createRouterMap 纔對,其實最開始確實是這個名字,可是一套推導下來發現它不只能夠構建出 pathMap 映射對象, addRoutes/addRoute/getRoutes 這幾個方法也能夠在這裏實現。

構建出 pathMap 映射對象是作什麼的?路由匹配啊!輸入 path 的時候可以獲取到對應的路由配置信息,pathMap 對象就至關於一個路由數據管家,寫入的全部路由配置都在這裏了,那動態添加路由的時候把新路由對象解析並添加到 pathMap 對象裏就能夠了,因此咱們把路由匹配及動態路由添加的幾個方法全放一塊合成了 createMatcher 函數,咱們叫它 路由匹配器函數 吧,主要做用就是生成一個路由匹配器對象,這個函數就返回了一個包含四個方法屬性的對象:

  • macth 路由匹配
  • addRoutes 動態添加路由(參數必須是一個符合 routes 選項要求的數組)
  • addRoute 動態添加路由(添加一條新路由規則)
  • getRoutes 獲取全部活躍的路由記錄列表

createRouteMap生成路由映射

首先咱們要構建 pathMap 對象,單獨拉出來一個文件寫這個方法,在 src/hello-vue-router/ 目錄下新建一個 create-route-map.js 文件:

/* * @path: src/hello-vue-router/create-route-map.js * @Description: 生成路由映射 */
// 生成路由映射
export function createRouteMap(routes){
  let routeMap = {}
  routes.forEach(route => {
    routeMap[route.path] = route
  })
  return routeMap
}
複製代碼

如上,幾行代碼就生成了一個 pathMap 路由映射對象,有問題嗎?沒有問題,但咱們上面只匹配了一層,路由配置裏面能夠有無限層子路由,好比下面這樣的配置:

const routes = [
  {
    path: "/about",
    name: "About",
    component,
  },
  {
    path: "/parent",
    name: "Parent",
    component,
    children:[
      {
        path: "child",
        name:"Child",
        component
      }
    ]
  }
];
複製代碼

咱們想要生成的 pathMap 對象是什麼,是下面這樣:

{
  "/about": {...},
  "/parent": {...},
  "/parent/child": {...}
}
複製代碼

但是如今的代碼邏輯只生成了下面這種:

{
  "/about": {...},
  "/parent": {...}
}
複製代碼

有問題嗎?有大問題,一層路由是 ok 的,多層級的嵌套路由直接 gameover。因此要遞歸處理解析,修改一下代碼,仍是老套路,先看完整代碼再逐步解析。

export function createRouteMap(routes){
  const pathMap = Object.create(null);
  // 遞歸處理路由記錄,最終生成路由映射
  routes.forEach(route => {
    // 生成一個RouteRecord並更新pathMap
    addRouteRecord(pathMap, route, null)
  })
  return pathMap
}

// 添加路由記錄
function addRouteRecord(pathMap, route, parent){
  const { path, name } = route

  // 生成格式化後的path(子路由會拼接上父路由的path)
  const normalizedPath = normalizePath(path, parent)

  // 生成一條路由記錄
  const record = {
    path: normalizedPath, // 規範化後的路徑
    regex: "", // 利用path-to-regexp包生成用來匹配path的加強正則對象,用來匹配動態路由 (/a/:b)
    components: route.component, // 保存路由組件,省略了命名視圖解析
    name,
    parent, // 父路由記錄
    redirect: route.redirect, // 重定向的路由配置對象
    beforeEnter: route.beforeEnter, // 路由獨享的守衛
    meta: route.meta || {}, // 元信息
    props: route.props == null ? {} : route.props// 動態路由傳參
  }

  // 處理有子路由狀況,遞歸
  if (route.children) {
    // 遍歷生成子路由記錄
    route.children.forEach(child => {
      addRouteRecord(pathMap, child, record)
    })
  }

  // 若pathMap中不存在當前路徑,則添加pathList和pathMap
  if (!pathMap[record.path]) {
    pathMap[record.path] = record
  }
}

// 規格化路徑
function normalizePath( path, parent ) {
  // 下標0爲 / ,則是最外層path
  if (path[0] === '/') return path
  // 無父級,則是最外層path
  if (!parent) return path
  // 清除path中雙斜杆中的一個
  return `${parent.path}/${path}`.replace(/\/\//g, '/')
}
複製代碼

其實這塊代碼比較簡單,也都帶上了註釋,簡單說幾個點吧。

咱們在遞歸中其實把每個路由配置對象都格式化了一下,生成了一個新的 record 對象,該對象的的 path 實際上是完整 path,也就是若是原 path 是以 / 開頭,說明本身是頂級路由,path 就是它自己,若是原 path 不是以 / 開頭,說明它是子級路由,那咱們就須要拼接上父級 path,爲此咱們單獨寫了一個 normalizePath 函數來生成完整 path,也就是將 path 規格化。

由於遞歸時傳入了 parent ,除了頂級路由爲 null 以外,子級路由都有父級,而咱們子路由遞歸時是在 record 對象生成以後的,因此每一個傳入的父級都是格式化好的 record 對象,父級的 path 也是完整 path,這樣不論多少子級,均可以拼出完整 path。

接着說 record 對象,咱們還爲其添加了一個 parent 屬性指向它的父級對象,讓父子之間有個聯繫,還有一些路由中可配置的參數像重定向 redirect、路由獨享守衛 beforeEnter、元信息 meta、路由名稱 name 這些咱們也都接收並放到了 record 對象裏。

單獨說 regex 屬性,相信你們都知道 VueRouter 裏支持動態路由,其實主要是利用一個三方包 path-to-regexp 生成用來匹配path 的加強正則對象,用來匹配對應的動態路由,生成正則以後就放在 regex 屬性裏,這塊對咱們手寫來講沒有特別大的意義,因此我沒寫,直接置空了,若是有興趣就直接看源碼這裏,主要仍是 path-to-regexp 這個包的使用,也不復雜。另外最後的 props 屬性是動態路由傳參用的,暫不作這塊可忽略。

最終一套下來,生成的 pathMap 對象就是 [{path: record}...] 這種格式了,key 是格式化後的完整 path,value是格式化好的路由配置對象 record。

到這裏路由映射對象 pathMap 對象解析方法就差很少寫完了。

createMatcher生成路由匹配器

接着,咱們在 src/hello-vue-router/ 文件夾下建立一個 create-matcher.js 文件,按照咱們上面分析大體結構以下:

/* * @path: src/hello-vue-router/create-route-map.js * @Description: 路由匹配器Matcher對象生成方法 */
import { createRouteMap } from "./create-route-map";

export function createMatcher(routes){
  // 生成路由映射對象 pathMap
  const pathMap = createRouteMap(routes)

  // 動態添加路由(添加一條新路由規則)
  function addRoute(){ }

  // 動態添加路由(參數必須是一個符合 routes 選項要求的數組)
  function addRoutes(){ }

  // 獲取全部活躍的路由記錄列表
  function getRoutes(){ }

  // 路由匹配
  function match(){ }

  return {
    match,
    addRoute,
    getRoutes,
    addRoutes
  }
}
複製代碼

路由匹配器 Matcher 對象生成方法即 createMatcher ,咱們只須要一個參數,那就是生成路由映射對象 pathMap 所需的 routes 數組(就是 router 配置文件裏的那個 routes)。

其實路由映射對象 pathMap 只有在匹配路由和動態添加路由的時候能夠用到,而這些狀況都包含在 createMatcher 函數內,因此在 createMatcher 函數內部直接使用剛寫好的 createRouteMap 方法生成了 pathMap 對象,在函數調用時,內部一直維護着這個對象,由於 createMatcher 函數返回的幾個方法裏都有對 pathMap 對象的引用,就是一個典型閉包場景,因此整個 VueRouter 實例初始化過程當中 createMatcher 函數只需調用一次就 OK,createRouteMap 方法也拋出了動態修改 pathMap 的方法。

addRoutes核心實現

先來看 addRoutes 實現吧,比較簡單,這個 API 的定義其實就是用來動態添加路由的,簡單點就是把傳入的新路由對象解析後加入到老 pathMap 對象裏,使用時參數必須是一個符合 routes 選項要求的數組,做用就是可讓咱們隨時隨地的添加幾個路由配置,由於參數是數組而且和 routes 是一致的格式,因此徹底能夠複用 createRouteMap 方法。

先把 createRouteMap 方法簡單修改一下,只須要加一個參數就 ok ,邏輯沒問題。

// 新增 oldPathMap 參數
export function createRouteMap(routes, oldPathMap){
  // const pathMap = Object.create(null); old
  const pathMap = oldPathMap || Object.create(null); // new
  
  // ...
}
複製代碼

如上,動態添加的時候,將舊的 pathMap 傳進去便可,以前咱們直接聲明瞭一個空 pathMap 對象,這裏能夠判斷一下 oldPathMap 參數是否存在,存在就給 pathMap 賦值,不存在默認仍是空對象便可。這樣就作到了把沒有解析的配置,解析並添加到老映射對象裏,是否是簡單? addRoutes 方法就更簡單了:

// 動態添加路由(參數必須是一個符合 routes 選項要求的數組)
function addRoutes(routes){
  createRouteMap(routes, pathMap)
}
複製代碼

getRoutes核心實現

至於 getRoutes ,就更更簡單了,直接返回 pathMap 對象便可

// 獲取全部活躍的路由記錄列表
function getRoutes(){
  return pathMap
}
複製代碼

addRoute核心實現

addRoute 這個方法咱們要稍微注意一下,由於這個方法將是將來 4.0+ 版本動態添加路由的主流,3.0+版本的 addRoute & addRoutes 兩個方法並存,但 4.0+ 中看 addRoutes 方法已經被刪除了,先看使用吧。

addRoute 有兩個參數,也是 2 種用法:

  • 添加一條新路由規則。若是該路由規則有 name,而且已經存在一個與之相同的名字,則會覆蓋它。
  • 添加一條新路由規則記錄做爲現有路由的子路由。若是該路由規則有 name,而且已經存在一個與之相同的名字,則會覆蓋它。

白話一下。第一種就是傳入一個路由配置對象,注意,不是以前的 routes 數組了,是隻有一個路由配置的對象,固然你能夠在這個路由配置下寫無數個子路由,可是添加的時候只能傳入一個路由對象這種形式添加,一次只追加一條記錄,若是當前的路由配置中存在 name 相同的記錄,則會覆蓋掉,以下:

this.$router.addRoute({
  path: "/parent",
  name: "Parent",
  component,
  children:[
    {
      path: "child"
      // ...
    },
    // ...
  ]
})
複製代碼

第二種就是兩個參數,第一個參數爲一個已經存在的路由 name ,第二個參數爲一個路由配置對象,就和上那種使用方式的路由配置對象一致,只是,這種方式會把這個路由配置對象看成第一個參數 name 對應的路由對象的子路由追加進去,簡單說就是根據路由 name 定向添加子路由,添加過程當中有重複路由 name 也是覆蓋掉。

看着複雜,寫起來其實很簡單,再爲 createRouteMap 加一個 parent 參數便可。修改 createRouteMap 函數:

// 新增 parentRoute 參數
export function createRouteMap(routes, oldPathMap, parentRoute){  
  const pathMap = oldPathMap || Object.create(null);

  routes.forEach(route => {
    // addRouteRecord(pathMap, route, null) old
    addRouteRecord(pathMap, route, parentRoute) // new
  })
  return pathMap
}
複製代碼

如上所示,第三個參數表明父級路由,須要追加到一條記錄上時,只需拿到這個父級路由傳入便可,沒有第三個參數時默認爲 undefined 也不會影響下面邏輯。

接下來寫 addRoute 方法:

// 動態添加路由(添加一條新路由規則)
function addRoute(parentOrRoute, route){
  const parent = (typeof parentOrRoute !== 'object') ? pathMap[parentOrRoute] : undefined
  createRouteMap([route || parentOrRoute], pathMap, parent)
}
複製代碼

如上,addRoute 方法第一個參數有多是個字符串,也多是個路由對象,而 createRouteMap 方法第一個參數是路由數組,因此咱們調用時直接數組包裹,默認是第二個參數,第二個參數不存在拿第一個參數就是路由對象,而後傳入舊的 pathMap 對象,最後的 parent 咱們須要在函數開始就判斷一下。

當第一個參數不是一個對象時,也就是輸入的是一個路由 name 字符串,咱們這裏稍微改動一下,用路由 path 代替(明白意思就行),直接經過以前解析好的 pathMap 對象取出規格化路由賦值給 parent,若是是一個對象,那就確定只有一個參數了,直接給 parent 賦值爲 undefined,完美。

解釋下爲何不像官方那樣用路由 name 匹配,源碼中除了 pathMap 對象,還解析了一個 namePath 對象,咱們寫的是一個簡化版,這些相似的東西包括對路由名稱、路由別名、重定向參數、動態路由的處理我都省略了,作一個路由 path 的處理你們理解便可,其餘處理大多一致,都很簡單,不過癮能夠配合我打上註釋的源碼自行補全,總體架構都一致,無非是多加一些代碼。

match路由匹配核心實現

最後是路由匹配函數 match 方法,也很簡單:

// 路由匹配
function match(location){
  location = typeof location === 'string' ? { path: location } : location
  return pathMap[location.path]
}
複製代碼

match 方法咱們給它一個參數,這個參數能夠是字符串,也能夠是個必須帶有 path 屬性的對象,由於必需要使用 path 才能匹配到配置的路由模塊數據,使用以下:

// String | Object

match("/home")
match({path: "/home"})
複製代碼

在函數最開始校驗了一下參數類型並統一轉爲對象,隨後直接返回了 pathMap 的 path 映射,是否是很簡單?彆着急,這塊後續還要優化。

createMatcher的使用及實例方法掛載

回顧一下咱們在 createMatcher 方法中作了哪些事情,其實主要是生成了一個路由映射對象 pathMap,返回了四個函數:

  • addRoutes
  • getRoutes
  • addRoute
  • match

對於這幾個方法,其實最後都要掛載在 VueRouter 實例上,由於使用時是 this.$router.addRoute() 這種方式,這裏只是核心實現,後續還要在實例掛載,其中 match 方法後續還有優化。

因此,來看看 createMatcher 函數的使用和這幾個實例方法的掛載,再次回到 VueRouter 類這裏:

export default class VueRouter(){
  constructor(options) {
    this.options = options;
    // 建立路由matcher對象,傳入routes路由配置列表及VueRouter實例,主要負責url匹配
    this.matcher = createMatcher(options.routes);
    
    // ...
  }
  
  // 匹配路由
  match(location) {
    return this.matcher.match(location)
  }
  
  // 獲取全部活躍的路由記錄列表
  getRoutes() {
    return this.matcher.getRoutes()
  }
  
  // 動態添加路由(添加一條新路由規則)
  addRoute(parentOrRoute, route) {
    this.matcher.addRoute(parentOrRoute, route)
  }
  
  // 動態添加路由(參數必須是一個符合 routes 選項要求的數組)
  addRoutes(routes) {
    this.matcher.addRoutes(routes)
  }
}
複製代碼

如上,咱們直接在 VueRouter 類的 constructor 裏調用了 createMatcher 函數,並將其返回值掛載到了實例的 matcher 屬性上,其實這個對象就包含那四個方法,接着掛載這幾個方法到實例上,不贅述了。

如今 VueRouter 實例上就有這些方法了,而 this.$router 在 install 中作了代理到 VueRouter 實例的操做,因此就可使用這些方法了。

路由模式父類History實現

路由匹配器實現告一段落,還記得在 VueRouter 類 constructor 中除了路由匹配器,還有什麼嗎?沒錯,校驗了傳入的 mode 參數,而且經過判斷分別爲三種模式建立了一個類並實例化後統一掛載到了 VueRouter 實例的 history 屬性上。

那下面咱們就逐一實現這幾個類,分別是 HTML5History | HashHistory | AbstractHistory。首先在 src/hello-vue-router/ 文件夾下新建 history/ 的文件夾,在這此文件夾下新建三個文件,對應三種模式構建類:

  • hash.js
  • html5.js
  • abstract.js

接下來先給三個路由模式類定義一個父類。

思考:爲何要定義父類?

其實在初始化實例上 this.history 掛載的一些方法都是一致的,雖然實現方式上幾種模式可能不太一致,但不能給用戶增長負擔,因此使用要統一,爲了節省代碼以及統一,咱們能夠定義一個父類,讓三個子類都繼承這個父類。

So,在剛剛新建子類的 history/ 文件夾下,新建一個 base.js 文件並導出一個 History 類:

/* * @path: src/hello-vue-router/history/base.js * @Description: 路由模式父類 */

export class History {
  constructor(router) {
    this.router = router;
    // 當前路由route對象
    this.current = {};
    // 路由監聽器數組,存放路由監聽銷燬方法
    this.listeners = [];
  }
  
  // 啓動路由監聽
  setupListeners() { }

  // 路由跳轉
  transitionTo(location) { }

  // 卸載
  teardown() {
    this.listeners.forEach((cleanupListener) => {
      cleanupListener();
    });

    this.listeners = [];
    this.current = "";
  }
}
複製代碼

如上,History 類 constructor 中主要作了三件事:

  • 保存傳入的路由實例 router
  • 聲明瞭一個當前路由對象 current
  • 聲明瞭一個路由監聽器數組,存放路由監聽銷燬方法

而後寫了幾個公共方法:

  • setupListeners 啓動路由監聽的方法
  • transitionTo 路由跳轉的方法
  • teardown 卸載 VueRouter 實例時卸載路由模式類中的監聽並清空數據方法

暫時寫了這 3 個方法,其實 setupListeners 方法這裏只是聲明一下,主要邏輯還會在子類中複寫, 而後這裏只把 teardown 這個卸載的方法完善了,transitionTo 這個路由跳轉方法以及後面實現子類過程當中須要添加的一些公共方法後續慢慢完善

先看這個銷燬方法,思考爲何要銷燬?

其實不管是 hash 或 history 這兩種模式在實現過程當中確定都會寫一些監聽,而當 VueRouter 實例卸載的時候,這些監聽並不會被銷燬,就會形成內存泄漏,因此咱們手動寫一個卸載銷燬,代碼十分簡單

首先是維護了一個公共的路由監聽器數組 listeners ,未來在子類中每寫一個監聽事件,直接就寫一個卸載監聽方法 push 到這個數組中來,當監聽到 VueRouter 卸載時,手動調用卸載方法,方法裏就是循環調用一下 listeners 數組中的方法從而銷燬監聽,能夠看到卸載方法的最後把 listeners 數組以及當前路由對象 current 都清空了。

保存的 router 實例對象後面會用到,可能你們不瞭解的應該是 current 這個對象吧,接下來着重介紹。

思考:咱們怎麼獲取當前的路由對象?

答:$route

思考:路由對象應該在哪裏維護?有什麼做用?

先回顧下使用 $route 時,它都有什麼屬性?

其實它保存着當前路由的 path、hash、meta、query、params 等等一切與當前路由有關的東西其實都在這裏存着,而且官方定義這個路由對象是隻讀的

current ,就是當前的意思,它其實就是這個路由對象,每當咱們監聽到路由 path 改變時,就要同步去修改這個路由對象,而當路由對象改變,router-view 組件須要渲染的視圖也要改變,能夠說這個路由對象就是整個 VueRouter 的中樞。

可能你們要問,剛剛不是說過這個對象是隻讀的嗎?怎麼還會改變?其實路由對象自己是被凍結的,咱們只讀的是對象中的屬性,可是咱們能夠切換整個路由對象啊!

上面咱們爲 current 這個路由對象定義的初始值是空對象,其實由於路由對象是一個面向用戶、具備固定格式的對象,因此應該由一個統一的方法來建立這個固定格式的路由對象,此方法咱們叫它 createRoute

createRoute方法

仍是單拿出來一個文件來實現這樣一個方法。

src/hello-vue-router/ 目錄下新建一個 utils/ 文件夾,在該文件夾下新建一個 route.js 文件,實現並導出一個 createRoute 方法。

先新建好文件,說 createRoute 方法以前,咱們思考一下何時須要建立這個路由對象?

首先固然是咱們的 current 屬性初始化的時候須要建立一個空的路由對象,除此以外呢?

捋一下,要讓 path 路徑改變,有兩種方式,一是直接改 URL,二是用 push 方法。

// No.1 oldURL => newURL
let oldURL = "http://localhost:8081/#/about"
let newURL = "http://localhost:8081/#/home?a=1"

// No.2
this.$router.push({
  path: "/home",
  query: {a: 1}
})
複製代碼

能夠看到,在改變路由時,可附帶不少屬性,就像官方文檔中 push 方法支持的屬性就有下面這些,具體做用看文檔:

name
path
hash
query
params
append
replace
複製代碼

路徑改變,要去往一個新的 path,新的 path 加上這些能夠攜帶的屬性咱們稱之爲 目標信息對象。而當前路由對象 route 要包含當前路由的全部信息,path 匹配的路由配置對象+目標信息信息對象=全部信息,全部信息格式化後就是當前路由對象 route。

因此更新當前路由對象就須要先經過 path 匹配到路由配置對象,而後路由配置對象和目標信息信息對象合併格式化爲 route。在哪裏作這樣一個更新操做呢?

回顧下以前咱們寫的 createMatcher 函數,其中返回了一個 match 方法,以下:

// 路由匹配
function match(location){
  location = typeof location === 'string' ? { path: location } : location
  return pathMap[location.path]
}
複製代碼

這裏咱們當時返回的是路由配置對象,其實咱們的最終目的就是讓其匹配到當前路由對象,咱們也分析了當前路由對象=路由配置對象+目標信息對象,因此直接匹配到路由對象的話就是最完整的數據,如今改寫這個方法:

/* * @path: src/hello-vue-router/create-route-map.js * @Description: 路由匹配器Matcher對象生成方法 */
import { createRouteMap } from "./create-route-map";
// 導入route對象建立方法
import { createRoute } from "./utils/route"

export function createMatcher(routes){
  const pathMap = createRouteMap(routes)
  
  // 路由匹配
  function match(location){
    location = typeof location === 'string' ? { path: location } : location
    return createRoute(pathMap[location.path], location) // 修改
  }
  
  // ...
}
複製代碼

如上,在 createMatcher 函數返回的 match 方法中,直接建立一個新路由對象返回。分析到這裏咱們就能夠肯定 createRoute 函數的參數了,就如同上面 createRoute 方法裏有 2 個參數,第一個就是路由匹配對象 record,第二個就是目標信息對象 location(這也是爲何咱們給 match 方法的參數起名爲 location 並容許它有對象和字符串兩種格式的緣由)。

咱們常用的 push 方法其實其中的參數就是 location 對象,既能夠是字符串路徑,也能夠是對象,爲對象時可傳入的屬性就和上面 push 方法可配置的那些屬性是一致的

不過上面寫的屬性中 append、replace 是兩個是附加功能,須要額外解析, push 方法支持,router-link 組件一樣支持,做用看下面文檔,咱們暫時省略這兩個參數的解析,由於不是核心邏輯。

分析準備就緒,能夠開始實現 createRoute 方法了,老規矩,先看總體代碼,再逐步分析:

/* * @path: src/hello-vue-router/utils/route.js * @Description: route對象相關方法 */
export function createRoute(record, location) {
  let route = {
    name: location.name || (record && record.name),
    meta: (record && record.meta) || {},
    path: location.path || "/",
    hash: location.hash || "",
    query: location.query || {},
    params: location.params || {},
    fullPath: location.path || "/",
    matched: record && formatMatch(record),
  };
  return Object.freeze(route);
}

// 初始狀態的起始路由
export const START = createRoute(null, {
  path: '/'
})

// 關聯全部路由記錄
function formatMatch(record) {
  const res = []
  while (record) {
    // 隊列頭添加,因此父record永遠在前面,當前record永遠在最後
    // 在router-view組件中獲取匹配的route record時會用到
    // 精準匹配到路由記錄是數組最後一個
    res.unshift(record)
    record = record.parent
  }
  return res
}
複製代碼

如上,createRoute 方法裏經過兩個參數互相取一些值來構建 route 對象。這裏須要注意的有兩個地方,fullPath 參數實際上是一個 path+qs+hash 的完整路徑,可是這裏咱們只寫了path,先不考慮參數的問題。

還有 matched 這個屬性,咱們直接寫了一個 formatMatch 函數生成,函數中只作了一件事,拿到當前 path 關聯的全部路由配置對象。

函數行參 record 就是路由配置對象,生成路由配置對象的時候,咱們爲其添加了 parent 屬性,指向其父路由,不記得就回顧一下 createRouteMap 方法。 formatMatch 函數裏就是遞歸找當前路徑包括它的父級路由配置對象,組成一個數組即 matched 參數,舉個例子,以下這個路由配置:

let routes = [
   {
    path: "/parent",
    name: "Parent",
    component,
    children:[
      {
        path: "child",
        name:"Child",
        component,
      }
    ]
  }
]
複製代碼

那麼此路由配置解析成 pathMap 以下:

pathMap = {
  "/parent": {path:"/parent", ...},
  "/parent/child": {path:"/parent/child", ...},
}
複製代碼

假如要跳轉的新 path 是 /parent/child,生成 route 時,通過 formatMatch 方法關聯它的全部路由記錄,最終該路由對象的 matched 屬性就是下面這樣:

[
  {path:"/parent", component, parent ...},
  {path:"/parent/child", component, parent ...}
]
複製代碼

注意,由於 formatMatch 函數遞歸查找父級時,咱們使用的是 unshift 方法,因此最終的數組最後一項必定是當前 path 的模塊。

這裏實際上是爲嵌套路由作準備,由於當存在嵌套路由,子路由記錄被匹配到時,其實表明着父路由記錄也必定被匹配到了。例如匹配 /foo/bar, 當 /foo/bar 自己被匹配了,其父路由對象 /foo 確定也匹配了,最終匹配結果以下:

metched = [{path:"/foo", ...},{path:"/foo/bar"}] 
// 「/foo/bar」 自己匹配模塊在數組最後,而第一項是頂級路由匹配項
複製代碼

總結來講,路由對象的 matched 屬性是一個數組,數組項是匹配到的路由配置對象,數組項順序依次是頂級路由匹配對象到當前子級路由自己匹配對象,到此一個簡單的路由生成函數就 OK 了。

思路切回 History 類,current 對象咱們還沒爲其賦初始路由值呢,因此,咱們在 route.js 文件中還寫了一個初始化路由對象並導出,調用了一下 createRoute 方法,參數一置空,參數二隻寫一個 path 屬性值爲 "/" 的對象:

// 初始狀態的起始路由
export const START = createRoute(null, {
  path: '/'
})
複製代碼

最後修改一下 base.js 文件中的 History 類,將路由對象初始值 START 導入並賦值給 current

// 導入初始化route對象
import { START } from "../utils/route";

export class History {
  constructor(router) {
    this.router = router;
    
    // 當前路由route對象
    // this.current = {};
    // => this.current = START;
    this.current = START;
    
    this.listeners = [];
  }
  
 // ...
}
複製代碼

到這裏,父類中的 transitionTo 即路由跳轉方法就能夠繼續補充了,調用路由跳轉方法就會傳入一個目標信息對象,這時應該作什麼?

  • 更新路由對象 current

  • 更新 URL

  • 更新視圖

// 路由跳轉
transitionTo(location, onComplete) {
  // 路由匹配,解析location匹配到其路由對應的數據對象
  let route = this.router.match(location);

  // 更新current
  this.current = route;

  // 更新URL
  this.ensureURL()

  // 跳轉成功拋出回調
  onComplete && onComplete(route)
}
複製代碼

如上,路由跳轉方法 transitionTo 其實傳入的就是 location 對象,push 方法也是基於此方法實現的。

那新的目標信息對象來了,咱們首先就要構建一個新的路由對象,History 是一個父類,後面咱們還會寫子類,子類繼承父類,子類在初始化實例的時候(index.js文件 mode 參數判斷那塊)其實傳入了當前 VueRouter 實例,因此咱們父類也能夠接收到,也就是咱們父類 constructor 中的 router 參數,咱們將它直接掛在了父類實例屬性 router 上,這樣咱們就能夠經過 this.router 獲取到 VueRouter 實例。

VueRouter 實例上咱們掛載了 match 方法還記得嗎?不記得回顧下代碼。

咱們使用 this.router.match 方法,傳入 location 參數,就能夠生成一個新的路由對象,最後將新的路由對象賦值給 current 屬性。

OK,按照咱們的邏輯,路由改變生成新的路由對象並賦值給 current 就完成了,還剩下更新URL以及更新視圖。

思考:爲何更新URL?

其實直接修改 URL 來跳轉,並不須要更新 URL,但若是使用 API 來作路由跳轉,例如 push 方法,咱們在代碼中能夠控制更新路由對象 current ,也能夠更新視圖,可是 URL 並無改變,因此咱們還須要更新 URL。

那麼問題來了,怎麼更新 URL?

能夠看到上面代碼中咱們調用了 ensureURL 方法來更新,並且是 this 調用的,其實這個方法並不在父類上,而在子類。

爲何將 ensureURL 方法寫在子類?

由於咱們存在 3 種模式,不一樣模式替換 URL 的方式是不一樣的,因此各個子類上寫本身的 URL 更新方法最好了。

爲何這裏能夠調用子類方法?

由於初始化實例的是子類,子類又繼承父類,能夠理解爲父類的方法以及屬性都被子類繼承了,transitionTo 方法固然也被繼承了,那在調用這個跳轉方法時,內部的 this 指向就是子類,因此可直接調用子類方法。

至於視圖更新,由於目前尚未完善 router-view 組件,子類也沒寫好,因此咱們放到後面完善。

最後拋出跳轉成功的回調,並傳入當前 route 對象參數。

路由模式子類初步構建

咱們先把三種模式子類初步構建一下,其實就是在三個文件中建立不一樣的子類,並讓他們都繼承父類,後面咱們一一實現。

hash.js

import { History } from './base'

export class HashHistory extends History {
  constructor(router){
    super(router);
  }
}
複製代碼

html5.js

import { History } from './base'

export class HTML5History extends History {
  constructor(router){
    super(router);
  }
}
複製代碼

abstract.js

import { History } from './base'

export class AbstractHistory extends History {
  constructor(router){
    super(router);
  }
}
複製代碼

HashHistory類實現

來到 history/ 的文件夾下的 hash.js 文件,咱們先實現 HashHistory 類:

/* * @path: src/hello-vue-router/index.js * @Description: 路由模式HashHistory子類 */
import { History } from './base';

export class HashHistory extends History {
  constructor(router) {
    // 繼承父類
    super(router);
  }
  
  // 啓動路由監聽
  setupListeners() {
    // 路由監聽回調
    const handleRoutingEvent = () => {
      let location = getHash();
      this.transitionTo(location, () => {
        console.log(`Hash路由監聽跳轉成功!`);
      });
    };

    window.addEventListener("hashchange", handleRoutingEvent);
    this.listeners.push(() => {
      window.removeEventListener("hashchange", handleRoutingEvent);
    });
  }
}

// 獲取location hash路由
export function getHash() {
  let href = window.location.href;
  const index = href.indexOf("#");
  if (index < 0) return "/";

  href = href.slice(index + 1);

  return href;
}
複製代碼

如上,咱們讓 HashHistory 類繼承 History 類,子類也就繼承了父類的一切。咱們先實現了 hash 模式下的 setupListeners 方法,即啓動路由監聽方法。

來看一下其中的邏輯,主要就是監聽了 hashchange 事件,也就是當 hash 路由改變,就會觸發其回調。

思考:監聽到路由path改變了咱們須要作什麼?

path 變了須要更新當前路由對象、更新視圖等等,這個步驟咱們前面作過,沒錯,就是 transitionTo 跳轉方法裏作的,因此咱們直接在監聽到路由改變時調用路由跳轉方法便可。

因此回調中先是經過一個 getHash 的工具函數獲取到當前 hash 值,返回 hash 路由 path,這個方法簡單,不贅述。拿到 path 後接着調用 transitionTo 方法。

另外,在啓動監聽後,咱們向 listeners 數組(繼承父類)中 push 了一個銷燬監聽的方法,用於卸載時銷燬監聽事件,這點上面也說過了。

接下來補充一會兒類的方法:

export class HashHistory extends History {
  constructor(router) {
    // 繼承父類
    super(router);
  }
  
  // 啓動路由監聽
  setupListeners() { /** ... **/ }
  
  // 更新URL
  ensureURL() {
    window.location.hash = this.current.fullPath;
  }
  
  // 路由跳轉方法
  push(location, onComplete) {
    this.transitionTo(location, onComplete)
  }

  // 路由前進後退
  go(n){
    window.history.go(n)
  }
  
  // 跳轉到指定URL,替換history棧中最後一個記錄
  replace(location, onComplete) {
    this.transitionTo(location, (route) => {
      window.location.replace(getUrl(route.fullPath))
      onComplete && onComplete(route)
    })
  }

  // 獲取當前路由
  getCurrentLocation() {
    return getHash()
  }
}

// 獲取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}`
}

// 獲取location hash路由
export function getHash() { /** ... **/ }
複製代碼

咱們補充了 5 個方法:

  • ensureURL

    • 更新 URL ,它的實現其實很簡單,更新導航欄 URL 的 hash,使用 window.location.hash API 就能夠,在父類跳轉方法裏,更新當前路由對象以後才調用了 ensureURL,而更新後路由對象中的 fullPath 屬性就是完整的hash path,因此直接賦值過去就能夠了。
  • push

    • 路由跳轉方法,此方法咱們在父類早已經實現好了,因此接在 push 中調用父類的 transitionTo 方法進行跳轉就好,參數也都一致。
  • go

    • 路由的前進後退,其實實現的不管是 hash 仍是 history 模式跳轉,每次跳轉都改變了URL,跳轉的記錄都存放在瀏覽器的 window.history 棧中,而瀏覽器也提供了一個 window.history.go 的方法讓用作前進後退路由,因此直接調用便可,參數都一致。
  • getCurrentLocation

    • 獲取當前 URL 路由地址,因爲這是 hash 類,咱們以前實現過一個 getHash 方法來獲取 hash 模式下 URL 中的路由,因此返回此方法的調用值便可。
  • replace

    • 跳轉到指定URL,替換history棧中最後一個記錄

咱們重點說 replace 方法:

先說做用,其實也是跳轉,只是使用 replace 跳轉不會在 window.history 棧中產生記錄,也就是當咱們從 a 頁面使用 push 跳轉到 b 頁面時,棧中是 [a,b],再使用 replace 跳轉從 b 頁面到 c 頁面時,棧中仍是 [a, b] ,那這個時候咱們返回上一個頁面,就直接從 c 頁面到了 a 頁面。

其實咱們大概也知道瀏覽器有 window.location.replace 方法就能夠實現此功能,但 VueRouter 中跳轉時須要考慮三塊更新(路由對象、URL、視圖)。

試想,假如咱們要 replace 一個新的路由,咱們須要怎麼作?

先更新當前路由對象,再更新URL,這裏的更新要使用 window.location.replace 更新纔不會留記錄,最後渲染視圖。

誒?好像和 transitionTo 中差很少,那咱們能夠修改 transitionTo 方法,把它原來更新URL的 ensureURL 方法放到跳轉成功回調的後面,這樣咱們調用 transitionTo 方法,在回調中使用 window.location.replace 更新URL就能夠了。

你可能會疑問,將 ensureURL 方法放到最後,在回調中 replace 但回調執行完畢仍是會調用 ensureURL 方法啊?

其實回調裏使用 window.location.replace 更新URL後,URL已是最新的了,這時再調用 ensureURL 更新URL,因爲要更新的URL和當前URL是一致的,因此頁面不會跳轉。

由於 ensureURL 方法裏其實調用的 window.location.hash ,假如當前頁面地址爲 http://localhost:8080/#/about,咱們使用此 API 將其 hash 改成 /about,因爲先後 hash 一致,其實等於啥也沒作。。。

因此咱們修改 transitionTo 方法只需修改其成功回調和更新URL的 ensureURL 方法調用順序便可,以下:

transitionTo(location, onComplete) {
  let route = this.router.match(location);
  this.current = route;

  // 跳轉成功拋出回調 放上面
  onComplete && onComplete(route)
  
  // 更新URL 放下面
  this.ensureURL()
}
複製代碼

接着實現 replace 方法:

export class HashHistory extends History {

  // 跳轉到指定URL,替換history棧中最後一個記錄
  replace(location, onComplete) {
    this.transitionTo(location, (route) => {
      window.location.replace(getUrl(route.fullPath))
      onComplete && onComplete(route)
    })
  }
  
  // ...
}

// 獲取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}`
}
複製代碼

如上,調用 transitionTo 方法,在其回調中 window.location.replace 一下就能夠了

注意這裏咱們又寫了一個工具方法,getUrl ,其實就是傳入 hash path,返回完整的新 URL 路徑,常規操做,不贅述。

到了這裏,其實咱們的 HashHitory 子類就差很少 OK 了。

接下來就是流程打通了。

以前在 VueRouter 類的實現中,咱們只是初始化了各個路由模塊子類,可是尚未開啓路由監聽,注意子類裏啓動監聽的方法是 setupListeners ,再次回到 src/hello-vue-router/index.js 文件,即 VueRouter 類中,給它添加一個初始化方法。

VueRouter實例初始化

初始化方法構建

思考:VueRouter類初始化時應該作什麼?

固然是啓動路由模式類的監聽,既然啓動了監聽,那必然要掛載一下銷燬。

思考:何時銷燬?

何時不須要監聽何時銷燬!!Vue根實例卸載後就不須要監聽了,因此咱們監聽一下Vue根實例的卸載就能夠了。

問題是咱們在外部要怎麼監聽一個Vue實例的卸載?

誒!hook: 前綴的特殊事件監聽就派上用場了,Vue官方支持。

小 Tips:hook: 前綴的特殊事件監聽

源碼中生命週期鉤子函數是經過 callHook 函數去調用的, callHook 函數中有一個 vm._hasHookEvent 的判斷,當它爲 true 的狀況下,有着 hook: 特殊前綴的事件,會在對應的生命週期當中執行。

組件中監聽事件解析後會使用 $on 註冊事件回調,使用 $on$once 監聽事件時,如事件名以 hook: 做爲前綴,那這個事件會被當作 hookEvent,註冊事件回調的同時,vm._hasHookEvent 會被置爲 true,後當使用 callHook 調用生命週期函數時,因爲 _hasHookEventtrue,會直接執行 $emit('hook:xxx'),因此註冊的生命週期函數就會執行。

  • 在模板中經過 @hook:created 這種形式註冊。
  • JS 中可經過vm.$on('hook:created', cb) 或者 vm.$once('hook:created', cb) 註冊,vm 指當前組件實例。

一道經典的面試題,如何在父組件中監聽子組件生命週期,答案就是在父組件中獲取到子組件實例(vm),而後經過註冊hook: 前綴+生命週期鉤子的特殊事件監聽就能夠了。

這裏咱們要監聽根實例,因此要拿到根實例對象再註冊監聽,監聽銷燬事件咱們不必使用 $on ,用 $once 就能夠,這樣只觸發一次,觸發以後監聽器就會被移除,以下:

// vm 爲根實例對象
vm.$once("hook:destroyed", () => {})
複製代碼

知道了這些問題,繼續實現 init 方法,既然要拿到根實例對象,那 init 方法的參數就有了,分析完畢,開始寫代碼吧!

export default class VueRouter{
  
	init(app) {
    // 綁定destroyed hook,避免內存泄露
    app.$once('hook:destroyed', () => {
      this.app = null

      if (!this.app) this.history.teardown()
    })

    // 存在即不須要重複監聽路由
    if (this.app) return;

    this.app = app;

    // 啓動監聽
    this.history.setupListeners();
  }
  
  // ...
}
複製代碼

如上,其實很簡單,init 方法傳入了一個 app 參數,即 Vue 根實例,方法裏判斷了 this.app 是否存在,存在直接返回表明已經註冊過監聽,不存在則將實例賦值給了 VueRouter 類的 app 屬性上,最後調用 VueRouter 實例 history 屬性的 setupListeners 方法啓動監聽。

history 就是咱們在 constructor 裏初始化的路由模式類實例,constructor 構造器在 new VueRouter 的時候就會執行,因此咱們徹底能夠拿到 history 實例。

而註冊的銷燬監聽也很簡單,就是上面說過的使用根實例的 $once 註冊一個 hook:destroyed 監聽,回調中將 app 屬性置空並調用 history 實例的卸載方法 teardown ,此方法是在路由模式父類中實現的,忘了的話能夠回看一下。

OK,init 方法暫時寫完了,咱們要在何時調用它呢?

初始化方法調用

由於 init 方法中還有啓動監聽,因此須要在一切都初始化好了再調用,而且這個時候還要能拿到 Vue 根實例。

回顧咱們上面全部環節,能拿到根實例的地方只有插件安裝 install 方法 mixin 混入的時候了。

因此,在 src/hello-vue-router/install.js 文件 install 方法的 mixin 中添加執行路由組件初始化方法:

/* * @path: src/hello-vue-router/install.js * @Description: 入口文件 VueRouter類 */
export function install(Vue){
  
  Vue.mixin({
    beforeCreate() {
      if (this.$options.router) {
        this._routerRoot = this;
        this._router = this.$options.router;
        
        // 調用VueRouter實例初始化方法
        // _router即VueRouter實,此處this即Vue根實例
        this._router.init(this) // 添加項 
        
        this._route = {};
      } else {
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
    },
  });
  
  // ...
}
複製代碼

這時你會發現,mixin_route 對象仍是空對象,咱們已經實現了當前路由對象即路由模式類的 current 屬性,因此這裏能夠爲其賦值了,再次修改代碼以下:

Vue.mixin({
  beforeCreate() {
    if (this.$options.router) {
      this._routerRoot = this;
      this._router = this.$options.router;
      this._router.init(this)

      // this._route = {}; old
      this._route =  this._router.history.current; // new
    } else {
      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
    }
  },
});
複製代碼

到了這裏其實咱們 hash 模式的整個流程基本通了,能夠打開項目連接看看,沒有報錯而且能夠點擊導航切換路由,有報錯那確定是你寫錯了,不是我。。雖無報錯,但頁面中路由模塊沒有渲染,由於 router-view 組件還沒完善。

RouterView組件完善

目前咱們的 RouterView 組件是這樣的:

/* * @path: src/hello-vue-router/components/view.js * @Description: router-view */
export default {
  name: "RouterView",
  functional: true,
  render(h) {
    return h('div', 'This is RoutePage')
  }
}
複製代碼

如上,組件渲染的永遠是固定的 div,如今就能夠開始完善它了。

路由組件動態渲染

思路很簡單,先拿到當前路由對象,由於當前路由對象的 matched 數組存着當前 path 全部有關聯的路由匹配對象,數組最後一項即當前path自己的路由匹配對象,因此咱們只須要取出數組最後一項,而後拿它的 components 屬性(即當前 path 對應的路由模塊),直接將它給到渲染函數便可。

開始修改 RouterView 組件:

export default {
  name: "RouterView",
  functional: true, // 函數式組件
  render(h, { parent, data}) {
    // parent:對父組件的引用
    // data:傳遞給組件的整個數據對象,做爲 createElement 的第二個參數傳入組件
    
    // 標識當前渲染組件爲router-view
    data.routerView = true

    let route = parent.$route
    let matched;
    if(route.matched){
      matched = route.matched[route.matched.length - 1]
    }

    if (!matched) return h();
  
    return h(matched.components, data)
  }
}
複製代碼

對函數式組件不瞭解的請看文檔 函數式組件文檔

其實代碼很簡單,先標識了一下當前渲染的是 RouterView 組件,代碼中給 data 添加了一個屬性,這個 data 最後會被做爲 createElement 的第二個參數傳入組件,當咱們想要知道一個組件是否是 RouterView 渲染出來的,就能夠經過這個屬性來判斷,這個屬性存放在組件實例下 $vnode 屬性的 data 對象中。

因爲咱們已經掛載了 $route 因此經過任何一個實例均可以訪問此路由對象,拿到路由對象,取其 matched 屬性數組的最後一項,即當前 path 對應的路由組件。

最後直接在 h(createElement)函數中返回組件便可。

貌似已經 OK 了,打開項目頁面看一下。

頁面中除了導航一片空白,也沒報錯,點擊導航也確實觸發跳轉監聽了(控制檯有輸出),可是並沒有任何組件渲染,以下:

怎麼回事?捋一遍流程。

首先,點擊導航跳轉,監聽到 hash 路由改變,走 transitionTo 方法,方法中作三件事:

  • 更新當前路由對象
  • 更新URL
  • 更新組件渲染

誒!更新組件渲染,這一步咱們好像到如今還沒作,找到問題所在了!

RouterView 組件咱們已經初步完善了,可是當路由 path 更新,咱們怎麼通知 RouterView 組件更新渲染呢??

想一下,Vue最核心的是什麼?固然是數據響應式,RouterView 的核心數據是 $route,若是咱們將它作成一個響應式的數據,那當它改變時豈不就能夠直接自動從新渲染!

說幹就幹,以前寫的 $route,它實際上是被代理到了 Vue 根實例的 _route 對象,因此只要將 _route 對象搞成響應式的就能夠了,作響應式固然仍是藉助 Vue 提供的方法,否則咱們在手寫一個數據響應式太費勁了,何況 Vue 自己構造函數就有提供這樣的 API,即 Vue.util.defineReactive 函數,使用也很簡單,修改一下 install 方法:

Vue.mixin({
  beforeCreate() {
    if (this.$options.router) {
      this._routerRoot = this;
      this._router = this.$options.router;
      this._router.init(this) 

      // this._route = this._router.history.current; old
      Vue.util.defineReactive(this, '_route', this._router.history.current); // new
    } else {
      this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
    }
  },
});
複製代碼

如上所示,咱們使用 Vue.util.defineReactive API,爲根實例(this)添加一個響應式屬性 _route 併爲其賦值爲路由對象,這裏可以直接使用 Vue 構造函數是由於 install 方法參數傳入了 Vue。

如此,每當 _route 這個對象更改的時候 RouterView 組件就能夠自動渲染了,咱們再看下頁面,點一點導航:

fuck,仍是老樣子,這是爲何呢?再捋捋。

首先,點擊導航跳轉,監聽到 hash 路由改變,走 transitionTo 方法,方法中作三件事:

  • 更新當前路由對象
  • 更新URL
  • 更新組件渲染

好像沒毛病啊,誒!等等,好像又發現了問題,更新當前路由對象的時候,好像只更新了 current,並無更新 _route_route 對象只在初始化的時候賦了一次值。。改它!!

首先爲 History 類增長一個 listen 方法,並接收一個回調,listen 函數內部則直接將此回調函數保存到了 History 類的 cb 屬性上,在 transitionTo 函數裏 current 更新後面調用 cb 回調並傳出了要更新的 route 對象,而 _route 更新的這一步操做,放在了 VueRouter 類的 init 方法裏,以下:

// History父類中新增listen方法 保存賦值回調
listen(cb){
  this.cb = cb
}

transitionTo(location, onComplete) {
  let route = this.router.match(location);
  this.current = route;

  // 修改
  // 調用賦值回調,傳出新路由對象,用於更新 _route
  this.cb && this.cb(route)

  onComplete && onComplete(route)
  this.ensureURL()
}
複製代碼

接着是 VueRouter 類的 init 方法:

init(app) {
  app.$once('hook:destroyed', () => {
    this.app = null

    if (!this.app) this.history.teardown()
  })

  if (this.app) return;

  this.app = app;

  this.history.setupListeners();

  // 新增 
  // 傳入賦值回調,爲_route賦值,進而觸發router-view的從新渲染 
  // 當前路由對象改變時調用
  this.history.listen((route) => {
    app._route = route
  })
}
複製代碼

可能有小夥伴會懵,其實也很好理解,就是在 init 方法中調用了 history 實例繼承於父類的 listen 方法,傳入一個更新 _route 的回調,listen 函數會將這個回調一直保存,每次更新路由對象的時候,傳入新的路由對象調用一次便可更新 _route

如今打開頁面再看一下,刷新頁面,沒有渲染,點擊導航又渲染了。

思考:爲何刷新時沒有渲染組件?

實際上是由於路由 path 改變時,咱們可以監聽到,進而都作了操做,但當頁面初始化時咱們沒有對初始的 path 進行解析。

知道了問題就解決!其實也簡單,直接在 init 方法中獲取當前路由path,而後調用 transitionTo 方法解析path渲染一下就好了,再次修改 VueRouter 類的 init 方法:

init(app) {
  app.$once('hook:destroyed', () => {
    this.app = null

    if (!this.app) this.history.teardown()
  })

  if (this.app) return;

  this.app = app;

  // 新增
  // 跳轉當前路由path匹配渲染 用於頁面初始化
  this.history.transitionTo(
    // 獲取當前頁面 path
    this.history.getCurrentLocation(),
    () => {
      // 啓動監聽放在跳轉後回調中便可
      this.history.setupListeners();
    }
  )

  this.history.listen((route) => {
    app._route = route
  })
}
複製代碼

如上,還記得路由模式子類中寫的 getCurrentLocation 方法嗎?其實就是獲取當前路由path,使用 history 實例的 transitionTo 方法傳入當前路由path,因爲這裏是 init 方法,因此至關因而在頁面初始化時執行的,也就是刷新時會獲取到當前頁面的 path 進行解析渲染一次,咱們把啓動監聽 setupListeners 函數放在了跳轉回調中監聽,這都無礙。

那再來看看頁面:

不管是刷新仍是跳轉都沒有問題,均可以正常顯示,nice!

嵌套路由組件渲染

再測試一下嵌套路由吧!

作下準備,先寫一個父級頁面,在 src/views/ 文件夾下新建 Parent.vue 文件,寫入在代碼:

<template>
  <div>
    parent page
    <router-view></router-view>
  </div>
</template>
複製代碼

接着寫一個子級頁面,在 src/views/ 文件夾下新建 Child.vue 文件,寫入代碼:

<template>
  <div>
    child page
  </div>
</template>
複製代碼

修改 src/router/index.js 文件的路由配置數組以下:

const routes = [
  // ...
  
  //新增路由配置
  {
    path: "/parent",
    name: "Parent",
    component: ()=>import("./../views/Parent.vue"),
    children:[
      {
        path: "child",
        name:"Child",
        component:()=>import("./../views/Child.vue")
      }
    ]
  }
];
複製代碼

接着修改 src/App.vue 文件中的路由導航,新增 Parent & Child 兩個導航以下:

<template>
  <div id="app">
    <div id="nav">
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link><!-- 新增 -->
      <router-link :to="{ path: '/parent' }">Parent</router-link> |
      <router-link :to="{ path: '/parent/child' }">Parent Child</router-link>
    </div>
    <router-view/>
  </div>
</template>
複製代碼

OK,這是一個很是簡單的嵌套路由,來看看頁面效果吧!

前兩個頁面正常,parent 頁面組件沒有渲染,控制檯直接爆棧了:

child 頁面顯示以下:

child 頁面由於只渲染出了子頁面的內容,這是一個嵌套路由,子頁頁面內容是在父頁面寫的 router-view 中渲染,因此點擊子頁面正常應該父頁面的內容也會顯示。

其實,全部的問題都因爲咱們在寫 RouterView 組件時,沒有考慮嵌套的狀況,回顧下 RouterView 組件代碼:

export default {
  name: "RouterView",
  functional: true,
  render(h, { parent, data}) {
    data.routerView = true

    let route = parent.$route
    let matched;
    if(route.matched){
      matched = route.matched[route.matched.length - 1]
    }

    if (!matched) return h();
  
    return h(matched.components, data)
  }
}
複製代碼

分析一下,以目前的 RouterView 組件代碼,假如當前 path 爲 /parent/child ,拿到當前路由對象 route,咱們知道 route.matched 這裏存放的是路徑解析後全部相關的路由配置對象,它應該是這樣的:

[
  {path: "/parent", components, ...},
  {path: "/parent/child", components, ...}
]
複製代碼

而咱們取最後一項,只取了子路由模塊,因此也就只渲染出了子路由組件。

再假如當前 path 爲 /parent ,當前路由對象解析後拿到的 route.matched 數組是下面這樣的:

[
  {path: "/parent", components, ...}
]
複製代碼

取最後一項,只渲染了父路由組件,因爲父路由組件中還有 router-view 組件,繼續走組件邏輯,接着渲染父組件。。。一直循環下去,因此就爆棧了。。

修改一下 RouterView 組件,以下,先看完整代碼再解釋。

export default {
  name: "RouterView",
  functional: true, // 函數式組件
  render(h, { parent, data}) {
    // parent:對父組件的引用
    // data:傳遞給組件的整個數據對象,做爲 createElement 的第二個參數傳入組件
    
    // 標識當前組件爲router-view
    data.routerView = true

    let depth = 0;
    // 逐級向上查找組件,當parent指向Vue根實例結束循環
    while(parent && parent._routerRoot !== parent){
      const vnodeData = parent.$vnode ? parent.$vnode.data : {};
      // routerView屬性存在即路由組件深度+1,depth+1
      if(vnodeData.routerView){
        depth++
      }

      parent = parent.$parent
    }


    let route = parent.$route
    
    if (!route.matched) return h();
    
    // route.matched仍是當前path所有關聯的路由配置數組
    // 渲染的哪一個組件,走上面邏輯時就會找到depth個RouterView組件
    // 因爲逐級向上時是從父級組件開始找,因此depth數量並無包含當前路由組件
    // 假如depth=2,則route.matched數組前兩項都是父級,第三項則是當前組件,因此depth=索引
    let matched = route.matched[depth]

    if (!matched) return h();

    return h(matched.components, data)
  }
}
複製代碼

這塊可能不太容易理解。

首先仍是給全部的 RouterView 組件作了一個標識。

接着開始從 parent 父級實例逐級向上遍歷組件,從當前父實例找到頂部根實例,也就是當 parent._routerRoot !== parent 成立時,跳出循環。

在遍歷的邏輯裏,判斷實例的 $vnode 屬性下 data 屬性中有無 routerView 屬性,有則 depth + 1,遍歷的最後讓 parent = parent.$parent$parent 拿到的是父組件實例,以此開啓遞歸。

要知道不論怎麼搞,當前 path 對應的路由對象 route 對象始終是不變的,而 route.matched 是當前 path 所有關聯的路由配置數組。

假如當前 path 是 /a/b/c ,三級嵌套路由,那它的 route.matched 應以下:

[
  {path: "/a", ...},
  {path: "/a/b", ...},
  {path: "/a/b/c", ...},
]
複製代碼

嵌套了三層,也就有三個 RouterView 組件, App.vue、a.vue、b.vue 中各一個,因此當渲染 /a/b/c 時,頁面應該是下面這樣的:

// /a/b/c
a
 b
  c
複製代碼

App.vue 頁面 RouterView 組件開始渲染,走組件邏輯查找 depth 層級,從父實例向上迭代到根實例查找帶有 routerView 屬性的組件,有 0 個,因此 depth = 0route.matched[0]/a 路由組件。

a.vue 頁面 RouterView 組件開始渲染,走組件邏輯查找 depth 層級,從父實例向上迭代到根實例查找帶有 routerView 屬性的組件,有 1 個,因此 depth = 1route.matched[1]/a 路由組件。

b.vue 頁面 RouterView 組件開始渲染,走組件邏輯查找 depth 層級,從父實例向上迭代到根實例查找帶有 routerView 屬性的組件,有 2 個,因此 depth = 2route.matched[2]/a 路由組件。

再來看看頁面,咱們發現嵌套路由兩個頁面都正常了。

/parent:

/parent/child:

因此,看懂了嗎?我以爲夠詳細了,不懂再看幾遍配合斷點或打印。

VueRouter實例方法掛載完善

路由模式類上面咱們實現了幾個路由跳轉相關的方法,尚未掛載到 VueRouter 類上,咱們一塊來掛載下,還有以前掛載的 addRoute & addRoutes 兩個方法,還須要完善一下。

回到 src/hello-vue-router/index.js 文件:

export default class VueRouter {
  
  // 導航到新url,向 history棧添加一條新訪問記錄
  push(location) {
    this.history.push(location)
  }

  // 在 history 記錄中向前或者後退多少步
  go(n) {
    this.history.go(n);
  }

  // 導航到新url,替換 history 棧中當前記錄
  replace(location, onComplete) {
    this.history.replace(location, onComplete)
  }

  // 導航回退一步
  back() {
    this.history.go(-1)
  }
}
複製代碼

如上,添加幾個路由跳轉相關的方法,其實就是調用已經實現好的 history 實例上的方法就 OK 了,不贅述了。

接着咱們看以前掛載的 addRoute & addRoutes 兩個方法。

目前這兩個方法調用時,確實進行追加了,普通狀況下也是沒問題的,可是有一種特殊狀況,即在當前頁面 path 初始化前,動態添加當前頁面的路由組件,這時咱們若是使用目前的API加載後,其實只是解析並添加了內部 pathMap, 但因爲當前路由對象並無更新,頁面直接就會報錯。

因此須要在動態添加後進行一次路由更新操做,其實仍是調用一下 transitionTo 方法跳轉當前頁面 path 便可,固然還需避免路由初始化時即當前路由等於 START (以前寫的路由 current 對象初始值)的狀況。

So,修改這兩個函數,以下:

// 新增START對象導入
import { START } from "./utils/route";

export default class VueRouter {
  
 // 動態添加路由(添加一條新路由規則)
  addRoute(parentOrRoute, route) {
    this.matcher.addRoute(parentOrRoute, route)
    // 新增
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }

  // 動態添加路由(參數必須是一個符合 routes 選項要求的數組)
  addRoutes(routes) {
    this.matcher.addRoutes(routes)
    // 新增
    if (this.history.current !== START) {
      this.history.transitionTo(this.history.getCurrentLocation())
    }
  }
  
  // ...
}
複製代碼

比較簡單,不贅述了。

至此,hash 模式的流程完整了。

接下來就是循序漸進的實現 history 模式也就是填充 HTML5History 類了。

HTML5History類實現

HTML5History 類雖然和 HashHistory 類實現細節上略有不一樣,可是咱們要寫的 API 都是一致的,這樣才能徹底契合外部的統一調用。

來到 history/ 文件夾下的 html5.js 文件,有了上面 HashHistory 類的經驗咱們這裏就直接貼代碼了,由於沒有什麼困難的地方。

/* * @path: src/hello-vue-router/history/html5.js * @Description: 路由模式HTML5History子類 */
import { History } from './base'

export class HTML5History extends History {
  constructor(router) {
    // 繼承父類
    super(router);
  }

  // 啓動路由監聽
  setupListeners() {
    // 路由監聽回調
    const handleRoutingEvent = () => {

      this.transitionTo(getLocation(), () => {
        console.log(`HTML5路由監聽跳轉成功!`);
      });
    };

    window.addEventListener("popstate", handleRoutingEvent);
    this.listeners.push(() => {
      window.removeEventListener("popstate", handleRoutingEvent);
    });
  }

  // 更新URL
  ensureURL() {
    if (getLocation() !== this.current.fullPath) {
      window.history.pushState(
        { key: Date.now().toFixed(3) }, 
        "", 
        this.current.fullPath
      );
    }
  }

  // 路由跳轉方法
  push(location, onComplete) {
    this.transitionTo(location, onComplete)
  }

  // 路由前進後退
  go(n){
    window.history.go(n)
  }

  // 跳轉到指定URL,替換history棧中最後一個記錄
  replace(location, onComplete) {
    this.transitionTo(location, (route) => {
      window.history.replaceState(window.history.state, '', route.fullPath)
      onComplete && onComplete(route)
    })
  }

  // 獲取當前路由
  getCurrentLocation() {
    return getLocation()
  }
}

// 獲取location HTML5 路由
function getLocation() {
  let path = window.location.pathname;
  return path;
}
複製代碼

如上咱們很輕鬆就實現了 HTML5Histoy 類,可是有一個問題,在使用 history ,不斷點擊 router-link 生成的同一個導航時,每次點擊都會刷新頁面,這其實就是咱們以前說的, router-link 最終生成的是 a 標籤,history 模式點擊 a 標籤,默認會觸發頁面的跳轉,因此須要攔截 a 標籤點擊事件默認行爲,hash 就不會,由於 hash 模式下 a 標籤中解析後的 href 屬性中是以 # 號開頭的。

在哪裏攔截?固然是 router-link 組件。

RouterLink組件完善

也比較簡單,統一給 RouterLink 組件返回的 a 標籤加了阻止默認跳轉,而後又加了手動跳轉:

export default {
  name: "RouterLink",
  props: {
    to: {
      type: [String, Object],
      require: true
    }
  },
  render(h) {
    const href = typeof this.to === 'string' ? this.to : this.to.path
    const router = this.$router
    let data = {
      attrs: {
        href: router.mode === "hash" ? "#" + href : href
      },
      //新增
      on: {
        click: e => {
          e.preventDefault()
          router.push(href)
        }
      }
    };
    return h("a", data, this.$slots.default)
  }
}
複製代碼

如上,咱們在 createElement(h)函數的第二個參數中,對點擊事件加入了阻止默認跳轉事件,沒有了默認跳轉,咱們進行了一次手動跳轉,即直接調用 router 實例的 push 方法進行跳轉。

AbstractHistory類實現

沒有了,其實實現起來很簡單,就是用數組模擬了一個歷史調用棧,找源碼看一眼幾分鐘就寫完了,徹底是由一個數組和各類數組操做API組成的類,篇幅問題,不贅述了。

植入router hook

若是你跟着實現,到了這其實 VueRouter 的核心內容都差很少搞定了,接下來能夠瘋狂發散下思路,再本身動手找源碼中相關實現來參考,最後完善出來 router hook,由於路由鉤子是餘下功能裏實現起來有必定難度的一個,這是一個很是好的鍛鍊機會。

Tips: 路由鉤子有三種:

  • 全局路由鉤子
  • 組件路由鉤子
  • 路由獨享beforeEnter守衛

寫在最後

若是看到這裏依然對其流程不太清楚,再來看這張圖,說不定能夠直接打通任督二脈哦!

整個實現的核心邏輯還算 OK,細節上還存在不少問題,由於咱們忽略了一些校驗及小功能的實現,但對理解 VueRouter 源碼仍是有很大幫助。建議跟着手敲一遍,搞完後直接去完整的看一遍 VueRouter 源碼,加油吧!歡迎刊誤!原創燒腦,寫做不易,若是對你有幫助,點個贊吧!!

項目代碼地址:hello-vue-router

根目錄下 src/hello-vue-router 文件夾即手寫 VueRouter 完整代碼,已做註釋

根目錄下 vue-router-source 文件夾即帶有註釋的 VueRouter V3.5.2 源碼

相關文章
相關標籤/搜索