Antd5一出,治好了我组件库选择内耗,我直接搭配React18+Vite+Ts做了一个管理后台(1)

Antd5最最最吸引我的点

image.png

Antd5 官网

又好用,又能好看。

没有Antd5之前,我是选Mui的

比如我要做一个对样式有很多需求的管理后台,或者一些官网主页,我都会选MUI

image.png

MUI官网

这个才是我认为最能变好看的组件库,MUI走在了Antd5之前,就搞CSS in JS了,定制主题那叫一个容易,但有一个点就是他不是很好用。。。。

Antd5,几乎让我没理由不选,又好用,又能好看

MUI不怎么好用,组件功能不多,远没有Antd4好用,想用只能自己动手封装,但是能让我还就纠结MUI和Antd选谁的主要原因,就是MUI真好看,现在Antd5有了让这么好用的自定义主题的本领,一下就根治精神内耗,直接Antd5,无脑入就对了。又好用,又能好看。

ok,那么简单的介绍我选Antd5的理由,接下来,我们就直奔主题,快速的过一下,使用React18+Antd5+Vite+Ts搭建管理后台的全过程,并在文章结尾出提出这个项目,供大家快速上手实操,体验一下Antd5的畅快。

Antd5搭配React18+Vite+Ts开发管理后台全流程

开发登陆页

image.png

封装字体特效组件,让页面看着淡雅不俗气

import "react";
import { useRef } from "react";
import { useEffect } from "react";
import styles from "./index.module.scss";

let defaultRun: boolean = true;
let infinite: boolean = true;
let frameTime: number = 75;
let endWaitStep = 3;
let prefixString = "";
let runTexts = [""];
let colorTextLength = 5;
let step = 1;
let colors = [
  "rgb(110,64,170)",
  "rgb(150,61,179)",
  "rgb(191,60,175)",
  "rgb(228,65,157)",
  "rgb(254,75,131)",
  "rgb(255,94,99)",
  "rgb(255,120,71)",
  "rgb(251,150,51)",
  "rgb(226,183,47)",
  "rgb(198,214,60)",
  "rgb(175,240,91)",
  "rgb(127,246,88)",
  "rgb(82,246,103)",
  "rgb(48,239,130)",
  "rgb(29,223,163)",
  "rgb(26,199,194)",
  "rgb(35,171,216)",
  "rgb(54,140,225)",
  "rgb(76,110,219)",
  "rgb(96,84,200)",
];
let inst = {
  text: "",
  prefix: -(prefixString.length + colorTextLength),
  skillI: 0,
  skillP: 0,
  step: step,
  direction: "forward",
  delay: endWaitStep,
};

function randomNum(minNum: number, maxNum: number): number {
  switch (arguments.length) {
    case 1:
      return parseInt((Math.random() * minNum + 1).toString(), 10);
    case 2:
      return parseInt(
        (Math.random() * (maxNum - minNum + 1) + minNum).toString(),
        10
      );
    default:
      return 0;
  }
}
let randomTime: number = randomNum(15, 150);
let destroyed: boolean = false;
let continue2: boolean = false;
let infinite0: boolean = true;

function render(dom: HTMLDivElement, t: string, ut?: string): void {
  if (inst.step) {
    inst.step--;
  } else {
    inst.step = step;
    if (inst.prefix < prefixString.length) {
      inst.prefix >= 0 && (inst.text += prefixString[inst.prefix]);
      inst.prefix++;
    } else {
      switch (inst.direction) {
        case "forward":
          if (inst.skillP < t.length) {
            inst.text += t[inst.skillP];
            inst.skillP++;
          } else {
            if (inst.delay) {
              inst.delay--;
            } else {
              inst.direction = "backward";
              inst.delay = endWaitStep;
            }
          }
          break;
        case "backward":
          if (inst.skillP > 0) {
            inst.text = inst.text.slice(0, -1);
            inst.skillP--;
          } else {
            inst.skillI = (inst.skillI + 1) % runTexts.length;
            inst.direction = "forward";
          }
          break;
        default:
          break;
      }
    }
  }
  if (ut != null) {
    inst.text = ut.substring(0, inst.skillP);
    if (inst.skillP > ut.length) {
      inst.skillP = ut.length;
    }
  }
  dom.textContent = inst.text;
  let value;
  if (inst.prefix < prefixString.length) {
    value = Math.min(colorTextLength, colorTextLength + inst.prefix);
  } else {
    value = Math.min(colorTextLength, t.length - inst.skillP);
  }
  dom.appendChild(fragment(value));
}

function getNextColor(): string {
  return colors[Math.floor(Math.random() * colors.length)];
}

function getNextChar(): string {
  return String.fromCharCode(94 * Math.random() + 33);
}
function fragment(value: number): DocumentFragment {
  let f = document.createDocumentFragment();
  for (let i = 0; value > i; i++) {
    let span = document.createElement("span");
    span.textContent = getNextChar();
    span.style.color = getNextColor();
    f.appendChild(span);
  }
  return f;
}
let flag = false;
export default (props) => {
  const { texts } = props;
  let container = useRef();
  let container2 = useRef();
  function init(): void {
    setTimeout(() => {
      if (destroyed) {
        return;
      }
      container.current && loop();
    }, randomTime);
  }
  function loop(): void {
    if (destroyed) {
      return;
    }
    setTimeout(() => {
      if (continue2 && container.current != null) {
        if (destroyed) {
          return;
        }
        let dom = container.current;
        let index = inst.skillI;
        let originText = texts[index];
        let currentText = runTexts[index];
        if (originText != currentText) {
          render(dom, currentText, originText);
          runTexts[index] = originText;
        } else {
          render(dom, currentText);
        }
      }
      if (infinite0) {
        loop();
      } else {
        if (inst.skillP < runTexts[0].length) {
          loop();
        }
      }
    }, frameTime);
  }
  useEffect(() => {
   {
      runTexts = texts;
      continue2 = defaultRun;
      infinite0 = infinite;
      inst.delay = endWaitStep;
      if (!infinite0) {
        if (runTexts.length > 1) {
          console.warn(
            "在设置infinite=false的情况下,仅第一个字符串生效,后续字符串不再显示。"
          );
        }
      }
      init();
    }
  }, []);
  return (
    <div className={styles.content}>
      <pre ref={container} className={styles.container} id="container"></pre>
      <pre ref={container2}></pre>
    </div>
  );
};

scss

使用的css Module技术


.content{
    color: black;
    height: 100%;
    width: 100%;
    .container {
        margin: 0;
        padding: 0;
        width: 100%;
        height: 100%;
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
        white-space: pre-wrap;
        word-wrap: break-word;
    }
}

登陆卡片,用的Antd5中的Card组件

这没啥可说的,但是效果确实看起来舒服多了

import { Card } from "antd";
import type { ReactNode } from "react";

const { Meta } = Card;

const App: React.FC = (props: { children: ReactNode }) => (
  <Card
    hoverable
    style={{ width: 400 }}
    cover={
      <img
        alt="example"
        src="https://s1.imagehub.cc/images/2022/10/27/c1ede234aeb41d1a0216fe5bc4d1c642aad1eed8.jpg942w_531h_progressive.webp"
      />
    }
  >
    {props.children}
  </Card>
);

export default App;

开发主页

image.png
image.png

主要挑干的说说几点:

  • 左侧菜单
  • tab标签
  • 面包屑

别看就这几个,组合一起,实现各种功能就费劲了,接下来具体说说

设计路由结构配置方案

路由数据的结构是树状的,我们把这个树状结构分离出来,结构数据和内容数据

  • 结构数据:描述结构关系,通过id
  • 内容数据:根据id去匹配对应的数据内容

结构数据

export const RouteIds = {
  hello: "hello",
  sys: "sys",
  role: "role",
  user: "user",
};

export const routesStructData = [
  {
    id: RouteIds.hello,
  },
  {
    id: RouteIds.sys,
    children: [{ id: RouteIds.role }, { id: RouteIds.user }],
  },
];

内容数据

import { Login, Center, Page1, Hello, UserPage, RolePage } from "../pages";
export default {
  center: {
    meta: {
      title: "中心",
    },
  },
  hello: {
    meta: {
      title: "首页",
    },
    component: Hello,
  },
  sys: {
    meta: {
      title: "系统管理",
    },
  },
  user: {
    meta: {
      title: "用户管理",
    },
    component: UserPage,
  },
  role: {
    meta: {
      title: "角色管理",
    },
    component: RolePage,
    state: { a: 1111 },
  },
};

然后二者组成完整路由数据的基本数据,这么做的好处就是可以把关注的事情分开,有些逻辑需要结构,就用结构数据,有些地方需要内容,就通过结构的id进行获取,这样,代码组织的难度更小。

别忘了使用的组件,要用lazy进行懒加载

import { lazy } from "react";
const Center = lazy(() => import("./center"));
const Login = lazy(() => import("./login"));
const Page1 = lazy(() => import("./page1"));
const Hello = lazy(() => import("./hello"));
const UserPage = lazy(() => import("./sys/user"));
const RolePage = lazy(() => import("./sys/role"));
export { Center, Login, Page1, Hello, UserPage, RolePage };

然后设计一个递归算法,生成整个完整的路由结构数据

const processRoute = (children: any[], routesData: any[], prefix: string) => {
  routesData.forEach((routeItem, index) => {
    const { id } = routeItem;
    if (permissions.includes(id)) {
      let routeData = routerConfig[id];
      // 沿途记录,然后拼接成path
      routeData.path = prefix + "/" + id;
      routeData.routeId = id;
      const { component: Component } = routeData;
      if (Component) {
        routeData.element = (
          <Suspense>
            <Component></Component>
          </Suspense>
        );
      }
      children!.push(routeData);
      if (routeItem.children!?.length > 0) {
        routeData.children = [];
        processRoute(routeData.children, routeItem.children!, routeData.path);
      }
    }
  });
};

通过算法,我们尽可能少的配置数据,有些关键数据,完全可以通过这个算法计算出来。

比如路由组件的path,我们就可以通过分析结构管理,拼接起来

好处是,我们不用但系调整结构数据,连带的path命名和修改的心智负担。

根据路由数据驱动显示菜单

在组件内部,通过useEffect,响应路由数据的创建完成

useEffect(() => {
    if (routerData.length) {
      let result = [];
      processRoute(routerData[1].children, result);
      setMenuData(result);
    }
  }, [routerData]);

然后下一步进行,根据路由结构数据,渲染菜单结构

const processRoute = (data, result: any) => {
  data.forEach((item) => {
    let temp: any = {
      key: item.routeId,
      icon: createElement(UserOutlined),
      label: item.meta.title,
    };
    result.push(temp);
    if (item?.children?.length) {
      temp.children = [];
      processRoute(item.children, temp.children);
    }
  });
};

然后将数据通过setMenuData之后,驱动显示菜单

{menuData.length > 0 && (
    <Menu
      theme="dark"
      mode="inline"
      selectedKeys={defaultSelectedKeys}
      defaultOpenKeys={defaultOpenKeys}
      style={{ height: "100%", borderRight: 0 }}
      items={menuData}
      onClick={({ key }) => {
        const path = routerConfig[key]?.path;
        if (path) {
          navigate(path);
        }
      }}
    />
  )}

Antd5的用法没啥变化,这里说说用selectedKeysopenKeys的原因:

selectedKeysdefaultOpenKeys需要让Menu变为可控组件
原因就是,我需要动态响应路由的改变,就算我直接刷新,也可以选中正确的菜单项目,展开正确的折叠项

为啥是defaultOpenKeys,因为不这样,你都点不开折叠,当然你也可以完全设置可控。

那么说到这,我们需要实现一个监听,react路由改变的功能,你知道怎么设计么?

封装useLocationListenhook,实现路由变化监听

import { useEffect } from "react";
import { useLocation } from "react-router-dom";

export default (listener) => {
  let location = useLocation();
  useEffect(() => {
    listener(location);
  }, [location]);
};

然后在组件内使用

useLocationListen((location: Location) => {
    const { pathname } = location;
    let temp = pathname.split("/").filter((item) => {
      return item;
    });
    setDefaultSelectedKeys([temp.at(-1)]);
    let temp2 = temp.slice(1, temp.length - 1);
    if (temp2.length) {
      setDefaultOpenKeys(temp2);
    }
    // 这个地方就是存储tab标签记录的逻辑
    globalStore.addTabHistory(location);
  })

然后传入回调函数,就能够实现响应,从而分析路由,获取Menu组件的展开和选中状态数据,使其完全可控。

也这是因为实现了路由的监听,菜单和下面要介绍的tab标签栏,完美联动,通过监听同一个location对象

记录路由变化,渲染标签栏

每当路由变化,都会通过一个数据存下来,而且还能够夸组件共享,那么就需要mobx这样的状态管理库了,我们安装一下mobx

yarn add mobx mobx-react

实现一个全局仓库

import { action, makeAutoObservable, toJS } from "mobx";
import type { Location } from "react-router-dom";

// Model the application state.
class Global {
     ...
  permissions: any[] = [];
        ...
  constructor() {
    makeAutoObservable(this);
  }
  init() {
  ...
    this.tabsHistory = {};
   ...
  }
  ...

  addTabHistory = (newItem: Location) => {
    let temp = toJS(this.tabsHistory);
    temp[newItem.pathname] = newItem;
    this.tabsHistory = temp;
  };

  deleteTabHistory = (pathName: string) => {
    let temp = toJS(this.tabsHistory);
    if (Object.values(temp).length > 1) {
      Reflect.deleteProperty(temp, pathName);
      this.tabsHistory = temp;
    }
  };
    ...
}

export default new Global();

用法相当的简单,只不过,我实现的对象的属性添加减少的监听,就算我用deep都不好使,所以我用了一个小技巧,就是我先把数据通过tojs转化成普通的数据,让后再修改,最后直接赋值给状态,这样,引用地址改变,就会触发组件的刷线,那么怎么react组件响应mobx的刷新?

react组件需要用到mobx-react提供的hocobserver

export default observer(() => {
    ...
    useEffect(() => {
    let tabsHistory = Object.values(toJS(globalStore.tabsHistory));
    setItems(
      tabsHistory.map((item) => {
        const { pathname } = item;
        let routeId = pathname.split("/").at(-1);
        const { meta } = routeConfig[routeId];
        return { label: meta.title, key: pathname };
      })
    );
  }, [globalStore.tabsHistory]);
  ...
})

这样,在主页的组件中进行路由全局监听,当路由发生变化就会记录,然后tabs标签组件内部,就会响应更新,从而渲染数据。

    <Tabs
      className={styles.content}
      type="editable-card"
      onChange={onChange}
      activeKey={activeKey}
      items={items}
      hideAdd={true}
      onEdit={(e, action) => {
        if (action == "remove") {
          ;
          globalStore.deleteTabHistory(e);
        }
      }}
    />

这就是Antd5中Tabs的使用,但是我需要修改他的默认样式,因为,我仅仅需要tab3提供切换,内部没有什么内容,但是会有多余的margin,我要去覆盖掉,还不能污染全局。

.content {
    :global(.ant-tabs-nav) {
        margin: initial !important;
    }
}

使用css module:global来搞就完了。

KeepAlive组件

我们做的是一个管理后台,经常会有表单填写,不能我们切换标签了,再回来啥都重置了,那体验可不好,我实现了keepAlive,放置切换重置。

ezgif.com-gif-maker (26).gif

keepAlive的代码,我封装在了R6helper里。

image.png

或者你可以直接在项目实现:

import { useRef, useEffect, useReducer, useMemo, memo } from 'react'
import { useLocation, useOutlet } from 'react-router-dom'

const KeepAlive = (props: any) => {
  const outlet = useOutlet()
  const { include, keys } = props
  const { pathname } = useLocation()
  const componentList = useRef(new Map())
  const forceUpdate = useReducer((bool: any) => !bool, true)[1] // 强制渲染
  const cacheKey = useMemo(
    () => pathname + '__' + keys[pathname],
    [pathname, keys]
  ) // eslint-disable-line
  const activeKey = useRef<string>('')

  useEffect(() => {
    componentList.current.forEach(function (value, key) {
      const _key = key.split('__')[0]
      if (!include.includes(_key) || _key === pathname) {
        this.delete(key)
      }
    }, componentList.current)

    activeKey.current = cacheKey
    if (!componentList.current.has(activeKey.current)) {
      componentList.current.set(activeKey.current, outlet)
    }
    forceUpdate()
  }, [cacheKey, include]) // eslint-disable-line

  return (
    <div>
      {Array.from(componentList.current).map(([key, component]) => (
        <div key={key}>
          {key === activeKey.current ? (
            <div>{component}</div>
          ) : (
            <div style={{ display: 'none' }}>{component}</div>
          )}
        </div>
      ))}
    </div>
  )
}

export default memo(KeepAlive)

然后替换Outlet标签,去掉<Outlet/>

 <KeepAlive
    include={["/center/sys/user", "/center/sys/role"]}
    keys={[]}
></KeepAlive>

封装主题定制Hoc,答应我别再重复定制主题了,封装一下好么。

Antd定制主题真的不要太方便,方法就是通过Antd提供的ConfigProvider配置theme就行了

import React from 'react';
import { ConfigProvider, Button } from 'antd';


const App: React.FC = () => (
  <ConfigProvider
    theme={{
      token: {
        colorPrimary: '#00b96b',
      },
    }}
  >
    <Button />
  </ConfigProvider>
);


export default App;

类似这样,但是你不能定制一个主题,就写这么一长串吧,那也太不优雅了,这你不赶紧封装一个Hoc

import "react";
import { ConfigProvider, Button } from "antd";

export default (Comp, theme) => {
  return (props) => {
    return (
      <ConfigProvider theme={theme}>
        <Comp {...props} />
      </ConfigProvider>
    );
  };
};

然后包装一下组件,从而传入theme数据,大大方便了定制过程。


export default themeProviderHoc(center, {
  components: {
    Menu: {
      colorPrimary: "blue",
    },
  },
});

Antd5主题,讲究了一个token,我不管它为啥这么叫,总之就是能设置样式,而且antd5还提供了,主题定制网页,

image.png

导出配置

image.png

拷贝过来就行了,

但是需要提示一下,就是这个配置文件是通过localStorage存储的,有记录,想清理的话,手动清理一下。

image.png

结尾

这篇先讲这么多,下一篇,我们具体聊聊如何设计权限管理,以及nodejs开发服务的逻辑。

项目地址
https://github.com/moderateMan/moderate-vue-starter

至此,一个对新手友好的管理后台项目就构建好了,而且还在不断完善中,未来会补全Java后端服务项目,敬请期待,有问题可以随时咨询我,或者留言,我整了个群叫闲D岛,群号551406017,结识一帮志同道合的小伙伴,交流技术,欢迎水群(我就会玩qq,整别的,我也不会,比如公众号啥的。。。哈哈哈哈)

© 版权声明
THE END
喜欢就支持一下吧
点赞10 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容