React 18 全局错误捕获 + React-router 6 动态权限路由

前言

前几天搭建了基础的react项目模版 从零搭建 react 模板 【React 18 + Webpack 5 + Antd 5 + React-Router 6 】,准备进行进一步完善

大纲

  • 优化首屏加载
  • 全局错误捕获
  • 页面级权限控制

优化首屏加载时间

未优化之前的性能分析

优化后的性能分析

相比较首屏加载快了0.1s

开发环境配置chunk

js 复制代码
//webpack.dev.js
{
    optimization: {
        splitChunks: {
            chunks: 'all'
        }
    }
}

路由懒加载

针对非首评页面采用react的lazy加载,结合Suspense实现懒加载,代码如下:

js 复制代码
//src/pages/index/index.js
import React, { lazy, Suspense } from 'react'

const Index = lazy(() => import('./index'))

export default function Index() {
    return (
        <div>
            <Suspense fallback={<div></div>}>
                <Index />
            </Suspense>
        </div>
    )
}

提示:如果直接在根路由配置,会出现首次加载切换路由闪烁问题

全局错误捕获

自React16 起,React 引入了错误边界(Error Boundaries)来捕获组件树中的 JavaScript 错误,并防止它们导致整个应用崩溃,同时,如果不配置错误捕获,会导致整个 React 组件树被卸载,从而出现白屏,(开发环境未配置错误捕获以及overlay的话会出现浏览器报错浮层(红色报错遮罩),来方便调试) 类似这种:

常见的错误捕获方式

try ... catch

javascript 复制代码
捕获运行时同步代码,如果出错则捕获并处理,但无法捕获异步代码,比如 setTimeout、Promise、Async/Await 等,这些代码在执行过程中可能会抛出错误,但不会被错误捕获器捕获。
js 复制代码
try {
    // 可能会抛出错误的代码
} catch (error) {
    // 处理错误
}
markdown 复制代码
react项目要注意:

    1.使用要放useEffect里面
js 复制代码
useEffect(() => {
    fetchData();
    try {
        // 可能会抛出错误的代码
    } catch (error) {
        // 处理错误
    }     
}, []);
csharp 复制代码
2.在async 函数里面try catch捕获要搭配await处理,实际是await使异步函数变成了同步
js 复制代码
async function fetchData() {
    try {
        // 可能会抛出错误的代码
        await someAsyncFunction();
    } catch (error) {
        // 处理错误
    }
}

window.onerror

可以捕获大多数错误,包括同步和异步代码,但无法捕获 Promise 异常。跨域脚本错误由于浏览器同源策略限制,跨域脚本执行失败时也会触发 onerror

js 复制代码
window.onerror = function(message, source, lineno, colno, error) {
    // 处理错误

};
/ *
*   message:错误信息(字符串)。可用于HTML onerror=""处理程序中的event。
*   source:发生错误的脚本URL(字符串)
*   lineno:发生错误的行号(数字)
*   colno:发生错误的列号(数字)
*   error:Error对象
*/

addEventListener("error")

可捕获资源加载异常(如脚本、图片加载失败),也能处理运行时错误。 ‌第三个参数设置为true可捕获捕获阶段的错误(如异步操作中的异常)

addEventListener("unhandledrejection")

用来容错promise漏掉catch时的错误捕获

React Error Boundary

捕获子组件内部的生命周期、渲染错误,并渲染 fallback UI,无法捕获异步错误,无法捕获事件处理函数中的错误,无法捕获服务端渲染错误,无法捕获错误边界本身的错误,无法捕获子组件的错误边界的错误

Vue errHandler

在 Vue.js 中,errorHandler 是一个全局的设置,用于在应用程序中捕获和响应未被组件的 errorCaptured 钩子捕获的错误。这对于开发和生产环境下的错误监控非常有用。但不能捕获同步代码中的错误

js 复制代码
Vue.config.errorHandler = function (err, vm, info) {
  // 处理错误
  // `err` 是错误对象
  // `vm` 是抛出错误的 Vue 组件实例
  // `info` 是关于错误信息的一个字符串,例如错误发生的位置
};
errorHandler 能捕获的错误类型
  1. 渲染函数或模板中的错误 当你在组件的渲染函数或模板中发生错误时,这些错误会被 errorHandler 捕获。

  2. 生命周期钩子中的错误 如果在组件的任何生命周期钩子(如 created, mounted, updated, destroyed 等)中发生错误,这些错误也会被捕获。

  3. 事件处理器中的错误 如果在组件的事件处理器(如 v-on 指令绑定的事件处理函数)中发生错误,这些错误同样会被捕获。

  4. 自定义方法中的错误 如果在组件中定义的方法里发生错误,这些错误也会被捕获。

  5. Vuex 中的错误 虽然 Vuex 通常有自己的错误处理机制(如通过中间件),但如果 Vuex 的 mutation 或 action 直接在组件中调用并引发错误,这些错误也可能被 errorHandler 捕获。

  6. 异步操作中的错误 如果在异步操作(如 setTimeout、setInterval、Promise、async/await 等)中发生错误,这些错误也会被捕获。

errorHandler 无法捕获的错误类型
  1. 同步代码的错误 例如在生命周期钩子中通过 async 函数或 Promise 产生的错误。如果你在同步代码块中抛出错误,例如在组件的 created 或 mounted 钩子中直接抛出异常,这些错误不会被 Vue 的全局错误处理器捕获
js 复制代码
created() {
    throw new Error('This error is not caught by Vue errorHandler');
}
  1. axios、fetch 等异步请求中的错误 如果在 axios 或 fetch 等异步请求中发生错误,这些错误不会被 errorHandler 捕获。你需要在请求的 catch 回调中手动处理这些错误。

  2. 服务端渲染中的错误 如果在服务端渲染过程中发生错误,这些错误不会被 errorHandler 捕获。你需要在服务端渲染的 catch 回调中手动处理这些错误。

vue的错误捕获通常也要结合try catch、window.onerror等

js 复制代码

常见的错误类型

JS错误

在JavaScript中,错误通常可以分为两大类:语法错误(Syntax errors)和运行时错误(Runtime errorss)。语法错误通常在代码编译时被检测到,而运行时错误则在代码执行时发生

语法错误(Syntax Errors)

语法错误发生在代码书写时就违反了JavaScript的语法规则。这类错误会导致程序无法被解析,因此会在代码运行之前被抛出,无法被try catch捕获,在react项目中可以被ErrorBoundary捕获,但无法被window.onerror捕获。例如:

javascript 复制代码
let x = 5;
let y = 6,
x = y ((10);  // SyntaxError: Unexpected token '('
运行时错误(Runtime Errors)运行时错误(Runtime Errors)

运行时错误发生在代码执行时,可能是由于不合法的操作、不正确的数据类型使用或者其他运行时问题导致的。这类错误通常在代码执行过程中抛出,并且可以被try...catch语句捕获。常见的运行时错误包括:

  1. TypeError 类型错误

    当使用了一个变量的值,但它并不是预期的类型时,会抛出TypeError。例如:

javascript 复制代码
let x = null;
console.log(x.length);  // TypeError: Cannot read properties of null (reading 'length')
  1. ReferenceError 引用错误

    尝试引用一个不存在的变量时抛出。例如:

javascript 复制代码
console.log(undeclaredVariable);  // ReferenceError: undeclaredVariable is not defined
  1. RangeError 范围错误

    当一个值超出有效范围时抛出,例如数组长度超出其最大长度。例如:

javascript 复制代码
let arr = [];
arr.length = Number.MAX_VALUE + 1; // RangeError: Invalid array length
  1. URIError URI错误

    当使用encodeURI()或decodeURI()对不正确的URI进行编码或解码时抛出。例如:

javascript 复制代码
decodeURI('%');  // URIError: URI malformed
  1. EvalError eval使用错误

    不推荐使用eval()函数,因为它可能会导致代码注入和其他安全问题。例如:

javascript 复制代码
eval('1 + "a string"');  // EvalError: Unexpected token
throw new EvalError('这是一个示例 EvalError')

Promise错误

promise 被reject,但并未catch处理,或者catch处理后并未resolve,promise状态为pending,会抛出异常。例如:

js 复制代码
const promise = new Promise((resolve, reject) => {
    reject(new Error("Promise rejected"));
});

这类异常通常由promise自带的catch处理,包括fetch、axios等库的catch处理。如果一旦忘记处理,可以由 window.addEventListener("unhandledrejection") 来兜底捕获未处理的promise异常。

资源加载错误

例如图片、css、js等资源加载失败,抛出错误。例如:

js 复制代码
import './index.css';

这类异常通常由window.addEventListener("error")来捕获。

事件错误

例如点击事件、输入事件等,抛出错误。例如:

js 复制代码
document.getElementById("myButton").addEventListener("click", function() {
    throw new Error("Button click error");
});

这类异常通常由window.addEventListener("error")来捕获。

网络错误

例如fetch、axios等网络请求失败,抛出错误。例如:

js 复制代码
fetch('https://example.com/api')
    .then(response => {
        if (!response.ok) {
            throw new Error('Network response was not ok');
        }
        return response.json();
    })
    .catch(error => {
        console.error('Fetch error:', error);
    });

这类异常通常由其内部的catch来捕获(推荐),也可以用window.addEventListener("error")来兜底处理一些漏下的(不推荐)。

框架错误

react/vue 等内部渲染、生命周期错误,采用框架自带的错误处理机制处理

vue errHandler

Vue 2.x

js 复制代码
Vue.config.errorHandler = function (err, vm, info) {
    // do something
};

Vue 3.x

js 复制代码
const app = createApp(App)
app.config.errorHandler = (err, vm, info) => {
    // do something
}
react ErrorBoundary

特点:

  1. 仅能在Class组件使用
  2. 不能捕获事件处理错误
  3. 不能捕获setTimeout、setInterval、promise等异步错误
  4. 用来捕获子组件内部的生命周期、渲染错误,并渲染 fallback UI,用法如下:
js 复制代码
class ErrorBoundary extends React.Component {
    constructor(props) {
        super(props);
        this.state = { hasError: false };
    }

    static getDerivedStateFromError(error) {
        // 更新 state 使下一次渲11呈使用 fallback UI
        return { hasError: true };
    }
    
    componentDidCatch(error, info) {
        // 你可以将错误日志上报给服务器
        logErrorToMyService(error, info);
    }

    render() {
        if (this.state.hasError) {
            // 你可以自定义 fallback UI
            return <h1>Something went wrong.</h1>;
        }
        return this.props.children;
    }
}

该组件仅能在Class组件使用,在当今hooks的天下,可以使用react-error-boundary库来实现相同功能。

js 复制代码
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
    return (
        <div role="alert">
            <p>Something went wrong:</p>
            <pre>{error.message}</pre>
            <button onClick={resetErrorBoundary}>Try again</button>
        </div>
    );
}

function App() {
    return (
        <ErrorBoundary FallbackComponent={ErrorFallback} onReset={() => {}}>
            <p
            onClick={() => {
                throw new Error('Whoops!');
            }}
        >
            Click me to trigger an error
        </p>
    );
}

react项目下的错误处理

上面介绍了常见的错误处理方式以及常见的错误,下面介绍react项目下的错误处理。

我们知道ErrorBoundary组件有一定的局限性,所以本方案采用ErrorBoundary + window.addEventListener("error") + window.addEventListener("unhandledrejection")来实现全局错误处理。基本上可以满足绝大部分的错误处理需求。

全局上下文统一管理错误

采用全局上下文来createContext来管理错误处理,这样可以避免在组件内部 进行错误的汇总处理

js 复制代码
//src/components/ErrorContext.js
export const ErrorContext = createContext({
    error: null,
    setError: () => {},
    handleError: () => {},
});

export const ErrorProvider = ({ children }) => {
    const [error, setError] = useState(null);

    const handleError = (error) => {
        setError(error);
    };

    useEffect(() => {
        if (error) {
            console.log("捕获到错误",JSON.stringify(error));
            // 可扩展:如上报服务器
        }
    }, [error]);

    useEffect(() => {

        window.addEventListener("error", handleError, true);
        window.addEventListener("unhandledrejection", handleError);
        
        return () => {
            window.removeEventListener("error", handleError, true);
            window.removeEventListener("unhandledrejection", handleError);
        };

    }, []);

    useEffect(() => {
        if (error) {
            console.log("捕获到错误",JSON.stringify(error));
            // 可扩展:如上报服务器
        }
    }, [error]);
    return (
        <ErrorContext.Provider value={{ error, setError, handleError }}>
            {children}
        </ErrorContext.Provider>
    );
};

ErrorBoundary组件监测子组件错误

js 复制代码
//src/components/ErrorBoundary.js
class ErrorBoundary extends React.Component {
    constructor(props) {
        super(props);
        this.state = { hasError: false };
    }

    static getDerivedStateFromError(error) {
        // 更新 state 使下一次渲染使用 fallback UI
        return { hasError: true };
    }
    
    componentDidCatch(error, info) {
        // 你可以将错误日志上报给服务器
        logErrorToMyService(error, info);
    }

    render() {
        if (this.state.hasError) {
            // 你可以自定义 fallback UI
            return <h1>Something went wrong.</h1>;
        }
        return this.props.children;
    }
}

export default ErrorBoundary;

上面是基础版的全局错误处理,后面针对错误捕获实际验证发现了一些问题

重复捕获错误

在基础版的基础上,触发错误后,在控制台出现了多次捕获

开始排查问题,放到服务器上对比本地,尝试阻止错误冒泡、去掉ErrorBoundary等(想钻研的可以继续研究,欢迎有结果第一时间告诉我)发现有的重复错误消失了,有的还是会重复捕获,最后还是决定针对错误进行去重,改造handleError函数

js 复制代码
// parseError解析错误对象
export const determineErrorType = (error) => {
    // render: '渲染错误',
    // type: '类型错误',
    // reference: '引用错误',
    // syntax: '语法错误', 
    // range: '范围错误',
    // chunk_load: '代码加载错误',
    // async: '异步错误',
    // eval: 'eval错误',
    // uri: 'uri错误',
    // resource: '资源加载错误',
    // network: '网络错误',
    // business: '业务错误',
    // validation: '数据验证错误',
    // auth: '权限认证错误',
    // config: '配置错误',
    // timeout: '超时错误',
    // memory: '内存溢出',
    // unknown: '未知错误'

   // 网络错误
    if(error instanceof TypeError && error.message.includes('network')) {
        return 'network';
    }
    
    // 资源加载错误
    if(error instanceof Error && error.message.includes('loading')) {
        return 'resource'; 
    }
    // 权限错误
    if(error instanceof Error && error.message.includes('permission')) {
        return 'auth';
    }
    // 业务错误
    if(error instanceof Error && error.message.includes('business')) {
        return 'business';
    }
    // 标准错误类型判断
    if (error instanceof TypeError) return 'type';
    if (error instanceof ReferenceError) return 'reference';
    if (error instanceof SyntaxError) return 'syntax';
    if (error instanceof RangeError) return 'range';
    if (error instanceof EvalError) return 'eval';
    if (error instanceof URIError) return 'uri';
    
    // 特殊错误类型
    if (error?.name === 'ChunkLoadError') return 'chunk_load';
    if (error?.code === 'MODULE_NOT_FOUND') return 'module_not_found';
    
    return 'unknown';
};
js 复制代码
// handleError 处理错误
const handleError = useCallback((eventOrError) => {

    const errorObj = parseError(eventOrError); // parseError解析错误对象,

    // 生成错误唯一标识
    const errorKey = `${errorObj.type}-${errorObj.message}-${Date.now()}`;

    
    // 检查是否是重复错误且在短时间内发生
    const now = Date.now();
    const lastErrorTime = errorMap.get(errorKey);

    if (lastErrorTime && (now - lastErrorTime) < 1000) { // 1秒内的重复错误将被忽略
        return;
    }

    // 更新错误时间戳
    errorMap.set(errorKey, now);

    setError(errorObj);

}, [errorMap]);

ErrorBoundary降级UI跳转页面

在组件显示降级UI后,如果想跳转到一个指定的页面,需要使用路由跳转,但是react router v6 跳转用useNavigate,是一个hooks,不支持class的路由跳转, 要兼容使用需要react router v5,最后采用高阶组件包裹来实现支持路由跳转

js 复制代码
// 包装高阶组件
function withRouter(ComponentClass) {
    return function (props) {
        const navigate = useNavigate();
        const location = useLocation();
        return <ComponentClass {...props} navigate={navigate} location={location} />;
    };
}

然后用高阶组件包裹ErrorBoundary组件

js 复制代码
export default withRouter(ErrorBoundary);

这样就可以在ErrorBoundary组件中使用useNavigate,实现路由跳转了,附上完整代码

js 复制代码
// ErrorBoundary.js
import React, { Component } from 'react';
import { Button, Result } from 'antd';
import { useNavigate, useLocation } from "react-router-dom";

import { ErrorContext } from "./ErrorContext";
import { determineErrorType } from '@/utils/utils';


// 包装高阶组件
function withRouter(ComponentClass) {
    return function (props) {
        const navigate = useNavigate();
        const location = useLocation();
        return <ComponentClass {...props} navigate={navigate} location={location} />;
    };
}


class ErrorBoundary extends Component {
    static contextType = ErrorContext;
    constructor(props) {
        
        super(props);
        this.state = { hasError: false };
    }

    static getDerivedStateFromError() {
        return { hasError: true };
    }

    componentDidCatch(error, errorInfo) {

        // 错误类型判断
        let errorType = 'unknown'; // 默认未知错误

        errorType = determineErrorType(error);

        // 转换为字符串
        const errorMsg = error instanceof Error ? error.message : String(error);
        const stack = errorInfo && errorInfo.componentStack ? errorInfo.componentStack : '';


        // 通过 context 传递错误信息
        if (this.context && this.context.handleError) {
            // 阻止错误继续传播
            // error.preventDefault && error.preventDefault();
            // 渲染错误
            this.context.handleError({ type: errorType, message: errorMsg, stack });

        }
    }

    componentDidUpdate(prevProps) {
        // 如果当前有错误且路由发生变化,重置错误状态
        if (
            this.state.hasError &&
            this.props.location.pathname !== prevProps.location.pathname
        ) {
            this.setState({ hasError: false });
        }

    }


    render() {
        if (this.state.hasError) {
            return (
                <Result
                    status="404"
                    title="404"
                    subTitle="抱歉,您访问的页面不存在。"
                    extra={
                        <Button
                            type="primary"
                            onClick={() => this.props.navigate('/')}
                        >
                            返回首页
                        </Button>
                    }
                />
            );
        }
        return this.props.children;
    }
}

export default withRouter(ErrorBoundary);

以上就是全局错误捕获的最终解决方案

------------ 2025-07-12 更新------------

开启CSS Module

js 复制代码
// pageA.js
import './index.less'
.container{
    background: red;
}
js 复制代码
// pageB.js
import './index.less'
.container{
    background: blue;
}

如果按上面使用样式,会导致样式冲突,因为两个页面的.class类名是一样的,所以需要CSS Modules。

CSS Modules是一种CSS的打包方式,它允许你在JavaScript中以模块化的方式引用CSS,从而实现局部作用域的CSS类名,避免了全局污染的问题。每个模块内的类名都是唯一的,提高了代码的安全性和可维护性。

js 复制代码
// webpack.base.js
{
    test: /\.css$/,
    use: [
            "style-loader",
            {
            loader: "css-loader",
                options: {
                    modules: true,
                    // modules:{
                    //     localIdentName: "[name]__[local]___[hash:base64:5]", // 自定义类名格式
                    // }
                },
            },
            "postcss-loader",
        ],
    }

按上面配置,理论上就可以使用,看到这的可以试一下下面代码

js 复制代码
// pageA.js
import styles from './index.css'
.container{
    background: red;
}
function PageA(){
    return <div className={styles.container}></div>
}
js 复制代码
// pageB.js
import styles from './index.css'
.container{
    background: blue;
}
function PageB(){
    return <div className={styles.container}></div>
}

得到的结果:

巨坑的Css Modules

百度搜了就是这么配置的,但就是报错,不知道是不是该项目哪里少了什么配置,最后花了很久找到原因,是modules里面的namedExport,需要配置为false才可以,查了webpack5官方文档,webpack5默认是true

感兴趣的可以取研究研究

本项目采用less样式,所以需要配置less相关的

js 复制代码
{
    test: /\.less$/,
    use: [
        "style-loader",
        {
            loader: "css-loader",
            options: {
                modules: {
                    namedExport: false,
                    localIdentName: "[name]__[local]___[hash:base64:5]", // 自定义类名格式
                },
            },
        },
        "postcss-loader",
        "less-loader",
    ],
},

配置了这个之后,重启

js 复制代码
// pageA.js
import styles from './index.less'
.container{
    background: red;
}
function PageA(){
    return <div className={styles.container}></div>
}
js 复制代码
// pageB.js
import styles from './index.less'
.container{
    background: blue;
}
function PageB(){
    return <div className={styles.container}></div>
}

每个页面可以正常设置局部作用域单独的样式,避免命名冲突

Redux Tookit

项目采用redux,使用官方推荐的状态管理工具集Redux Tookit

安装

js 复制代码
yarn add @reduxjs/toolkit  -S

使用

网上教程包括官网都有介绍,这里不再赘述

js 复制代码
import { configureStore } from "@reduxjs/toolkit";
import globalSlice from "./reducers/global";

// configureStore创建一个redux数据
const store = configureStore({
  // 合并多个Slice
  reducer: {
    global: globalSlice
  },
});

export default store;

配置 globalSlice

该Slice用来处理menus、userinfo等全局信息

js 复制代码
import { createSlice } from '@reduxjs/toolkit'
import { isDataInLocalStorageAndNotEmpty } from "@/utils/utils";

// 缓存menus
const catchMenus = isDataInLocalStorageAndNotEmpty('menus') ?JSON.parse(localStorage.getItem('menus')):[];

// 缓存的userinfo
const userinfo = isDataInLocalStorageAndNotEmpty('userinfo')?JSON.parse(localStorage.getItem('userinfo')):{};

export const globalSlice = createSlice({
  name: 'global',
  initialState: {
    // 用户信息
    userInfo: {...userinfo},
    // 权限信息
    permissionInfo:{},
    menus:[...catchMenus]
  },
  reducers: {
    initmenus: (state, action) => {
        state.menus = action.payload
    },
    setmenus: (state, action) => {
        state.menus = action.payload
        localStorage.setItem("menus", JSON.stringify(action.payload));
    },
    login: (state, action) => {
        state.userInfo = action.payload.userinfo
        localStorage.setItem('userinfo', JSON.stringify(state.userInfo));
        localStorage.setItem('token', action.payload.token);
    },
    setPermissions: (state, action) => {
      state.permissionInfo = action.payload;
    },
    logout: (state) => {
      state.userInfo = {};
      state.menus = [];
      state.permissionInfo = {};
      localStorage.removeItem('menus');
      localStorage.removeItem('userInfo');
      localStorage.removeItem('token');
    }
  }
})
// 每个 case reducer 函数会生成对应的 Action creators
export const { login, logout, setPermissionInfo ,setmenus} = globalSlice.actions

export default globalSlice.reducer

动态权限路由

该项目模版仅考虑页面级的权限控制,如需按钮级的可以自行扩展研究,谢谢

针对动态权限路由一般有两种主流的方案:

  1. 一种是前端注册全部路由,后端返回权限角色内的可访问路由,然后通过路由守卫authRouter控制渲染对应的页面,实现权限控制; 这一种还有一种变种,通过在路由表里添加authority配置好对应的角色,只需返回角色role,然后通过路由守卫校验路由role过滤控制显示,如果对应角色可访问页面有变化就需要重新改代码路由里的authority,重新打包,适合角色少,页面少,能确定具体访问的场景,例如之前用的umi项目就采用这种,不够灵活,所以一般采用前面的方案。

  2. 另一种是后端返回权限角色内的可访问路由,前端根据返回的路由动态引入页面,动态注册路由,无需路由守卫,通过router自带的处理未定义路径跳转即可实现控制,本项目采用该方案

注册基础路由

js 复制代码
// router/baseRouter.js
const routers = [
    {
        path: "/",
        element: <BasicLayout />,
        children: [],
    },
    {
        path: "/login",
        element: <Login />,
    },
    // 找不到对应路径时的重定向
    {
        path: "*",
        element: <NoMatch />,
        handle: {
            crumb: () => "404"
        }
    },
];

从后端获取menu菜单/路由

js 复制代码
// service/index.js
// 模拟获取菜单
export const getAdminMenus = () => {
    return new Promise((resolve) => {
        window.setTimeout(() => {
            resolve([
                {
                    key: "/",
                    path: "/",
                    icon: 'UserOutlined',
                    label: "首页",
                    filepath: "home",
                },
                {
                    key: "/admin",
                    path: "",
                    icon: 'VideoCameraOutlined',
                    label: "管理员",
                    filepath: "",
                    children: [
                        {
                            key: "/admin/list",
                            path: "/admin/list",
                            icon: 'VideoCameraOutlined',
                            label: "列表",
                            filepath: "admin/list",
                        },
                        {
                            key: "/admin/detail",
                            path: "/admin/detail",
                            icon: 'VideoCameraOutlined',
                            label: "详情",
                            filepath: "admin/detail",
                        }
                    ]
                },
                {
                    key: "/backend",
                    path: "/backend",
                    icon: 'UploadOutlined',
                    label: "非管理员",
                    filepath: "backend",
                },
                {
                    key: "/demo",
                    path: "/demo",
                    icon: 'UploadOutlined',
                    label: "测试页",
                    filepath: "demo",
                },
            ]);
        }, 1000);
    });
};

项目采用Antd的Layout组件以及Menu菜单,所以需要对返回的菜单数据进行处理,添加key,icon,label等字段,以上数据是为了方便处理 所以多定义了路由需要的path和filepath字段,path是路由的path,filepath是对应的页面文件路径,方便后面动态引入页面

动态引入页面

正常项目一般页面为了性能都会进行懒加载处理,这里采用webpack的require.context来实现动态引入,对应vite的有import.meta.glob

js 复制代码
// router/components.js
import React from 'react';
// 创建一个require.context,搜索pages目录下的所有js文件
const requireComponent = require.context('../pages', true, /\.(js|jsx)$/);
// 创建一个对象来存储所有组件的引用
export const components = {};
// 遍历require.context返回的所有模块ID
requireComponent.keys().forEach((fileName) => {
    
  // 获取文件名(不带路径和扩展名)作为组件名
  const componentName = fileName.replace(/^\.\//, '').replace(/\/index\.(js|jsx)$/, '');
  
  // 动态导入组件并存储到components对象中
  components[componentName] = React.lazy(() => import(`../pages/${componentName}/index`));

});
export default components;

懒加载为了防止页面白屏闪烁问题,一般结合React.Suspense使用

js 复制代码
const lazyComponent = (Component) => {
    return (
        <ErrorBoundary> 
            <Suspense fallback={<Skeleton active />}>
                <Component />
            </Suspense>
        </ErrorBoundary>

    );
}
// ErrorBoundary是错误捕获,不需要的可以去掉

登录页面请求接口

获取需要的menus和userinfo信息,存储到 redux中,上面已经配置好了对应的redux

js 复制代码
    import { useSelector, useDispatch } from 'react-redux'
    import { useNavigate } from "react-router-dom";
    import { login ,setmenus} from "@/store/reducers/global";
    import { getAdminMenus } from "@/services/index";
    const navigate = useNavigate();
    const dispatch = useDispatch();
    const onFinish = (values) => {
      let res = {
        token: "000000000000000",
        userinfo: {
          username: "管理员",
          avatar:
            "https://gw.alipayobjects.com/zos/rmsportal/KDpgvguMpGfqaHPjicRK.svg",
        },
        permissions: {
          role: [],
          routers: [],
        },
      };

      dispatch(
        login({
          userinfo: res.userinfo,
          token: res.token,
        })
      );

      // 获取菜单
      getAdminMenus().then((list) => {

        dispatch(setmenus(list));

        message.success("登录成功",1, () => {
          navigate("/", { replace: true });
        });
      });
    };

登录调取后台接口,拿到用户信息,redux存储token令牌,存储menus菜单、存储userinfo用户信息(同步缓存localStorage,防止刷新页面数据丢失),具体redux处理见上面的Redux Tookit

主路由页面处理menus

扁平化属性Menus

通过对比纯静态路由,扁平化的menus数据更好处理成router

js 复制代码
/**
 * 树形结构扁平化
 * @param {Array} items - 要扁平化的树形数组
 * @param {String} filepath - 文件路径
 * @param {String} path - 路由路径
 * @returns {Array} - 扁平化后的数组
 */
export const flattenMenus = (items) => {
    return items.reduce((pre, item) => {
        
        // 创建当前项的扁平对象
        const flatItem = {
            children:item.children,
            path:item.path,
            filepath:item.filepath
        };

        // 删除children属性,避免重复
        delete flatItem.children;
        
        if(!item.children){
            // 将当前项添加到结果数组
            pre.push(flatItem);
        }
        
        // 如果有子项,递归处理
        if (item.children && item.children.length > 0) {
            
            pre.push(...flattenMenus(item.children));
        }
        return pre;
    }, []);
};

const flattenMenu = flattenMenus(menus);// 得到扁平化的menu

通过map递归遍历结合上面的动态引入组件lazyComponent,转化成router路由数据

js 复制代码
// 动态生成路由配置
const generateRoutes = useCallback((menus) => { //useCallback可能没用
    return menus.map(({path='',filepath,children}) => {
        const route = {
            path,
            element:filepath?lazyComponent(components[filepath]):React.createElement(),
        };
        return route;
    });
}, [menus]);

const asyncrouter = generateRoutes(flattenMenu) // 拿到转换后的router路由数据(flattenMenu是扁平化后的数据)

更新Router

js 复制代码
const updatedRoutes = [...routers];// 基础路由

updatedRoutes[0].children = asyncrouter;//拼接新的完整路由

setRoutes(updatedRoutes);// 更新router

至此就拿到了权限路由,还需要处理初次加载Loading,以及刷新页面后的数据恢复,最后页面渲染进入到上面基础路由里面的BaseLayout组件,附上完整的主路由代码

js 复制代码
import React, { useEffect, useState, lazy, Suspense, useCallback } from 'react'
import { BrowserRouter, Routes, Route, useRoutes } from "react-router";
import { useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
import { useSelector, useDispatch } from 'react-redux';
import ErrorBoundary from "@/components/Errorhandler/ErrorBoundary";
import { Skeleton, Spin } from 'antd';
import components from './components'; // 动态lazy导入函数
import routers from './baseRouter';
import { getAdminMenus } from "@/services/index";
import RedirectPage from '@/pages/404/index';
import { login, setmenus } from "@/store/reducers/global";
import { flattenMenus } from "@/utils/utils"

// 解决懒加载第一次加载时的闪烁问题
const lazyComponent = (Component) => {

    return (
        <ErrorBoundary>
            <Suspense fallback={<Skeleton active />}>
                <Component />
            </Suspense>
        </ErrorBoundary>

    );
}


// 从后端更新menus
const fetchMenus = (dispatch) => {

    // 获取菜单
    getAdminMenus().then((list) => {
        dispatch(setmenus(list));
    });
};


const MainRoute = () => {
    const [isLoading, setLoading] = useState(true);
    const [routes, setRoutes] = useState(routers);
    const menus = useSelector((state) => state.global.menus);

    const dispatch = useDispatch()

    const routeElement = useRoutes(routes);

    // 动态生成路由配置
    const generateRoutes = useCallback((menus) => {
        return menus.map(({path='',filepath,children}) => {
            const route = {
                path,
                element:filepath?lazyComponent(components[filepath]):React.createElement(),
            };
            return route;
        });
    }, [menus]);


    useEffect(() => {
        if (menus.length > 0) {
            // 扁平化菜单
            const asyncrouter = generateRoutes(flattenMenus(menus))
            
            const updatedRoutes = [...routers];

            updatedRoutes[0].children = asyncrouter;

            setRoutes(updatedRoutes);
            setLoading(false);
            return
        }
        fetchMenus(dispatch);
    }, [menus,generateRoutes,dispatch]);

    if (isLoading) {
        return <Spin />;
    }

    return  routeElement ;
};

export default MainRoute;

动态渲染Layout的Menu菜单

映射Icon

接口返回的menus里的icon字符串要处理成图标组件

js 复制代码
//  layout/BasicLayout.jsx
useEffect(() => {
    // 加载menus,处理icon
    const loopMenuItem = (menus) => menus.map(({ icon, children, ...item }) => ({
        ...item,
        icon: icon && IconMap[icon],
        children: children ? loopMenuItem(children) : null,
    }));
    if (menulist?.length > 0) {
        setMenus(loopMenuItem(menulist));
    }
}, [menulist]);

设置菜单选中及点击跳转

menus的key绑定了对应的路由path

js 复制代码
//  layout/BasicLayout.jsx
/** 选中的菜单项 */
const selectMenukey = useMemo(() => {
    let path = location.pathname;
    return [path];
}, [location.pathname]);


<Menu
    theme="dark"
    mode="inline"
    defaultSelectedKeys={["/"]}
    selectedKeys={selectMenukey}
    onClick={({ key }) => navigate(key);} // 点击可以跳转路由
    items={menus}
/>

渲染子路由页面

BasicLayout组件设置的路径是"/",除基本路由外的任何路由都要经由BasicLayout来渲染页面,采用react router的Outlet组件来渲染子路由

js 复制代码
<Outlet />

缓存当前有效路由及登录失效判断

js 复制代码
//  layout/BasicLayout.jsx
    const location = useLocation();
    const navigate = useNavigate();
    const currentUser = useSelector((state) => state.global.userInfo);
    const isLogin = (localStorage.getItem("token") && location.pathname !== "/login") || false;

    // 使用useEffect处理认证逻辑-缓存历史有效路径
    useEffect(() => {
        if (!currentUser || !isLogin) {
            message.warning("登录已失效,请重新登录");
            navigate("/login", { replace: true });
            return
        }

        // 只记录有效页面(如不为 404 页)
        if (location.pathname !== "/login" && location.pathname !== "*" ) {
            sessionStorage.setItem("lastValidPath", location.pathname);
        }

    }, [currentUser, isLogin, navigate,location.pathname]);

附上完整BasicLayout代码

js 复制代码
//  layout/BasicLayout.jsx
import React, { useState, useEffect, useMemo } from "react";
import { Button, Layout, Menu, theme, Spin, message } from "antd";
import { useNavigate, Outlet, useLocation, Navigate, useOutlet } from "react-router-dom";
import { useSelector, useDispatch } from 'react-redux';
import { login, logout } from "@/store/reducers/global";
import GlobalHeader from "@/components/GlobalHeader";

// 导入对应的Icon
import {
    UserOutlined,
    MenuFoldOutlined,
    MenuUnfoldOutlined,
    UploadOutlined,
    VideoCameraOutlined,
} from '@ant-design/icons';

import styles from './index.less'

const { Header, Sider, Content } = Layout;

// Icon的对应表
const IconMap = {
    UploadOutlined: <UploadOutlined />,
    UserOutlined: <UserOutlined />,
    VideoCameraOutlined: <VideoCameraOutlined />
};

const BasicLayout = () => {

    const location = useLocation();
    const navigate = useNavigate();

    const [collapsed, setCollapsed] = useState(false);
    const currentUser = useSelector((state) => state.global.userInfo);
    const isLogin = (localStorage.getItem("token") && location.pathname !== "/login") || false;
    const menulist = useSelector((state) => state.global.menus)
    const [menus, setMenus] = useState([])
    const { token: { colorBgContainer, borderRadiusLG }, } = theme.useToken();

    // 使用useEffect处理认证逻辑-缓存历史有效路径
    useEffect(() => {
        if (!currentUser || !isLogin) {
            message.warning("登录已失效,请重新登录");
            navigate("/login", { replace: true });
            return
        }

        // 只记录有效页面(如不为 404 页)
        if (location.pathname !== "/login" && location.pathname !== "*" ) {
            sessionStorage.setItem("lastValidPath", location.pathname);
        }

    }, [currentUser, isLogin, navigate,location.pathname]);
   

    /** 选中的菜单项 */
    const selectMenukey = useMemo(() => {
        let path = location.pathname;
        return [path];
    }, [location.pathname]);
    
    
    useEffect(() => {
        // 加载menus,处理icon
        const loopMenuItem = (menus) => menus.map(({ icon, children, ...item }) => ({
            ...item,
            icon: icon && IconMap[icon],
            children: children ? loopMenuItem(children) : null,
        }));
        if (menulist?.length > 0) {
            setMenus(loopMenuItem(menulist));
        }
    }, [menulist]);

    return (
      <Layout>
        <Sider theme={"dark"} trigger={null} collapsible collapsed={collapsed}>
          <div className={styles.logobox} />
          <Menu
            theme="dark"
            mode="inline"
            defaultSelectedKeys={["/"]}
            selectedKeys={selectMenukey}
            onClick={({ key }) => {
              console.log(key);
              navigate(key);
            }}
            items={menus}
          />
        </Sider>
        <Layout>
          <Header
            className={styles.headerbox}
            style={{
              background: colorBgContainer,
            }}
          >
            <Button
              type="text"
              icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
              onClick={() => setCollapsed(!collapsed)}
              style={{
                fontSize: "16px",
                width: 64,
                height: 64,
              }}
            />
            <GlobalHeader currentUser={currentUser} />
          </Header>
          <Content
            className={styles.contentbox}
            style={{
              background: colorBgContainer,
              borderRadius: borderRadiusLG,
            }}
          >
            <Outlet />
          </Content>
        </Layout>
      </Layout>
    );
};
export default BasicLayout;

"未找到"路由 "*"

当没有其他路由与 URL 匹配时,您可以使用 path="*" 渲染"未找到"路由。此路由将匹配任何 URL,但具有最弱的优先级,因此路由器仅在没有其他路由匹配时才会选择它。

js 复制代码
// 举个例子
function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="dashboard" element={<Dashboard />} />
      <Route path="*" element={<NotFound />} />
    </Routes>
  );
}

未定义路由自动跳转回上一个有效路径-处理404

如果在地址栏输入不存在或错误的路由path,实现不显示错误页面,自动跳转到上一个有效的页面,需要处理基础路由里面的NoMatch组件

js 复制代码
//  pages/nomatch/index.jsx
export default function NoMatch() {
    const navigate = useNavigate();
    const location = useLocation();
    
    useEffect(() => {
        // BasicLayout页面记录了上一个有效路径
        const previousPath = location.state?.from || sessionStorage.getItem("lastValidPath") || '/'; 
        navigate(previousPath, { replace: true }); 
        
    }, [location,navigate]);

    return null
}

总结

通过接口返回menus 来动态引入组件,动态注册路由,动态渲染菜单,以及动态面包屑等功能,最终实现根据用户的动态权限路由,以上就是完整的配置,可以在此基础上扩展按钮级权限,如果有问题,欢迎评论区交流,谢谢 😊

项目 git 地址:github.com/nothingobje...

参考资料

# React封装自定义 Hook 捕获所有错误

# 基于react-router v6实现动态菜单、动态路由

写在最后

写作不易,如果觉得还不错, 一赞一评,就是我最大的动力

相关推荐
晓13131 小时前
JavaScript加强篇——第七章 浏览器对象与存储要点
开发语言·javascript·ecmascript
杨进军1 小时前
React 实现 useMemo
前端·react.js·前端框架
海底火旺1 小时前
浏览器渲染全过程解析
前端·javascript·浏览器
杨进军1 小时前
React 实现多个节点 diff
前端·react.js·前端框架
前端付豪1 小时前
15、前端可配置化系统设计:从硬编码到可视化配置
前端·javascript·架构
杨进军1 小时前
React 实现 useState
前端·react.js·前端框架
拾光拾趣录1 小时前
Webpack性能优化:10个杀手级提速技巧
前端·webpack·turbopack
aPurpleBerry1 小时前
hot100 hot75 栈、队列题目思路
javascript·算法
颜漠笑年2 小时前
可迭代对象≠数组,一起来揭开for...of背后隐藏的秘密吧
前端·javascript
脑袋大大的3 小时前
判断当前是否为钉钉环境
开发语言·前端·javascript·钉钉·企业应用开发