首页 > 代码库 > 理解浏览器历史记录(2)- hashchange、pushState

理解浏览器历史记录(2)- hashchange、pushState

本文也是一篇基础文章。继上文之后,本打算去研究pushState,偶然在一些信息中发现了锚点变化对浏览器的历史记录也会影响,同时锚点的变化跟pushState也有一些关联。所以就花了点时间,把这两个东西尽量都琢磨清楚。本文记录相关的一些要点及研究过程。

 

1. hashchange

 

这个部分的内容也已经补充到上文的最后了,这里只是细化一下。总的结论是:如果一个网页只是锚点,也就是location.hash发生变化,也会导致历史记录栈的变化;且变化相关的所有特性,都与上文描述的整个页面变化的特性相同。常见的改变网页锚点的方式有:

 

1)直接更改浏览器地址,在最后面增加或改变#hash;

2)通过改变location.href或location.hash的值;

3)通过触发点击带锚点的链接;

4)浏览器前进后退可能导致hash的变化,前提是两个网页地址中的hash值不同。

 

假如我们还用上文的demo(http://liuyunzhuge.github.io/blog/history/demo1.html)来测试,并按照以下步骤操作的话:

 

打开新选项卡;输入demo1.html;在地址栏后面加#1;将地址栏#1改成#2;将地址栏#2改成#3;将地址栏#3改成#1。

 

那么历史记录栈的存储状态就应该类似下面这个形式:

 

技术分享

 

由于锚点变化也会在历史记录栈添加新的记录,所以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="http://www.mamicode.com/#index">首页</a></li>‘,

            ‘        <li class="‘ + (actionName == ‘list‘ ? ‘active‘ : ‘‘) + ‘"><a href="http://www.mamicode.com/#list">列表页</a></li>‘,

            ‘        <li class="‘ + (actionName == ‘about‘ ? ‘active‘ : ‘‘) + ‘"><a href="http://www.mamicode.com/#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’)的按钮。

 

浏览器历史记录栈的变化过程应该是下面这个状态:

 

技术分享

 

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

 

这个api和history.pushState的用法完全一致,只不过它不会在历史记录栈中增加新的条目,只会影响当前条目,比如如果传递了stateObj,就会更新当前条目关联的状态对象;如果传递了url,就会替换当前条目的页面地址和更改浏览器地址栏的地址。有一种非常常见的场景,如果利用replaceState,可以优化它的实现方式。

 

网页中搜索列表是比较常见的功能:

 

技术分享

 

有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回调的:

 

技术分享

 

但是当我直接在地址栏后面添加一个#3的时候,页面上就会看到onhashchange回调打印的信息了:

 

技术分享

 

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

理解浏览器历史记录(2)- hashchange、pushState