版本:都是最新版本
React17 + React-Router5
使用create-react-app搭建的工程
0.前言
可能许多人和我的场景一样,第2点讲述了我的问题发生背景,可以跳过,但这也是我选择MemoryRouter的原因。使用MemoryRouter只是一种方式,可以看下第4点的其他方式。
1.需求描述
离开表单页前,如果表单还没有保存,需要让跳转路由的操作停下来,弹出确认弹窗让用户判断是否的确是不保存离开页面。
2.问题发生背景
本来是用HashRouter做的SPA应用,就是#/xxx这种页面路由。
没有尝试BrowserRouter是因为需要后端配合。
然后页面组件内使用Prompt组件。
或者搭配使用HashRouter的getUserConfirmation的属性,这里不再赘述该方法的使用,与Prompt的message大体一致。并且我觉得还不太好用,因为太顶层了(在App.js这种顶层入口中,设置HashRouter组件的地方),很多状态(history、redux的state等)都拿不到,拿到了还可能会有莫名的错误交互(尝试使用useSelector获取redux的state,结果导致页面资源加载两次)。
1)Prompt
看一下官网文档https://reactrouter.com/core/api/Prompt:
简单翻译下,Prompt组件的用处就是在离开页面前向用户给出确认提示。该组件有2个属性message和when。
when是个布尔值,为true时说明需要拦截路由,为false时则说明用户可以直接离开页面不需要确认提示。when非必传。
message可以是字符串或函数。当为字符串时,使用浏览器的window.confirm弹出确认提示,提示语为message。当为函数时,抛出2个数据(location, action),在方法中需要返回一个布尔值,当不需要弹出提示时返回布尔值true,当需要弹出提示时返回布尔值false。抛出参数的location是即将去向的路由数据;action是跳转方式,比如通过history.push跳转的话action就是字符串PUSH,如果是通过history.replace跳转的话action就是REPLACE。
2)Prompt的应用
网上查到的相关文章基本都是“如何自定义弹窗”。就是用when和message搭配使用,when用来判断页面是否需要弹窗,message中使用比如ant design的Modal.confirm。当onOk时,用拿到的location做页面跳转(可以在这里维护面包屑),onCancel的时候什么都不用写,主要原因是不容易拿到当前在哪个路由了,因为这个时候路由已经变了。
问题就出在“因为这个时候路由已经变了”,再举例描述一下这里的操作:使用history.push({pathname:'home'})
离开页面时,弹出了确认弹窗 的同时 页面url的路由已经变成了#/home
,而且就算点“取消”,不离开页面,路由也变不回来了。这个情况就很糟糕了:路由变了,而页面还在表单页;再点同一个按钮离开页面,就不会执行跳转了,因为路由已经变了,结果页面也不弹窗、也不跳转。
3)可以看下大佬说的
issue: https://github.com/remix-run/history/issues/690
直接看谷歌翻译的吧:
也就是因为浏览器本身以及history.block的限制,导致虽然路由变了,而页面还在表单页。
3.使用MemoryRouter解决问题
MemoryRouter就是使用内存存储history,一般用在移动端、客户端等不使用url直接访问指定页面的情况。因为页面不使用路由区分,也就是url对应到index.html以后,无论#/xxx后面写什么都是跳转到同一个设置为#/的页面。
不知道是不是我百度关键词有问题,都没有看到什么MemoryRouter的实践。
App.js中代替HashRouter:
import React from 'react';
import { MemoryRouter as Router, Switch, Route } from 'react-router-dom';
import routes from 'router/index';
function App() {
return (
<Router>
<Switch>
{routes.map(route => (
<Route key={route.path} component={route.component} exact
path={`/${route.path}`}
/>
))}
</Switch>
</Router>
);
}
export default App;
我是将离开页面二次确认这部分逻辑提出到了高阶组件中,这样后面的页面中最后包一层即可。
看代码前,看一下使用MemoryRouter后的history数据结构:
页面历史就存储在history的entries数组里面,当前路由就是entries的最后一项。而history的一些跳转方法push、replace、go等都还在,也就是业务代码中可以像正常使用HashRouter一样,使用history.push等去做页面跳转,使用上没有差异,只是url不会有变化。
pageWrap.js高阶组件:
import React, { useState } from 'react';
import { Prompt, useHistory, useLocation } from 'react-router';
import { Modal } from 'antd';
// 页面离开前的路由拦截
function LeaveHOC(WrappedComponent) {
return function HOC(props) {
//这里需要用到history,顺便获取location,代替withRouter将history和location传入props
const history = useHistory();
const location = useLocation();
//设置弹出弹窗时机
const [when, setwhen] = useState(false);
return (
<>
<Prompt
when={when}
message={(location, action) => {
Modal.confirm({
title: "确认离开吗?",
onCancel: () => { },//不离开页面
onOk: () => {//确认离开
setwhen(false);
setTimeout(() => history.replace(location), 0);//使setwhen生效后做跳转,避免when仍为true跳转后又弹出确认弹窗
}
});
return false;
}}
/>
<WrappedComponent
{...props}
setwhen={setwhen}
history={history}
location={location}
/>
</>
);
};
}
export default LeaveHOC;
在表单页中,如果发现表单修改了就要设置when为true使离开拦截路由二次确认,如果表单保存了就设置when为false正常离开页面。
表单页示例:
import React from 'react';
import { Form, Button, message } from 'antd';
import FormItems from 'common/components/formItems';//表单项组件(不用管)
import service from 'src/service';//请求方法(不用管)
import pageWrap from 'router/pageWrap';//高阶组件(上述主要逻辑)
import formConfig from './config';//表单项配置(不用管)
function ExampleForm({ history, setwhen }) {
// 表单数据修改
const onValuesChange = (e) => {
setwhen(true);// 操作过表单,离开页面需要二次确认
};
// 表单提交
const onFinish = (formData) => {
// 触发提交请求
service.submitForm(formData).then(() => {
message.success('操作成功!');
// 保存后,离开页面不需要二次确认
setwhen(false);
// when改值生效后跳转页面,避免立马跳转导致when仍为true,又弹出窗的情况
setTimeout(() => history.goBack(), 0);
});
};
return (
<Form
name="basic"
labelCol={{ span: 3 }}
wrapperCol={{ span: 20 }}
onFinish={onFinish}
onValuesChange={onValuesChange}
initialValues={{}}
>
<FormItems options={formConfig} />
<Form.Item wrapperCol={{ offset: 11, span: 2 }} key="submit">
<Button type="primary" htmlType="submit">
保存
</Button>
</Form.Item>
</Form>
);
}
// 导出前包一层高阶组件
export default pageWrap(ExampleForm);
在自己的应用中,离开前路由拦截的问题是能解决了。虽然有个弊端是不能通过url访问指定页面,但我认为这个弊端是在可接受范围内的,因为其实真正的用户不会关心url到底是什么的,也不会去背下url(可以收藏url),大部分都是在系统里点点点进去的,就像用户在移动端、客户端根本不需要知道url,也根本没有输url的地方。所以如果应用在需求上这一点是无关紧要的话,是可以考虑下我这种方式的。
写到这里还是有问题没有处理的,就是浏览器的关闭按钮、回退按钮、刷新等,后续再考虑。
4.当然有别的思路啦
还是第2点中的那个issue链接: https://github.com/remix-run/history/issues/690
往下看:
感觉这么做也不错,比较灵活,可以随时取用。
但是需要把表单内容存到本地客户端,存储在哪里是个问题,我看到CSDN是存储在IndexDB的,毕竟localStorage和sessionStorage存储大小限制比较小。而且会有不同客户端给用户表现不同的体验。把浏览器缓存清了,表现也不同了。不过也的确没有必要把这个临时表单存到服务端。
我没有使用这个方式的原因是:如上述,觉得本地缓存不靠谱;预感需要在业务页面中写许多代码来支持这一功能,如果有很多表单页呢,不太好维护。而我的方式在业务页面中,在需要的时机调用props.setwhen(true)即可。
参考文章:
https://github.com/remix-run/history/issues/690
https://segmentfault.com/a/1190000019230420 =>该文章的最后2段值得好好看