天天看點

原生 js 實作一個前端路由 router

效果圖:

原生 js 實作一個前端路由 router

項目位址:https://github.com/biaochenxuying/route

效果體驗位址:

1. 滑動效果: https://biaochenxuying.github.io/route/index.html

2. 淡入淡出效果: https://biaochenxuying.github.io/route/index2.html

1. 需求

因為我司的 H 5 的項目是用原生 js 寫的,要用到路由,但是現在好用的路由都是和某些架構綁定在一起的,比如 vue-router ,framework7 的路由;但是又沒必要為了一個路由功能而加入一套架構,現在自己寫一個輕量級的路由。

2. 實作原理

現在前端的路由實作一般有兩種,一種是 Hash 路由,另外一種是 History 路由。

2.1 History 路由

History 接口允許操作浏覽器的曾經在标簽頁或者架構裡通路的會話曆史記錄。

屬性

  • History.length 是一個隻讀屬性,傳回目前 session 中的 history 個數,包含目前頁面在内。舉個例子,對于新開一個 tab 加載的頁面目前屬性傳回值 1 。
  • History.state 傳回一個表示曆史堆棧頂部的狀态的值。這是一種可以不必等待 popstate 事件而檢視狀态而的方式。

方法

  • History.back()

前往上一頁, 使用者可點選浏覽器左上角的傳回按鈕模拟此方法. 等價于 history.go(-1).

Note: 當浏覽器會話曆史記錄處于第一頁時調用此方法沒有效果,而且也不會報錯。
  • History.forward()

在浏覽器曆史記錄裡前往下一頁,使用者可點選浏覽器左上角的前進按鈕模拟此方法. 等價于 history.go(1).

Note: 當浏覽器曆史棧處于最頂端時( 目前頁面處于最後一頁時 )調用此方法沒有效果也不報錯。
  • History.go(n)

通過目前頁面的相對位置從浏覽器曆史記錄( 會話記錄 )加載頁面。比如:參數為 -1的時候為上一頁,參數為 1 的時候為下一頁. 當整數參數超出界限時 ( 譯者注:原文為 When integerDelta is out of bounds ),例如: 如果目前頁為第一頁,前面已經沒有頁面了,我傳參的值為 -1,那麼這個方法沒有任何效果也不會報錯。調用沒有參數的 go() 方法或者不是整數的參數時也沒有效果。( 這點與支援字元串作為 url 參數的 IE 有點不同)。

  • history.pushState() 和 history.replaceState()

這兩個 API 都接收三個參數,分别是

a. 狀态對象(state object) — 一個JavaScript對象,與用 pushState() 方法建立的新曆史記錄條目關聯。無論何時使用者導航到新建立的狀态,popstate 事件都會被觸發,并且事件對象的state 屬性都包含曆史記錄條目的狀态對象的拷貝。

b. 标題(title) — FireFox 浏覽器目前會忽略該參數,雖然以後可能會用上。考慮到未來可能會對該方法進行修改,傳一個空字元串會比較安全。或者,你也可以傳入一個簡短的标題,标明将要進入的狀态。

c. 位址(URL) — 新的曆史記錄條目的位址。浏覽器不會在調用 pushState() 方法後加載該位址,但之後,可能會試圖加載,例如使用者重新開機浏覽器。新的 URL 不一定是絕對路徑;如果是相對路徑,它将以目前 URL 為基準;傳入的 URL 與目前 URL 應該是同源的,否則,pushState() 會抛出異常。該參數是可選的;不指定的話則為文檔目前 URL。

相同之處: 是兩個 API 都會操作浏覽器的曆史記錄,而不會引起頁面的重新整理。
不同之處在于: pushState 會增加一條新的曆史記錄,而 replaceState 則會替換目前的曆史記錄。

例子:

本來的路由

http://biaochenxuying.cn/
           

執行:

window.history.pushState(null, null, "http://biaochenxuying.cn/home");
           

路由變成了:

http://biaochenxuying.cn/home
           

詳情介紹請看:MDN

2.2 Hash 路由

我們經常在 url 中看到 #,這個 # 有兩種情況,一個是我們所謂的錨點,比如典型的回到頂部按鈕原理、Github 上各個标題之間的跳轉等,但是路由裡的 # 不叫錨點,我們稱之為 hash。

現在的前端主流架構的路由實作方式都會采用 Hash 路由,本項目采用的也是。

當 hash 值發生改變的時候,我們可以通過 hashchange 事件監聽到,進而在回調函數裡面觸發某些方法。

3. 代碼實作

3.1 簡單版 - 單頁面路由

先看個簡單版的 原生 js 模拟 Vue 路由切換。

原生 js 實作一個前端路由 router

原理

  • 監聽 hashchange ,hash 改變的時候,根據目前的 hash 比對相應的 html 内容,然後用 innerHTML 把 html 内容放進 router-view 裡面。

這個代碼是網上的:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <meta name="author" content="">
    <title>原生模拟 Vue 路由切換</title>
    <style type="text/css">
        .router_box,
        #router-view {
            max-width: 1000px;
            margin: 50px auto;
            padding: 0 20px;
        }
        
        .router_box>a {
            padding: 0 10px;
            color: #42b983;
        }
    </style>
</head>

<body>
    <div class="router_box">
        <a href="/home" class="router">首頁</a>
        <a href="/news" class="router">新聞</a>
        <a href="/team" class="router">團隊</a>
        <a href="/about" class="router">關于</a>
    </div>
    <div id="router-view"></div>
    <script type="text/javascript">
        function Vue(parameters) {
            let vue = {};
            vue.routes = parameters.routes || [];
            vue.init = function() {
                document.querySelectorAll(".router").forEach((item, index) => {
                    item.addEventListener("click", function(e) {
                        let event = e || window.event;
                        event.preventDefault();
                        window.location.hash = this.getAttribute("href");
                    }, false);
                });

                window.addEventListener("hashchange", () => {
                    vue.routerChange();
                });

                vue.routerChange();
            };
            vue.routerChange = () => {
                let nowHash = window.location.hash;
                let index = vue.routes.findIndex((item, index) => {
                    return nowHash == ('#' + item.path);
                });
                if (index >= 0) {
                    document.querySelector("#router-view").innerHTML = vue.routes[index].component;
                } else {
                    let defaultIndex = vue.routes.findIndex((item, index) => {
                        return item.path == '*';
                    });
                    if (defaultIndex >= 0) {
                        window.location.hash = vue.routes[defaultIndex].redirect;
                    }
                }
            };

            vue.init();
        }

        new Vue({
            routes: [{
                path: '/home',
                component: "<h1>首頁</h1><a href='https://github.com/biaochenxuying'>https://github.com/biaochenxuying</a>"
            }, {
                path: '/news',
                component: "<h1>新聞</h1><a href='http://biaochenxuying.cn/main.html'>http://biaochenxuying.cn/main.html</a>"
            }, {
                path: '/team',
                component: '<h1>團隊</h1><h4>全棧修煉</h4>'
            }, {
                path: '/about',
                component: '<h1>關于</h1><h4>關注公衆号:BiaoChenXuYing</h4><p>分享 WEB 全棧開發等相關的技術文章,熱點資源,全棧程式員的成長之路。</p>'
            }, {
                path: '*',
                redirect: '/home'
            }]
        });
    </script>
</body>

</html>
           

3.2 複雜版 - 内聯頁面版,帶緩存功能

首先前端用 js 實作路由的緩存功能是很難的,但像 vue-router 那種還好,因為有 vue 架構和虛拟 dom 的技術,可以儲存目前頁面的資料。

要做緩存功能,首先要知道浏覽器的 前進、重新整理、回退 這三個操作。

但是浏覽器中主要有這幾個限制:

  • 沒有提供監聽前進後退的事件
  • 不允許開發者讀取浏覽記錄
  • 使用者可以手動輸入位址,或使用浏覽器提供的前進後退來改變 url

是以要自定義路由,解決方案是自己維護一份路由曆史的記錄,存在一個數組裡面,進而區分 前進、重新整理、回退。

  • url 存在于浏覽記錄中即為後退,後退時,把目前路由後面的浏覽記錄删除。
  • url 不存在于浏覽記錄中即為前進,前進時,往數組裡面 push 目前的路由。
  • url 在浏覽記錄的末端即為重新整理,重新整理時,不對路由數組做任何操作。

另外,應用的路由路徑中可能允許相同的路由出現多次(例如 A -> B -> A),是以給每個路由添加一個 key 值來區分相同路由的不同執行個體。

這個浏覽記錄需要存儲在 sessionStorage 中,這樣使用者重新整理後浏覽記錄也可以恢複。

3.2.1 route.js

3.2.1.1 跳轉方法 linkTo

像 vue-router 那樣,提供了一個 router-link 元件來導航,而我這個架構也提供了一個 linkTo 的方法。

// 生成不同的 key 
        function genKey() {
            var t = 'xxxxxxxx'
            return t.replace(/[xy]/g, function(c) {
                var r = Math.random() * 16 | 0
                var v = c === 'x' ? r : (r & 0x3 | 0x8)
                return v.toString(16)
            })
        }

        // 初始化跳轉方法
        window.linkTo = function(path) {
                if (path.indexOf("?") !== -1) {
                    window.location.hash = path + '&key=' + genKey()
                } else {
                    window.location.hash = path + '?key=' + genKey()
                }
        }
           

用法:

//1. 直接用 a 标簽
<a href='#/list' >清單1</a>

//2. 标簽加 js 調用方法
<div onclick='linkTo(\"#/home\")'>首頁</div>

// 3. js 調用觸發
linkTo("#/list")
           

3.2.1.2 構造函數 Router

定義好要用到的變量

function Router() {
        this.routes = {}; //儲存注冊的所有路由
        this.beforeFun = null; //切換前
        this.afterFun = null; // 切換後
        this.routerViewId = "#routerView"; // 路由挂載點 
        this.redirectRoute = null; // 路由重定向的 hash
        this.stackPages = true; // 多級頁面緩存
        this.routerMap = []; // 路由周遊
        this.historyFlag = '' // 路由狀态,前進,回退,重新整理
        this.history = []; // 路由曆史
        this.animationName = "slide" // 頁面切換時的動畫
    }
           

3.2.1.3 實作路由功能

包括:初始化、注冊路由、曆史記錄、切換頁面、切換頁面的動畫、切換之前的鈎子、切換之後的鈎子、滾動位置的處理,緩存。

Router.prototype = {
        init: function(config) {
            var self = this;
            this.routerMap = config ? config.routes : this.routerMap
            this.routerViewId = config ? config.routerViewId : this.routerViewId
            this.stackPages = config ? config.stackPages : this.stackPages
            var name = document.querySelector('#routerView').getAttribute('data-animationName')
            if (name) {
                this.animationName = name
            }
            this.animationName = config ? config.animationName : this.animationName

            if (!this.routerMap.length) {
                var selector = this.routerViewId + " .page"
                var pages = document.querySelectorAll(selector)
                for (var i = 0; i < pages.length; i++) {
                    var page = pages[i];
                    var hash = page.getAttribute('data-hash')
                    var name = hash.substr(1)
                    var item = {
                        path: hash,
                        name: name,
                        callback: util.closure(name)
                    }
                    this.routerMap.push(item)
                }
            }

            this.map()

            // 初始化跳轉方法
            window.linkTo = function(path) {
                console.log('path :', path)
                if (path.indexOf("?") !== -1) {
                    window.location.hash = path + '&key=' + util.genKey()
                } else {
                    window.location.hash = path + '?key=' + util.genKey()
                }
            }

            //頁面首次加載 比對路由
            window.addEventListener('load', function(event) {
                // console.log('load', event);
                self.historyChange(event)
            }, false)

            //路由切換
            window.addEventListener('hashchange', function(event) {
                // console.log('hashchange', event);
                self.historyChange(event)
            }, false)

        },
        // 路由曆史紀錄變化
        historyChange: function(event) {
            var currentHash = util.getParamsUrl();
            var nameStr = "router-" + (this.routerViewId) + "-history"
            this.history = window.sessionStorage[nameStr] ? JSON.parse(window.sessionStorage[nameStr]) : []

            var back = false,
                refresh = false,
                forward = false,
                index = 0,
                len = this.history.length;

            for (var i = 0; i < len; i++) {
                var h = this.history[i];
                if (h.hash === currentHash.path && h.key === currentHash.query.key) {
                    index = i
                    if (i === len - 1) {
                        refresh = true
                    } else {
                        back = true
                    }
                    break;
                } else {
                    forward = true
                }
            }
            if (back) {
                this.historyFlag = 'back'
                this.history.length = index + 1
            } else if (refresh) {
                this.historyFlag = 'refresh'
            } else {
                this.historyFlag = 'forward'
                var item = {
                    key: currentHash.query.key,
                    hash: currentHash.path,
                    query: currentHash.query
                }
                this.history.push(item)
            }
            console.log('historyFlag :', this.historyFlag)
                // console.log('history :', this.history)
            if (!this.stackPages) {
                this.historyFlag = 'forward'
            }
            window.sessionStorage[nameStr] = JSON.stringify(this.history)
            this.urlChange()
        },
        // 切換頁面
        changeView: function(currentHash) {
            var pages = document.getElementsByClassName('page')
            var previousPage = document.getElementsByClassName('current')[0]
            var currentPage = null
            var currHash = null
            for (var i = 0; i < pages.length; i++) {
                var page = pages[i];
                var hash = page.getAttribute('data-hash')
                page.setAttribute('class', "page")
                if (hash === currentHash.path) {
                    currHash = hash
                    currentPage = page
                }
            }
            var enterName = 'enter-' + this.animationName
            var leaveName = 'leave-' + this.animationName
            if (this.historyFlag === 'back') {
                util.addClass(currentPage, 'current')
                if (previousPage) {
                    util.addClass(previousPage, leaveName)
                }
                setTimeout(function() {
                    if (previousPage) {
                        util.removeClass(previousPage, leaveName)
                    }
                }, 250);
            } else if (this.historyFlag === 'forward' || this.historyFlag === 'refresh') {
                if (previousPage) {
                    util.addClass(previousPage, "current")
                }
                util.addClass(currentPage, enterName)
                setTimeout(function() {
                    if (previousPage) {
                        util.removeClass(previousPage, "current")
                    }
                    util.removeClass(currentPage, enterName)
                    util.addClass(currentPage, 'current')
                }, 350);
                // 前進和重新整理都執行回調 與 初始滾動位置為 0
                currentPage.scrollTop = 0
                this.routes[currHash].callback ? this.routes[currHash].callback(currentHash) : null
            }
            this.afterFun ? this.afterFun(currentHash) : null
        },
        //路由處理
        urlChange: function() {
            var currentHash = util.getParamsUrl();
            if (this.routes[currentHash.path]) {
                var self = this;
                if (this.beforeFun) {
                    this.beforeFun({
                        to: {
                            path: currentHash.path,
                            query: currentHash.query
                        },
                        next: function() {
                            self.changeView(currentHash)
                        }
                    })
                } else {
                    this.changeView(currentHash)
                }
            } else {
                //不存在的位址,重定向到預設頁面
                location.hash = this.redirectRoute
            }
        },
        //路由注冊
        map: function() {
            for (var i = 0; i < this.routerMap.length; i++) {
                var route = this.routerMap[i]
                if (route.name === "redirect") {
                    this.redirectRoute = route.path
                } else {
                    this.redirectRoute = this.routerMap[0].path
                }
                var newPath = route.path
                var path = newPath.replace(/\s*/g, ""); //過濾空格
                this.routes[path] = {
                    callback: route.callback, //回調
                }
            }
        },
        //切換之前的鈎子
        beforeEach: function(callback) {
            if (Object.prototype.toString.call(callback) === '[object Function]') {
                this.beforeFun = callback;
            } else {
                console.trace('路由切換前鈎子函數不正确')
            }
        },
        //切換成功之後的鈎子
        afterEach: function(callback) {
            if (Object.prototype.toString.call(callback) === '[object Function]') {
                this.afterFun = callback;
            } else {
                console.trace('路由切換後回調函數不正确')
            }
        }
    }
           

3.2.1.4 注冊到 Router 到 window 全局

window.Router = Router;
    window.router = new Router();
           

完整代碼:https://github.com/biaochenxuying/route/blob/master/js/route.js

3.2.2 使用方法
3.2.2.1 js 定義法
  • callback 是切換頁面後,執行的回調
<script type="text/javascript">
        var config = {
            routerViewId: 'routerView', // 路由切換的挂載點 id
            stackPages: true, // 多級頁面緩存
            animationName: "slide", // 切換頁面時的動畫
            routes: [{
                path: "/home",
                name: "home",
                callback: function(route) {
                    console.log('home:', route)
                    var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>首頁</h2> <input type='text'> <div><a href='javascript:void(0);' onclick='linkTo(\"#/list\")'>清單</a></div><div class='height'>内容占位</div>"
                    document.querySelector("#home").innerHTML = str
                }
            }, {
                path: "/list",
                name: "list",
                callback: function(route) {
                    console.log('list:', route)
                    var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>清單</h2> <input type='text'> <div><a href='javascript:void(0);' onclick='linkTo(\"#/detail\")'>詳情</a></div>"
                    document.querySelector("#list").innerHTML = str
                }
            }, {
                path: "/detail",
                name: "detail",
                callback: function(route) {
                    console.log('detail:', route)
                    var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>詳情</h2> <input type='text'> <div><a href='javascript:void(0);' onclick='linkTo(\"#/detail2\")'>詳情 2</a></div><div class='height'>内容占位</div>"
                    document.querySelector("#detail").innerHTML = str
                }
            }, {
                path: "/detail2",
                name: "detail2",
                callback: function(route) {
                    console.log('detail2:', route)
                    var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>詳情 2</h2> <input type='text'> <div><a href='javascript:void(0);' onclick='linkTo(\"#/home\")'>首頁</a></div>"
                    document.querySelector("#detail2").innerHTML = str
                }
            }]
        }

        //初始化路由
        router.init(config)
        router.beforeEach(function(transition) {
            console.log('切換之 前 dosomething', transition)
            setTimeout(function() {
                //模拟切換之前延遲,比如說做個異步登入資訊驗證
                transition.next()
            }, 100)
        })
        router.afterEach(function(transition) {
            console.log("切換之 後 dosomething", transition)
        })
    </script>
           
3.2.2.2 html 加
  • id=“routerView” :路由切換時,頁面的視圖視窗
  • data-animationName=“slide”:切換時的動畫,目前有 slide 和 fade。
  • class=“page”: 切換的頁面
  • data-hash="/home":home 是切換路由時執行的回調方法
  • window.home : 回調方法,名字要與 data-hash 的名字相同
<div id="routerView" data-animationName="slide">
        <div class="page" data-hash="/home">
            <div class="page-content">
                <div id="home"></div>
                <script type="text/javascript">
                    window.home = function(route) {
                        console.log('home:', route)
                            // var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>首頁</h2> <input type='text'> <div><a href='#/list' >清單1</div></div><div class='height'>内容占位</div>"
                        var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>首頁</h2> <input type='text'> <div><div href='javascript:void(0);' onclick='linkTo(\"#/list\")'>清單</div></div><div class='height'>内容占位</div>"
                        document.querySelector("#home").innerHTML = str
                    }
                </script>
            </div>
        </div>
        <div class="page" data-hash="/list">
            <div class="page-content">
                <div id="list"></div>
                <div style="height: 700px;border: solid 1px red;background-color: #eee;margin-top: 20px;">内容占位</div>

                <script type="text/javascript">
                    window.list = function(route) {
                        console.log('list:', route)
                        var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>清單</h2> <input type='text'> <div><a href='javascript:void(0);' onclick='linkTo(\"#/detail\")'>詳情</a></div>"
                        document.querySelector("#list").innerHTML = str
                    }
                </script>
            </div>
        </div>
        <div class="page" data-hash="/detail">
            <div class="page-content">
                <div id="detail"></div>
                <script type="text/javascript">
                    window.detail = function(route) {
                        console.log('detail:', route)
                        var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>詳情</h2> <input type='text'> <div><a href='javascript:void(0);' onclick='linkTo(\"#/detail2\")'>詳情 2</a></div><div class='height'>内容占位</div>"
                        document.querySelector("#detail").innerHTML = str
                    }
                </script>
            </div>
        </div>
        <div class="page" data-hash="/detail2">
            <div class="page-content">
                <div id="detail2"></div>
                <div style="height: 700px;border: solid 1px red;background-color: pink;margin-top: 20px;">内容占位</div>

                <script type="text/javascript">
                    window.detail2 = function(route) {
                        console.log('detail2:', route)
                        var str = "<div><a class='back' onclick='window.history.go(-1)'>傳回</a></div> <h2>詳情 2</h2> <input type='text'> <div><a href='javascript:void(0);' onclick='linkTo(\"#/home\")'>首頁</a></div>"
                        document.querySelector("#detail2").innerHTML = str
                    }
                </script>
            </div>
        </div>
    </div>
    <script type="text/javascript" src="./js/route.js"></script>
    <script type="text/javascript">
        router.init()
        router.beforeEach(function(transition) {
            console.log('切換之 前 dosomething', transition)
            setTimeout(function() {
                //模拟切換之前延遲,比如說做個異步登入資訊驗證
                transition.next()
            }, 100)
        })
        router.afterEach(function(transition) {
            console.log("切換之 後 dosomething", transition)
        })
    </script>
           

參考項目:https://github.com/kliuj/spa-routers

5. 最後

部落格常更位址1 :https://github.com/biaochenxuying/blog

部落格常更位址2 :http://biaochenxuying.cn/main.html

足足一個多月沒有更新文章了,因為項目太緊,加班加班啊,趁着在家有空,趕緊寫下這篇幹貨,免得忘記了,希望對大家有所幫助。

微信公衆号:BiaoChenXuYing

分享 前端、後端開發等相關的技術文章,熱點資源,随想随感,全棧程式員的成長之路。

繼續閱讀