最新一直在看關於 Vue 和 React 路由這塊的知識,最終發現這些路由框架的模組功能的實現都是基於瀏覽器原生路由 API 的。本著追根溯源的初心,於是就想著將瀏覽器原生的路由 API 整體梳理一遍,以便更加順暢的理解 Vue-Router 和 React-Router 的相關實現和原理。
背景
瀏覽器的主要功能就是根據輸入的 URL 在視窗載入對應的文件,與此同時,瀏覽器會記錄一個 tab 視窗載入過的所有文件,同時會提供 "前進"、"後退" 和 "重新整理" 的功能,以便使用者可以在這些已經記錄的文件之間進行切換瀏覽和過載當前頁面獲取最新的瀏覽資訊。
這些功能的實現最早是在伺服器端實現的,因為那時候的引用都是前後端不分離的,頁面內容也是動態生成的,所以這些頁面的跳轉、切換、重新整理都是在服務端實現的。後來出現了 SPA(Single Page Application 單頁應用),頁面都是通過 JavaScript 動態生成和載入到頁面的,並且可以在無重新整理的情況下載入頁面最新的狀態資訊,這時候如果要提供上述的功能就需要自己進行處理(因為此時的頁面都是現實在同一個大的框架頁面裡面的,根本不存在頁面的跳轉切換),所以催生了各種框架對應的 Router 實現。
在瀏覽器中實現前端路由主要有兩種方式:一個是我們常用的 hash,另一個是 HTML5 提供的 history。其實還有另外一種利用 stack 實現的方式適用於 Node.js 伺服器端,這裡我們著重說一下瀏覽器提供的 hash 和 history 吧,stack 具體怎麼實現等我們說到 x-Router 原始碼的時候再詳細說一下。
Hash
在瀏覽器 URL 位址列,我們總會發現像這樣的地址:react.docschina.org/docs/react-… React 官閘道器於 lazy 的一個地址)。大家肯定發現:這串 URL 的最後有以 # 號開始的一串標識,那它到底是起著什麼樣的作用呢?肯定不會平白無故的出現吧。
hash 特性
你可以直接在瀏覽器中開啟這個連結地址,你是不是發現頁面會自動滾動到(頁面頂部定位到)標題為 React.lazy 的部分文件。你再將頁面往上滾動,肯定會發現上面還有部分的文件內容。此時,你修改位址列的地址為 react.docschina.org/docs/react-… React.Suspense 部分。
在早些年,hash 作為 URL 的一部分主要用來定位文件中的文件片段。在上面的例子中,我們通過在 URL 後面新增 #reactlazy 和 #reactsuspense 定位到了文件對應標題為 React.lazy 和 React.Suspense 的部分。那他們到底是怎麼做到的呢?
通過稽核元素我們發現:在 React.lazy 和 React.Suspense 對應的標題部分分別都有一個 h3 標籤,而且標籤的 id 屬性對應就是我們在 URL 位址列輸入的 hash 值部分(只是少了 # 號)。
hash 定位文件片段
可能有同學會有疑惑:為什麼 hash 是通過元素上面的 id 屬性來定位文件的?
前面我們提到過,URL 中的 hash 部分是用來定位文件中的文件片段的。大家想想:所需要定位的文件片段肯定是唯一的,不然定位肯定是不準確了,那這個定位文件就有點雞肋了,在文件中標識唯一的屬性只有是 id 了,如果是我,我也會通過 hash 匹配元素的 id 來定位文件。
現在來驗證一下我們的猜想:
- 1、首先在新的 tab 視窗開啟 react.docschina.org/docs/react-… 頁面,然後在稽核元素下找到上圖所展示的 DOM 元素,修改其中的 h3 標籤的 id 屬性值為 reactlazy1,接著在 URL 位址列追加 #reactlazy hash 值並按下Enter鍵,此時頁面並沒有定位到標題為 React.lazy 的文件片段,最後將 URL 位址列的 #reactlazy hash 值改成 #reactlazy1 hash 值並按下Enter鍵,此時頁面並沒有定位到標題為 React.lazy 的文件片段,這一系列的表現說明 hash 定位還是和元素的 id 屬性值還是有關聯的;
- 2、依然是在新的 tab 視窗開啟 react.docschina.org/docs/react-… 頁面,然後將頁面手動滾動到標題為 React.lazy 的文件片段,將滑鼠放在標題上會出現一個錨點的圖示,點選圖示發現頁面定位到了標題為 React.lazy 的文件片段並且 URL 位址列變成了 react.docschina.org/docs/react-… #reactlazy hash 值。此時再回頭看看我們前面給出的截圖發現 id 屬性值為 reactlazy 的 h3 標籤中有一個 href 屬性值為 #reactlazy 的 a 標籤,其實我們在頁面上看到的錨點圖示就是這個 a 標籤的展示。當我們點選錨點圖示就是點選了 a 連結,然後將 url 定位到了 id 屬性值為 reactlazy 的 h3 標籤,還是很好的說明了 hash 定位還是和元素的 id 屬性值還是有關聯的;
- 3、MDN 官方定義如下:
hash 路由
hash 的存在除了可以通過設定文件中元素的 ID 來定位文件片段之外,還可以設定為任意的字串來表示路由。在 Vue、React 等現代前端框架中,為了實現功能完備的 SPA 應用都配備了對應的路由系統。在這些路由系統都會提供 hash 路由模式。
在 hash 模式下,hash 會支援任意的字串來表示對應的 URL。這些路由系統針對 hash 模式的實現基本都是大同小異:在設定 location.hash 屬性值後,應用就會想盡一切辦法檢測狀態值變化,以便能夠讀取出儲存在片段識別符號中的狀態並相應地更新自己的狀態。支援 HTML5 的瀏覽器一旦發現片段識別符號發生了變化,就會在 Window 物件上觸發 hashchange 事件,這時就會觸發物件的函式處理邏輯 —— 對 location.hash 的值進行解析,然後使用該值包含的狀態資訊重新渲染應用。
這裡只是提到了一個基礎的思路,路由系統的具體實現,後續會娓娓道來!
hash 事件
在支援 HTML5 的瀏覽器中,當 URL 的 hash 值變化時會觸發 hashchange 事件,我們可以通過監聽這個事件來說一些處理:
// 在 window 下監聽 hashchange 事件
window.onhashchange = function() {
// 當事件觸發時輸出當前的 hash 值
console.log(window.location.hash)
}
複製程式碼
在不支援 HTML5 的瀏覽器中,我們可以通過 100ms 輪詢監聽 url 變化來模擬:
(function(window){
// 如果瀏覽器不支援原生實現的事件,則開始模擬,否則退出。
if ( "onhashchange" in window.document.body ) return;
var location = window.location,
oldUrl = location.href,
oldHash = location.hash;
// 每隔 100ms 檢查 hash 是否發生變化
setInterval(function() {
var newUrl = location.href,
newHash = location.hash;
// hash 發生變化且全域性註冊有 onhashchange 方法(這個名字是為了和模擬的事件名保持統一);
if (newHash !== oldHash && typeof window.onhashchange === "function" ) {
// 執行方法
window.onhashchange({
type: "hashchange",
oldURL: oldUrl,
newURL: newUrl
});
oldUrl = newUrl;
oldHash = newHash;
}
}, 100);
})(window)
複製程式碼
⚠️注意:設定 location.hash 屬性會更新顯示在位址列中的 URL,同時會在瀏覽器的歷史記錄中新增一條記錄。
History
為了標準化管理瀏覽器歷史管理,HTML5 定義了相對複雜的 API —— history。
history api
1、history 裡面新增了兩個 API,history.pushState() 和 history.replaceState()。這兩個 API 都接受同樣的引數:
它們之間的不同之處是:history.pushState() 方法是將新狀態新增到瀏覽器的歷史記錄中,也就是說還可以通過點選 "後退" 按鈕,退到前一個頁面;history.replaceState() 是用新的狀態代替當前的歷史狀態,也就是說沒有更多的歷史記錄,"後退" 按鈕不能操作了,頁面不能 "後退" 了。
⚠️注意:當執行這兩個 API 時,瀏覽器的 URL 位址列會變化,但是頁面內容不會重新整理!
- 狀態物件(state<Object | Null>):** 一個 JavaScript 物件,該物件包含用於恢復當前文件所需的所有資訊。可以是任何能夠通過 JSON.stringify() 方法轉換成相應字串形式的物件,也可以是其他類似 Date、RegExp 這樣特定的本地型別。
- 標題(title<String | Null>):**瀏覽器可以使用它標識瀏覽歷史記錄中儲存的狀態,可以傳一個空字串,也可以傳入一個簡短的標題,標明將要進入的狀態。
- 地址(URL):**用來表示當前狀態的位置。新的 URL 不一定是絕對路徑;如果是相對路徑,它將以當前 URL 為基準(類似 #reactlazy 這樣的 hash);傳入的 URL 與當前 URL 應該是同源的,否則 pushState() 會丟擲異常。該引數是可選的;不指定的話則為文件當前 URL。
為此,我們可以利用語雀網站做一系列的實驗:
window.history.pushState(null, null, "https://www.yuque.com/dashboard/?name=littleLane");
// result: https://www.yuque.com/dashboard/?name=littleLane
window.history.pushState(null, null, "https://www.yuque.com/dashboard/name/littleLane");
//result: https://www.yuque.com/dashboard/name/littleLane
window.history.pushState(null, null, "?name=littleLane");
//result: https://www.yuque.com/dashboard?name=littleLane
window.history.pushState(null, null, "name=littleLane");
//result: https://www.yuque.com/dashboard/name=littleLane
window.history.pushState(null, null, "/name/littleLane");
//result: https://www.yuque.com/dashboard/name/littleLane
複製程式碼
在控制檯中執行上面一系列語句時,瀏覽器的 URL 變化成了我們備註的 result 的結果,但是頁面並沒有發生重渲染,還有當我們每次執行 pushState 時,瀏覽器歷史都會新增一條記錄,大家可以通過 "後退" 按鈕進行檢視。大家執行完上面的測試語句後,還可以將 pushState 替換成 replaceState 再次進行一輪測試,此時新的瀏覽記錄都會代替當前的歷史記錄,還是可以通過 "後退" 按鈕進行檢視。
⚠️注意:這裡的 url 不支援跨域,當我們把 www.yuque.com 換成 www.baidu.com 時就會報錯。
2、除了上面新增的 API,history 物件上還有表示瀏覽歷史列表數量的 length 屬性,還定義了 back()、forward() 和 go() 進行瀏覽記錄切換的方法。
History 物件的 back() 和 forward() 方法與瀏覽器的 "後退" 和 "前進" 按鈕功能一樣:它們可以使瀏覽器在瀏覽歷史中後退或前進跳轉一格。而 go() 方法會接受一個整數,可以在瀏覽歷史列表中向前(接受正整數引數)或向後(接受負整數引數)跳過任意多個頁。比如 history.go(-1) 就會向後跳轉一頁,history.go(0) 就是重新整理當前頁,history.go(1) 就會向前跳轉一頁。
history 事件 - popstate
當使用者通過 "前進" 和 "後退" 按鈕瀏覽儲存的歷史狀態時,瀏覽器會在 Window 物件上觸發一個 popstate 事件。與該事件相關的事件物件有一個 state 屬性,該屬性包含傳遞給 pushState() 方法的狀態物件的副本。
// 在 window 下監聽 onpopstate 事件
window.onpopstate = function(state) {
// 當 onpopstate 事件 (使用者通過 "前進" 和 "後退" 按鈕切換瀏覽記錄) 觸發時輸出當前狀態
console.log(state)
}
複製程式碼
Location
Window 物件的 location 屬性和 Document 物件的 location 屬性引用的都是 Location 物件,它用來表示該視窗中當前顯示的文件的 URL,並定義了方法來使視窗載入新的文件。
window.location === document.location // 總是返回 true
複製程式碼
解析 URL
Location 物件的 href 屬性是一個字串,表示當前 URL 的完整文字。Location 物件的 toString() 方法返回 href 屬性的值,因此在會隱式呼叫 toString() 的情況下,可以使用 location 代替 location.href。
該物件的 protocol、host、hostname、port、pathname 和 search 分別表示 URL 的各個部分,它們因此被稱為 URL 分解屬性。一般我們用的比較多的就是提取 URL 裡面的引數了:
// 獲取位址列引數
const getUrlParame = (paramName) => {
const urlParams = {};
let params = window.location.search.substring(1);
if (!params) {
return;
}
params = params.split('&');
for (let i = 0; i < params.length; i += 1) {
let item = params[i];
item = item.split('=');
urlParams[item[0]] = decodeURIComponent(item[1]);
}
if (paramName) {
return urlParams[paramName];
}
return urlParams;
};
複製程式碼
載入新文件
Location 物件的 assign() 方法可以使視窗載入並顯示指定的 url 中的文件。replace() 方法也有類似的功能,但是它會在新文件載入之前將當前文件從瀏覽歷史中刪除,就是說 "後退" 按鈕並不會將瀏覽器帶到原始的文件。
Location 物件還定義可 reload() 方法用來重新載入當前文件。
總結
上述的內容我們主要了解了在瀏覽器中支援的兩種路由模式 —— hash 和 history,然後對它們各自的特性、api 和對應的事件做了詳細的講解,後面又說到了瀏覽器路由中至關重要的物件 —— Location,這一系列的內容為我們後續理解 Vue-Router、React-Router 等路由系統的實現和閱讀原始碼打下了堅實的基礎。