跳转拦截
如何在 form 表单未提交的情况下阻止页面跳转?
前言
当我们在一个包含表单的页面里填了一部分内容,忘记点击保存按钮,或者误触误点了其他部分,导致跳转了页面,那么我们没有保存的内容就丢了。特别是表单类目比较多的场景下,可能会使用户丧失耐心。
表单的使用在中后台项目中特别多。同时像常规网站的用户信息编辑等场景下,也特别需要注意这个问题,来提升用户体验。
前置知识
浏览器拦截
window.onbeforeunload = funcRef
当窗口即将被卸载(关闭)时,会触发该事件的回调。可以拦截页面刷新和页面跳转、页面关闭,显示浏览器默认的拦截弹窗。
单页面应用
spa 页面,切换 hash、history 路由是不牵涉到页面卸载的,所以自然也不会触发beforeunload
的监听和回调的。
那么我们就需要额外的方法来实现“页面跳转”拦截。
方案设计
- 实现一个拦截的方法(/组件);
- 需要拦截的页面,注册这个拦截方法,传入判断是否需要拦截的函数和拦截方法;
- 导航前置守卫中调用拦截方法
- 关闭窗口,刷新页面时候调用
onbeforeunload
事件回调,判断是否需要拦截。
具体实现
- 拦截的方法
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 更多通用的比较表单数据变化的方法,大佬们可以在评论区告诉我。
- 在需要拦截的页面,注册这个拦截方法
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;
}
- 导航前置守卫中调用拦截方法
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 都需要拦截功能的情况,比如下面这种场景:
三个 tab 下面都有表单输入,当切换 tab 时,仍然是在当前页面操作,但是当切换路由时,三个 tab 下面的保存提示应该被一次触发弹窗,提示用户保存输入内容。
- 关闭窗口,刷新页面时候调用
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 一起加油,做一个优秀的表单工程师!