领导:“来同学在用户点击后退的时候加个弹窗做个引导” 同学:”不好意思我会但是不想加~~“

文章起因

这篇文章的起因来源于一个需求,产品:"我需要在浏览器点击后退的时候,加一个弹框引导用户做其他的操作",老实讲这一块之前研究了很多次通过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版本对应的链接🔽

github.com/remix-run/r...

从这个链接点击去之后我们可以看到Prompt方法,主要是下面这一段我们捡重点解析一下

  1. 获取history上面的block
  2. 在初始化阶段将我们的message传递到block中执行,并且获取到当前的unblock
  3. 离开的时候执行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 方法其实这个里面就是执行了一些生命周期,这里就不解析了贴个图大家自己看下吧,都能看懂

github.com/remix-run/r...

到了这里可能有些疑惑,这里好像并没有什么其他操作就是调用了一个block,起初我以为block是原生history上面提供的方法后来去查了一下api发现上面并没有这个方法,此刻就更加好奇block内部的实现方式了

因为我们项目的上下文里面使用的history是来自于一个npm包,后面就继续看看这个history内部怎么实现的

js 复制代码
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()

createBrowserHistory

传送门在这里👇🏻感兴趣的同学直接去看源码

github.com/remix-run/h...

直接看里面的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()
    }
}

现在来分析一下上面的代码

  1. prompt是我们传进来的弹框组件或者普通字符串信息
  2. transitionManager是一个工厂函数将prompt存到了函数内部以便后面触发的时候使用
  3. checkDOMListeners去做挂载操作就是监听popstate那一步
  4. 返回出去的函数是在外面在离开的时候做销毁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源码下载下来之后做几个步骤

  1. 安装history相关依赖package
  2. 启动服务会有一个本地域名
  1. 之后在你真实项目中引入这个资源开始做调试

后面其实就一直打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方法来说做的事情其实就下面这几步

  1. 封装了一个自己本地的history,当然跳转能力等还是依赖原生的history
  2. 在URL路径变更的时候history可以决定是否通知单页面应用的路由
  3. 如果通知了就相当于我们的ok是true,需要页面也更新一下
  4. 如果未通知相当于ok是false,就不需要页面更新

这就是为什么history里面的block为什么可以用go就能实现当前页面回退,本质上浏览器历史记录确实回退了,但是history并没有通知应用页面更新,并且继续执行go(1)这样给用户看到的视角就是页面没有回退并且url也没有变化

结论

其实在使用的时候history还是有一些问题如果当前页面reload了,那么revertPop里面的go就不会执行,因为此时的delta是0,这样就会导致即使页面没有变化但是url更新成了上一个记录

说一下我的看法这可能是这个history遗留的bug也可能是有意而为之但是也不重要了我们搞懂了原理就好。

其实浏览器后退加监听的行为感觉还是一个比较高频的需求操作,后面我打算自己写一个插件专门就做后退拦截的操作~

到底了------

今天圣诞节了 祝你们圣诞节快乐 Merry Christmas🎄🎄🎄 !!!希望都能收到喜欢的礼物🎁

相关推荐
腾讯TNTWeb前端团队5 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰8 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪8 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪8 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy9 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom10 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom10 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom10 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom10 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom10 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试