「前端進階」徹底弄懂前端路由(轉)

什麼是路由

路由這個概念最先是後端出現的,其響應過程是這樣的
1.瀏覽器發出請求
2.服務器監聽到80端口(或443)有請求過來,並解析url路徑
3.根據服務器的路由配置,返回相應信息(可以是 html 字串,也可以是 json 數據,圖片等)
4.瀏覽器根據數據包的Content-Type來決定如何解析數據

簡單來說路由就是用來跟後端服務器進行交互的一種方式,通過不同的路徑,來請求不同的資源,請求不同的頁面是路由的其中一種功能。

前段路由的誕生

最開始的網頁是多頁面的,直到 Ajax 的出現,才慢慢有了 單頁面web應用(SPA-(single-page application))。

SPA 的出現大大提高了 WEB 應用的交互體驗。在與用戶的交互過程中,不再需要重新刷新頁面,獲取數據也是通過 Ajax 異步獲取,頁面顯示變的更加流暢。

但由於 SPA 中用戶的交互是通過 JS 改變 HTML 內容來實現的,頁面本身的 url 並沒有變化,這導致了兩個問題:

  1. SPA 無法記住用戶的操作記錄,無論是刷新、前進還是後退,都無法展示用戶真實的期望內容。
  2. SPA 中雖然由於業務的不同會有多種頁面展示形式,但只有一個 url,對 SEO 不友好,不方便搜索引擎進行收錄。

前端路由就是爲了解決上述問題而出現的。

前端路由的兩種實現原理

1. Hash模式

這裏的 hash 就是指 url 後的 # 號以及後面的字符。比如說 "www.baidu.com/#hashhash" ,其中 "#hashhash" 就是我們期望的 hash 值。

由於 hash 值的變化不會導致瀏覽器像服務器發送請求,而且 hash 的改變會觸發 hashchange 事件,瀏覽器的前進後退也能對其進行控制,所以在 H5 的 history 模式出現之前,基本都是使用 hash 模式來實現前端路由。

使用到的api

window.location.hash = 'hash字符串'; // 用於設置 hash 值

let hash = window.location.hash; // 獲取當前 hash 值

// 監聽hash變化,點擊瀏覽器的前進後退會觸發
window.addEventListener('hashchange', function(event){ 
    let newURL = event.newURL; // hash 改變後的新 url
    let oldURL = event.oldURL; // hash 改變前的舊 url
},false)

注:react-router中的hashHistory也是基於此實現
接下來我們來實現一個路由對象
創建一個路由對象, 實現 register 方法用於註冊每個 hash 值對應的回調函數

class HashRouter{
    constructor(){
        //用於存儲不同hash值對應的回調函數
        this.routers = {};
    }
    //用於註冊每個視圖
    register(hash,callback = function(){}){
        this.routers[hash] = callback;
    }
}

不存在hash值時,認爲是首頁,所以實現 registerIndex 方法用於註冊首頁時的回調函數

class HashRouter{
    constructor(){
        //用於存儲不同hash值對應的回調函數
        this.routers = {};
    }
    //用於註冊每個視圖
    register(hash,callback = function(){}){
        this.routers[hash] = callback;
    }
    //用於註冊首頁
    registerIndex(callback = function(){}){
        this.routers['index'] = callback;
    }
}

通過 hashchange 監聽 hash 變化,並定義 hash 變化時的回調函數

class HashRouter{
    constructor(){
        //用於存儲不同hash值對應的回調函數
        this.routers = {};
        window.addEventListener('hashchange',this.load.bind(this),false)
    }
    //用於註冊每個視圖
    register(hash,callback = function(){}){
        this.routers[hash] = callback;
    }
    //用於註冊首頁
    registerIndex(callback = function(){}){
        this.routers['index'] = callback;
    }
    //用於調用不同視圖的回調函數
    load(){
        let hash = location.hash.slice(1),
            handler;
        //沒有hash 默認爲首頁
        if(!hash){
            handler = this.routers.index;
        }else{
            handler = this.routers[hash];
        }
        //執行註冊的回調函數
        handler.call(this);
    }
}

我們做一個例子來演示一下我們剛剛完成的 HashRouter

<body>
    <div id="nav">
        <a href="#/page1">page1</a>
        <a href="#/page2">page2</a>
        <a href="#/page3">page3</a>
    </div>
    <div id="container"></div>
</body>
let router = new HashRouter();
let container = document.getElementById('container');

//註冊首頁回調函數
router.registerIndex(()=> container.innerHTML = '我是首頁');

//註冊其他視圖回到函數
router.register('/page1',()=> container.innerHTML = '我是page1');
router.register('/page2',()=> container.innerHTML = '我是page2');
router.register('/page3',()=> container.innerHTML = '我是page3');

//加載視圖
router.load();

來看一下效果:
[圖片上傳失敗...(image-8d3aa5-1602561145767)]
基本的路由功能我們已經實現了,但依然有點小問題

缺少對未在路由中註冊的 hash 值的處理
hash 值對應的回調函數在執行過程中拋出異常

對應的解決辦法如下:

我們追加 registerNotFound 方法,用於註冊 hash 值未找到時的默認回調函數;
修改 load 方法,追加 try/catch 用於捕獲異常,追加 registerError 方法,用於處理異常

代碼修改後:

class HashRouter{
    constructor(){
        //用於存儲不同hash值對應的回調函數
        this.routers = {};
        window.addEventListener('hashchange',this.load.bind(this),false)
    }
    //用於註冊每個視圖
    register(hash,callback = function(){}){
        this.routers[hash] = callback;
    }
    //用於註冊首頁
    registerIndex(callback = function(){}){
        this.routers['index'] = callback;
    }
    //用於處理視圖未找到的情況
    registerNotFound(callback = function(){}){
        this.routers['404'] = callback;
    }
    //用於處理異常情況
    registerError(callback = function(){}){
        this.routers['error'] = callback;
    }
    //用於調用不同視圖的回調函數
    load(){
        let hash = location.hash.slice(1),
            handler;
        //沒有hash 默認爲首頁
        if(!hash){
            handler = this.routers.index;
        }
        //未找到對應hash值
        else if(!this.routers.hasOwnProperty(hash)){
            handler = this.routers['404'] || function(){};
        }
        else{
            handler = this.routers[hash]
        }
        //執行註冊的回調函數
        try{
            handler.apply(this);
        }catch(e){
            console.error(e);
            (this.routers['error'] || function(){}).call(this,e);
        }
    }
}

再來一個例子,演示一下:

<body>
    <div id="nav">
        <a href="#/page1">page1</a>
        <a href="#/page2">page2</a>
        <a href="#/page3">page3</a>
        <a href="#/page4">page4</a>
        <a href="#/page5">page5</a>
    </div>
    <div id="container"></div>
</body>
let router = new HashRouter();
let container = document.getElementById('container');

//註冊首頁回調函數
router.registerIndex(()=> container.innerHTML = '我是首頁');

//註冊其他視圖回到函數
router.register('/page1',()=> container.innerHTML = '我是page1');
router.register('/page2',()=> container.innerHTML = '我是page2');
router.register('/page3',()=> container.innerHTML = '我是page3');
router.register('/page4',()=> {throw new Error('拋出一個異常')});

//加載視圖
router.load();
//註冊未找到對應hash值時的回調
router.registerNotFound(()=>container.innerHTML = '頁面未找到');
//註冊出現異常時的回調
router.registerError((e)=>container.innerHTML = '頁面異常,錯誤消息:<br>' + e.message);

來看一下效果:


至此,基於 hash 方式實現的前端路由,我們已經將基本雛形實現完成了。
接下來我們來介紹前端路由的另一種模式:history 模式。

2.history 模式

在 HTML5 之前,瀏覽器就已經有了 history 對象。但在早期的 history 中只能用於多頁面的跳轉:

history.go(-1);       // 後退一頁
history.go(2);        // 前進兩頁
history.forward();     // 前進一頁
history.back();      // 後退一頁

在 HTML5 的規範中,history 新增了以下幾個 API:

history.pushState();         // 添加新的狀態到歷史狀態棧
history.replaceState();      // 用新的狀態代替當前狀態
history.state                // 返回當前狀態對象

HTML5引入了 history.pushState() 和 history.replaceState() 方法,它們分別可以添加和修改歷史記錄條目。這些方法通常與window.onpopstate 配合使用。
history.pushState() 和 history.replaceState() 均接收三個參數(state, title, url)

參數說明如下:

state:合法的 Javascript 對象,可以用在 popstate 事件中
title:現在大多瀏覽器忽略這個參數,可以直接用 null 代替
url:任意有效的 URL,用於更新瀏覽器的地址欄

history.pushState() 和 history.replaceState() 的區別在於:

history.pushState() 在保留現有歷史記錄的同時,將 url 加入到歷史記錄中。
history.replaceState() 會將歷史記錄中的當前頁面歷史替換爲 url。

由於 history.pushState() 和 history.replaceState() 可以改變 url 同時,不會刷新頁面,所以在 HTML5 中的 histroy 具備了實現前端路由的能力。
回想我們之前完成的 hash 模式,當 hash 變化時,可以通過 hashchange 進行監聽。
而 history 的改變並不會觸發任何事件,所以我們無法直接監聽 history 的改變而做出相應的改變。
所以,我們需要換個思路,我們可以羅列出所有可能觸發 history 改變的情況,並且將這些方式一一進行攔截,變相地監聽 history 的改變。
對於單頁應用的 history 模式而言,url 的改變只能由下面四種方式引起:

點擊瀏覽器的前進或後退按鈕
點擊 a 標籤
在 JS 代碼中觸發 history.pushState 函數
在 JS 代碼中觸發 history.replaceState 函數

思路已經有了,接下來我們來實現一個路由對象

  1. 創建一個路由對象, 實現 register 方法用於註冊每個 location.pathname 值對應的回調函數
  2. 當 location.pathname === '/' 時,認爲是首頁,所以實現 registerIndex 方法用於註冊首頁時的回調函數
  3. 解決 location.path 沒有對應的匹配,增加方法 registerNotFound 用於註冊默認回調函數
  4. 解決註冊的回到函數執行時出現異常,增加方法 registerError 用於處理異常情況
class HistoryRouter{
    constructor(){
        //用於存儲不同path值對應的回調函數
        this.routers = {};
    }
    //用於註冊每個視圖
    register(path,callback = function(){}){
        this.routers[path] = callback;
    }
    //用於註冊首頁
    registerIndex(callback = function(){}){
        this.routers['/'] = callback;
    }
    //用於處視圖未找到的情況
    registerNotFound(callback = function(){}){
        this.routers['404'] = callback;
    }
    //用於處理異常情況
    registerError(callback = function(){}){
        this.routers['error'] = callback;
    }
}
  1. 定義 assign 方法,用於通過 JS 觸發 history.pushState 函數
  2. 定義 replace 方法,用於通過 JS 觸發 history.replaceState 函數
class HistoryRouter{
    constructor(){
        //用於存儲不同path值對應的回調函數
        this.routers = {};
    }
    //用於註冊每個視圖
    register(path,callback = function(){}){
        this.routers[path] = callback;
    }
    //用於註冊首頁
    registerIndex(callback = function(){}){
        this.routers['/'] = callback;
    }
    //用於處理視圖未找到的情況
    registerNotFound(callback = function(){}){
        this.routers['404'] = callback;
    }
    //用於處理異常情況
    registerError(callback = function(){}){
        this.routers['error'] = callback;
    }
    //跳轉到path
    assign(path){
        history.pushState({path},null,path);
        this.dealPathHandler(path)
    }
    //替換爲path
    replace(path){
        history.replaceState({path},null,path);
        this.dealPathHandler(path)
    }
    //通用處理 path 調用回調函數
    dealPathHandler(path){
        let handler;
        //沒有對應path
        if(!this.routers.hasOwnProperty(path)){
            handler = this.routers['404'] || function(){};
        }
        //有對應path
        else{
            handler = this.routers[path];
        }
        try{
            handler.call(this)
        }catch(e){
            console.error(e);
            (this.routers['error'] || function(){}).call(this,e);
        }
    }
}
  1. 監聽 popstate 用於處理前進後退時調用對應的回調函數
  2. 全局阻止A鏈接的默認事件,獲取A鏈接的href屬性,並調用 history.pushState 方法
  3. 定義 load 方法,用於首次進入頁面時 根據 location.pathname 調用對應的回調函數
    最終代碼如下:
class HistoryRouter{
    constructor(){
        //用於存儲不同path值對應的回調函數
        this.routers = {};
        this.listenPopState();
        this.listenLink();
    }
    //監聽popstate
    listenPopState(){
        window.addEventListener('popstate',(e)=>{
            let state = e.state || {},
                path = state.path || '';
            this.dealPathHandler(path)
        },false)
    }
    //全局監聽A鏈接
    listenLink(){
        window.addEventListener('click',(e)=>{
            let dom = e.target;
            if(dom.tagName.toUpperCase() === 'A' && dom.getAttribute('href')){
                e.preventDefault()
                this.assign(dom.getAttribute('href'));
            }
        },false)
    }
    //用於首次進入頁面時調用
    load(){
        let path = location.pathname;
        this.dealPathHandler(path)
    }
    //用於註冊每個視圖
    register(path,callback = function(){}){
        this.routers[path] = callback;
    }
    //用於註冊首頁
    registerIndex(callback = function(){}){
        this.routers['/'] = callback;
    }
    //用於處理視圖未找到的情況
    registerNotFound(callback = function(){}){
        this.routers['404'] = callback;
    }
    //用於處理異常情況
    registerError(callback = function(){}){
        this.routers['error'] = callback;
    }
    //跳轉到path
    assign(path){
        history.pushState({path},null,path);
        this.dealPathHandler(path)
    }
    //替換爲path
    replace(path){
        history.replaceState({path},null,path);
        this.dealPathHandler(path)
    }
    //通用處理 path 調用回調函數
    dealPathHandler(path){
        let handler;
        //沒有對應path
        if(!this.routers.hasOwnProperty(path)){
            handler = this.routers['404'] || function(){};
        }
        //有對應path
        else{
            handler = this.routers[path];
        }
        try{
            handler.call(this)
        }catch(e){
            console.error(e);
            (this.routers['error'] || function(){}).call(this,e);
        }
    }
}

再做一個例子來演示一下我們剛剛完成的 HistoryRouter

<body>
    <div id="nav">
        <a href="/page1">page1</a>
        <a href="/page2">page2</a>
        <a href="/page3">page3</a>
        <a href="/page4">page4</a>
        <a href="/page5">page5</a>
        <button id="btn">page2</button>
    </div>
    <div id="container">

    </div>
</body>
let router = new HistoryRouter();
let container = document.getElementById('container');

//註冊首頁回調函數
router.registerIndex(() => container.innerHTML = '我是首頁');

//註冊其他視圖回到函數
router.register('/page1', () => container.innerHTML = '我是page1');
router.register('/page2', () => container.innerHTML = '我是page2');
router.register('/page3', () => container.innerHTML = '我是page3');
router.register('/page4', () => {
    throw new Error('拋出一個異常')
});

document.getElementById('btn').onclick = () => router.assign('/page2')


//註冊未找到對應path值時的回調
router.registerNotFound(() => container.innerHTML = '頁面未找到');
//註冊出現異常時的回調
router.registerError((e) => container.innerHTML = '頁面異常,錯誤消息:<br>' + e.message);
//加載頁面
router.load();

來看一下效果:


至此,基於 history 方式實現的前端路由,我們已經將基本雛形實現完成了。
但需要注意的是,history 在修改 url 後,雖然頁面並不會刷新,但我們在手動刷新,或通過 url 直接進入應用的時候,
服務端是無法識別這個 url 的。因爲我們是單頁應用,只有一個 html 文件,服務端在處理其他路徑的 url 的時候,就會出現404的情況。
所以,如果要應用 history 模式,需要在服務端增加一個覆蓋所有情況的候選資源:如果 URL 匹配不到任何靜態資源,則應該返回單頁應用的 html 文件。
接下來,我們來探究一下,何時使用 hash 模式,何時使用 history 模式。

拓展:一個問題:

react router爲什麼推薦使用browserHistory而不推薦hashHistory?

首先 browserHistory 其實使用的是 HTML5 的 History API,瀏覽器提供相應的接口來修改瀏覽器的歷史記錄;而 hashHistory 是通過改變地址後面的 hash 來改變瀏覽器的歷史記錄;

History API 提供了 pushState() 和 replaceState() 方法來增加或替換歷史記錄。而 hash 沒有相應的方法,所以並沒有替換歷史記錄的功能。但 react-router 通過 polyfill 實現了此功能,具體實現沒有看,好像是使用 sessionStorage。

另一個原因是 hash 部分並不會被瀏覽器發送到服務端,也就是說不管是請求 http://domain.com/index.html#foo 還是 http://domain.com/index.html#bar ,服務只知道請求了 index.html 並不知道 hash 部分的細節。而 History API 需要服務端支持,這樣服務端能獲取請求細節。

還有一個原因是因爲有些應該會忽略 URL 中的 hash 部分,記得之前將 URL 使用微信分享時會丟失 hash 部分。

注意:原文主要產出於下面兩篇文章,在此自己記錄下方便日後查看
https://juejin.im/post/6844903890278694919
https://juejin.im/post/6844903759458336776#heading-10

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章