多种方式实现自定义 React 路由拦截弹窗

16,518 阅读4分钟

前言

在做项目的时候,你的产品可能会提出这样的需求:在某些拥有表单的页面中,如果用户还未保存表单数据,就想去浏览别的页面内容。这时候需要给一个弹窗,提示用户是否需要在未保存数据的情况下跳转页面。

最简单的实现就是使用 react-router 提供的 Prompt 组件来控制是否显示拦截弹窗,当需要显示拦截弹窗时,会触发 history 库里的 getConfirmation 方法,该方法内部又调用了 window.confirm() 方法显示浏览器默认的弹窗。但是,这个弹窗的样式太丑了,哪怕你实现了,产品依然会不满意,这个时候就需要你去自定义实现一个拦截弹窗。

动手前,先了解下 React 路由相关的库:

  • react-router:实现了路由的核心功能,可以看做是一个路由的基础库,提供了一些基础的 API 供调用者使用,其他 React 路由库(react-router-domconnected-react-routerreact-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.”

实现效果

GIF 2020-9-10 19-33-18.gif

方法一

  • 通过 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

react-router/issues/5405

推荐阅读

你真的了解 React 生命周期吗

React Hooks 详解 【近 1W 字】+ 项目实战

React SSR 详解【近 1W 字】+ 2个项目实战

傻傻分不清之 Cookie、Session、Token、JWT

TS 常见问题整理(60多个,持续更新ing)

三年 Git 使用心得 & 常见问题整理

CSS 之使用径向渐变实现卡券效果

从 0 到 1 实现一款简易版 Webpack