对于页面的”返回“不会更改路由的问题,这样处理比较合适🎯🎯

3,945 阅读5分钟

我正在参加「掘金·启航计划」

注意:对比于本文以下的方法,现在有更好的方法解决【页面的”返回“不会更改路由的问题】,具体请看# 为了彻底解决路由返回安全的问题,Chrome提供了navigation.entries这个API

🏃 场景描述

在页面中,经常会带有“返回”按钮。对于这些返回功能的实现,可能会有以下两种常见的处理方式:

  1. 调用加载历史栈的前一个地址API,例如vue-router提供的router.back(),如下代码所示。

    back(){
      this.$router.back()
    }
    

    但这种实现方式存在的问题是:如果用户是直接在浏览器地址栏输入URL进入该页面,当用户点击返回按钮时,会因为历史栈只存在当前地址而无跳转反应。

  2. 直接跳转到一个写死的对应上一个页面的地址,如下代码所示。

    back(){
     // 跳转到该地址上一级的页面
      this.$router.push('/xxx')
    }
    

    但这种写法存在的问题,如果该页面可以被多个不同地址的页面跳转进入访问。则当点击返回时,就有可能返回不到用户之前访问的页面。

    举一个常见的场景:如果用户处于表格页面,其URL/table?current=3current代表表格分页页码。当用户点击查阅表格中一个条目而进入详情页面,然后再试图通过点击详情页面的返回按钮回到刚刚访问的表格页面时,如果开发者写死了返回地址为/table,表格会因为current的缺失而呈现第 1 页的数据,和用户想继续查看第 3 页的数据的行为预期不一致,从而影响用户的体验。

那么,有没有一种方法可以完美解决上述的情况呢。

🏄 思路分析

解决思路其实很简单:在每次返回时,看一下历史栈是否存在前一个地址,如果有直接调用back()之类的API,如果没有,则直接跳转到写死的对应上一个页面即可,如下所示:

back() {
  // 通过hasBackHistory布尔量来判断历史栈是否存在前一个地址
  if (this.hasBackHistory) {
    this.$router.back();
  } else {
    this.$router.push('xxx');
  }
}

那么问题是,如何知道历史栈是否存在前一个地址呢?只要同时符合以下两种条件都可以判断存在前一个地址

  1. 存在前一个路由:无论是vue-router还是react-router,都会生成一个初始路由,我们可以通过判断当前路由下的前一个路由是否等于初始路由
  2. 浏览器的导航行为类型为push:如果是replacepopgoback或者浏览器前进后退按钮导致的路由变动),其前一个路由虽然会被vue-routerreact-router记录,但本质上是不可以跳转的过去的。

由于vue-routerreact-router的设计和提供的API不一样,因此对于上述思路的实现方式都不一样。下面的解决方案中会分vuereact两种场景去分析如何得知历史栈是否存在前一个地址

🎣 解决方案

🏊 在 Vue 项目中如何实现

适用的依赖版本为:

  • vue: 2.6以上
  • vue-router3.6以上

vue中可以直接借用vue-router提供的beforeRouteEnter来处理。实现步骤如下所示:

  1. 由于Vue-Router没有提供可以判断导航行为类型API,因此只能在Vue-Router通过hack来记录导航行为类型,如下所示:

    const originPush = VueRouter.prototype.push;
    VueRouter.prototype.push = function () {
      this.currentNavigateType = "push";
      return originPush.apply(this, [...arguments]);
    };
    
    const originReplace = VueRouter.prototype.replace;
    VueRouter.prototype.replace = function () {
      this.currentNavigateType = "replace";
      return originReplace.apply(this, [...arguments]);
    };
    
    const originGo = VueRouter.prototype.go;
    VueRouter.prototype.go = function () {
      this.currentNavigateType = "go";
      return originGo.apply(this, [...arguments]);
    };
    
  2. beforeRouteEnter路由守卫函数中获取前一个路由,然后对之前说的两种条件进行判断,如下所示:

    <script>
      export default {
        data() {
          return {
            hasBackHistory: false,
          };
        },
        // 借助beforeRouteEnter,该钩子会在渲染该组件的对应路由被生成前调用
        beforeRouteEnter(to, from, next) {
          next((vm) => {
            // 通过判断from是否等于VueRouter.START_LOCATION来判断该页面是否直接从地址栏访问
            /** VueRouter.START_LOCATION为初始导航,他的数据结构如下所示:
             *  {
             *    fullPath: "/",
             *    hash: "",
             *    matched: [],
             *    meta: {},
             *    name: null,
             *    params: {},
             *    path: "/",
             *    query: {},
             * }
             */
            //  由于该当守卫函数执行时,组件实例还没被创建,不能获取组件实例 `this`,因此我们可以通过`next`的回调函数中去获取操作
            vm.hasBackHistory =
              vm.$router.currentNavigateType === "push" &&
              from !== VueRouter.START_LOCATION;
          });
        },
        methods: {
          back() {
            if (this.hasBackHistory) {
              this.$router.back();
            } else {
              // 这里推荐用replace而非push去更改路由,理由会在代码块下面的文字中解释
              this.$router.replace({ path: "xxx" });
            }
          },
        },
      };
    </script>
    

对于back函数中,如果hasBackHistoryfalse,即没有前一个地址的情况下,会使用replace去更改路由。而为什么推荐用replace而不是用push?主要是为了防止一种情况:假设该页面是直接通过输入地址栏加载出来的,然后该页面的上一级页面也有一个返回按键,如果用push方法返回到上一级页面后,上一级页面用this.$router.back()返回,就会回到该页面中,从而出现 bug。

大家也可以通过这个示例项目来查看效果。

🏊 在 React 项目中如何实现

适用的依赖版本为:

  • react: 17以上
  • react-router-dom6以上。5可以自行根据下面的思路实现,这里我懒得展示了)

由于react-router缺乏类似vue-router提供的路由守卫函数去获取前一个路由,因此需要自己写逻辑监听记录路由变化,这需要借助react-router-domuseLocationReact.Context来实现。实现步骤如下所示:

  1. 新建LastLocationProvider来存储前一个路由和监听路由变化

    import React, { useContext, useEffect, useRef } from "react";
    import { Location, useLocation } from "react-router-dom";
    
    const LastLocationContext =
      React.createContext<Location | undefined>(undefined);
    
    interface LastLocationProviderProps {
      lastLocation?: Location;
      children?: React.ReactNode;
    }
    
    const LastLocationProvider: React.FC<LastLocationProviderProps> = ({
      children,
    }) => {
      const location = useLocation();
      const lastLocation = useRef<Location>();
      // 通过useEffect记录上次数据,useEffect中的函数执行时机为reconcile之后,commit之前,因此LastLocationContext.Provider的value值是lastLocation在(lastLocation.current = location)语句执行前的值。
      useEffect(() => {
        lastLocation.current = location;
      }, [location]);
    
      return (
        <LastLocationContext.Provider value={lastLocation.current}>
          {children}
        </LastLocationContext.Provider>
      );
    };
    
    export default LastLocationProvider;
    

    LastLocationProvider<RouterProvider/>的子组件里被调用,因为useLocation需要在<RouterProvider/>的包裹下才能调用。

  2. 新建useLastLocationhook 用以获取前一个路由,通过useNavigationType获取导航行为类型,然后对之前说的两种条件进行判断,如下所示:

    export function useLastLocation() {
      return useContext(LastLocationContext);
    }
    

    然后放在带有返回功能的组件中调用,如下所示:

    const navigate = useNavigate();
    const navigationType = useNavigationType();
    const lastLocation = useLastLocation();
    
    const back = useCallback(() => {
      // lastLocation为null 或者 lastLocation.key !== "default"时,可判断其为初始路由
      const hasBackHistory =
        lastLocation &&
        lastLocation.key !== "default" &&
        navigationType !== "REPLACE";
      if (hasBackHistory) {
        navigate(-1);
      } else {
        navigate("/table", { replace: true });
      }
    }, []);
    

    大家也可以通过这个示例项目来查看效果。

🏆 后记

这篇文章写到这里就结束了,如果觉得有用可以点赞收藏,如果有疑问可以直接在评论留言,欢迎交流 👏👏👏。