在应用设计中,某些画面如果用户有未保存或提交的重要输入、编辑信息时,在用户离开前一般会提示用户当前的变更、编辑未保存,是否确认丢弃并离开,这样的设计可能会非常有用,毕竟一个不小心的点错或者意外就可能导致前面辛辛苦苦的输入和编辑付诸东流,非常地影响用户的体验。

通常为了避免这种意外的发生,一般会有两种处理方式:

  1. 将用户临时编辑输入的信息做持久化处理,下次再进入画面时读取出来并自动填充
  2. 提示询问用户是否确认丢弃并继续(在提示用户方面没有什么是弹窗解决不了的,如果有,那就再弹一个)

第一种方式实现起来比较简单,这里就不再展开,这里只讨论第二种方式。如何实现在用户离开画面前提醒其编辑输入的信息未提交保存?这里其实又分为两种情况需要我们去处理:

  1. 路由能监测到的情况
    • 点击浏览器的前进、后退按钮
    • 用户在画面上做的可导致画面跳转的操作(点击链接、按钮等等)
  2. 路由不能监测到的情况
    • 浏览器的刷新按钮点击事件
    • 关闭浏览器标签页时

这两种情况需要分开处理,下面分别具体介绍如何用代码去处理这两种情况。

路由能监测到的情况

这里以 React 生态里常用的 React Router 为例(Vue Router 也提供路由守卫的功能接口,可以实现同样的功能)。React Router 底层依赖一个叫 history 的库来进行和管理路由的跳转,这两个库由同一个组织开发和维护。

React Router 本身提供一个叫 Prompt 的组件来做路由跳转前的确认,它可以单独拿来使用,跳转前它会根据传入的 when prop 值来决定是否弹出一个浏览器默认的询问对话框来让用户选择接下来的操作,使用比较简单,参考代码实现和效果图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const ProfileEdit: React.FC = () => {
const [name, setName] = useState<string>('');

const onNameChange = useCallback((e) => {
setName(e.target.value);
}, []);

return (
<div>
<div>
<Link to="/">← 首页</Link>
</div>
<span>Name:</span>
<input type="text" value={name} onChange={onNameChange}/>
<Prompt when={!!name} message={"Are you sure to leave?"} />
</div>
)
}

虽然 Prompt 组件使用起来简单高效,但是它有个最大的问题,就是它使用浏览器默认的行为和样式来询问提示用户,没有办法自定义弹框的样式,而且各个浏览器默认的弹框样式都不一样,没办法做到多浏览器统一,所以如果只是想实现功能而不在意弹框的样式的话使用 Prompt 是最简单高效的方式,但如果需要自定义弹窗的样式就不能直接使用 Prompt 了。

那么如何实现自定义弹窗询问提示用户是否放弃编辑呢?解决方案就是利用 React Router 底层依赖的 history 库提供的 block 接口,利用这个接口我们就可以自己决定什么时候弹、弹什么样的弹窗了。由于版本更新导致这个接口在 React Router 5.x 和 6.x 上使用方式有点不一样(根本原因还是它们底层依赖的 history 版本不同导致的),下面分别介绍下这个接口的大致使用方法。

React Router v5.x 上的实现

React Router 5.x 依赖的 history(4.x) 提供了一个 block 方法接口,该方法接收一个回调函数作为参数,在路由跳转前会执行这个回调函数,根据这个回调函数执行的返回值来决定是否进行路由跳转。参考代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useHistory } from 'react-router-dom';
import Modal from '../modal';

export interface RouteLeavingGuardProps {
shouldBlock: boolean;
promptMessage?: string;
}

const RouteLeavingGuard: React.FC<RouteLeavingGuardProps> = ({
shouldBlock,
promptMessage = "Are you sure to leave?",
}) => {
const [showModal, setShowModal] = useState(false);
const lastNaviActionRef = useRef<() => void>(() => {});
const unblockRef = useRef<() => void>(() => {});
const history = useHistory();

useEffect(() => {
const unblock = history.block((location, action) => {
if (shouldBlock) {
// 保存弹出询问对话框前用户的操作,等用户确认离开时再执行
// v5.x 的 history 的 block 回调函数参数有个 retry 的方法,低版本需要我们自己仿造一个 retry 方法出来
let lastNaviAction = () => {};
if (action === 'POP') {
lastNaviAction = () => {
history.go(-1); // 这里有问题,并不一定是返回上一个画面,没有想到解法
}
} else if (action === 'REPLACE') {
lastNaviAction = () => {
history.replace(location.pathname);
}
} else {
lastNaviAction = () => {
history.push(location.pathname);
}
}

lastNaviActionRef.current = lastNaviAction;、
setShowModal(true); // 显示自定义的弹窗

return false; // 让 history 不要进行路由跳转,由我们的代码来控制路由跳转
}
});

unblockRef.current = unblock;

return () => {
unblock();
}
}, [history, shouldBlock]);

const onModalCancel = useCallback(() => {
setShowModal(false);
}, []);

const onModalConfirm = useCallback(() => {
if (unblockRef.current) {
unblockRef.current(); // 取消 block
}

lastNaviActionRef.current(); // 执行用户弹窗前原本的操作
}, [])

// 渲染自定义的提示询问弹窗
return (
<Modal show={showModal} onCancel={onModalCancel} onConfirm={onModalConfirm} title={promptMessage} />
)
}

export default RouteLeavingGuard;

React Router v6 上的实现

React Router 6.x 依赖的是 5.x 的 history,新版本的 React Router 提供了一个叫 useBlocker hook,并且新版本的 history 的 block 函数接受的回调函数在回调时有个 retry 的方法可以方便地重试用户原本的操作,这样新版本上实现起来比老版本简洁很多,参考实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useBlocker } from 'react-router-dom';
import Modal from '../modal';

export interface RouteLeavingGuardProps {
shouldBlock: boolean;
promptMessage?: string;
}

const RouteLeavingGuard: React.FC<RouteLeavingGuardProps> = ({
shouldBlock,
promptMessage = "Are you sure to leave?",
}) => {
const [showModal, setShowModal] = useState(false);
const [confirmedLeaving, setConfirmedLeaving] = useState(false);
const retryRef = useRef<() => void>(() => {});

useBlocker(tx => {
setShowModal(true);
retryRef.current = tx.retry;
}, shouldBlock && !confirmedLeaving);

useEffect(() => {
if (confirmedLeaving) {
retryRef.current();
}
}, [confirmedLeaving]);

const onModalCancel = useCallback(() => {
setShowModal(false);
}, []);

const onModalConfirm = useCallback(() => {
setConfirmedLeaving(true);
}, [])

return (
<Modal show={showModal} onCancel={onModalCancel} onConfirm={onModalConfirm} title={promptMessage} />
)
}

export default RouteLeavingGuard;

在不同版本的 React Router 上实现思路和原理是一样的,区别就是 React Router 和 history 这两个库的不同版本导致的接口稍微有一点变化,最后达到的效果也是一样的:

!

路由监测不到的情况

使用 React Router 的 SPA 的画面跳转都是依赖 history 来进行和管理的,所以绝大部分的画面跳转都可以通过 history 的 block 接口来进行拦截,但是一些浏览器的行为是路由(history)监测不到的,比如点击刷新按钮,关闭标签页等用户操作等,而这些路由监测不到的情况也可以通过监听一些特定的 DOM 事件来进行询问提示,最常用的就是监听 beforeunload 事件来实现,这个事件会在 document 卸载前发送,提示用户是否确认离开,每个浏览器对这个事件处理的默认行为和样式基本不一致,而且基本上都不可以改变浏览器的默认行为,所以监听处理这个事件只是起一个弹窗提示询问用户的作用,不能自定义弹窗的样式和提示信息,移动端的 Safari 甚至不会发送这个事件,所以实际项目里不能过于依赖通过监听处理这个事件来确保达到离开前提示询问用户的目的。

处理刷新按钮和关闭标签页等动作的代码实现和效果参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { useEffect } from 'react';

const useUnloadDiscardPrompt = (shouldShowPrompt: boolean) => {
useEffect(() => {
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (shouldShowPrompt) {
const e = event || window.event;
// Cancel the event
e.preventDefault();
if (e) {
e.returnValue = ''; // Legacy method for cross browser support
}
return ''; // Legacy method for cross browser support
}
};

window.addEventListener('beforeunload', onBeforeUnload);

return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
}
}, [shouldShowPrompt]);
};

export default useUnloadDiscardPrompt;

这样通过结合 history 的 block 接口和监听处理 beforeunload 事件两种方式,我们就可以实现在某些特殊场景下任何的画面迁移需要用户确认的需求了。