如何实现页面跳转拦截?

5,074 阅读5分钟

「这是我参与2022首次更文挑战的第23天,活动详情查看:2022首次更文挑战

前言

大家好,我是前端surpman,资深表单工程师。相信大家在业务中都曾遇到过这样的场景: 当我们在一个包含表单的页面里填了一部分内容,忘记点击保存,或者误点了其他部分,导致跳转了页面,那么我们没有保存的内容就丢了。特别是表单类目比较多的场景下,可能会使用户丧失耐心。

那么我们要如何在form表单未提交的情况下阻止页面跳转呢?我们来一探究竟。

表单的使用在中后台项目中特别多。同时像常规网站的用户信息编辑等场景下,也特别需要注意这个问题,来提升用户体验。

前置知识

浏览器拦截

浏览器在页面级别为我们提供了拦截方法, window.onbeforeunload = funcRef

当窗口即将被卸载(关闭)时,会触发该事件的回调。可以拦截页面刷新和页面跳转、页面关闭,显示浏览器默认的拦截弹窗。如下: image.png

单页面应用怎么吧?

vue是典型的spa应用,路由切换只是DOM的显隐变化,并不会造成页面卸载,所以自然也不会触发beforeunload的监听和回调的。

那么我们就需要通过额外的方法来实现 “页面跳转” 拦截。

方案设计

  1. 实现一个拦截的方法(/组件);
  2. 需要拦截的页面,注册这个拦截方法,传入判断是否需要拦截的函数和拦截方法;
  3. 导航前置守卫中调用拦截方法
  4. 关闭窗口,刷新页面时候调用onbeforeunload事件回调,判断是否需要拦截。

具体实现

  1. 拦截的方法
async intercept() {
    const needIntercept = this.needIntercept();
    if (needIntercept) {
        let goNext = true;
        try {
            await this.confirmDialog('填写的内容尚未提交,是否立即提交?', '保存', '不保存');
            goNext = await this.$refs.target?.save?.();
        } catch (action) {
            goNext = action !== 'close';
            if (goNext) {
                this.$refs.target?.recovery?.()
            }
        }
        return goNext;
    } else {
        this.$refs.target?.recovery?.()
        return true;
    }
}

needIntercept返回是否有数据变化。

当数据变化时,弹窗提示“填写的内容尚未提交”,是否保存?

  • 点击保存调用组件的保存方法,返回一个promise;
  • 点击关闭弹窗 action==='close',goNext = false, 取消跳转;
  • 当点击不保存按钮,goNext返回true,调用表单组件的recovery方法,恢复数据(这一步根据需要添加,有些场景下组件直接销毁,就没必要再执行recovery了)。
needIntercept () {
    return JSON.stringify(this.members) !== this.nativeMembers
}

这里可以用序列化的方式比较前后数据有没有变化,也算是比较通用的方法之一。

JSON.stringify,还有第二个参数,类型为函数或者数组,数组表示需要序列化哪些数据项,可选。具体可参考mdn 更多通用的比较表单数据变化的方法,大佬们可以在评论区告诉我。

  1. 在需要拦截的页面,注册这个拦截方法
created() {
    this.register = registerInterceptor(this.needIntercept, this.intercept);
}
//不要忘记移除拦截器
beforeDestroy() {
    destroyInterceptor(this.register);
}

在页面创建之后,注册这个拦截器。传入判断是否需要拦截的函数,和拦截回调函数。

function registerInterceptor(intercept, callback) {
  const _id = v4();
  LEAVE_INTERCEPTORS[_id] = {
    intercept,
    callback,
  };
  if (!interceptor_running) {
    interceptor_running = true;
    window.addEventListener('beforeunload', interceptBeforeBrowserLeave);
  }
  return _id;
}
  1. 导航前置守卫中调用拦截方法
router.beforeEach(async (to, from, next) => {
  try {
    const nextloop = await interceptBeforeRouteLeave()
    if (!nextloop) {
      return next(false)
    }
  } catch {
    return next(false)
  }

根据拦截器返回结果判断路由是否通过。

// 执行全部的拦截器,返回拦截器执行结果
// 全部返回true才能返回true
function interceptBeforeRouteLeave() {
  const interceptors = Object.keys(LEAVE_INTERCEPTORS)
    .filter(key => LEAVE_INTERCEPTORS[key].intercept())
    .map(key => LEAVE_INTERCEPTORS[key].callback());
  const results = await Promise.all(interceptors);
  return results.every(result => !!result);
}

这里之所以把拦截器存储起来,放在LEAVE_INTERCEPTORS里,是为了满足页面理由多个tab都需要拦截功能的情况,比如下面这种场景: image.png

三个tab下面都有表单输入,当切换tab时,仍然是在当前页面操作,但是当切换路由时,三个tab下面的保存提示应该被一次触发弹窗,提示用户保存输入内容。

  1. 关闭窗口,刷新页面时候调用onbeforeunload事件回调,判断是否需要拦截。 细心地同学一经发现了,我们在上线的代码里面已经提现了浏览器拦截的部分,
  if (!interceptor_running) {
    interceptor_running = true;
    window.addEventListener('beforeunload', interceptBeforeBrowserLeave);
  }

每次注册拦截器的时候,会判断是否已经加入了浏览器拦截,没有的话就会加入。

function interceptBeforeBrowserLeave(e) {
  const intercept = Object.keys(LEAVE_INTERCEPTORS).some(key =>
    LEAVE_INTERCEPTORS[key].intercept()
  );
  console.log('intercept', intercept);

  e = e || window.event;
  if (intercept) {
    if (e) {
      e.returnValue = intercept;
    }
    return intercept;
  }
}

浏览器拦截逻辑实现比较简单,就是some拦截器对象数组中是否有 intercept === true的情况,如果有就触发浏览器拦截提示。

总结

我们在埋头肝需求的时候,往往会忽略用户体验,不管是C端项目,还是中后台项目,使用体验才是最能体现出前端价值的地方,也是前端自我成就的捷径。

所以在开发的过程中,我们应该站在使用者的角度,不断优化性能和使用体验,这也是进阶高级前端的必要技能。

写在最后

码字不易,周末尤甚,动动手点个赞鼓励一下,这将成为我持续输出的动力。

2022一起加油,做一个优秀的表单工程师!