前言
本次主题,大家都能在网上搜到许多解决方案的文章,本篇我的文章所给你带来不一样的地方是:
- pushState和popState的关系许多细节干货介绍,网上都没有细说的细节
- 完整的方案介绍,虽然思路跟其他文章差不多,但是实际写起来的细节点更为详细,值得留意
- 网上大多数介绍的popState方案,也有文章提及到hash的方案,但是并无代码提供,基本上都是说思路一样,但是其实真的写起来,需要注意点会略有不同
- 发现一些浏览器差异问题,存在一些怪异现象,供大家了解
- 文末提供工具函数,方便大家直接拿来即用
背景介绍
需要实现一个效果,在用户进行了url后退操作,提供一个二次弹窗确认是否需要离开(或有些需求直接不给后退),一般都是处于有表单编辑输入的页面,以防客户输入了一堆内容,不小心触发了回退操作,导致内容丢失了。
所以关键点就在于,需要监听到回退的事件,然后做出自己的处理逻辑。而要实现上述的效果,有两个方法:
- 监听popstate
- 监听hashchange
思路
首先我们要知道,前端领域里浏览器的后退行为是无法阻止的,即你点击浏览器上的后退按钮,不论怎样,浏览器就是会后退一个访问记录。所以我们要实现拦截阻止这个后退行为,得使用障眼法。
希望在不刷新影响当前界面的情况下,插入一条新的url访问记录,那么这样当我们点击返回操作的时候,就会仍然回到当前页面,但是仍然希望不会发生刷新重载的结果。
要达到上述目的的关键是,url的变化不会影响到页面的变化,这么一说,是不是很熟悉,不知道各位是否脑海中立即呈现一个东西,我们很常见的就能达到这个效果。
没错,就是hash
,当然,除了hash
外,其实还有一个方法,就是pushSate
所以下文就是围绕着这两种方法来说明解决方案
监听popstate
使用该方案,我希望你能基本知道两个js的api------pushState & popstate
当你看完官方文档后,你可能会觉得你读懂了,理解了。那么我向你提问几个问题,如果你能清晰回答出来,那就证明,你真的懂了:
- 当使用
pushSate
时传入了一个新的url地址,此时界面变化是怎样,浏览器地址是怎样,会重新加载新的url吗? - 当你在某个页面上监听了
popstate
,从别的url上切回到这个页面(不论是前进还是后退),会不会触发popstate
- 多次url地址的切换,是所有页面都是触发到
popstate
事件吗? - 多个页面添加了
popstate
,实际上到底哪个会被触发? popstate
和pushSate
的关系是怎样的?- 还有什么其他方式会导致
popstate
的发生
当你心中尚存上述问题的疑问,那么请继续阅读下面我通过实践总结出来的一些细节点,把一些官方资料没说得很清楚的或者漏说的一些细节点补充一下。
当然你不是很清晰也没关系,对于你要解决拦截阻止后退这个需求不受到影响,这里只是提供一些干活给大家。
pushState 和 popstate的理解
pushState
不会触发页面跳转,仅仅是url地址发生变动,但是不会加载真实url对应的页面- 通过
pushState
方法添加的历史会话记录,在添加的会话记录A与被添加的会话记录B中来回前进与后退(包括浏览器上的前进与后退按钮,以及js执行history
的back/forward/go
方法),都会触发绑定在对应页面页面上的popstate
事件;也不会发生页面的改变(除非你监听popstate
事件主动做出的页面改变等),即不会去加载真实url对应的页面: 若在A真实页面上,与pushSate
出来的B进行一个会话记录的访问变化,就会触发的是A页面上绑定的popstate
事件。若在B真实页面上,就会触发的B上绑定的popsate
事件。反正要牢记,popstate
事件只在A和B这有pushstate
建立联系的会话记录上变化才会触发,以当前真实页面的popstate
为准。 - 若离开了A页面(包括url显示B地址,但是还是在A页面上的情况),绑定在A上的
popstate
事件就会销毁了。所以当你从别的页面回退到A页面,不会触发A上的popstate
事件,因为回退的时候该事件才重新开始建立监听绑定,不是之前的那次监听绑定了。或者是回退到因A通过pushState
产生的B页面上时,此时时真实的对应url上的B页面,这样更加不会触发A上的popstate
事件,都不是一个页面,此时window.onpopstate
是null,除非B页面自己也重新建立了绑定监听。 - 尽管发生了B的的跳转,但是通过访问历史会话记录的方式重新回到B,
history.state
始终都是当时会话记录的state
情况,即pushState
的state
的值。重新通过地址访问B的地址,此时是一个新的会话记录,所以这种情况下history.state
不是当时在A上pushState
的state
的值。
资料上写着的 【每当用户导航到新的 state,都会触发 popstate 事件】,一开始咋一看,以为是要导航到的state不一样,才会触发popstate
事件,实际上并不是的,不用管state的值的情况。
上述例子是两个会话历史的说明,多条亦是如此。重点在于是否由pushState
产生的。
hash
的变化,如通过location.hash
来设置hash
值,则也会触发popstate
,跟是不是pushState
产生的记录无关,只要改变了hash
就会触发了。
实现
就算你真的没有深入了解过pushState
和 popstate
,大概知道啥作用,其实也不太影响你对接下来解决方案的理解那接下来,正式说解决方案。
- 在进入需要拦截阻止浏览器后退行为的页面后,马上使用
pushState
插入一条记录,以及添加popstate
事件的监听。 - 当用户触发了后退行为,就会触发了
popstate
事件的监听函数,函数里需要使用pushState
再次插入一条记录,目的是为了当用户再次点击后退按钮,仍然会触发后退效果。 - 若你的需求需要弹出弹窗给用户选择是否继续后退,则函数里就需要触发一个弹窗,当用户选择确定后退,则调用
histroy.go(-2)
触发后退两次记录,因为此时有两条记录是代表当前页的,一条是页面自身的记录,一条是pushState
插入的记录,所以你的目的记录是两条前的记录。
是不是很简单,那么用代码展示下:
js
// 第一个入参state信息若无特别需要,则可以随便写
function addStopHistory () {
history.pushState({
id: 'stopBack'
}, '', window.location.href)
}
addStopHistory()
// 若有需要,则需区分是否是hash的变动引起的popstate
window.addEventlistener('popstate', () => {
addStopHistory()
// 有如果需要弹窗提示
// ...
// 需要确定后退则 histroy.go(-2)
})
需要注意的是,当你需要解绑添加的popstate
事件时,你需要主动执行一次histroy.go(-2)
,目的是为了消灭主动插入的新的记录,不然当真的需要点击后退时,第一次后退是不会成功的,得点两次
监听hashchange
做法同样很简单:
- 在进入需要拦截阻止浏览器后退行为的页面后,马上使用
location.hash
插入一条记录,以及添加hashchange
事件的监听。 - 当用户触发了后退行为,就会触发了
hashchange
事件的监听函数,函数里需要使用location.hash
再次插入一条记录,目的是为了当用户再次点击后退按钮,仍然会触发后退效果。当然这里不同于pushState
,主动添加hash
也是会引起hashchange
的,所以需要区分是后退行为引起的还是主动插入的。 - 若你的需求需要弹出弹窗给用户选择是否继续后退,则函数里就需要触发一个弹窗,当用户选择确定后退,则调用
histroy.go(-2)
触发后退两次记录,因为此时有两条记录是代表当前页的,一条是页面自身的记录,一条是location.hash
插入的记录,所以你的目的记录是两条前的记录。
那么用代码展示下:
js
// 这个变量是用来标识此时是hash变化是主动插入记录所用的,则不要执行hashchange的内容了
let hashAdding = false
function addStopHistory () {
location.hash = 'stopback'
}
// 注意不要立马页面执行改变hash,不然浏览器会认为这是一个历史记录,得延迟一些执行
hashAdding = true
addStopHistory()
window.addEventlistener('hashchange', () => {
// 主动插入记录引起的hashchange就不用执行真正的阻止拦截行为了
if (hashAdding) {
hashAdding = false
return
}
hashAdding = true
addStopHistory()
// 有如果需要弹窗提示
// ...
// 需要确定后退则 histroy.go(-2)
})
需要注意的是,当你需要解绑添加的popstate
事件时,你需要主动执行一次histroy.go(-2)
,目的是为了消灭主动插入的新的记录,不然当真的需要点击后退时,第一次后退是不会成功的,得点两次。
怪异现象
这里发现有两个问题:
- 有些页面会报错
Use of history.pushState in a trivial session history context, which maintains only one session history entry, is treated as history.replaceState.
- 如果不对界面进行任何操作(随便点击一下界面也好呀,就是让页面聚焦),则不会触发
popstate
和hashchange
经过测试发现,上述的怪异问题表现,不同浏览器表现不同的,像FireFox
就压根没有上述问题。Chrome
自己进行了优化,因此上述表现更为明显,它把上述场景视为一个是非必要的情况,因此给优化了。像Edge
和360
等浏览器,也有不同程度的优化。
那么接下来简单了解下这里的两个问题。
问题一
bash
Use of history.pushState in a trivial session history context, which maintains only one session history entry, is treated as history.replaceState.
这个问题的产生是使用了pushState
,网上搜索了一番,没找到对应的解释,看来较少人遇到或使用。
那么这个问题产生的影响结果是:无法插入一条新的历史记录。
因为没有对应的解释,只能看提示推测了,英文翻译过来的意思就是
scss
使用history.pushState在一个简单的会话历史上下文中(它只维护一个会话历史条目)被视为history.replacestate
提示得很隐晦,具体是个啥也得靠猜。
既然这样,尝试去解决它,从解决手段来反推敲原因,也不失一个好办法。
经过多种测试:
- 进入页面后,设置定时器延时执行第一次的
pushState
。 √ - 在页面的
load
事件里执行第一次的pushState
。 × - 进入页面后先
replaceState
赋予一个state
,再用pushState
。×
最终还是使用setTimeout
生效,而且时间还不能太短,我这边测试700ms OK。从这个表现来看,是浏览器自身做的优化,它把极短时间内二次变动的地址信息,把这个添加行为视为原本url想要的最终效果,故合并在一起了。
当然因为我是简单的demo测试,没有类似异步之类的各种复杂渲染过程,在实际项目中,你正常调用pushState
本身的时机可能就是延迟后的
这个跟通过location.hash
改变url的hash很相似,如果你进入页面的立马改变hash,也是同样会把当前url和改变hash后的url合并成一个历史记录。
经过测试,发现因为简单的一个页面demo测试,会发生上述问题,当放到几个实际的项目中,其实较少发生上述情况,概率上来讲较少发生。
问题二
尽管通过pushState
或改变hash
能插入一条新的历史记录了,如果不对界面进行任何操作,则不会触发popstate
和hashchange
。同样该问题也是存在浏览器差异,也是浏览器自身的一个优化,只不过此时对我们来讲是一个"负优化"而已。
还是火狐浏览器坚挺,其他浏览器多少都会有这个优化,如chrome、360、edge
这个暂时没有解决办法,但是正常使用来讲,从实际产品使用来讲,本身你进入了一个界面,点都没点下,就点击后退了,不阻止也是正常的。而但凡你对页面点击一下,证明你关心过当前界面,也就能成功拦截阻止了。你说它这样人性化嘛,也的确挺智能的。。。
注意上述问题一和二,可能随着浏览器的版本迭代而变化,毕竟这本身只是一个浏览器自身优化问题。
工具函数
上述思路,两种方法都介绍完毕了,并且细节需要留意的点也都提及了。我这里已经封装好了一个工具函数了,大家可以直接引用来使用,一键开启关闭功能。
安装
csharp
npm i uiueutils
// or
pnpm add uiueutils
// or
yarn add uiueutils
使用
js
/**
* 方法入参说明
* @param {String} type - 实现拦截的方式,分为使用pushState和添加hash两种,值对应为 state 和 hash
* @param {Function} callback - 监听到回退的实践函数,例如添加二次确认弹窗等
* @param {OBject} options - { timer, msg } timer:延迟添加拦截的秒数;msg:添加的state值或hash值
* @returns {Function} 用于取消拦截的函数,调用一次即取消了拦截
*/
import { backWatcher } from 'uiueutils'
// 添加拦截
backWatcher.add('state')
// 取消拦截,例如在你页面关闭时调用
backWatcher.remove()
// 添加拦截回调函数
const remove = backWatcher.add('state', () => {
console.info('已拦截')
})
// 取消拦截,跟backWatcher.remove()一样
remove()
// 添加拦截,并延迟执行,且添加的hash值是stop
backWatcher.add('hash', null, {
timer: 700,
msg: 'stop'
})
如果本篇文章对你有所帮助,欢迎点个赞吧~感谢!
文章为个人原创,未经允许请勿私自转载