如何通过history.pushState获取有关历史更改的通知?

230
现在HTML5引入了history.pushState来改变浏览器历史记录,网站开始将其与Ajax结合使用,而不是更改URL的片段标识符。可悲的是,这意味着无法再通过onhashchange检测这些调用。
我的问题是:有没有可靠的方法(黑客?;))来检测网站何时使用history.pushState?规范没有说明任何被引发的事件(至少我找不到)。我尝试创建一个外观,并替换window.history为自己的JavaScript对象,但它根本没有任何效果。
进一步说明:我正在开发一个Firefox插件,需要检测这些更改并相应地采取行动。我知道几天前有一个类似的问题,问是否监听一些DOM事件会很有效,但我宁愿不依赖于它们,因为这些事件可能由许多不同的原因生成。
更新: 这里有一个jsfiddle(使用Firefox 4或Chrome 8),显示当调用pushState时不会触发onpopstate(或者我做错了什么?请随意改进!)。
更新2:
另一个(次要的)问题是在使用pushState时,window.location没有更新(但我认为我已经在SO上读到过这个问题)。

16个回答

247

5.5.9.1 事件定义

在导航到会话历史记录条目时,popstate 事件将在某些情况下触发。

根据这个说明,当使用pushState时,并没有理由触发popstate事件。但是像pushstate这样的事件会很方便。由于history是一个宿主对象,您应该小心使用它,但Firefox在这种情况下似乎很友好。这段代码完全可以正常工作:

(function(history){
    var pushState = history.pushState;
    history.pushState = function(state) {
        if (typeof history.onpushstate == "function") {
            history.onpushstate({state: state});
        }
        // ... whatever else you want to do
        // maybe call onhashchange e.handler
        return pushState.apply(history, arguments);
    };
})(window.history);

您的 jsfiddle 变成了:

window.onpopstate = history.onpushstate = function(e) { ... }

你可以以同样的方式对window.history.replaceState进行猴子补丁。

注意:当然,你可以简单地将onpushstate添加到全局对象中,甚至可以通过add/removeListener使其处理更多事件。


2
@user280109 - 如果我知道的话,我会告诉你。 :) 我认为目前在Opera中没有办法做到这一点。 - gblazex
也许这是一个基础问题,但为什么要构建一个函数并将 window.history 作为参数调用它,而不是直接执行代码并在 history 参数的位置使用 window.history 呢? - cprcrack
1
@cprcrack 这是为了保持全局范围的清洁。您必须保存原生的 pushState 方法以供以后使用。因此,我选择将整个代码封装到 IIFE 中,而不是使用全局变量:http://en.wikipedia.org/wiki/Immediately-invoked_function_expression - gblazex
什么是常青浏览器的最佳解决方案?我不想使用轮询。 - SuperUberDuper
这是我们下面定义的自定义事件处理程序。请阅读整个答案。 - gblazex
显示剩余16条评论

41

我使用简单的代理来实现这个功能。这是替代原型的一种方式。

window.history.pushState = new Proxy(window.history.pushState, {
  apply: (target, thisArg, argArray) => {
    // trigger here what you need
    return target.apply(thisArg, argArray);
  },
});

这对我很有用,只需将我的触发器添加到window.onpopstate以覆盖用户按下返回按钮即可。 - Avindra Goolcharan
4
这对我完美地起作用了(我不需要支持IE)。然而,提醒一下:这是TypeScript。要转换为纯JavaScript,您需要删除每个参数上的: any。我还要指出,在TypeScript中: any是多余的。 - trlkly
4
我后来发现需要稍作更改。在原有的代码里,“触发”代码会在页面历史记录被更改之前执行,这使得测试新URL变得困难。因此,我在apply函数内部进行了修改:let output = target.apply(thisArg, argArray); /* 触发代码 */ return output; - trlkly

17

终于找到了一种“正确”的方法(没有猴子补丁,也不会破坏其他代码的风险)来做这件事!它需要向您的扩展添加一个特权(是的,评论中有帮助指出这一点的人,它是针对扩展API的这就是要求的内容),并使用后台页面(而不仅仅是内容脚本),但它确实起作用。

你想要的事件是browser.webNavigation.onHistoryStateUpdated,当页面使用history API更改URL时触发该事件。它只会为您有权限访问的站点触发,如果需要,您还可以使用URL过滤器进一步减少垃圾邮件。它需要webNavigation权限(当然还需要相关域的主机权限)。

事件回调可获取选项卡ID,正在“导航”的URL以及其他相关详细信息。如果在事件触发时需要在内容脚本中执行操作,则可以直接从后台页面注入相关脚本,或者在内容脚本加载时打开与后台页面的端口,让后台页面将该端口保存在按选项卡ID索引的集合中,并在事件触发时从后台脚本向内容脚本发送消息(通过相关端口)。


24
该API仅适用于网络扩展和插件,不是常规的DOM API。不幸的是,抱歉无法提供更多帮助。 - ccnokes
5
正确,但这正是OP根据问题所尝试构建的。对于纯网页内容,我认为您别无选择,只能包装函数和/或事件。 - CBHacking

7

感谢@KalanjDjordjeDjordje提供的答案。我试图将他的想法完善为一个完整的解决方案:

const onChangeState = (state, title, url, isReplace) => { 
    // define your listener here ...
}

// set onChangeState() listener:
['pushState', 'replaceState'].forEach((changeState) => {
    // store original values under underscored keys (`window.history._pushState()` and `window.history._replaceState()`):
    window.history['_' + changeState] = window.history[changeState]
    
    window.history[changeState] = new Proxy(window.history[changeState], {
        apply (target, thisArg, argList) {
            const [state, title, url] = argList
            onChangeState(state, title, url, changeState === 'replaceState')
            
            return target.apply(thisArg, argList)
        },
    })
})

这个 + @run-at document-start 对我的用户脚本非常有效。谢谢! - Mattwmaster58

6

我曾经使用过这个:

var _wr = function(type) {
    var orig = history[type];
    return function() {
        var rv = orig.apply(this, arguments);
        var e = new Event(type);
        e.arguments = arguments;
        window.dispatchEvent(e);
        return rv;
    };
};
history.pushState = _wr('pushState'), history.replaceState = _wr('replaceState');

window.addEventListener('replaceState', function(e) {
    console.warn('THEY DID IT AGAIN!');
});

这几乎与galambalazs所做的相同。

虽然通常会过度,而且可能在所有浏览器中都无法工作。(我只关心我的浏览器版本。)

(它留下了一个变量_wr,因此您可能需要对其进行包装或其他处理。我不关心那个。)


1
几乎与这个答案完全相同。 - ggorlen

6

除了其他答案之外,我们可以使用History接口而不是存储原始函数。

history.pushState = function()
{
    // ...

    History.prototype.pushState.apply(history, arguments);
}

3

我宁愿不覆盖原生的history方法,因此这个简单的实现创建了一个名为eventedPushState的自己的函数,该函数只是分发一个事件并返回history.pushState()。 两种方法都可以正常工作,但我认为这种实现稍微更清晰一些,因为原生方法将继续以未来开发人员所期望的方式执行。

function eventedPushState(state, title, url) {
    var pushChangeEvent = new CustomEvent("onpushstate", {
        detail: {
            state,
            title,
            url
        }
    });
    document.dispatchEvent(pushChangeEvent);
    return history.pushState(state, title, url);
}

document.addEventListener(
    "onpushstate",
    function(event) {
        console.log(event.detail);
    },
    false
);

eventedPushState({}, "", "new-slug"); 

1

galambalazs的答案window.history.pushStatewindow.history.replaceState进行了monkey patch,但由于某些原因它停止工作了。这里有一个替代方案,不太好看,因为它使用轮询:

(function() {
    var previousState = window.history.state;
    setInterval(function() {
        if (previousState !== window.history.state) {
            previousState = window.history.state;
            myCallback();
        }
    }, 100);
})();

有没有轮询的替代方案? - SuperUberDuper
@SuperUberDuper:请看其他答案。 - Flimm
1
@Flimm 轮询不好看,甚至很丑。但是你说了,没别的选择。 - Yairopro
这比猴子补丁好多了,猴子补丁可以被其他脚本撤销,并且你不会收到任何通知。 - Ivan Castellanos
我不知道为什么这个被踩了。这是一个非常可行的解决方案,而且运作得非常好。有时候最简单的答案才是最好的。 - Mark Shust at M.academy

1

嗯,我看到很多替换historypushState属性的例子,但我不确定这是一个好主意,我更喜欢创建一个基于服务事件的类似于历史记录的API,这样就不仅可以控制推送状态,还可以替换状态,并且为许多不依赖全局历史记录API的其他实现打开大门。请查看以下示例:

function HistoryAPI(history) {
    EventEmitter.call(this);
    this.history = history;
}

HistoryAPI.prototype = utils.inherits(EventEmitter.prototype);

const prototype = {
    pushState: function(state, title, pathname){
        this.emit('pushstate', state, title, pathname);
        this.history.pushState(state, title, pathname);
    },

    replaceState: function(state, title, pathname){
        this.emit('replacestate', state, title, pathname);
        this.history.replaceState(state, title, pathname);
    }
};

Object.keys(prototype).forEach(key => {
    HistoryAPI.prototype = prototype[key];
});

如果您需要EventEmitter的定义,上面的代码基于NodeJS事件发射器:https://github.com/nodejs/node/blob/36732084db9d0ff59b6ce31e839450cd91a156be/lib/events.jsutils.inherits的实现可以在此处找到:https://github.com/nodejs/node/blob/36732084db9d0ff59b6ce31e839450cd91a156be/lib/util.js#L970

我刚刚编辑了我的回答并提供了所需的信息,请查看! - Victor Queiroz
@VictorQueiroz 这是一个很好且干净的封装函数的想法。但是,如果某个第三方库调用pushState,您的HistoryApi将不会被通知。 - Yairopro

1

我认为即使你可以修改本地函数,也不是一个好主意,你应该始终保持应用程序的范围,因此一个好的方法是不使用全局pushState函数,而是使用自己的函数:

function historyEventHandler(state){ 
    // your stuff here
} 

window.onpopstate = history.onpushstate = historyEventHandler

function pushHistory(...args){
    history.pushState(...args)
    historyEventHandler(...args)
}

<button onclick="pushHistory(...)">Go to happy place</button>

请注意,如果任何其他代码使用本机pushState函数,您将不会收到事件触发(但如果发生这种情况,您应该检查您的代码)。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接