前言
在做项目的时候,你的产品可能会提出这样的需求:在某些拥有表单的页面中,如果用户还未保存表单数据,就想去浏览别的页面内容。这时候需要给一个弹窗,提示用户是否需要在未保存数据的情况下跳转页面。
最简单的实现就是使用 react-router 提供的 Prompt 组件来控制是否显示拦截弹窗,当需要显示拦截弹窗时,会触发 history 库里的 getConfirmation 方法,该方法内部又调用了 window.confirm() 方法显示浏览器默认的弹窗。但是,这个弹窗的样式太丑了,哪怕你实现了,产品依然会不满意,这个时候就需要你去自定义实现一个拦截弹窗。
动手前,先了解下 React 路由相关的库:
- react-router:实现了路由的核心功能,可以看做是一个路由的基础库,提供了一些基础的 API 供调用者使用,其他 React 路由库(react-router-dom、connected-react-router 、react-router-native)都是基于它实现的。
- react-router-dom:是浏览器端的路由,加入了一些和浏览器操作相关的功能(BrowserRouter、HashRouter、Link、NavLink)。
- history:借鉴 HTML5 history 对象的理念,在其基础上又扩展了一些功能。提供 3 种类型的 history:browserHistory,hashHistory,memoryHistory,并保持统一的 api。支持发布/订阅功能,当 history 发生改变的时候,可以自动触发订阅的函数。提供跳转拦截、跳转确认和 basename 等实用功能。
- react-router-native:在 RN 中使用的路由库。
- connected-react-router :一个将 react 、redux 、router 关联起来的库,我们可以向仓库派发一个跳转路径的动作,动作发生后由中间件进行拦截处理,然后跳转对应的路径,当路径改变时,可以把当前的路径信息(location)存放到仓库中的 router 状态里。普通的组件想要获取路由信息,就不需要使用 WithRouter 高阶组件在外面包一层,可以直连 redux 从 store 中获取(不过现在函数组件可以使用 react-router 的 hook 获取到路由信息)。
react-router 不像 vue-router 那样,直接提供给开发者一些钩子函数,如使用 router.beforeEach 注册一个全局前置守卫,可以在路由跳转前进行一些逻辑的判断,以此决定是否需要跳转路由。因为 react-router 的作者希望 react-router 是灵活的,不希望在里面添加太多的 api,这些 api 应该是让使用者,根据自己的需求去实现自己的路由拦截。
作者原话:“You can do this from within your render function. JSX doesn't need an API for this because it's more flexible.”
实现效果
方法一
- 通过 Router 的 getUserConfirmation 属性 + Prompt 组件 + ReactDOM.render 方法实现。
// App.js
import './App.css';
import React from 'react';
import {BrowserRouter, Switch,Route,Redirect} from 'react-router-dom';
import Home from './views/Home';
import My from './views/My';
import UserConfirmation from "./components/UserConfirmation";
function App() {
return (
<div className="App">
<BrowserRouter
getUserConfirmation={(message, callback) => {
UserConfirmation(message, callback);
}}
>
<Switch>
<Route path={'/home'} exact component={Home}/>
<Route path={'/my'} exact component={My}/>
<Redirect to={'/home'}/>
</Switch>
</BrowserRouter>
</div>
);
}
export default App;
// My.jsx
import React, {useState} from 'react';
import {Link, useHistory} from 'react-router-dom';
import {Prompt} from "react-router";
export default function My(props) {
const [text, setText] = useState('');
const [isBlocking, setIsBlocking] = useState(false);
return (
<div>
<Link to={'/home'}>Go Home</Link>
<Prompt
when={isBlocking}
message={(location, action) => {
return JSON.stringify({
action,
location,
curHref:'/my',
message: `Are you sure you want to go to ${location.pathname}`,
});
}}
/>
<div> Page My</div>
<input type="text"
value={text}
onChange={(ev) => {
const val = ev.target.value;
setText(val);
setIsBlocking(!!val);
}}/>
</div>
);
}
// UserConfirmation.jsx
import React from 'react';
import ReactDOM from 'react-dom';
import {Modal} from 'antd';
const container = document.createElement('div');
const UserConfirmation = (payload, callback) => {
const {action, location, curHref, message} = JSON.parse(payload);
/**
* 弹窗关闭事件回调
* @param callbackState
*/
const handlerModalClose = (callbackState) => {
ReactDOM.unmountComponentAtNode(container);
// callback 接收的参数,如果值为假,那么 history 库内部就不会去更新 location 值,路由也就不会切换
callback(callbackState);
// 当用户点击了一下浏览器后退按钮,然后再关闭弹窗时,恢复到之前的路由
if (!callbackState && action === 'POP') {
window.history.replaceState(null, null, curHref);
}
};
ReactDOM.render(
<Modal
visible={true}
onCancel={() => handlerModalClose(false)}
onOk={() => handlerModalClose(true)}
title="Warning"
>
{message}
</Modal>,
container,
);
};
export default UserConfirmation;
方法二
- 借助 history.block 这个 api 实现路由的拦截。
// UserConfirmationTwo.jsx
import React, {useEffect, useState} from 'react';
import {useHistory} from 'react-router';
import {Modal} from 'antd';
export default function UserConfirmationTwo(props) {
const {when = false} = props;
const [isShowModal, setIsShowModal] = useState(false);
const history = useHistory();
const [nextLocation, setNextLocation] = useState(null);
const [action, setAction] = useState();
const [unblock, setUnblock] = useState(null);
useEffect(() => {
if (!when || unblock) {
return;
}
const cancel = history.block((nextLocation, action) => {
if (when) {
setIsShowModal(true);
}
setNextLocation(nextLocation);
setAction(action);
return false;
});
setUnblock(() => {
return cancel;
});
}, [when, unblock]);
useEffect(() => {
return () => {
unblock && unblock();
};
}, []);
function onConfirm() {
unblock && unblock();
if (action === 'PUSH') {
history.push(nextLocation);
} else if (action === 'POP') {
history.goBack();
} else if (action === 'REPLACE') {
history.replace(nextLocation);
}
setIsShowModal(false);
}
function onCancel() {
setIsShowModal(false);
}
return (
<>
{isShowModal && <Modal
title="Warning"
visible={true}
onOk={onConfirm}
onCancel={onCancel}
>
<div>
Are you sure you want to go to {nextLocation && nextLocation.pathname}
</div>
</Modal>}
</>
);
}
// My.jsx
import React, { useState } from "react";
import { Link } from "react-router-dom";
import UserConfirmationTwo from "../components/UserConfirmationTwo";
export default function My(props) {
const [text, setText] = useState("");
const [isBlocking, setIsBlocking] = useState(false);
return (
<div>
<Link to={"/home"}>Go Home</Link>
<UserConfirmationTwo when={isBlocking} />
<div> Page My</div>
<input
type="text"
value={text}
onChange={(ev) => {
const val = ev.target.value;
setText(val);
setIsBlocking(!!val);
}}
/>
</div>
);
}
方法三
- 这个我称之为“大胆”而失败的想法,思路是扩展 BrowserRouter 接收到的 history 对象里的 push、replace 方法,在新方法内部去控制拦截操作。虽然最终也实现了拦截效果,但是改写的方法需要放在 BrowserRouter 组件内部,才能接收到 history 属性,而且写起来略微麻烦,总感觉哪里怪怪的。
// App.js
import './App.css';
import React, {useEffect} from 'react';
import {BrowserRouter, Switch, Route, Redirect} from 'react-router-dom';
import {useHistory} from "react-router";
import getNewHistory from "./utils/history-extension";
import Home from './views/Home';
import My from './views/My';
function EmptyComponent() {
const history = useHistory();
useEffect(() => {
getNewHistory(history);
}, [history]);
return null;
}
function App() {
return (
<div className="App">
<BrowserRouter>
<Switch>
<Route path={'/home'} exact component={Home}/>
<Route path={'/my'} exact component={My}/>
<Redirect to={'/home'}/>
</Switch>
// 这个组件需要放到 BrowserRouter 下,才能接收到 history
<EmptyComponent />
</BrowserRouter>
</div>
);
}
export default App;
// history-extension.js
// 扩展 history 库中的方法
import eventCenter from './events';
export default function getNewHistory(history) {
if (history.extended) {
return;
}
history.extended = true;
history.canLeave = true;
history.curHref = '';
history.curState = null;
history.curAction = 'POP';
let oldPush = history.push;
history.push = (path, state) => {
history.curHref = path;
history.curState = state;
history.curAction = 'PUSH';
if (!history.canLeave) {
eventCenter.emit('blocked', {
blocked: true,
});
return;
}
oldPush(path, state);
};
}
// event.js
import events from 'events';
const event = new events();
export default event;
// My.jsx
import React, {useState, useEffect, useCallback} from 'react';
import {Link} from 'react-router-dom';
import eventCenter from '../utils/events';
import {Modal} from "antd";
import {useHistory} from "react-router";
function useMonitorBlocked(callback) {
useEffect(() => {
eventCenter.on('blocked', callback);
}, [callback]);
useEffect(() => {
return () => {
eventCenter.removeListener('blocked', callback);
}
}, [callback]);
}
export default function My() {
const [text, setText] = useState('');
const [isShowModal, setIsShowModal] = useState(false);
const history = useHistory();
const callback = useCallback((options) => {
if (options.blocked) {
setIsShowModal(true);
}
}, []);
useMonitorBlocked(callback);
function handlerInputChange(ev) {
const val = ev.target.value;
setText(val);
history.canLeave = false;
}
function onConfirm() {
const action = history.curAction;
const path = history.curHref;
const state = history.curState;
history.canLeave = true;
if (action === 'PUSH') {
history.push(path, state);
} else if (action === 'REPLACE') {
history.replace(path, state);
}
setIsShowModal(false);
}
function onCancel() {
setIsShowModal(false);
}
return (
<div>
<Link to={'/home'}>Go Home</Link>
<div> Page My</div>
<input type="text"
value={text}
onChange={handlerInputChange}/>
{isShowModal && <Modal
title="Warning"
visible={true}
onOk={onConfirm}
onCancel={onCancel}
>
<div>
Are you sure you want to go to ${history.curHref}
</div>
</Modal>}
</div>
);
}
问题
- 你以为这就完了吗?其实上面的方法都有一个共同的缺陷。当用户点击浏览器后退按钮时,虽然路由被拦截了,弹窗也显示了,但是地址栏被更改了,要是用户刷新页面的话,就会退回到上一个页面。(不过这是无法避免的,连 window.confirm 都无法阻止用户点击后退按钮操控浏览器)
- getUserConfirmation 在未来的 history 5.0 版本中会被移除,那么就需要使用 history.block 来拦截路由了。具体可参考:history/releases/tag/v5.0.0
参考
Writing a custom UserConfirmation modal with the React Router Prompt
Using React-Router v4/v5 Prompt with custom modal component