面試官系列(3): 前端路由的實現
往期
文章目錄
- 基於hash的前端路由實現
- 基於hash的前端路由升級
- 基於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儲存與執行
在初始化完畢後我們需要思考兩個問題:
- 將路由的hash以及對應的callback函式儲存
- 觸發路由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 呼叫back
、forward
、go
方法時才會觸發。
另外,該事件只針對同一個文件,如果瀏覽歷史的切換,導致載入不同的文件,該事件也不會觸發。
以上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
,禁不住深究:
- 同是資料劫持,與
Proxy
相比有何優劣? - 除了資料劫持可以實現雙向繫結還有沒有其他方法?
- 其他方法(例如髒檢測、Observable模式、資料模型等)與資料劫持相比優劣如何?
由於涉及的框架和知識點過多,我開了一個頭已經小2000字了,,在考慮要不要分上下篇發出來,不過我相信它解決你對雙向繫結所有的疑問。