文章起因
这篇文章的起因来源于一个需求,产品:"我需要在浏览器点击后退的时候,加一个弹框引导用户做其他的操作",老实讲这一块之前研究了很多次通过popstate
去监听,但是始终有问题没有达到理想的状态或者说并没有完全研究透彻,本来想直接回复做不了砍掉这里吧,后来出于职业道德的遵守还是说试试看吧!
后来
之后在网上遨游了一段时间找了很多实现方案最后发现有一个Api叫做prompt
,他来自于react-router,正好 项目中目前使用的路由就是react-router
jsx
import { Prompt } from 'react-router' //v5.2.0版本
我不太清楚看到这篇文章的同学有没有用到过这个Api,我大致介绍一下用法
jsx
const App = () =>{
const [isBlocking, setIsBlocking] = useState(true)
return <>
<Prompt
//这里是个Boolean 控制是否监听浏览器的后退 默认监听
when={isBlocking}
message={(_, action) => {
if (action === 'POP') {
Dialog.show({ //普普通通的弹框而已,,,,
title: '提示',
actions: [
{
key: 'back',
text: '回到浪浪山',
onClick() {
history.go(-1)
//用户选择按钮之后关闭掉监听
setIsBlocking(false)
},
},
{
key: 'saveAndBack',
text: '去往光明顶',
onClick: async () => null
},
{
key: 'cancle',
text: intl.t('取消'),
},
],
})
return false // 返回false通知该组件需要拦截到后退的操作 将执行权交给用户
}
return true //返回true 正常后退不做拦截
}}
></Prompt>
{/* ...内容部分 */}
</>
}
export default App
上面这样可以实现我的需求,但是因为之前研究过这好一阵子那会并没找到这个Api,现在找到了本着一种知其然知其所以然的态度,深究一下内部到底是怎么实现可以禁止浏览器后退的,如果你不知道就跟着一起寻找一下吧,可能需要占用一杯咖啡的时间☕️
| Prompt
最初的想法就是直接去看Prompt实现的源码就好了,看看是怎么实现的这里的逻辑 其实在看之前内心是有一些猜测的觉得可能是下面这样做的
- 可能是有一些浏览器提供的api但是我不清楚可以直接做到禁止后退,然后Prompt内部有调用
- 或者是先添加了浏览器记录然后在后退的时候监听又删除
git上找react-router源码,注意要切换到对应的版本V5.2.0,免得对不上号
react-router5.2.0版本对应的链接🔽
从这个链接点击去之后我们可以看到Prompt方法,主要是下面这一段我们捡重点解析一下
- 获取history上面的block
- 在初始化阶段将我们的message传递到block中执行,并且获取到当前的unblock
- 离开的时候执行self.release()执行卸载操作
jsx
/**
* The public API for prompting the user before navigating away from a screen.
*/
function Prompt({ message, when = true }) {
return (
<RouterContext.Consumer>
{context => {
invariant(context, "You should not use <Prompt> outside a <Router>");
if (!when || context.staticContext) return null;
// 这个context是当前使用的环境上下文我们内部路由跳转用的history的包
const method = context.history.block;
return (
<Lifecycle
onMount={self => {
//初始化的阶段执行该方法
self.release = method(message);
}}
onUpdate={(self, prevProps) => {
if (prevProps.message !== message) {
self.release();
self.release = method(message);
}
}}
onUnmount={self => {
self.release();
}}
message={message}
/>
);
}}
</RouterContext.Consumer>
);
}
既然看到了这里再继续看下 Lifecycle
方法其实这个里面就是执行了一些生命周期,这里就不解析了贴个图大家自己看下吧,都能看懂
到了这里可能有些疑惑,这里好像并没有什么其他操作就是调用了一个block,起初我以为block是原生history上面提供的方法后来去查了一下api发现上面并没有这个方法,此刻就更加好奇block内部的实现方式了
因为我们项目的上下文里面使用的history是来自于一个npm包,后面就继续看看这个history内部怎么实现的
js
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()
createBrowserHistory
传送门在这里👇🏻感兴趣的同学直接去看源码
直接看里面的block方法
jsx
let isBlocked = false
const block = (prompt = false) => {
const unblock = transitionManager.setPrompt(prompt)
if (!isBlocked) {
checkDOMListeners(1)
isBlocked = true
}
return () => {
if (isBlocked) {
isBlocked = false
checkDOMListeners(-1)
}
return unblock()
}
}
现在来分析一下上面的代码
- prompt是我们传进来的弹框组件或者普通字符串信息
- transitionManager是一个工厂函数将prompt存到了函数内部以便后面触发的时候使用
- checkDOMListeners去做挂载操作就是监听popstate那一步
- 返回出去的函数是在外面在离开的时候做销毁popstate监听的
现在按照上面的步骤在逐步做代码分析,下面会看具体的部分有些不重要的地方会做删减
| transitionManager.setPrompt
- 可以看到工厂函数里面存储了prompt
- 销毁的时机是在上面unblock的时候执行重置prompt
jsx
const createTransitionManager = () => {
let prompt = null
const setPrompt = (nextPrompt) => {
prompt = nextPrompt
return () => {
if (prompt === nextPrompt)
prompt = null
}
}
return {
setPrompt,
}
}
| checkDOMListeners
- 上面默认传了1初始化的时候会进行popstate监听
- 离开的时候传了-1移除监听
js
let listenerCount = 0
const checkDOMListeners = (delta) => {
listenerCount += delta
if (listenerCount === 1) {
addEventListener(window, PopStateEvent, handlePopState)
} else if (listenerCount === 0) {
removeEventListener(window, PopStateEvent, handlePopState)
}
}
| handlePopState
- 调用getDOMLocation获取到一个location
jsx
const handlePopState = (event) => {
handlePop(getDOMLocation(event.state))
}
- getDOMLocation 内部调用createLocation创建了一个
- createLocation内部大家感兴趣可以自己去看一下,没有什么可讲的就是创建一些常规的属性
- 比如state、pathname之类的
jsx
const getDOMLocation = (historyState) => {
const { key, state } = (historyState || {})
const { pathname, search, hash } = window.location
let path = pathname + search + hash
if (basename)
path = stripBasename(path, basename)
return createLocation(path, state, key)
}
那我们现在知道getDOMLocation是创建一个location并且传递到了handlePop方法内部现在去看看这个内部都干了啥
| handlePop
- 我们要看的主要在else里面
- confirmTransitionTo是我们上面提到的工厂函数里面的一个方法
- 该方法内部执行了Prompt并返回了Prompt执行后的结果
jsx
let forceNextPop = false
const handlePop = (location) => {
if (forceNextPop) {
forceNextPop = false
setState()
} else {
const action = 'POP'
transitionManager.confirmTransitionTo(location, action, getUserConfirmation, (ok) => {
if (ok) {
setState({ action, location })
} else {
revertPop(location)
}
})
}
}
敲黑板 重点来了!!!
现在来看下confirmTransitionTo内部的代码
jsx
const confirmTransitionTo = (location, action, getUserConfirmation, callback) => {
if (prompt != null) {
const result = typeof prompt === 'function' ? prompt(location, action) : prompt
if (typeof result === 'string') {
if (typeof getUserConfirmation === 'function') {
getUserConfirmation(result, callback)
} else {
callback(true)
}
} else {
// 重点在这里,result是我们调用block时候的返回参数 true or false
// 如果返回false 那浏览器回退将被禁止 反之则正常
callback(result !== false)
}
} else {
callback(true)
}
}
所以现在回到上面的handlePop函数我们就能推测出如果我们回调中返回的false,说明我们想阻止浏览器的回退操作,那么执行的就是revertPop方法(其实名字大家可能也能猜出来 恢复 pop操作😂)
| revertPop
- delta的逻辑是计算从开始到目前为止走过的路径做个差值计算
- 这个时候正常来讲delta应该是1
- 我们看最后一个逻辑就好这里是禁止撤回的重点
- 当delta为1的时候就执行了go(1)
- go方法内部实际调用了window.history.go(n)
js
const revertPop = (fromLocation) => {
const toLocation = history.location
let toIndex = allKeys.indexOf(toLocation.key)
if (toIndex === -1)
toIndex = 0
let fromIndex = allKeys.indexOf(fromLocation.key)
if (fromIndex === -1)
fromIndex = 0
const delta = toIndex - fromIndex
if (delta) {
forceNextPop = true
//window.history.go
go(delta)
}
}
之前我看到这里有个疑问就是如果最后的结果只是调用了go的话,那这个好像我们自己监听也可以实现一下于是就有了以下代码
jsx
function History() {
this.handelState = function (event) {
history.go(1)
}
this.block = function (Prompt) {
window.addEventListener('popstate', this.handelState)
return () => {
window.removeEventListener('popstate', this.handelState)
}
}
}
const newHistory = new History()
等到我实验的时候发现页面回退确实阻止住了,但是会闪一下上一个页面,给大家举个例子
Step1
我从PageA页面一路push到PageC
PageA -> PageB -> PageC
Step2
从PageC页面点击返回,之后页面的过程是这样的
PageC -> PageB -> PageC
就是说我本应该在PageC点击撤回,理想的效果是就停留在了PageC页面,但是目前效果是先回到了PageB因为我使用了go(1)就又回到了PageC,相当于在点击回退的时候多加载了PageB页面
这使我又陷入了沉思,其实研究到这里如果不把这个弄懂之前的努力就白费了,抱着这种想法又扎到了history代码中遨游一番
之后光看代码捋逻辑对这确实有些迷茫,没有办法只能开始调试history的源码了,这里比较简单,history源码下载下来之后做几个步骤
- 安装history相关依赖package
- 启动服务会有一个本地域名
- 之后在你真实项目中引入这个资源开始做调试
后面其实就一直打log和断点不断调试history源码查看执行路径,发现了问题所在
刚才上面提到的handlePop方法内部有一段代码那会忽略掉了,就是ok为true的时候,因为之前一直关注false的情况忽略了这里,后面把这个里面就研究了一下才明白其中的原委
js
if (ok) {
setState({ action, location })
} else {
revertPop(location)
}
| setState
这个方法做了几件事
- 更新本地history状态,nextState可以理解为下一个目标地址其中包含location和action
- 更新本地history的长度这里没有完全搞懂为什么要更新一下长度,但是猜测可能是为了和原生history状态一直保持同步吧防止出现意外情况
- 这里又看到了transitionManager工厂函数,此时调用的notifyListeners这个就是解决我们上面的谜团所在
js
const setState = (nextState) => {
// 1.更新本地history状态
Object.assign(history, nextState)
history.length = globalHistory.length
//2.更新依赖history相关的订阅方法
transitionManager.notifyListeners(
history.location,
history.action
)
}
| notifyListeners
notifyListeners更新订阅的方法,我直接把这块代码贴出来了,一个发布订阅模式没什么好讲的
js
let listeners = []
const appendListener = (fn) => {
let isActive = true
const listener = (...args) => {
if (isActive)
fn(...args)
}
listeners.push(listener)
return () => {
isActive = false
listeners = listeners.filter(item => item !== listener)
}
}
const notifyListeners = (...args) => {
listeners.forEach(listener => listener(...args))
}
重点的地方是react-router内部会调用history中的listen,这个listen方法会调用上面的appendListener进行存储,之后在合适的时间点执行react-router中传递的方法
这些方法的入参是目标页面的history属性(location,action),在接收到参数的时候根据参数中的location更新当前的页面
现在可以得出结论我们上面的例子不能成功的原因,是因为我们在执行的过程中没有绕过setState(因为此刻没有能让ok返回false的操作)所以当我们页面路径变更的时候自然页面也会更新
最后整体捋一下这个流程吧
到这里其实细心的同学会发现浏览器的回退我们确实是控制不了的只要点击了就一定会执行后退的操作。在history中针对block方法来说做的事情其实就下面这几步
- 封装了一个自己本地的history,当然跳转能力等还是依赖原生的history
- 在URL路径变更的时候history可以决定是否通知单页面应用的路由
- 如果通知了就相当于我们的ok是true,需要页面也更新一下
- 如果未通知相当于ok是false,就不需要页面更新
这就是为什么history里面的block为什么可以用go就能实现当前页面回退,本质上浏览器历史记录确实回退了,但是history并没有通知应用页面更新,并且继续执行go(1)这样给用户看到的视角就是页面没有回退并且url也没有变化
结论
其实在使用的时候history还是有一些问题如果当前页面reload了,那么revertPop里面的go就不会执行,因为此时的delta是0,这样就会导致即使页面没有变化但是url更新成了上一个记录
说一下我的看法这可能是这个history遗留的bug也可能是有意而为之但是也不重要了我们搞懂了原理就好。
其实浏览器后退加监听的行为感觉还是一个比较高频的需求操作,后面我打算自己写一个插件专门就做后退拦截的操作~
到底了------
今天圣诞节了 祝你们圣诞节快乐 Merry Christmas🎄🎄🎄 !!!希望都能收到喜欢的礼物🎁