reactRouter路由守卫与最佳实践

目录

  • 前言
  • 项目目录
  • 阶段一
  • 阶段二
  • 阶段三
  • 阶段四
  • 参考

 
 

前言

路由守卫是开发中很常用的功能,然而reactRouter是没有提供路由守卫的api的,只能由我们自己实现。我会先实现路由守卫的基本功能,然后逐步完善它来为reactRouter增添更多功能。我想结合自己对代码的迭代过程来对代码做介绍。
阶段一:实现基本功能
阶段二:多重权限(角色)
阶段三:按需加载路由模块
阶段四:优化路由配置信息的书写格式并实现路由别名功能

如果您时间不多或对我的迭代过程并不感兴趣的话,可以直接从这个地址clone项目源码:https://gitee.com/yangguang110/react-navigation-guards 这里有项目最新的代码。
或者访问项目主页

项目目录

src
├── router
│   ├── index.ts         #入口文件
│   ├── route.config.ts  #路由配置
│   ├── Guards.tsx       #路由守卫组件
│   └── NotFound.tsx     #404页面
├── view
│ 	├── console
│ 	│	└──  index.tsx
│ 	├── login
│ 	│	└──  index.tsx
│ 	├── main
│ 	│	└──  index.tsx
│ 	├── news
│ 	│	└──  index.tsx
│ 	└── user-manage
│ 		└──  index.tsx
└── utils
 	└── index.tsx

阶段一

这个阶段我想做到以下几点:

  1. 根据路由配置动态生成嵌套的路由
  2. 对路由增加访问权限,个别页面只能允许已经登陆的用户访问。权限不足时跳转到登录页面。
  3. 实现404页面

结合个例子说明。比如,我现在想给一个后台管理系统实现路由,这个后台管理系统有5个页面,分别是控制台(console)、登录页(login)、新闻(news)、用户管理(userManage)、main(控制台主页),其中news、userManage以及main页面都是console的子页面(嵌在console页面内)。除了login之外的四个页面都需要登录后才允许访问。

因此,逻辑是这样的:
如果用户访问不存在的页面则跳转到404。
如果用户未登录,那么访问login页面可以正常访问,而访问其他四个页面会跳转到登录页。
如果用户已经登陆,那么访问login页面会跳转到控制台主页,否则跳转到用户所访问的页面。

下面开始编写代码,因为我打算根据路由配置信息来动态创建路由,所以先编写配置文件。

// src/router/route.config.ts
import Main from "@/view/main"
import Login from "@/view/login"
import News from "@/view/news"
import Console from "@/view/console"
import UserManage from "@/view/user-manage"

export interface RouteModel {
    path: string,
    component: object,
    auth: boolean,
    childRoutes?: RouteModel[]
}

/* 
    这里只需配置好这些路由的相互关系即可
    比如login和console是同级(兄弟),而news是console的子路由(父子)
*/
export const routeConfig: RouteModel[] = [
    {
        path: "/login",
        component: Login,
        auth: false  // false表示不需要登录即可访问
    },
    {
        path: "/console",
        component: Console,
        auth: true,
        childRoutes: [
            {
                path: "/console/main",
                component: Main,
                auth: true
            },
            {
                path: "/console/news",
                component: News,
                auth: true
            },
            {
                path: "/console/userManage",
                component: UserManage,
                auth: true
            }
        ]
    }
]

然后是路由守卫组件,贴源码之前说一下思路。
首先,当我们使用react-router-dom提供的Switch组件将自定义组件包裹起来之后,自定义组件的props中会被注入一些与路径有关的信息,这部分数据的原型如下:

interface RouteProps {
    location?: H.Location;
    component?: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
    render?: (props: RouteComponentProps<any>) => React.ReactNode;
    children?: ((props: RouteChildrenProps<any>) => React.ReactNode) | React.ReactNode;
    path?: string | string[];
    exact?: boolean;
    sensitive?: boolean;
    strict?: boolean;
}

其中props.location.pathname表示当前用户访问的目标路径,在路由守卫中会根据这个来判断应该渲染哪一个组件。

其次,读者需要知道reactRouter是如何实现嵌套路由的,这个可以参考官方示例。以及如何给子路由传值。

最后,必须明确的是,如果我们的路由配置信息中有嵌套路由,那么路由守卫就不可能是一个,每一层路由都会对应一个路由守卫,这些路由守卫之间唯一的区别就是他们需要渲染的路由列表不同。管理第一层路由的守卫需要判断何时该渲染login和console,管理第二层的路由的守卫则判断何时渲染news、userManage和main。
举个现实例子:如果我们访问/console/news,那么第一层的路由守卫是无法去渲染News组件的,因为它拿不到相应的配置信息,有关/console/news这个路径的配置是在第二层的路由守卫手里的,但是第一层的路由守卫却有/console这个路径的信息,于是它只需要把Console组件渲染出来即可。因为News、Main和UserManage都是Console的子路由,所以我们把第二层的路由守卫设置在Console组件内,当Console组件被渲染出来之后,其上的第二层路由路由守卫继续判断/console/news这个路径,发现这个路径正好是自己管理的,于是将对应的News组件渲染在页面上。

源码如下:

// src/router/Guards.tsx
import React, { Component } from 'react';
import { RouteProps } from "react-router"
import { Route, Redirect } from "react-router-dom"
import NotFound from "./NotFound"
import { RouteModel } from "./route.config"
import { permission } from "@/store/mobx"

export interface NavigationGuardsProps extends RouteProps {
    routeConfig: RouteModel[]
}
class NavigationGuards extends Component<NavigationGuardsProps, any> {

    /**
     * 判断一个路径是否是另一个路径的子路径
     * @param pathConfig 配置文件中的路径
     * @param pathTarget 用户请求的路径
     */
    static switchRoute(pathConfig:string,pathTarget:string){
        
        if(pathConfig===pathTarget) return true;

        const reg=new RegExp(`(^${pathConfig})(?=/)`)
        return reg.test(pathTarget)
    }

    render() {
        const { location, routeConfig } = this.props
        const targetPath: string | undefined = location && location.pathname
        const targetRoute: RouteModel | undefined = routeConfig.find((item) =>
            targetPath&&NavigationGuards.switchRoute(item.path,targetPath))
        
        const isLogin = permission.isLogin
        if (!targetRoute) {
            return <NotFound></NotFound>
        }

        if (isLogin) {
            return <LoginHandler targetRoute={targetRoute}></LoginHandler>
        } else {
            return <NotLoginHandler targetRoute={targetRoute}></NotLoginHandler>
        }
    }
}

//已经登陆的状态下,处理路由
function LoginHandler(props): any {
    const { targetRoute } = props
    const { path } = targetRoute
    if (path === '/login') {
        return <Redirect to="/console/main"></Redirect>
    } else {
        return <Route path={targetRoute.path} render={
            props => (
                <targetRoute.component {...props} childRoutes={targetRoute.childRoutes}></targetRoute.component>
            )
        }></Route>
    }
}

//未登录状态下,处理路由
function NotLoginHandler(props): any {
    const { targetRoute } = props
    const { auth } = targetRoute
    if (auth) {
        return <Redirect to="/login"></Redirect>
    } else {
        return <Route path={targetRoute.path} render={
            props => (
                <targetRoute.component {...props} childRoutes={targetRoute.childRoutes}></targetRoute.component>
            )
        }></Route>
    }
}

export default NavigationGuards;

阶段二

阶段一中实现的路由自认为已经可以应付大多数场景了,但再设想下,复杂的后台项目往往涉及众多的权限,有可能每种权限能够访问的页面并不同。此时上面的路由守卫就不够用了,于是在阶段二中我希望路由守卫可以限制用户访问权限外的页面。这里主要是为了应对用户直接输入url的情况,毕竟如果用户只是乖乖在页面上点击按钮的话,是怎么也不会点到权限之外的页面的,因为我们不会渲染那些按钮嘛。废话有点多,下面开始编码。

首先,编写角色类型,我这里使用的是枚举类型,你也可以使用对象,这部分源码如下:

// src/utils/index.tsx
export enum permissionTypes{
    NONE="none", //用作初始值
    USER="user",
    MANAGER="manager"
}

之后,修改路由配置信息,给各个路由添加上允许访问的角色,源码如下:

// src/router/route.config.ts
import Main from "@/view/main"
import Login from "@/view/login"
import News from "@/view/news"
import Console from "@/view/console"
import UserManage from "@/view/user-manage"
import {permissionTypes} from "@/utils"
export interface RouteModel {
    path: string,
    component: object,
    auth?: string[],
    childRoutes?: RouteModel[]
}

/* 
    这里只需配置好这些路由的相互关系即可
    比如login和console是同级(兄弟),而news是console的子路由(父子)
*/
const {USER,MANAGER} =permissionTypes
export const routeConfig: RouteModel[] = [
    {
        path: "/login",
        component: Login,
    },
    {
        path: "/console",
        component: Console,
        auth:[USER,MANAGER],
        childRoutes: [
            {
                path: "/console/main",
                component: Main,
                auth: [USER,MANAGER]
            },
            {
                path: "/console/news",
                component: News,
                auth: [USER,MANAGER]
            },
            {
                path: "/console/userManage",
                component: UserManage,
                auth: [MANAGER]
            }
        ]
    }
]


最后,修改路由守卫的代码,让它可以对角色做出反应。

// src/router/Guards.tsx
import React, { Component } from 'react';
import { RouteProps } from "react-router"
import { Route, Redirect } from "react-router-dom"
import NotFound from "./NotFound"
import { RouteModel } from "./route.config"
import { permission } from "@/store/mobx"

export interface NavigationGuardsProps extends RouteProps {
    routeConfig: RouteModel[]
}
class NavigationGuards extends Component<NavigationGuardsProps, any> {

    /**
     * 判断一个路径是否是另一个路径的子路径
     * @param pathConfig 配置文件中的路径
     * @param pathTarget 用户请求的路径
     */
    static switchRoute(pathConfig:string,pathTarget:string){
        
        if(pathConfig===pathTarget) return true;

        const reg=new RegExp(`(^${pathConfig})(?=/)`)
        return reg.test(pathTarget)
    }


    static permissionAuthentication(authArray,myPermis){
        return !!authArray.find((item)=>item===myPermis)
    }

    render() {
        const { location, routeConfig } = this.props
        const targetPath: string | undefined = location && location.pathname
        const targetRoute: RouteModel | undefined = routeConfig.find((item) =>
            targetPath&&NavigationGuards.switchRoute(item.path,targetPath))
        
        const isLogin = permission.isLogin
        if (!targetRoute) {
            return <NotFound></NotFound>
        }

        if (isLogin) {
            return <LoginHandler targetRoute={targetRoute}></LoginHandler>
        } else {
            return <NotLoginHandler targetRoute={targetRoute}></NotLoginHandler>
        }
    }
}

//已经登陆的状态下,处理路由
function LoginHandler(props): any {
    const { targetRoute } = props
    const { path,auth } = targetRoute
    if (path === '/login') {
        return <Redirect to="/console/main"></Redirect>
    } else if(NavigationGuards.permissionAuthentication(auth,permission.role)) {
        return <Route path={targetRoute.path} render={
            props => (
                <targetRoute.component {...props} childRoutes={targetRoute.childRoutes}></targetRoute.component>
            )
        }></Route>
    }else{
        return <NotFound message="您无权访问此页"></NotFound>
    }
}

//未登录状态下,处理路由
function NotLoginHandler(props): any {
    const { targetRoute } = props
    const { auth } = targetRoute
    if (auth&&auth.length>0) {
        return <Redirect to="/login"></Redirect>
    } else {
        return <Route path={targetRoute.path} render={
            props => (
                <targetRoute.component {...props} childRoutes={targetRoute.childRoutes}></targetRoute.component>
            )
        }></Route>
    }
}

export default NavigationGuards;

阶段三

默认情况下,webpack会将我们所有的代码打成一个包。而一个后台项目往往有非常多的模块,可能某些模块还会使用到一些体积比较大的包,这就导致webpack最终打包出来的bundle是很大的,将来用户访问我们应用首屏的时候必须将整个包下载下来之后才能根据js代码渲染首屏的内容,这个时间往往比较长,导致用户首次访问的时候会经历一个白屏的时间段,体验并不好,而且,对于我上面的例子来说,不同角色只能访问应用的某一部分页面,我们一次性将所有代码下载下来也是种浪费,毕竟有些代码永远不会被执行到。所以这就涉及到webpack的分包,也就是让webpack在打包的时候将代码拆分成多个部分,每个部分打成一个包。我的习惯是按照模块划分,这种划分方式和角色的访问权限正好对应。拿我上面的例子来说,普通用户可以访问news、main,管理员还可以访问userManage,既然普通用户根本没权限访问userManage页面,那他根本没必要下载userManage这个包。
明白了这点之后,还是要强调一下,按模块分开打包是webpack的工作,我们只需要告诉webpack怎么划分bundle即可。而按模块下载是我们和webpack共同完成的,我们自己也需要编码。

说一下思路,首先我们想让webpack分开打包很简单,只需要将import Main from "@/view/main"替换为 () => import("@/view/main" /* webpackChunkName:"user" */)即可,使用这个方式引入模块之后,再次打开项目,可以在谷歌浏览器的NetWork里面看到有个user.chunk.js被下载了,这里chunk就是webpack打出来的包的后缀名,user是我们引入模块的时候使用魔法注释/* webpackChunkName:"user" */指定的名字。但是! 我们import进来的只是个Promise对象,我们需要等Promise对象的状态凝固之后获取下载好的React组件,这时候就需要我们手写一个HOC来解决这个问题。下面贴源码。

首先修改路由配置信息,大家注意我给各个模块起的包名,包名相同的模块会被打进一个包里。像我这里的Main、Console、Login、News模块都会被打进user包里,而UserManage模块会被打进userManage包里。
注意区分包名和权限的关系,两者本身是毫无关系的。 只是当用户访问各个url的时候,我们的路由守卫会返回相应的模块,就是这个时候才会下载模块所在的包

// src/router/route.config.ts
import { permissionTypes } from "@/utils"
import AsyncModuleLoader from "@/components/async-module-loader"
const Modules = {
    Main: AsyncModuleLoader(() => import("@/view/main" /* webpackChunkName:"user" */)),
    Console: AsyncModuleLoader(() => import("@/view/console" /* webpackChunkName:"user" */)),
    Login: AsyncModuleLoader(() => import("@/view/login" /* webpackChunkName:"user" */)),
    News: AsyncModuleLoader(() => import("@/view/news" /* webpackChunkName:"user" */)),
    UserManage: AsyncModuleLoader(() => import("@/view/user-manage" /* webpackChunkName:"manager" */))
}


export interface RouteModel {
    path: string,
    component: object,
    auth?: string[],
    childRoutes?: RouteModel[]
}

/* 
    这里只需配置好这些路由的相互关系即可
    比如login和console是同级(兄弟),而news是console的子路由(父子)
*/
const { USER, MANAGER } = permissionTypes
const { Main, Console, Login, News, UserManage } = Modules
export const routeConfig: RouteModel[] = [
    {
        path: "/login",
        component: Login,
    },
    {
        path: "/console",
        component: Console,
        auth: [USER, MANAGER],
        childRoutes: [
            {
                path: "/console/main",
                component: Main,
                auth: [USER, MANAGER]
            },
            {
                path: "/console/news",
                component: News,
                auth: [USER, MANAGER]
            },
            {
                path: "/console/userManage",
                component: UserManage,
                auth: [MANAGER]
            }
        ]
    }
]


然后定义async-module-loader组件,如下:

// src/components/async-module-loader/index.tsx
import React from 'react'

export interface AyncModuleLoaderState {
    asyncComponent: any
}
export default function AyncModuleLoader(importComponent: any) {
    return class AsyncComponent extends React.Component<unknown, AyncModuleLoaderState> {
        constructor(props: unknown) {
            super(props);
            this.state = {
                asyncComponent: null
            };
        }
        async componentDidMount() {
            if (this.state.asyncComponent) {
                return;
            }
            const { default: component } = await importComponent();
            this.setState({
                asyncComponent: component
            });
        }
        render() {
            const {asyncComponent:Component} = this.state
            return Component ? <Component {...this.props} /> : null;
        }
    }
}

阶段四

其实我的路由目前还有一个重大的缺陷,那就是当用户访问"/"这个路径的时候,会显示404页面,因为路由配置信息里面并没有对这个路径做配置,而"/"逻辑上是等同于"/console/main"的,我会用路由重定向来解决它。
除此之外,我的子路由的路径是绝对路径,每次都需要加上父路由路径的前缀,就比如Main模块的路径是"/console/main",而"/console"是父组件的路径,每次都写这个前缀太麻烦了,更重要的是,如果父路由的路径更改了,那么每一个子路由的路径前缀都要手动修改,这个就麻烦死了。于是我希望子路由路径中可以省略父路由的路径,也就是说"/console/main"可以写为"main"或者"/main",这两者是等价的。

下面贴出源码:

// src/router/route.config.ts
import { permissionTypes } from "@/utils"
import AsyncModuleLoader from "@/components/async-module-loader"
const Modules = {
    Main: AsyncModuleLoader(() => import("@/view/main" /* webpackChunkName:"user" */)),
    Console: AsyncModuleLoader(() => import("@/view/console" /* webpackChunkName:"user" */)),
    Login: AsyncModuleLoader(() => import("@/view/login" /* webpackChunkName:"user" */)),
    News: AsyncModuleLoader(() => import("@/view/news" /* webpackChunkName:"user" */)),
    UserManage: AsyncModuleLoader(() => import("@/view/user-manage" /* webpackChunkName:"manager" */))
}

export interface RouteModel {
    path: string,
    component?: object,
    redirect?:string,
    auth?: string[],
    childRoutes?: RouteModel[]
}

/* 
    这里只需配置好这些路由的相互关系即可
    比如login和console是同级(兄弟),而news是console的子路由(父子)
*/
const { USER, MANAGER } = permissionTypes
const { Main, Console, Login, News, UserManage } = Modules
export const routeConfig: RouteModel[] = [
    {
        path:"/",
        redirect:"/console/main"
    },
    {
        path: "/login",
        component: Login,
    },
    {
        path: "/console",
        component: Console,
        auth: [USER, MANAGER],
        childRoutes: [
            {
                path: "main",
                component: Main,
                auth: [USER, MANAGER]
            },
            {
                path: "/news", //两种写法均可
                component: News,
                auth: [USER, MANAGER]
            },
            {
                path: "userManage",
                component: UserManage,
                auth: [MANAGER]
            }
        ]
    }
]


路由守卫组件做出的调整较多,请仔细阅读:

// src/router/Guards.tsx
import React, { Component } from 'react';
import { Route, Redirect } from "react-router-dom"
import NotFound from "./NotFound"
import { RouteModel } from "./route.config"
import { permission } from "@/store/mobx"

export interface NavigationGuardsProps {
    routeConfig: RouteModel[],
    match?: any
    location?: any
}
class NavigationGuards extends Component<NavigationGuardsProps, any> {

    /**
     * 判断一个路径是否是另一个路径的子路径
     * @param pathConfig 配置文件中的路径的全路径
     * @param pathTarget 用户请求的路径
     */
    static switchRoute(pathConfig: string, pathTarget: string) {
        if (pathConfig === pathTarget) return true;

        const reg = new RegExp(`(^${pathConfig})(?=/)`)
        return reg.test(pathTarget)
    }


    static permissionAuthentication(authArray, myPermis) {
        return !!authArray.find((item) => item === myPermis)
    }

    /**
     * 将简化后的路径还原成带前缀的路径
     * @param parentPath 父组件路径
     * @param pathOfTargetRouteConfig 用户希望访问的组件的在路由配置信息中填写的路径
     */
    static combinationPath(parentPath, pathOfTargetRouteConfig): string {
        let combinedPath = !pathOfTargetRouteConfig.startsWith("/") ? `/${pathOfTargetRouteConfig}` : pathOfTargetRouteConfig
        combinedPath = parentPath ? `${parentPath}${combinedPath}` : combinedPath
        return combinedPath
    }

    /**
     * 在路由配置信息中查找用户希望访问的组件
     * @param parentPath 父路由的路径
     * @param targetPath 用户当前希望访问的路径
     * @param routeConfig 路由配置信息
     */
    static findTargetRoute(parentPath: string, targetPath: string, routeConfig: RouteModel[]): RouteModel | null {
        for (let i = 0; i < routeConfig.length; i++) {
            let item = routeConfig[i]
            let path = NavigationGuards.combinationPath(parentPath, item.path)
            if (targetPath && NavigationGuards.switchRoute(path, targetPath)) {
                return { ...item, path }
            }
        }
        return null
    }

    render() {
        const { location, routeConfig, match } = this.props
        //父路由的路径
        const parentPath = match && match.path
        //用户当前希望访问的路径
        const targetPath: string | undefined = location && location.pathname

        let targetRoute: RouteModel | null | "" | undefined =
            targetPath && NavigationGuards.findTargetRoute(parentPath, targetPath, routeConfig)

        const isLogin = permission.isLogin

        if (!targetRoute) {
            return <NotFound></NotFound>
        }

        if(targetRoute.redirect){
            return <Redirect to={targetRoute.redirect}></Redirect>
        }

        if (isLogin) {
            return <LoginHandler targetRoute={targetRoute}></LoginHandler>
        } else {
            return <NotLoginHandler targetRoute={targetRoute}></NotLoginHandler>
        }
    }
}

//已经登陆的状态下,处理路由
function LoginHandler(props: any): any {
    const { targetRoute } = props
    const { path, auth } = targetRoute

    if (path === '/login') {
        return <Redirect to="/console/main"></Redirect>
    } else if (NavigationGuards.permissionAuthentication(auth, permission.role)) {
        return <Route path={path} render={
            props => (
                <targetRoute.component {...props} childRoutes={targetRoute.childRoutes}></targetRoute.component>
            )
        }></Route>
    } else {
        return <NotFound message="您无权访问此页"></NotFound>
    }
}

//未登录状态下,处理路由
function NotLoginHandler(props): any {
    const { targetRoute } = props
    const { path, auth } = targetRoute

    if (auth && auth.length > 0) {
        return <Redirect to="/login"></Redirect>
    } else {
        return <Route path={path} render={
            props => (
                <targetRoute.component {...props} childRoutes={targetRoute.childRoutes}></targetRoute.component>
            )
        }></Route>
    }
}

export default NavigationGuards;

到这里就是本文的全部内容了,之后本项目可能还会添加更多的功能,或者将代码迭代得更健壮。如果有任何建议欢迎留言!

参考

reactRouter官方示例
react-router4按需加载踩坑,填坑

你可能感兴趣的:(经验总结与分享,react)