通过window的popstate事件可以监听history的变化,但是,“popstate会在浏览器前进后退操作、history.go/back/forward调用、hashchange的时候触发”,它是一个复合事件,你根本判断不了到底是哪种情况引起的popstate。更难理解的是,我不知道为什么要把forward也设计为popstate,pop这个词的意思不就是从stack顶把最上面的一个从栈中移除么?forward明显是push的行为,怎么也放到popstate里面。这个事件明显是有设计缺陷的。回到题目,我们怎么去判断用户是点击了“退回”按钮?
我们要用到一些取巧的办法。具体我总结为3步:
- 充实history stack,以提供更多信息让我们可以在用户刷新浏览器的情况下,仍然获得上下页信息关联
- 为history创建一个私有的记录值,用以区分history当前的state和上一个state(我们在事件回调中只能拿到当前state)
- 通过第一步和第二步铺垫的内容,在回调函数中进行判断,从而判断是否是用户点击了“退回”按钮
接下来我们进行实施。在此之前,我们需要了解一些简单的知识。history是浏览器用户记录用户浏览器历史的全局对象,既然是“浏览历史”,那么就是一组单一数据的列表(有顺序),这个所谓单一数据,就是其内部的state概念,一个state表示用户在浏览一个界面(对应一个url)时留下的痕迹,不过,这个state必须由开发者定义,如果开发者不定义,那么state就是null,而且为null的时候,就没哟意义。注意,浏览器不会主动帮你记录当前的url,虽然它自己记录在了浏览器内部,你可以通过浏览器的“浏览历史”功能查看,但是,你无法在代码层面直接读取这些历史记录,你只能读取history当前的state,即通过 window.history.state
来读取,当然,你也可以通过 window.location
来读取当前的url信息。“只能读取当前的”,也就意味着这是一种栈结构的数据管理,而且,这个栈在用户刷新当前浏览器tab时,仍然是维持的。
基于这一特性,我们可以自己在state栈中,构建一个链表结构,从而可以读取整个栈内的state链。具体怎么做呢?
我们知道history有pushState和replaceState两个接口,对于SPA应用而言,整个应用中只会使用pushState和replaceState两个接口进行url的跳转(还有一种是在a标签href中使用#触发,例如 <a href="#/base/xxx">
这种也可以做到无刷新的界面跳转,但是由于它走另外一个体系,也就是hashchange的体系,是比较老的技术,现在大部分框架都是基于state的体系来做,因此,我们本文不考虑hashchange这种方案),因此,我们可以对这两个接口进行改造,从而在跳转时,对state进行信息充实。
const originalPushState = history.pushState.bind(history); // 要求在调用pushState时state必须是一个对象 history.pushState = function(state, title, url) { const { state: currentState } = history; // 获取未跳转之前的state,也就是当前的state const nextState = state || {}; nextState.prevState = currentState; originalPushState(nextState); };
通过这一改造,我们重写了history.pushState,这样我们就让每一个被push到state栈中的state拥有了prevState属性,通过该属性就可以形成一条反向链表,用以追踪state之间的关系。
接下来,我们创建一个私有的变量,用来记录history.state发生变化前的state.
let latestState = history.state; window.addEventListener('popstate', () => { const prevState = latestState; const { state } = history; latestState = state; .... })
在每一次popstate被触发时,我们去修改latestState,这样,我们就可以记录在state发生变化前的state是哪一个。
最后,我们通过latestState和当前的state进行对比,来猜测用户是否点击了“退回”按钮。
window.addEventListener('popstate', () => { const currentState = latestState; const { state: nextState } = history; latestState = nextState; if (currentState?.prev === nextState) { // 用户点击了“退回”按钮 } })
当变化后的state正好是变化前state.prev时,我们就认为用户点击了“退回”按钮。当然,这里有一个点需要提前预设,因为在不同的SPA框架中,触发popstate的可能包含在代码中调用history.back()和history.go(-1)的情况,所以,在代码层面,需要继续去区分到底是程序里调用.back还是用户点击“退回”按钮。区分方法也很简单,你可以重写back和go方法,在里面做一个标记,从而在判断时增加对该标记的判断。