理解瀏覽器歷史記錄(2)-hashchange、pushState

閱讀目錄

本文也是一篇基礎文章。繼上文之後,本打算去研究pushState,偶然在一些信息中發現了錨點變化對瀏覽器的歷史記錄也會影響,同時錨點的變化跟pushState也有一些關聯。所以就花了點時間,把這兩個東西儘量都琢磨清楚。本文記錄相關的一些要點及研究過程。

1. hashchange

這個部分的內容也已經補充到上文的最後了,這裏只是細化一下。總的結論是:如果一個網頁只是錨點,也就是location.hash發生變化,也會導致歷史記錄棧的變化;且變化相關的所有特性,都與上文描述的整個頁面變化的特性相同。常見的改變網頁錨點的方式有:

1)直接更改瀏覽器地址,在最後面增加或改變#hash; 
2)通過改變location.href或location.hash的值; 
3)通過觸發點擊帶錨點的鏈接; 
4)瀏覽器前進後退可能導致hash的變化,前提是兩個網頁地址中的hash值不同。

假如我們還用上文的demo來測試,並按照以下步驟操作的話: 
打開新選項卡;輸入demo1.html;在地址欄後面加#1;將地址欄#1改成#2;將地址欄#2改成#3;將地址欄#3改成#1。 
那麼歷史記錄棧的存儲狀態就應該類似下面這個形式:

image

由於錨點變化也會在歷史記錄棧添加新的記錄,所以history.length也會在錨點變化之後改變。每當錨點發生變化的時候,主流瀏覽器還會觸發window對象的onhashchange事件,在這個事件回調裏面,我們通過事件對象和location能夠拿到很有用三個參數:

window.onhashchange = function(event) {
    console.log(event.oldURL);
    console.log(event.newURL);
    console.log(location.hash);
};

event.oldURL返回錨點變化前的完整瀏覽器地址; 
event.newURL返回錨點變化後的完整瀏覽器地址; 
location.hash返回錨點變化後頁面地址中的錨點值。

藉助於這三個信息,可以在hashchange回調內加一些控制器的邏輯,來實現單頁程序開發裏面關鍵的路由功能。現簡單實現舉例如下:

<!doctype html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Document</title>
    <link rel="stylesheet" href="./css/quick_layout.css"/>
    <script src="./js/jquery.js"></script>
    <script src="./js/demo.js"></script>
    <style type="text/css">
        ul {
            list-style: none;
        }

        * {
            padding: 0;
            margin: 0;
        }

        .menu {
            width: 320px;
            margin: 10px auto;
            text-align: center;
        }

        .menu li,
        .menu a {
            float: left;
            width: 100px;
        }

        .menu > .active > a {
            font-weight: bold;
        }

        .menu > li + li {
            margin-left: 10px;
        }
    </style>
</head>
<body>
<div id="container" class="container"></div>
<script>
    //容器
    var Container = {
        $element: $('#container'),
        actions: {}
    };

    //action實例配置定義
    var Actions = {
        'index': {
            destroy: function () {
                this.$content.remove();
            },
            doAction: function () {
                var $content = this.$content = $('<div class="content">這是首頁的內容</div>');
                $content.appendTo(Container.$element);
            }
        },
        'list': {
            destroy: function () {
                this.$content.remove();
            },
            doAction: function () {
                var $content = this.$content = $('<div class="content">這是列表頁的內容</div>');
                $content.appendTo(Container.$element);
            }
        },
        'about': {
            destroy: function () {
                this.$content.remove();
            },
            doAction: function () {
                var $content = this.$content = $('<div class="content">這是關於頁的內容</div>');
                $content.appendTo(Container.$element);
            }
        }
    };

    //公共方法,渲染菜單
    var getMenu = function (actionName) {
        return ['<ul class="menu fix">',
            '        <li class="' + (actionName == 'index' ? 'active' : '') + '"><a href="#index">首頁</a></li>',
            '        <li class="' + (actionName == 'list' ? 'active' : '') + '"><a href="#list">列表頁</a></li>',
            '        <li class="' + (actionName == 'about' ? 'active' : '') + '"><a href="#about">關於頁</a></li>',
            '    </ul>'].join("");
    };

    function hashchange(event) {
        var actionName = (location.hash || '#index').substring(1);

        //重複
        if (Container._current && Container._current.actionName == actionName) {
            return;
        }

        //未定義
        if (!Actions[actionName]) {
            return;
        }

        //已定義的action
        var action = Container.actions[actionName];

        //銷燬之前的action
        Container._current && Container._current.destroy();

        if (!action) {
            //未定義則立即創建
            action = (function () {
                //action實例
                var ret = $.extend(true, {
                    destory: $.noop,
                    doAction: $.noop
                }, Actions[actionName]);

                //添加actionName屬性
                ret.actionName = actionName;

                //代理destroy方法,封裝公共邏輯
                ret.destroy = (function () {
                    var _destroy = ret.destroy;

                    return function () {
                        //移除菜單
                        ret.$menu.remove();

                        //調用Actions中定義的destroy方法
                        _destroy.apply(ret, arguments);
                    };
                })();

                //代理doAction方法,封裝公共邏輯
                ret.doAction = (function () {
                    var _doAction = ret.doAction;
                    return function () {
                        //添加菜單
                        var $menu = ret.$menu = $(getMenu(ret.actionName));
                        $menu.appendTo(Container.$element);

                        //調用Actions中定義的doAction方法
                        _doAction.apply(ret, arguments);
                    }
                })();

                return ret;
            })();
        }

        Container._current = action;
        action.doAction();
    }

    //初始化調用
    hashchange();
    //用hashchange當頁面切換的控制器
    window.onhashchange = hashchange;

</script>
</body>
</html>

本代碼demo可通過以下地址訪問測試:http://liuyunzhuge.github.io/blog/pushState/demo1.html。這個demo中,瀏覽器前進後退,頁面刷新,鏈接跳轉,都能保證內容正確顯示。當然這只是一個極爲簡單的舉例,真正的SPA的路由功能遠比此複雜,下一步我會花時間研究一個較爲流行的路由實現,到時再寫文來總結單頁路由的實現思路。

window.onhashchange的mdn參考:https://developer.mozilla.org/zh-CN/docs/Web/API/Window/onhashchange

以上是我瞭解到hashchange的絕大部分用得着的內容,下面要介紹的pushState,還會有一點跟它相關的東西。在SPA的路由實現中,hashchange與pushState是搭配在一起使用的,所以在真正瞭解路由實現前,把這2個東西的基礎知識瞭解透徹也是非常有必要的。

2 . pushState

有了之前對歷史記錄棧的認識,再來了解pushState就會比較容易。pushState相關的內容包含三個東西:2個api和一個事件。2個api分別是history.pushState和history.replaceState,1個事件是指window.onpopstate事件。pushState提供給我們的是一種在不改變網頁內容的前提下,操作瀏覽器歷史記錄的能力。

下面詳細看看這2個api和1個事件的內容:

1)history.pushState(stateObj,title,url)

這個方法用來在瀏覽器歷史記錄棧中當前指針後面壓入一條新的條目,然後將當前指針移到這條最新的條目;如果在壓入新條目的時候,當前指針的後面還有舊的條目,在壓入新的之後也會被廢棄掉。整體特性其實跟上一篇博客介紹的,在同一個窗口打開另外一個頁面對歷史記錄棧的作用完全相似,只不過history.pushState僅僅是添加新的條目,並且激活它,然後改變瀏覽器的地址,但是不會改變網頁內容,它也不會去驗證這個新條目對應的網頁是否存在。

這個api有三個參數,第二個參數目前瀏覽器都是忽略它的,在使用的時候一般傳入空字符串即可;第三個參數對應的是新條目的地址,如果沒有,默認就是當前文檔的地址;第一個參數是一個object對象,它會與新條目綁定在一起,可以用來存儲一些簡單的數據,不過不能存太多,firefox對它的限制是640K,這個對象可以通過onpopstate事件對象的state屬性來訪問。

爲了驗證前面這部分的理論,可以通過這個demo:http://liuyunzhuge.github.io/blog/pushState/demo2.html,按以下步驟做一些操作測試: 
打開新選項卡;輸入該demo地址;點擊demo3的鏈接;點擊demo4的鏈接;點擊demo4裏的返回;點擊demo3裏的返回;點擊pushState(‘foo’)的按鈕;點擊pushState(‘bar')的按鈕。

瀏覽器歷史記錄棧的變化過程應該是下面這個狀態: 
image

2)history.replaceState(stateObj,title,url)

這個api和history.pushState的用法完全一致,只不過它不會在歷史記錄棧中增加新的條目,只會影響當前條目,比如如果傳遞了stateObj,就會更新當前條目關聯的狀態對象;如果傳遞了url,就會替換當前條目的頁面地址和更改瀏覽器地址欄的地址。有一種非常常見的場景,如果利用replaceState,可以優化它的實現方式。

網頁中搜索列表是比較常見的功能: 
image 
有2種常見的方式來實現這樣的功能: 
一是將查詢條件區封裝好,列表展示區封裝好,當查詢條件改變的時候,利用ajax,觸發列表的查詢;但是這種方式有個不好的體驗問題就是,查詢條件更改後,如果刷新頁面,查詢條件不能恢復刷新前的狀態;所以就有了第二種方式; 
二是在查詢條件更改的時候,不用ajax更換列表,而是更新url參數,重新刷新頁面,然後在後端或在前端將查詢條件的狀態根據url裏面的參數初始化好再展示。

目前電商都是第二種方式多,一來比較簡單,二來兼容性也好。如果不考慮兼容IE9以前的瀏覽器,利用replaceState可以優化第一種做法:就是在查詢條件更改的時候,除了用ajax查詢數據,同時用replaceState更新頁面的url,把條件封裝到url參數中;當用戶刷新頁面時,根據url裏面的條件參數做查詢條件的初始化,這一步跟第二個方案的做法一致。

history.pushState和history.replaceState還有一個共同的特點就是都不會觸發hashchange,你可以下面這個demo來測試:http://liuyunzhuge.github.io/blog/pushState/demo5.html,以新選項卡打開這個demo,不管先點擊什麼按鈕,頁面上都不會看到有任何的打印信息,儘管我在代碼中是有添加window.onhashchange回調的: 
image 
但是當我直接在地址欄後面添加一個#3的時候,頁面上就會看到onhashchange回調打印的信息了: 
image

3) window.onpopstate事件

這個事件觸發的時機比較有特點: 
一、history.pushState和history.replaceState都不會觸發這個事件 
二、僅在瀏覽器前進後退操作、history.go/back/forward調用、hashchange的時候觸發 
你可以下面這個demo來驗證:http://liuyunzhuge.github.io/blog/pushState/demo6.html,這個demo裏我添加了onpopstate回調,嘗試打印一些信息,如果按以下幾組步驟測試: 
a. 打開新選項卡,輸入demo地址,點擊pushState的按鈕,再點擊瀏覽器的後退按鈕,再點擊瀏覽器前進按鈕; 
b. 打開新選項卡,輸入demo地址,點擊pushState的按鈕,點擊replaceState的按鈕,再點擊瀏覽器的後退按鈕,再點擊瀏覽器前進按鈕;
c. 打開新選項卡,輸入demo地址,點擊#yes的鏈接,再點擊瀏覽器的後退按鈕,再點擊瀏覽器前進按鈕; 
d. 打開新選項卡,輸入demo地址,點擊location.hash = '#no'的鏈接,再點擊瀏覽器的後退按鈕,再點擊瀏覽器前進按鈕。 
最後會得到的結果如下: 
a. 點擊pushState的按鈕不會有打印信息,點擊後退按鈕後會有打印信息,再點擊前進按鈕會有打印信息; 
b. 點擊pushState&replaceState的按鈕不會有打印信息,點擊後退按鈕後會有打印信息,再點擊前進按鈕會有打印信息; 
c&d. 點擊鏈接,點擊後退按鈕,點擊前進按鈕都會有打印信息。 
雖然測試的場景不多,但是也夠我們去判斷前面那兩點結論的正確性了。

比較有意思的是,history.pushState會增加歷史記錄的條目,但是不會觸發hashchange和popstate;hashchange也可以增加歷史記錄的條目,但是它卻可以觸發popstate。[疑惑]

前面介紹說到pushState和replaceState的第一個參數stateObj,會與第三個參數對應的歷史條目綁定在一塊,當popstate事件觸發的時候,意味着有新的歷史記錄條目被激活,在popstate的事件對象裏面,有一個state屬性,會返回這個激活條目關聯的stateObj對象的拷貝。一個歷史記錄條目只有當它是被pushState創建的,或者用replaceState改過的,纔可能有關聯的stateObj對象,所以當某些非這2種條件的歷史記錄條目被激活的時候,可能拿到的stateObj就是null,正如你在demo6裏面看到的打印信息顯示的那樣。

stateObj是會被持久化的硬盤上進行存儲的,至少firefox是這麼說的,我猜只要歷史記錄不銷燬,它關聯的stateObj就會一直存在。所以假如某一個網頁在用戶最後一次操作後,有關聯某個stateObj,那麼當用戶再次打開這個網頁的時候,它的stateObj也是可以被訪問的。如果要直接訪問當前網頁對應條目的stateObj,可以通過history.state屬性來訪問。

firfox,chrome在頁面首次打開時都不會觸發popstate事件,但是safari會。。。

popstate事件作用範圍僅在於一個document裏面,由於pushState和hashchange都不會改變網頁的內容也就是document,所以這樣的網頁裏面纔能有效使用popstate。假如我們輸入一個網頁,並且在它裏面添加了popstate回調;然後通過鏈接跳轉的方式轉到另外一個網頁;再點擊後退按鈕回到第一個網頁。這樣的情況,第一個網頁裏面的popstate回調,除了有可能因爲頁面初始化被觸發外,瀏覽器的後退前進是不會觸發它的,因爲這種方式改變了窗口的document。

以上就是pushState的相關內容。現在主流的SPA路由主要是靠pushState,它比hashchange的優勢,我認爲最大的一點就是url的友好性,因爲它比hashchange看起來更像是常規的跳轉操作,可是體驗上又跟hashchange一樣,不會給用戶造成瀏覽器發生了刷新的感覺;而且從url的規劃層面來說,pushState的url跟原來的url形式都是根據具體場景而定的,hashchange可能就得用同一個url加不同的hash的形式了,這種形式對於系統設計跟seo來說也是不合理的。缺點就是pushState的兼容性沒有hashchange那麼靠前。要是在移動端,這個自然就不成問題了。

pushState參考資料:

https://developer.mozilla.org/zh-CN/docs/DOM/Manipulating_the_browser_history

https://developer.mozilla.org/zh-CN/docs/Web/API/Window/onpopstate

如果您覺得本文對你有用,不妨幫忙點個贊,或者在評論裏給我一句讚美,小小成就都是今後繼續爲大家編寫優質文章的動力,流雲拜謝! 歡迎您持續關注我的博客:)

作者:流雲諸葛

出處:http://www.cnblogs.com/lyzg/

版權所有,歡迎保留原文鏈接進行轉載:)