离开过渡页后限制返回的实现思路

需求描述

存在这样一个过渡页,只要目标页面不设限制,可以去往任何页面。但是该过渡页只能从特定页面进入,因此,离开该过渡页后,如果想返回该页面,需要取消本次导航。存在以下几种情况:

  1. 若是从规定的特定页面第一次进入,则直接放行:next() ;否则拒绝:back() 或 go(-1)
  2. 若是从特定页进入后
    2.1 返回到特定页,则不允许再次 forward 进入过渡页,但可以 back :next(false)
    2.2 通过提供的链接跳转到特定页,通过 back() 或 go(-1),则后续处理与 2.1 相同;若通过 push() 触发新路由,则处理方式与下文提到的 2.3 相同
  3. 若是从过渡页离开去往其他页面,则返回时,会直接返回到特定页:router.go(-1)
  4. 第 3 种情况下,若是再次前往过渡页,则会跳过并导航至之前进入的其他页面:router.go(1)

分析几种情况,除了第一种正常导航之外,其他三种情况都是忽略过渡页的路由记录,或是退回,或是前往再下一个路由。

分析

常见的登录页也有类似的需求,但较为简单,只需在登录验证成功后,使用 router.replace() 替换掉登录页的路由记录即可(或者通过声明式导航的 replace 属性替换),这样就避免了离开登录页后,通过前进后退会回到登录页,只有可能通过页面内的链接/按钮或者直接地址栏输入,才会触发登录页的导航守卫判定规则。

但这里的过渡页更为复杂,因此,需要在过渡页组件独享的 beforeEnter 钩子中,对 4 种情况分别判定。

VueRouter 提供的 API 只有 push()repalce()go()back()forward() 等操作路由记录的方法,但并未开放 history 对象,也没有删除 history 对象中路由记录的方法。因此,想要知道用户的具体行为,就必须手动设置监听器。

已排除的方案

一开始想的是监听路由的变化或是通过导航守卫,在离开过渡页时直接覆盖当前位置的路由记录,并尝试了一下方式:

  • watch 侦听路由变化:这种方式只能侦听到路由改变后(也就是进入后),无法侦听离开路由,因此无法调用 replace()
  • 在组件内的 beforeRouteLeave 导航守卫中使用 replace 无效,并没有替换掉路由记录;有时候虽然路由切换了,但组件内容没有改变,原因未知,可能跟 replace 方法与 next 回调的实现有关。(另外,在守卫中使用 push 和 replace 需要小心,用不好很容易触发死循环)

此外,浏览器用户若是直接在地址栏输入,VueRouter 的钩子函数或 Vue 都没有提供监听的 API (watch 虽然能侦听路由的变化,但是无法判断用户是执行了哪种操作导致的变化,即:无法区分 back 事件和地址栏事件,也就无法调用 router.replace 替换路由记录。当然,浏览器本身的 hasChange 事件是可以监听到地址栏的变化的,但是需要自己手动维护一个路由记录列表,并将每次更新的路由记录与列表作比较才能知道当前执行了哪种操作,比较麻烦。

因此,可以考虑侦听前进/后退事件。在浏览器中有个 popstate 事件,触发该事件时,可以获取 event.state.key 的值,它是一个字符串类型的浮点值。其大小会随着记录的增加而增加,因此可以通过判定当前保存的 key 与事件接收的 key 的大小,得知当前是前进还是后退操作(注意,刷新后 key 值会重新计算,此时不论后退还是前进,都会得到一个大于 0 的浮点值)。 currentKey > newKey 就是后退,反之就是前进。我在 MDN 上没有找到关于 key 值计算方式的介绍,猜测是根据进入网站的时间逐渐增加,并在用户浏览并访问不同页面时记录下新的 key 值。因为数字之间毫无规律,而且在一个页面中停留的时间越长,下一个 key 与当前 key 的差值越大,并不是随着浏览记录的数量增加而等比增加。

其实如果 replace 能用的话解决起来是最方便的,替换之后通过页面交互的方式已经不会再进入过渡页了,而通过地址栏输入也可以简单地 back() 。但这块的相关源码逻辑还没看懂,只能先尝试其他方式。而且,多尝试一些不一样的方式,也有助于开拓思维。

解决方案

思路概述:

  • 特定页组件进入过渡页时,初始化路由信息:fromRouteNamefromRoutePathtoRouteNametoRoutePath
  • 通过路由独享的 beforeEnter 钩子检测路由变化,并保存路由来源:fromRouteNamefromRoutePath,以便再次尝试进入过渡页时限制导航,并返回原路由或者跳过过渡页前往下一个路由
  • 在过渡页组件的 beforeRouteLeave 钩子中判定路由的去向(返回特定页或是前往其他路由),并记录路由去向: toRouteNametoRoutePath
  • 全局监听 popstate 事件判定当前执行了前进(forward)还是后退(back)操作,以便往正确的方向跳过过渡页或者简单 back() ;经验证,popstate 事件比路由的 enter 和 leave 更早被触发,因此可以通过该事件提前设置路由方向的状态
  • 以上的公共状态保存到 store 中
  • 为了方便在组件外使用 vuex 的 map 函数获取数据和方法,重新包装 mapState、mapMutations 等函数

各种情况下,记录的路由来源和去向信息的变化(Detail 是特定页):

参数 fromRoute, toRoute 变化

  1. (fromRoute = undefined, toRoute = undefined) => (fromRoute = 'Detail', toRoute = undefined)
  2. 2.1 (fromRoute = 'Detail', toRoute = undefined) 不变
    2.2 根据触发方式的不同,可以选择与 2.1 或 2.3 的处理方式相同
    2.3 (fromRoute = 'Detail', toRoute = undefined) => (fromRoute = 'Detail', toRoute = 'Detail')
  3. (fromRoute = 'Detail', toRoute = 'OtherRoute') 不变
  4. (fromRoute = 'OtherRoute', toRoute = 'Detail') 不变
    下面是原先的错误写法,这样写会导致路由只能在两个页面之间来回切换,除非去往其他路由打破循环
  5. (fromRoute = 'Detail', toRoute = 'OtherRoute') => (fromRoute = 'OtherRoute', toRoute = 'Detail')
  6. (fromRoute = 'OtherRoute', toRoute = 'Detail') => (fromRoute = 'Detail', toRoute = 'OtherRoute')

注意, VueRouter 内部已经声明了 popstate 事件的监听回调,因此,在组件中另外声明的回调会在 VueRouter 的回调之后执行,也就是执行了 back() 之后,组件中的 popstate 回调才被触发,也就做不到提前判定了,只能事后修改数据状态。

VueRoute 3 声明的 popstate 回调在 /vue-router/src/history/html5.js 文件中的 setupListeners 函数。

包装 mapState

为了方便在组件之外使用 mapState 获取数据,可以包装一下 mapState (因为这些 map 函数原先是要在组件中通过 this.$store 取值的,而在组件之外使用时 this 是 undefined,因此需要重新绑定):

import { mapState as vuexMapState, mapMutations as vuexMapMutations } from 'vuex'

export const mapState = (store, namespace, states) => {
    const mappedStates = vuexMapState(namespace, states);
    let statesResult = {};
    Object.entries(mappedStates).forEach(([key, mapFunc]) => {
        statesResult[key] = mapFunc.call({ '$store': store });
    })
    return statesResult;
}
// 同理,包装 mapMutations
export const mapMutations = (store, namespace, mutations) => {
    const mappedMutations = vuexMapMutations(namespace, mutations);
    let mutationsResult = {};
    Object.entries(mappedMutations).forEach(([key, mapFunc]) => {
        mutationsResult[key] = mapFunc.bind({ '$store': store });
    })
    return mutationsResult;
}

一开始是直接复制了源代码并修改,后来想到把 mapState 包装一下就可以了,没必要全部搬过来。如果想了解 mapState 相关的函数有哪些,可以展开查看。

展开查看 为了方便在 vue 组件之外使用 mapState 获取数据,可以复制源码中的以下函数:mapState、normalizeNamespace、normalizeMap、getModuleByNamespace、isObject、isValidMap

并修改部分参数:

  • mapState 函数内部的 this.$store 需要改成 store,并将参数 function (namespace, states) 改成 function (store, namespace, states)
  • normalizeNamespace 函数的返回值函数也需要修改参数: function (namespace, map) 改成 function (store, namespace, map)return fn(namespace, map) 改成 return fn(store, namespace, map)

另外需要注意的是,原 mapState 函数的返回值是一组函数,因为是需要注入到 computed 选项中去的。这里是在组件外部使用,所以需要依次调用所有函数,得到实际的变量值:

// 方法 1
// 将以下代码替代 return res; 作为 mapState 函数的返回值
let statesResult = {};
Object.entries(res).forEach(([key, mapFunc]) => {
  statesResult[key] = mapFunc();
})
return statesResult;

// 方法 2
// 或者可以直接修改原 mapState 函数中的内部函数 mappedState 的返回值,将其作为 IIFE 调用
// 注意此时需要将以下代码注释掉,因为此时 res[key] 是一个保存公共数据的变量(大多数情况下都不是 Function),不一定能添加属性
// res[key].vuex = true;

完整示例:

// 工具函数
function isObject(obj) {
    return obj !== null && typeof obj === 'object'
}

function isValidMap(map) {
    return Array.isArray(map) || isObject(map)
}

// 一致性处理
function normalizeNamespace(fn) {
    return function (store, namespace, map) {
        if (typeof namespace !== 'string') {
            map = namespace;
            namespace = '';
        } else if (namespace.charAt(namespace.length - 1) !== '/') {
            namespace += '/';
        }
        return fn(store, namespace, map)
    }
}
function normalizeMap(map) {
    if (!isValidMap(map)) {
        return []
    }
    return Array.isArray(map)
        ? map.map(function (key) { return ({ key: key, val: key }); })
        : Object.keys(map).map(function (key) { return ({ key: key, val: map[key] }); })
}

// 获取模块(也就是命名空间)
function getModuleByNamespace(store, helper, namespace) {
    var module = store._modulesNamespaceMap[namespace];
    if (!module) {
        console.error(("[vuex] module namespace not found in " + helper + "(): " + namespace));
    }
    return module
}

// 最终的导出函数
export const mapState = normalizeNamespace(function (store, namespace, states) {
    var res = {};
    if (!isValidMap(states)) {
        console.error('[vuex] mapState: mapper parameter must be either an Array or an Object');
    }
    normalizeMap(states).forEach(function (ref) {
        var key = ref.key;
        var val = ref.val;

        res[key] = (function mappedState() {
            var state = store.state;
            var getters = store.getters;
            if (namespace) {
                var module = getModuleByNamespace(store, 'mapState', namespace);
                if (!module) {
                    return
                }
                state = module.context.state;
                getters = module.context.getters;
            }
            return typeof val === 'function'
                ? val.call(this, state, getters)
                : state[val]
        })();
        // mark vuex getter for devtools
        // res[key].vuex = true;
    });
    return res;
    // 以上是方法 1,通过 IIFE 方式直接获取返回值
    
    // 方法 2
    // res 的属性都是函数,因此需要调用并得到返回值
    // let statesResult = {};
    // Object.entries(res).forEach(([key, mapFunc]) => {
    //     statesResult[key] = mapFunc();
    // })
    // return statesResult;
});

记录路由信息

在 vuex 的过渡页模块中,定义如下数据和方法:

export default {
    namespaced: true,
    state: {
        fromRouteName: '', // 去往过渡页时,记录 from 路由的 name
        toRouteName: '', // 离开过渡页时,记录 to 路由的 name
        fromRoutePath: '', // 去往过渡页时,记录 from 路由的 path
        toRoutePath: '', // 离开过渡页时,记录 to 路由的 path
    },
    mutations: {
        SET_FROM_ROUTE_NAME(state, name) {
            state.fromRouteName = name;
        },
        SET_TO_ROUTE_NAME(state, name) {
            state.toRouteName = name;
        },
        SET_FROM_ROUTE_PATH(state, path) {
            state.fromRoutePath = path;
        },
        SET_TO_ROUTE_PATH(state, path) {
            state.toRoutePath = path;
        },
    },
    actions: {
        CLEAR_FROM_ROUTE_NAME({ commit }) {
            commit('SET_FROM_ROUTE_NAME', '');
        },
        CLEAR_TO_ROUTE_NAME({ commit }) {
            commit('SET_TO_ROUTE_NAME', '');
        },
        CLEAR_FROM_ROUTE_PATH({ commit }) {
            commit('SET_FROM_ROUTE_PATH', '');
        },
        CLEAR_TO_ROUTE_PATH({ commit }) {
            commit('SET_TO_ROUTE_PATH', '');
        },
        CLEAR_ALL_ROUTE({ dispatch }) {
            dispatch('CLEAR_FROM_ROUTE_NAME');
            dispatch('CLEAR_TO_ROUTE_NAME');
            dispatch('CLEAR_FROM_ROUTE_PATH');
            dispatch('CLEAR_TO_ROUTE_PATH');
        },
    }
};

监听前进和后退事件

history.js 中定义一个工具函数,判断当前是前进还是后退(注意该函数在浏览器刷新后可能无法准确判定)

export function isPopStateBack(currentStateKey, newStateKey) {
  if (newStateKey === undefined) return false; // 地址栏输入时会触发 pushState 新增历史记录,但 state 为 null
  return currentStateKey > newStateKey;
}

在 vuex 的 history 模块中,定义如下数据和方法:

import { isPopStateBack } from '@/utils/history';

export default {
  namespaced: true,
  state: {
    hasPopStateListener: false, // 避免重复添加事件监听器
    currentStateKey: 0, // 最新触发的 popState 事件中获取的值
    tempCurrentStateKey: 0, // 当需要在导航守卫中更新 currentStateKey 值时,先暂存在这里
    resultViewStateKey: 0, // 离开过渡页时,根据是后退还是前进,保存一个在 currentStateKey 值基础上 -1 或 +1 的值
    isPopStateBack: false,
  },
  getters: {},
  mutations: {
    SET_POP_STATE_LISTENER(state, hasPopStateListener) {
      state.hasPopStateListener = hasPopStateListener;
    },
    SET_POP_STATE(state, isPopStateBack) {
      state.isPopStateBack = isPopStateBack;
    },
    SET_CURRENT_STATE_KEY(state, stateKey) {
      state.currentStateKey = stateKey;
    },
    SET_TEMP_CURRENT_STATE_KEY(state, stateKey) {
      state.tempCurrentStateKey = stateKey;
    },
    SET_RESULT_VIEW_STATE_KEY(state, stateKey) {
      state.resultViewStateKey = stateKey;
    },
    SET_STAGED_STATE_KEY(state, stateKey) {
      state.stagedStateKey = stateKey;
    },
  },
  actions: {
    INIT_POP_STATE({ commit }) {
      commit('SET_POP_STATE', false);
      commit('SET_CURRENT_STATE_KEY', 0);
    },
    IS_POP_STATE_BACK({ commit, state }, newStateKey) {
      let result = isPopStateBack(state.tempCurrentStateKey, newStateKey);
      commit('SET_POP_STATE', result);
      commit('SET_TEMP_CURRENT_STATE_KEY', newStateKey);
    },
    UPDATE_CURRENT_STATE_KEY({ commit, state }) {
      commit('SET_CURRENT_STATE_KEY', state.tempCurrentStateKey);
    },
    START_POP_STATE_LISTENER({ state, rootState, commit, dispatch }) {
      if (!state.hasPopStateListener) {
        dispatch('INIT_POP_STATE');
        // 程序运行期间不会重新添加监听器,因此可以不必 remove
        //   而且, removeEventListener 要求所传的函数对象必须与  addEventListener 引用内存中的同一个函数,那么就不能使用匿名函数
        //   而此处需要给回调函数传入 event 和 state/dispatch ,要么像下面这样在匿名函数内部直接通过闭包引用,或者通过匿名函数包装一层,并在内部的实际处理函数调用时传入参数,而不论哪一种都与上述要求冲突了
        // window.addEventListener('popstate', popStateHandler(state, dispatch));
        window.addEventListener('popstate', function (event) {
          // popstate 事件比路由的进入和离开更早被触发
          // const stateKey= +event.state.key; // 两种方法都可以获取到 state.key
          const StateKey = +window.history?.state?.key;
          const fromRouteName = rootState.addCartRouteStatus.fromRouteName,
            toRouteName = rootState.addCartRouteStatus.toRouteName;
          dispatch('IS_POP_STATE_BACK', stateKey);
          if (fromRouteName === 'Detail' && toRouteName === 'Detail') {
            dispatch('addCartRouteStatus/CLEAR_TO_ROUTE_NAME', undefined, {
              root: true,
            });
            dispatch('addCartRouteStatus/CLEAR_TO_ROUTE_PATH', undefined, {
              root: true,
            });
          }
        });
        commit('SET_POP_STATE_LISTENER', true);
      }
    },
    STOP_POP_STATE_LISTENER({ commit, dispatch }) {
      dispatch('INIT_POP_STATE');
      // window.removeEventListener('popstate', popStateHandler);
      commit('SET_POP_STATE_LISTENER', false);
    },
  },
};

并在 main.js 中初始化:

// 刷新页面时监听器会被取消
//   而此时若是对 store 做了刷新后保持状态的操作,就必须先停止,然后重新启动,否则刷新后由于 hasPopStateListener 始终为 true,不会重新添加监听器
//   或者可以在 unload 事件中初始化
store.dispatch('STOP_POP_STATE_LISTENER');
store.dispatch('START_POP_STATE_LISTENER');

过渡页组件中

computed: {
  ...mapState('history', ['currentStateKey', 'isPopStateBack']),
},
beforeRouteLeave(to, from, next) {
  let currentStateKey = this.currentStateKey;
  const commit = this.$store.commit;
  commit('addCartRouteStatus/SET_TO_ROUTE_NAME', to.name);
  commit('addCartRouteStatus/SET_TO_ROUTE_PATH', to.path);
  let isDetailRewrote = false; // 是否通过地址栏进入特定页,而非 back
  // 记录离开过渡页时的方向,因为此时可能是通过点击链接或按钮离开的
  if (to.name !== 'Detail') {
    commit('history/SET_POP_STATE', false);
    currentStateKey -= 1;
  } else {
    // commit('history/SET_POP_STATE', true); // 由于需要将去往特定页的所有导航都限制为 back,因此统一在 popstate 事件中进行判定,此处不需要再单独判定
    isDetailRewrote = !this.isPopStateBack;
    currentStateKey += 1;
  }
  commit('history/SET_CURRENT_STATE_KEY', currentStateKey);
  commit('history/SET_RESULT_VIEW_STATE_KEY', currentStateKey);
  // isDetailRewrote ? go(-2) : next(); // 这里有问题,popstate 事件先被触发,但路由导航先完成,isPopStateBack 变量才被重新赋值,暂未发现确切的原因
},

添加过渡页组件独享的导航守卫

不考虑地址栏修改路由的情况下,示例如下。这里的 store 就是当前项目中导出的 vuex 仓库实例,直接导入即可。

beforeEnter: (to, from, next) => {
    const { fromRouteName, toRouteName } = mapState(
        store,
        'addCartRouteStatus',
        ['fromRouteName', 'toRouteName', 'fromRoutePath', 'toRoutePath']
    );
    const { isPopStateBack, currentStateKey, resultViewStateKey } =
        mapState(store, [
            'isPopStateBack',
            'currentStateKey',
            'resultViewStateKey',
        ]);
    const {
        SET_FROM_ROUTE_NAME: setFromRouteName,
        SET_FROM_ROUTE_PATH: setFromRoutePath,
    } = mapMutations(store, 'addCartRouteStatus', [
        'SET_FROM_ROUTE_NAME',
        'SET_FROM_ROUTE_PATH',
    ]);
    const updateCurrentStateKey = () =>
        store.dispatch('UPDATE_CURRENT_STATE_KEY');

    if (!fromRouteName) {
        // 情况 1
        if (from.name === 'Detail') {
            // 指定页面首次进入,放行
            setFromRouteName(from.name);
            setFromRoutePath(from.path);
            next();
        } else {
            // from 非指定的页面,不允许进入
            // 禁止进入时最好不要用 next(false),因为这时导航还是执行了,只是进入后重定向到了原地址,
            //   这会导致禁止的路由成为当前路由的上一个记录,可能导致无法使用 back
            router.back(); // 这里使用 back() 或 go(-1) 都可以
            // router.go(-1);
        }
    } else {
        if (!toRouteName) {
            // 情况 2
            //   进入过渡页后 back 到指定页,拒绝再次进入过渡页
            router.back();
        } else {
            if (isPopStateBack || currentStateKey === resultViewStateKey) {
                // 情况 3
                //   从过渡页离开去往其他页面,back 时,跳过过渡页
                router.back();
            } else {
                // 情况 4
                //   从其他页面返回至特定页后,再次 forward,跳过过渡页
                router.forward();
            }
        }
    }
    updateCurrentStateKey();
}

这里有一点需要注意,官方对 next(false) 的用法说明是:中断导航并重置到 from 路由对应的地址。也就是说,当前的路由导航被中断并重定向,而不是返回原地址,这两者是由很大区别的。若被中断的路由设置了某些限制规则,则 next(false) 可能导致浏览器的回退功能失效,因为此时被中断的路由成为了原 from 记录的 from 。

通过一个简单的数组来表示浏览记录如下:

  1. 从操作页进入结果页: [ /**...这里还有一些路由记录*/ '/action', '/result'],当前位于 /result
  2. 从结果页返回操作页: [ /**...这里还有一些路由记录*/ '/action', '/result'],当前位于 /action
  3. 从操作页尝试通过 forward 再次进入结果页,并通过 next(false) 中断导航: [ /**...这里还有一些路由记录*/ '/action', '/result', '/action'] ,当前位于后一个 /action

此时再想使用 back ,就会一直触发 next(false) ,无法返回第一个 /action 之前的页面,只能直接点击页面中的其他链接或按钮前往其他页面。

正确的使用方式应该是 go(-1)back() 。不过这里有一个小缺点:路由返回原地址时,上面的地址栏可能会闪烁,虽然这并不影响正常使用。

其他问题

浏览器开发大概率会遇到的一个问题就是“刷新”,这里也不例外。刷新后,popstate 会重新计算,虽然可以在 store 中加一个参数,并在刷新前保存下来,之后就能接着上一次的最新结果继续计算;但这里还有一个问题,popstate 计算规则是:每次进入程序后,不会管之前进入软件时的第一个页面是哪个,都会将当前重新加载后进入的页面作为第一个页面。

也就是说,刷新后,通过状态持久化保存在 store 中的路由记录仍然存在,但 popstate 会重新计算,且不论接下来用户执行哪种操作(back/forward),最新的 event.state.key 都会增加,而不是跟刷新前的页面 key 值保持一样的大小关系。若要实现刷新后保持路由记录的前后关系,需要自己维护一个列表,以及刷新前的路由在列表中的位置。这种方式可以更稳定地判断路由前进/后退,因为不需要关注 event.state.key 的具体值,popstate 仅仅作为触发当前路由改变的事件,并不参与路由记录的维护过程。

posted @ 2022-11-05 23:19  CJc_3103  阅读(215)  评论(0编辑  收藏  举报