面試官: 你瞭解前端路由嗎?

尋找海藍96發表於2018-04-09

面試官系列(3): 前端路由的實現


往期


文章目錄

  1. 基於hash的前端路由實現
  2. 基於hash的前端路由升級
  3. 基於H5 History的前端路由實現

前言

前端路由是現代SPA應用必備的功能,每個現代前端框架都有對應的實現,例如vue-router、react-router。

我們不想探究vue-router或者react-router們的實現,因為不管是哪種路由無外乎用相容性更好的hash實現或者是H5 History實現,與框架幾個只需要做相應的封裝即可。

提前宣告: 我們沒有對傳入的引數進行及時判斷而規避錯誤,也沒有考慮相容性問題,僅僅對核心方法進行了實現.


1.hash路由

hash路由一個明顯的標誌是帶有#,我們主要是通過監聽url中的hash變化來進行路由跳轉。

hash的優勢就是相容性更好,在老版IE中都有執行,問題在於url中一直存在#不夠美觀,而且hash路由更像是Hack而非標準,相信隨著發展更加標準化的History API會逐步蠶食掉hash路由的市場。

面試官: 你瞭解前端路由嗎?

1.1 初始化class

我們用Class關鍵字初始化一個路由.

class Routers {
  constructor() {
    // 以鍵值對的形式儲存路由
    this.routes = {};
    // 當前路由的URL
    this.currentUrl = '';
  }
}
複製程式碼

1.2 實現路由hash儲存與執行

在初始化完畢後我們需要思考兩個問題:

  1. 將路由的hash以及對應的callback函式儲存
  2. 觸發路由hash變化後,執行對應的callback函式
class Routers {
  constructor() {
    this.routes = {};
    this.currentUrl = '';
  }
  // 將path路徑與對應的callback函式儲存
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }
  // 重新整理
  refresh() {
    // 獲取當前URL中的hash路徑
    this.currentUrl = location.hash.slice(1) || '/';
    // 執行當前hash路徑的callback函式
    this.routes[this.currentUrl]();
  }
}
複製程式碼

1.3 監聽對應事件

那麼我們只需要在例項化Class的時候監聽上面的事件即可.

class Routers {
  constructor() {
    this.routes = {};
    this.currentUrl = '';
    this.refresh = this.refresh.bind(this);
    window.addEventListener('load', this.refresh, false);
    window.addEventListener('hashchange', this.refresh, false);
  }

  route(path, callback) {
    this.routes[path] = callback || function() {};
  }

  refresh() {
    this.currentUrl = location.hash.slice(1) || '/';
    this.routes[this.currentUrl]();
  }
}
複製程式碼

對應效果如下:

面試官: 你瞭解前端路由嗎?

完整示例

點選這裡 hash router by 尋找海藍 (@xiaomuzhu) on CodePen.


2.增加回退功能

上一節我們只實現了簡單的路由功能,沒有我們常用的回退前進功能,所以我們需要進行改造。

2.1 實現後退功能

我們在需要建立一個陣列history來儲存過往的hash路由例如/blue,並且建立一個指標currentIndex來隨著後退前進功能移動來指向不同的hash路由。


class Routers {
  constructor() {
    // 儲存hash與callback鍵值對
    this.routes = {};
    // 當前hash
    this.currentUrl = '';
    // 記錄出現過的hash
    this.history = [];
    // 作為指標,預設指向this.history的末尾,根據後退前進指向history中不同的hash
    this.currentIndex = this.history.length - 1;
    this.refresh = this.refresh.bind(this);
    this.backOff = this.backOff.bind(this);
    window.addEventListener('load', this.refresh, false);
    window.addEventListener('hashchange', this.refresh, false);
  }

  route(path, callback) {
    this.routes[path] = callback || function() {};
  }

  refresh() {
    this.currentUrl = location.hash.slice(1) || '/';
    // 將當前hash路由推入陣列儲存
    this.history.push(this.currentUrl);
    // 指標向前移動
    this.currentIndex++;
    this.routes[this.currentUrl]();
  }
  // 後退功能
  backOff() {
    // 如果指標小於0的話就不存在對應hash路由了,因此鎖定指標為0即可
    this.currentIndex <= 0
      ? (this.currentIndex = 0)
      : (this.currentIndex = this.currentIndex - 1);
    // 隨著後退,location.hash也應該隨之變化
    location.hash = `#${this.history[this.currentIndex]}`;
    // 執行指標目前指向hash路由對應的callback
    this.routes[this.history[this.currentIndex]]();
  }
}
複製程式碼

我們看起來實現的不錯,可是出現了Bug,在後退的時候我們往往需要點選兩下。

點選檢視Bug示例 hash router by 尋找海藍 (@xiaomuzhu) on CodePen.

問題在於,我們每次在後退都會執行相應的callback,這會觸發refresh()執行,因此每次我們後退,history中都會被push新的路由hash,currentIndex也會向前移動,這顯然不是我們想要的。

  refresh() {
    this.currentUrl = location.hash.slice(1) || '/';
    // 將當前hash路由推入陣列儲存
    this.history.push(this.currentUrl);
    // 指標向前移動
    this.currentIndex++;
    this.routes[this.currentUrl]();
  }
複製程式碼

如圖所示,我們每次點選後退,對應的指標位置和陣列被列印出來

面試官: 你瞭解前端路由嗎?

2.2 完整實現hash Router

我們必須做一個判斷,如果是後退的話,我們只需要執行回撥函式,不需要新增陣列和移動指標。

class Routers {
  constructor() {
    // 儲存hash與callback鍵值對
    this.routes = {};
    // 當前hash
    this.currentUrl = '';
    // 記錄出現過的hash
    this.history = [];
    // 作為指標,預設指向this.history的末尾,根據後退前進指向history中不同的hash
    this.currentIndex = this.history.length - 1;
    this.refresh = this.refresh.bind(this);
    this.backOff = this.backOff.bind(this);
    // 預設不是後退操作
    this.isBack = false;
    window.addEventListener('load', this.refresh, false);
    window.addEventListener('hashchange', this.refresh, false);
  }

  route(path, callback) {
    this.routes[path] = callback || function() {};
  }

  refresh() {
    this.currentUrl = location.hash.slice(1) || '/';
    if (!this.isBack) {
      // 如果不是後退操作,且當前指標小於陣列總長度,直接擷取指標之前的部分儲存下來
      // 此操作來避免當點選後退按鈕之後,再進行正常跳轉,指標會停留在原地,而陣列新增新hash路由
      // 避免再次造成指標的不匹配,我們直接擷取指標之前的陣列
      // 此操作同時與瀏覽器自帶後退功能的行為保持一致
      if (this.currentIndex < this.history.length - 1)
        this.history = this.history.slice(0, this.currentIndex + 1);
      this.history.push(this.currentUrl);
      this.currentIndex++;
    }
    this.routes[this.currentUrl]();
    console.log('指標:', this.currentIndex, 'history:', this.history);
    this.isBack = false;
  }
  // 後退功能
  backOff() {
    // 後退操作設定為true
    this.isBack = true;
    this.currentIndex <= 0
      ? (this.currentIndex = 0)
      : (this.currentIndex = this.currentIndex - 1);
    location.hash = `#${this.history[this.currentIndex]}`;
    this.routes[this.history[this.currentIndex]]();
  }
}
複製程式碼

檢視完整示例 Hash Router by 尋找海藍 (@xiaomuzhu) on CodePen.

前進的部分就不實現了,思路我們已經講得比較清楚了,可以看出來,hash路由這種方式確實有點繁瑣,所以HTML5標準提供了History API供我們使用。


3. HTML5新路由方案

3.1 History API

我們可以直接在瀏覽器中查詢出History API的方法和屬性。

面試官: 你瞭解前端路由嗎?

當然,我們常用的方法其實是有限的,如果想全面瞭解可以去MDN查詢History API的資料

我們只簡單看一下常用的API

window.history.back();       // 後退
window.history.forward();    // 前進
window.history.go(-3);       // 後退三個頁面

複製程式碼

history.pushState用於在瀏覽歷史中新增歷史記錄,但是並不觸發跳轉,此方法接受三個引數,依次為:

state:一個與指定網址相關的狀態物件,popstate事件觸發時,該物件會傳入回撥函式。如果不需要這個物件,此處可以填null
title:新頁面的標題,但是所有瀏覽器目前都忽略這個值,因此這裡可以填null
url:新的網址,必須與當前頁面處在同一個域。瀏覽器的位址列將顯示這個網址。

history.replaceState方法的引數與pushState方法一模一樣,區別是它修改瀏覽歷史中當前紀錄,而非新增記錄,同樣不觸發跳轉。

popstate事件,每當同一個文件的瀏覽歷史(即history物件)出現變化時,就會觸發popstate事件。

需要注意的是,僅僅呼叫pushState方法或replaceState方法 ,並不會觸發該事件,只有使用者點選瀏覽器倒退按鈕和前進按鈕,或者使用 JavaScript 呼叫backforwardgo方法時才會觸發。

另外,該事件只針對同一個文件,如果瀏覽歷史的切換,導致載入不同的文件,該事件也不會觸發。

以上API介紹選自history物件,可以點選檢視完整版,我們不想佔用過多篇幅來介紹API。

3.2 新標準下路由的實現

上一節我們介紹了新標準的History API,相比於我們在Hash 路由實現的那些操作,很顯然新標準讓我們的實現更加方便和可讀。

所以一個mini路由實現起來其實很簡單

class Routers {
  constructor() {
    this.routes = {};
    // 在初始化時監聽popstate事件
    this._bindPopState();
  }
  // 初始化路由
  init(path) {
    history.replaceState({path: path}, null, path);
    this.routes[path] && this.routes[path]();
  }
  // 將路徑和對應回撥函式加入hashMap儲存
  route(path, callback) {
    this.routes[path] = callback || function() {};
  }

  // 觸發路由對應回撥
  go(path) {
    history.pushState({path: path}, null, path);
    this.routes[path] && this.routes[path]();
  }
  // 監聽popstate事件
  _bindPopState() {
    window.addEventListener('popstate', e => {
      const path = e.state && e.state.path;
      this.routes[path] && this.routes[path]();
    });
  }
}
複製程式碼

點選檢視H5路由 H5 Router by 尋找海藍 (@xiaomuzhu) on CodePen.


小結

我們大致探究了前端路由的兩種實現方法,在沒有相容性要求的情況下顯然符合標準的History API實現的路由是更好的選擇。

想更深入瞭解前端路由實現可以閱讀vue-router程式碼,除去開發模式程式碼、註釋和型別檢測程式碼,核心程式碼並不多,適合閱讀。


下期預告

下期準備一篇關於雙向繫結的話題,因為許多人只知道Object.definedProperty,禁不住深究:

  1. 同是資料劫持,與Proxy相比有何優劣?
  2. 除了資料劫持可以實現雙向繫結還有沒有其他方法?
  3. 其他方法(例如髒檢測、Observable模式、資料模型等)與資料劫持相比優劣如何?

由於涉及的框架和知識點過多,我開了一個頭已經小2000字了,,在考慮要不要分上下篇發出來,不過我相信它解決你對雙向繫結所有的疑問。

相關文章