V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
爱意满满的作品展示区。
zhangfg
V2EX  ›  分享创造

腿夹腿,带你用 react 撸后台,系列二(项目骨架篇)

  •  1
     
  •   zhangfg · 59 天前 · 1563 次点击
    这是一个创建于 59 天前的主题,其中的信息可能已经有所发展或是发生改变。

    Github 地址 | 文档 | 在线预览 | 拓展 pro 版在线预览

    react-antd-console 是一个后台管理系统的前端解决方案,封装了后台管理系统必要功能(如登录、鉴权、菜单、面包屑等),帮助开发人员专注于业务快速开发。项目基于 React 18Ant design 5ViteTypeScript 等新版本。对于使用到的各项技术,会被持续更新至最新版本。可放心用于生产环境。

    为了方便大家更好的掌握和使用本项目,推出系列文章:

    • 腿夹腿,带你用 react 撸后台,系列一( Vite 篇)
    • 腿夹腿,带你用 react 撸后台,系列二(项目骨架篇)

    如果你喜欢这个项目或认为对你有用,欢迎使用体验和 Star

    1. 概述

    上篇我们搞定了构建工具,接下来,我们继续搭建项目骨架。值得一提的是,项目骨架搭建好后,会是一个通用的 react 开发模板,也可以用来开发其他 react 项目

    2. react 初始化

    上篇提到 <root>/index.html 引入了 src/main.tsx,接下来我们在 <root>/index.html 中添加一个 div 元素作为 react 根组件的容器

    <!-- <root>/index.html -->
    <html>
      <body>
        <div id="root"></div>
        <script type="module" src="/src/main.tsx"></script>
      </body>
    </html>
    

    然后在 src/main.tsx 中初始化 react

    // src/main.tsx
    import { createRoot } from 'react-dom/client';
    import App from './App';
    // createRoot lets you create a root to display React components inside a browser DOM node.
    const root = createRoot(document.getElementById('root')!);
    root.render(
        <App />
      );
    

    参考文档:

    3. react-router 初始化

    3.1 准备工作

    在初始化 react-router 之前,我们先来考虑一些事情:

    3.1.1 一套配置生成多种数据

    众所周知,一个路由对应一个页面。 而侧边菜单,往往也是一个路由对应一个菜单项(除了一些特定的页面例如登录页/403 页等,和从某列表点开来的详情/编辑页等)。 而面包屑组件,也是根据菜单数据生成。 因此,我们需要只用一套数据,就可以自动生成 react 路由、菜单、面包屑等数据。

    故作如下设计:

    数据 类型 来源 作用
    routesConfig RouteConfig[] 手动配置 配置路由
    reactRoutes RouteObject[] 自动生成 用于生成 react-router 路由
    routes RouteConfig[] 自动生成 树型结构,用于生成 菜单面包屑 等数据
    flattenRoutes RouteConfig[] 自动生成 routes 的展平数据结构,方便查询路由
    interface RouteConfig {
        /** 配置数据: 路径,同 react-router */
        path: string;
        /** 生成数据: 对应 react-router 的 pathname */
        pathname?: string;
        /**
         * 生成数据: ['', '/layout', '/layout/layout-children1',
         *  '/layout/layout-children1/permission']
         */
        collecttedPathname?: string[];
        /** 生成数据: ['', 'layout', 'layout-children1', 'permission'] */
        collecttedPath?: string[];
        /** 配置数据: 组件的文件地址 */
        component?: () => Promise<any>;
        /** 配置数据: 隐藏在菜单 */
        hidden?: boolean;
        /** 配置数据: 菜单名称 */
        name?: string;
        /** 配置数据: 菜单 icon */
        icon?: React.ReactNode;
        /** 配置数据: 页面标题,不传则用 name */
        helmet?: string;
        /** 配置数据: 菜单权限 */
        permission?: string;
        /** 配置数据: 重定向 path */
        redirect?: string;
        /** 配置数据: 将子路由的菜单层级提升到本级 */
        flatten?: boolean;
        /** 配置数据: 子路由,同 react-router */
        children?: RouteConfig[];
        /** 配置数据: 同 react-router */
        caseSensitive?: boolean;
        /** 配置数据: 是否是外链 */
        external?: boolean;
        /** 生成数据: 父路由 */
        parent?: RouteConfig;
    };
    

    为此,我们封装了 Router 类,导出在了 react-router-toolset 中。 react-router-toolset 还导出了 useRouter 方法,用于在 react 组件中获取最新的上述数据。

    用法:

    // router/index.ts
    import { Router } from 'react-router-toolset';
    import { routesConfig } from './config';
    
    /**
     * 路由配置在 `src/router/config/index.tsx` 文件的 `routesConfig` 中
     * routesConfig 要满足 RouteConfig 类型约束
     * routesConfig 配置参考:
     * https://github.com/diandian18/react-antd-console/blob/master/src/router/config/index.tsx
     */
    const router = new Router(routesConfig);
    
    // router 包含了 reactRoutes/routes/flattenRoutes 等数据
    export default router;
    

    3.1.2 组件外跳转

    react-router 只提供了组件内跳转路由的 api useNavigate。我们还希望在组件外可以跳转。而 history 是专门做这个事情的 另一方面,react-router 没有提供 history 模式的 Router 组件,我们需要封装一个

    // history.ts
    import { createBrowserHistory } from 'history';
    const history = createBrowserHistory();
    export default history;
    
    // HistoryRouter.tsx
    import { useState, useLayoutEffect } from 'react';
    import { Router } from 'react-router-dom';
    import type { BrowserHistory } from 'history';
    
    export interface HistoryRouterProps {
      history: BrowserHistory
      basename?: string
      children?: React.ReactNode
    }
    
    export default function HistoryRouter({
      basename,
      children,
      history,
    }: HistoryRouterProps) {
      const [state, setState] = useState({
        action: history.action,
        location: history.location,
      });
    
      useLayoutEffect(() => history.listen(setState), [history]);
    
      return (
        <Router
          basename={basename}
          location={state.location}
          navigationType={state.action}
          navigator={history}
        >
          { children }
        </Router>
      );
    }
    

    我们把 historyHistoryRouter 都导出在了 react-router-toolset 中

    // router/index.ts
    export * from 'react-router-toolset'; // 包括 history 和 HistoryRouter
    
    // 组件外跳转示例
    import { history } from '@/router';
    history.push('/login');
    

    3.2 初始化

    准备工作做好后,我们可以很简洁地初始化 react-router

    // src/main.tsx
    import { history, HistoryRouter } from '@/router';
    
    root.render(
      <HistoryRouter history={history}>
        <App />
      </HistoryRouter>,
    );
    
    // src/App.tsx
    import { useRoutes } from 'react-router-dom';
    import router from '@/router';
    
    function App() {
      const element = useRoutes(router.reactRoutes);
      return element;
    }
    

    3.3 关于 react-router-toolset

    react-router-toolset 作为一个通用的 react-router 工具集,发布了 npm 包,源码地址

    下面额外介绍一些其他有用的方法:

    /**
     * 设置路由项。setItem
     * @param pathname 指定路由
     * @param cb 参数为 pathname 对应的路由
     */
    Router.setItem: (pathname: string | ((routesConfigItem: RouteConfig) => void), cb?: (routesConfigItem: RouteConfig) => void) => void
    
    /**
     * 设置 pathname 的兄弟路由
     * @param pathname 指定路由
     * @param cb 参数 routesConfigs 为 pathname 的兄弟路由
     * @param cb 参数 parentRoute 为 pathname 的父路由
     */
    Router.setSiblings(pathname: string | ((routesConfig: RouteConfig[], parentRoute: RouteConfig) => void), cb?: (routesConfig: RouteConfig[], parentRoute: RouteConfig) => void): void
    
    /**
     * 根据 pathname 获取 router 的 path
     * router 的 path 里可能有:id
     * @example '/123/home' -> '/:id/home'
     */
    Router.getRoutePath(pathname: string): string
    
    /**
     * 根据当前路由 params
     * 替换掉目标 routePath 中的动态路由参数如":id"
     * @example '/:id/home' -> '/123/home'
     */
    (method) Router.getPathname(routePath: string): string
    
    /**
     * 在 react 组件中获取最新的 reactRoutes/routes/flattenRoutes/curRoute
     */
    function useRouter(router: Router): {
      reactRoutes: RouteObject[];
      routes: RouteConfig[];
      flattenRoutes: Map<string, RouteConfig>;
      curRoute: RouteConfig | undefined;
    }
    

    4. 继续完善

    4.1 动态浏览器标题

    每个页面都有自己的标题,我们希望在浏览器标题上动态展示页面对应的标题,可以使用 react-helmet-async 实现

    // src/App.tsx
    import { Helmet, HelmetProvider } from 'react-helmet-async';
    import router, { useRouter } from '@/router';
    
    function App() {
      // useRouter 除了返回路由相关数据,还返回了当前路由数据
      const { curRoute } = useRouter(router);
      const element = useRoutes(router.reactRoutes);
      const { t: t_menu } = useTranslation('menu');
    
      return (
        <HelmetProvider>
          <Helmet>
            <title>{curRoute?.name ? `${t_menu(curRoute.name)} - ${DEFAULT_TITLE}` : DEFAULT_TITLE}</title>
          </Helmet>
          { element }
        </HelmetProvider>
      );
    }
    

    4.2 antd

    antd 甚至不用初始化,安装好直接用即可。

    npm i -S antd
    

    打包的时候还能自动做 tree shaking (说是这么说,但是 antd 打出来的包依然很大。如果你嫌大,可以把 antd 做 cdn 引入。可以参考 vite-plugin-cdn-importVite 配置 build.rollupOptions.external 去解决,在此不做展开)

    值得一提的是,antd 功能覆盖的场景还是非常多的,很可能有一些很有用的功能,由于我们查看文档不够仔细而被忽略掉

    antd 文档还是比较详细的,平时开发会经常用到,随时查阅,在此不做赘述

    4.3 请求

    可参考 react-antd-console 文档

    4.4 Mock

    可参考 react-antd-console 文档

    4.5 国际化

    可参考 react-antd-console 文档

    5. 目录结构

    完成上述工作后,我们最后再确定下目录结构

    ./src
    ├── assets      # 公共静态资源
    ├── components  # 公共组件
    ├── consts      # 公共常量
    ├── hooks       # 公共 hooks
    ├── http        # http
    ├── layouts     # 通用布局
    ├── locales     # 多语言配置
    ├── mock        # mock
    ├── models      # 公共数据管理
    ├── pages       # 页面组件
    ├── router      # 路由配置
    ├── services    # 接口配置
    ├── styles      # 公共样式
    ├── utils       # 公共工具
    ├── App.tsx     # 根组件
    └── main.tsx    # 入口组件
    

    下面从:

    • 固定目录(不需要修改)
    • 配置目录
    • 公共目录

    三种目录概述

    5.1 固定目录

    • src/lyaouts: 通用布局。以后介绍
    • src/http: 封装了 axios,配置了请求和响应拦截,导出了 axios 实例
    • src/mock: 封装了 msw,若要添加 mock 文件,请在 src/mock/browser.ts 中添加

    5.2 配置目录

    • src/router: 包含路由配置和导出了一些重要的路由方法
    • src/pages: 业务页面
    • src/locales: 多语言配置
    • src/services: 接口配置

    5.3 公共目录

    以下目录,定义为通用目录,存放的都是可以复用的代码

    • src/assets: 公共静态资源
    • src/components: 公共组件
    • src/consts: 公共常量
    • src/hooks: 公共 hooks
    • src/models: 公共数据管理。以后介绍
    • src/styles: 公共样式
    • src/utils: 公共工具

    逻辑相关性强的文件之间,应当尽可能的靠近,最好放在同一目录下。而公共目录,应当只包含:全局可复用的代码

    特别需要注意的是,除非由特殊原因,我们不认为每个页面组件都需要对应一个model数据,因为这么做会增加结构复杂度和维护难度。model中的数据,应该是一些全局可复用的数据

    至此,项目骨架搭建完毕。

    6. 系列文章

    • 腿夹腿,带你用 react 撸后台,系列一( Vite 篇)
    • 腿夹腿,带你用 react 撸后台,系列二(项目骨架篇)

    如果你喜欢这个项目或认为对你有用,欢迎使用体验和 Star

    Github 地址 | 文档 | 在线预览 | 拓展 pro 版在线预览

    5 条回复    2024-10-31 15:08:38 +08:00
    zhangfg
        1
    zhangfg  
    OP
       59 天前
    v.1.1.0 Latest

    New features:
    - Dynamic route: Change route in any way.
    - Dynamic meta: Change meta info in route.
    - External link: Click external link will open a new brower tab and arrive the link.
    - Separation layout: A empty layout.
    - Single sider layout: Menu has no children.

    Fixed
    - router.setSiblings has no effect on non-dynamic parameter prefix routes.
    xyovo999
        2
    xyovo999  
       59 天前
    做的不错
    zhangfg
        3
    zhangfg  
    OP
       59 天前
    @xyovo999 谢谢你😊
    xhawk
        4
    xhawk  
       57 天前
    @zhangfg 我觉得做得挺好的. 如果考虑去掉 antd, 然后添加 shadcn 的话, 可以一起探讨探讨搞一个.
    zhangfg
        5
    zhangfg  
    OP
       56 天前
    @xhawk 很好的想法。我对这个库还不了解,但我去看了一下,看上去像是 react 版的 html 标签,有很多原子组件,样式可定制。如果要实现我们需要的侧边菜单、面包屑等功能,还需要进一步封装组合这些原子组件,才能输出有像 antd 一样效果且有用法简单的组件。看起来它像是一种可以创造类似 antd 的 ui 库的工具,但直接用来做业务开发,没有 antd 方便快捷。
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   2990 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 14:04 · PVG 22:04 · LAX 06:04 · JFK 09:04
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.