「前端進階」完全弄懂前端路由

前言

現代前端項目多爲單頁Web應用(SPA),在單頁Web應用中路由是其中的重要環節。javascript

每一個現代前端框架都有與之對應的路由實現,例如 vue-router、react-router 等。html

本文並不涉及 vue-router、react-router 的實現方式,而是介紹前端路由的基本實現原理及實現方式。前端

vue-router、react-router 的源碼解析,會在之後的文章中逐步推出。vue

什麼是 SPA

SPA 是 single page web application 的簡稱,譯爲單頁Web應用。java

簡單的說 SPA 就是一個WEB項目只有一個 HTML 頁面,一旦頁面加載完成,SPA 不會由於用戶的操做而進行頁面的從新加載或跳轉。 取而代之的是利用 JS 動態的變換 HTML 的內容,從而來模擬多個視圖間跳轉。react

從傳統頁面到視圖

對於初學者來講,理解傳統頁面與 SPA 視圖間的差別是困難的。git

在這裏,用兩張圖,來分別代表傳統頁面與 SPA 視圖間的區別:github

上圖代表了,在傳統的網站設計中,每一個HTML文件都是一個完成的HTML頁面,涵蓋了完整的HTML結構。web

上圖代表了,在 SPA 的應用設計中,一個應用只有一個HTML文件,在HTML文件中包含一個佔位符(即圖中的 container),佔位符對應的內容由每一個視圖來決定,對於 SPA 來講,頁面的切換就是視圖之間的切換。vue-router

前端路由的由來

最開始的網頁是多頁面的,直到 Ajax 的出現,才慢慢有了 SPA。

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

但因爲 SPA 中用戶的交互是經過 JS 改變 HTML 內容來實現的,頁面自己的 url 並無變化,這致使了兩個問題:

  1. SPA 沒法記住用戶的操做記錄,不管是刷新、前進仍是後退,都沒法展現用戶真實的指望內容。
  2. SPA 中雖然因爲業務的不一樣會有多種頁面展現形式,但只有一個 url,對 SEO 不友好,不方便搜索引擎進行收錄。

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

什麼是前端路由

簡單的說,就是在保證只有一個 HTML 頁面,且與用戶交互時不刷新和跳轉頁面的同時,爲 SPA 中的每一個視圖展現形式匹配一個特殊的 url。在刷新、前進、後退和SEO時均經過這個特殊的 url 來實現。

爲實現這一目標,咱們須要作到如下二點:

  1. 改變 url 且不讓瀏覽器像服務器發送請求。
  2. 能夠監聽到 url 的變化

接下來要介紹的 hash 模式和 history 模式,就是實現了上面的功能

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)
複製代碼

接下來咱們來實現一個路由對象

建立一個路由對象, 實現 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();
複製代碼

來看一下效果:

基本的路由功能咱們已經實現了,但依然有點小問題

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

對應的解決辦法以下:

  1. 咱們追加 registerNotFound 方法,用於註冊 hash 值未找到時的默認回調函數;
  2. 修改 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 模式。

history 模式

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

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

在 HTML5 的規範中,history 新增瞭如下幾個 API:

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

來自MDN的解釋:

HTML5引入了 history.pushState() 和 history.replaceState() 方法,它們分別能夠添加和修改歷史記錄條目。這些方法一般與window.onpopstate 配合使用。

history.pushState() 和 history.replaceState() 均接收三個參數(state, title, url)

參數說明以下:

  1. state:合法的 Javascript 對象,能夠用在 popstate 事件中
  2. title:如今大多瀏覽器忽略這個參數,能夠直接用 null 代替
  3. 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 的改變只能由下面四種方式引發:

  1. 點擊瀏覽器的前進或後退按鈕
  2. 點擊 a 標籤
  3. 在 JS 代碼中觸發 history.pushState 函數
  4. 在 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 模式。

hash、history 如何抉擇

hash 模式相比於 history 模式的優勢:

  • 兼容性更好,能夠兼容到IE8
  • 無需服務端配合處理非單頁的url地址

hash 模式相比於 history 模式的缺點:

  • 看起來更醜。
  • 會致使錨點功能失效。
  • 相同 hash 值不會觸發動做將記錄加入到歷史棧中,而 pushState 則能夠。

綜上所述,當咱們不須要兼容老版本IE瀏覽器,而且能夠控制服務端覆蓋全部狀況的候選資源時,咱們能夠愉快的使用 history 模式了。

反之,很遺憾,只能使用醜陋的 hash 模式~

尾聲

本文簡單分析並實現了單頁路由中的 hash 模式和 history 模式,固然,它與 vue-router、react-router 相比還太過簡陋,關於 vue-router、react-router 的源碼解析,會在之後的文章中逐步推出。

系列文章推薦

參考

寫在最後

  • 文中若有錯誤,歡迎在評論區指正,若是這篇文章幫到了你,歡迎點贊關注
  • 本文同步首發與github,可在github中找到更多精品文章,歡迎Watch & Star ★
  • 後續文章參見:計劃

歡迎關注微信公衆號【前端小黑屋】,每週1-3篇精品優質文章推送,助你走上進階之旅

相關文章
相關標籤/搜索