likes
comments
collection
share

github高星vite+react+Typescript后台管理项目学习通用组件设计

作者站长头像
站长
· 阅读数 40

这是一个以slash-admin为学习项目的教程,也是我个人觉的最好的react+Typescript的练手项目

该项目建议有react跟typescript一定基础的伙伴看,因为我只会说一些核心部分,比如动态路由作者大佬是怎么设计的,不会说的很细,毕竟很多小伙伴应该跟我一样,代码会写,代码不知道应该怎么设计,所以主要学习思路,我是根据slash-admin项目来进行github.com/d3george/sl…

首先看配置文件vite.config.ts,代码相对简单,看代码注释应该就能看懂

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'; // 分析模块大小的插件
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'; // vite缓存icon文件夹
import tsconfigPaths from 'vite-tsconfig-paths';
import path from "path"; // 这个插件让vite能够同步tsconfig.json的path设置alias
// https://vitejs.dev/config/
export default defineConfig({
  base:"./", // 设置打包之后引入路径的问题
  css: {
    // 开css sourcemap方便找css
    devSourcemap: true,
  },
  plugins: [
    react(),
    // 同步tsconfig.json的path设置alias
    tsconfigPaths(),
    createSvgIconsPlugin({
      // 指定需要缓存的图标文件夹
      iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
      // 指定symbolId格式
      symbolId: 'icon-[dir]-[name]',
    }),
    visualizer({
      open: true,// 是否开启模块分析
    }),
  ],
  server: {
    // 自动打开浏览器
    open: true,
    host: true,
    port: 3001,
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
  build: {
    target: 'esnext',
    minify: 'terser',
    // rollupOptions: {
    //   output: {
    //     manualChunks(id) {
    //       if (id.includes('node_modules')) {
    //         // 让每个插件都打包成独立的文件
    //         return id.toString().split('node_modules/')[1].split('/')[0].toString();
    //       }
    //       return null;
    //     },
    //   },
    // },
    terserOptions: {
      compress: {
        // 生产环境移除console
        drop_console: true,
        drop_debugger: true,
      },
    },
  },
})

请求工具,src下新建一个api文件夹来进行请求工具跟api地址的管理,api下新建一个apiClient.ts的文件

import axios, { AxiosRequestConfig, AxiosError, AxiosResponse } from 'axios';
// 这里之所以要as重命名一下是因为会跟下面响应拦截中的message重名
import { message as Message } from 'antd';
// 这里使用的#会根据tsconfig.json配置paths指向来指到根目录的types目录
import { Result } from '#/api';
// 导入国际化i18n
import {t} from "@/locales/i18n.ts";
import {ResultEnum} from "#/enum.ts";
import {isEmpty} from "ramda"; // 工具函数库
// 创建 axios 实例
const axiosInstance = axios.create({
  baseURL: import.meta.env.VITE_APP_BASE_API as string, // as 是typescript将这个值的类型注解为string
  timeout: 50000,
  headers: { 'Content-Type': 'application/json;charset=utf-8' },
});


// 请求拦截
axiosInstance.interceptors.request.use(
  (config) => {
    // 在请求被发送之前做些什么
    config.headers.Authorization = 'Bearer Token';
    return config;
  },
  (error) => {
    // 请求错误时做些什么
    return Promise.reject(error);
  },
);

// 响应拦截
axiosInstance.interceptors.response.use(
  // 在影响拦截中的返回值,在typescript下是需要声明类型的,AxiosResponse 是 Axios 库中定义的响应类型,而且这个类型是接受一个泛型来进行对data的类型声明的,所以我们可以根据api的返回体的情况来创建一个Result的泛型结构
  // 根据slash-admin的目录结构,我们在根目录下创建一个types的文件夹,来存放一些全局的ts的接口跟类型
  (res: AxiosResponse<Result>) => {
    // 这里的t是使用了国际化插件的i18n
    if (!res.data) throw new Error(t('sys.api.apiRequestFailed'));

    const { status, data, message } = res.data;
    // 业务请求成功
    const hasSuccess = data && Reflect.has(res.data, 'status') && status === ResultEnum.SUCCESS;
    if (hasSuccess) {
      return data;
    }

    // 业务请求错误
    throw new Error(message || t('sys.api.apiRequestFailed'));
  },
  (error: AxiosError<Result>) => {
    const { response, message } = error || {};
    let errMsg = '';
    try {
      errMsg = response?.data?.message || message;
    } catch (error) {
      throw new Error(error as unknown as string);
    }
    // 对响应错误做点什么,这里的isEmpty使用的是ramda这个工具库
    if (isEmpty(errMsg)) {
      // checkStatus
      // errMsg = checkStatus(response.data.status);
      errMsg = t('sys.api.errorMessage');
    }

    Message.error(errMsg);
    return Promise.reject(error);
  },
);


// 创建一个类
class APIClient {

  // 这里创建一个泛型函数,返回的是Promise的泛型函数
  request<T = any>(config: AxiosRequestConfig): Promise<T> {
    return new Promise((resolve, reject) => {
      axiosInstance
        .request<any, AxiosResponse<Result>>(config)
        .then((res: AxiosResponse<Result>) => {
          resolve(res as unknown as Promise<T>);
        })
        .catch((e: Error | AxiosError) => {
          reject(e);
        });
    });
  }

  get<T = any>(config:AxiosRequestConfig):Promise<T>{
    return this.request({ ...config, method: 'GET' });
  }

  post<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'POST' });
  }

  put<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'PUT' });
  }

  delete<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'DELETE' });
  }
}

// 导出这个实例类
export default new APIClient();

这里解析一下上面文件的写法跟作用

1,整体来说axios是封装了请求的工具
import { Result } from '#/api'; 根目录下的types文件夹,这个文件夹会用来存放一些全局的ts类型,创建一个api.ts来进行全局响应数据结构的一个管理,内容其实也很简单,
export interface Result<T = any>{
  // 这里的t=any项目默认的情况下会报红出错,因为eslintrc.cjs的默认配置是不允许显示使用any这个类型的,我们可以在eslintrc.cjs这个文件中配置    '@typescript-eslint/no-explicit-any': 'off', // 关闭全局使用any类型的报红错误
  status:number,
  message:string,
  data?:T // 这个接口接受一个泛型并且将泛型给到data
}

新的项目需要在 这里的t=any项目默认的情况下会报红出错,因为eslintrc.cjs的rules默认配置是不允许显示使用any这个类型的,我们可以在eslintrc.cjs这个文件中配置 '@typescript-eslint/no-explicit-any': 'off', 关闭全局使用any类型的报红错误

修改后的.eslintrc.cjs

module.exports = {
  root: true,
  env: { browser: true, es2020: true },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react-hooks/recommended',
  ],
  ignorePatterns: ['dist', '.eslintrc.cjs'],
  parser: '@typescript-eslint/parser',
  plugins: ['react-refresh'],
  rules: {
    'react-refresh/only-export-components': [
      'warn',
      { allowConstantExport: true },
    ],
    '@typescript-eslint/no-explicit-any': 'off', // 关闭全局使用any类型的报红错误
  },
}

axios封装好之后,在下面新建一个APIClient的类,然后新建对应的请求方法,最后导入这么一个类的实例化对象,其实到这里的时候我也暂时不太明白slash-admin的作者为什么要用这种方式进行api的导出使用,有知道的小伙伴可以指导一下,感谢

// 创建一个类
class APIClient {

  // 这里创建一个泛型函数,返回的是Promise的泛型函数
  request<T = any>(config: AxiosRequestConfig): Promise<T> {
    return new Promise((resolve, reject) => {
      axiosInstance
        .request<any, AxiosResponse<Result>>(config)
        .then((res: AxiosResponse<Result>) => {
          resolve(res as unknown as Promise<T>);
        })
        .catch((e: Error | AxiosError) => {
          reject(e);
        });
    });
  }

  get<T = any>(config:AxiosRequestConfig):Promise<T>{
    return this.request({ ...config, method: 'GET' });
  }

  post<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'POST' });
  }

  put<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'PUT' });
  }

  delete<T = any>(config: AxiosRequestConfig): Promise<T> {
    return this.request({ ...config, method: 'DELETE' });
  }
}

请求工具封装完成,我们现在看main.ts这个入口文件

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // 这个插件的作用是管理异步的函数hook,可以使react中发送请求更加优雅
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; // 这个就是上面插件的浏览器开发工具,在浏览器中能更好的看到react-query的队列跟状态
import {Suspense} from 'react'
import ReactDOM from 'react-dom/client'
import { HelmetProvider } from 'react-helmet-async'; // 可以动态改变头部标签的插件,作用域单页面的SEO
import 'virtual:svg-icons-register'; // svgIcon的使用
import App from './App.tsx'
import { worker } from '@/_mock/index.js';
import './index.css'
import './locales/i18n';
// tailwind css原子化的css,也就是可以通过w-[80px]这种自定义类名来生成css
import './theme/index.css';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 3, // 失败重试次数
      cacheTime: 300_000, // 缓存有效期 5m
      staleTime: 10_1000, // 数据变得 "陈旧"(stale)的时间 10s
      refetchOnWindowFocus: false, // 禁止窗口聚焦时重新获取数据
      refetchOnReconnect: false, // 禁止重新连接时重新获取数据
      refetchOnMount: false, // 禁止组件挂载时重新获取数据
    },
  },
});

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);

root.render(
  <HelmetProvider>
    <QueryClientProvider client={queryClient}>
      <ReactQueryDevtools initialIsOpen={false} />
      <Suspense>
        <App />
      </Suspense>
    </QueryClientProvider>
  </HelmetProvider>,
);

// 🥵 start service worker mock in development mode
worker.start({ onUnhandledRequest: 'bypass' });

tailwind css这个原子化css非常好用的,没有用过的同学可以去看一下,它的使用在于你可以通过类名ml-[12px]来直接代表margin-left:12px可以省去很多写css的方式

文件中可以看到作者是新建了_mock文件夹来进行管理模拟请求,在_mock文件夹下新建一个index.js的文件,可以看到是使用msw来作为模拟api

import { setupWorker } from 'msw'; // 这个是做mock模拟api的插件

import orgMockApi from './_org';
import userMockApi from './_user';

export const handlers = [...userMockApi, ...orgMockApi];
export const worker = setupWorker(...handlers);

其他的几个引用文件其实就没什么可说,就是简单的接口的设计,里面用到了一个fake.js来进行模拟的数据创建,不过这里要注意一个点,要修改一下tsconfig.json这个文件,在compilerOptions中加上这个配置"allowJs": true

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "allowJs": true, // 允许编译JavaScript文件,需要配置这个,否则文件路径的别名会无法引入js的文件

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    // 这使得导入和使用 CommonJS 模块的过程变得更加简洁方便。
    "esModuleInterop": true,
    "baseUrl": ".",
//    alias,因为使用了vite-tsconfig-paths在vite中配置,所以在这里设置了paths之后,就等同于在vite同设置了
    "paths": {
      "@/*": ["src/*"],
      "#/*": ["types/*"]
    }
  },
  "include": ["src", "test", "types/**/*.ts", "**.ts", "*.json", "**.*js"],
  "exclude": ["node_modules", "dist"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

然后我们顺着入口文件文件往下看App.tsx

import { App as AntdApp } from 'antd';

import Router from '@/router/index';
import AntdConfig from '@/theme/antd';

import { MotionLazy } from './components/animate/motion-lazy';

function App() {
  return (
    <AntdConfig>
      <AntdApp>
        <MotionLazy>
          <Router />
        </MotionLazy>
      </AntdApp>
    </AntdConfig>
  );
}

export default App;

代码中可以看到使用的UI框架是Ant.,然后是路由是自定义封装的路由组件,这里就是动态生成路由核心我个人觉的作者的一些很好的内容都在这里,我们慢慢的拆解

import { lazy } from 'react';
import { Navigate, RouteObject, RouterProvider, createHashRouter } from 'react-router-dom';

import DashboardLayout from '@/layouts/dashboard';
import AuthGuard from '@/router/components/auth-guard';
import { usePermissionRoutes } from '@/router/hooks';
import { ErrorRoutes } from '@/router/routes/error-routes';

import { AppRouteObject } from '#/router';

const { VITE_APP_HOMEPAGE: HOMEPAGE } = import.meta.env;
const LoginRoute: AppRouteObject = {
  path: '/login',
  Component: lazy(() => import('@/pages/sys/login/Login')),
};
const PAGE_NOT_FOUND_ROUTE: AppRouteObject = {
  path: '*',
  element: <Navigate to="/404" replace />,
};

export default function Router() {
  const permissionRoutes = usePermissionRoutes();
  const asyncRoutes: AppRouteObject = {
    path: '/',
    element: (
      <AuthGuard>
        <DashboardLayout />
      </AuthGuard>
    ),
    children: [{ index: true, element: <Navigate to={HOMEPAGE} replace /> }, ...permissionRoutes],
  };

  const routes = [LoginRoute, asyncRoutes, ErrorRoutes, PAGE_NOT_FOUND_ROUTE];

  const router = createHashRouter(routes as unknown as RouteObject[]);

  return <RouterProvider router={router} />;
}

我们从上往下看,会看到用户鉴权组件

import AuthGuard from '@/router/components/auth-guard';

我们打开这个文件,发现这个文件其实是封装了用户是否登录的鉴权,作者通过useCallback来缓存监听了token跟router的变化,然后如果token不存在的话就直接跳到登录页,这个跟一般只在http的响应拦截中做逻辑,感觉又多了一层保障

import { useCallback, useEffect } from 'react';

import { useUserToken } from '@/store/userStore';

import { useRouter } from '../hooks';

type Props = {
  children: React.ReactNode;
};
export default function AuthGuard({ children }: Props) {
  const router = useRouter();
  const { accessToken } = useUserToken();

  const check = useCallback(() => {
    if (!accessToken) {
      router.replace('/login');
    }
  }, [router, accessToken]);

  useEffect(() => {
    check();
  }, [check]);

  return children;
}

鉴权组件看完,我们看来一下userStore这个文件,学习一下大佬们是怎么封装store的全局状态的,代码中看到是使用了zustand来做全局状态的管理

import { useMutation } from '@tanstack/react-query';
import { App } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { create } from 'zustand'; // 类似于react-redux的状态管理库

import userService, { SignInReq } from '@/api/services/userService';
import { getItem, removeItem, setItem } from '@/utils/storage';
import { UserInfo, UserToken } from '#/entity';
import { StorageEnum } from '#/enum';
const { VITE_APP_HOMEPAGE: HOMEPAGE } = import.meta.env;

// 定义了一个userStore的类型
type UserStore = {
  userInfo: Partial<UserInfo>; // Partial这个是typescript的内置工具函数,会将这个类型的所有属性都变成可选的
  userToken: UserToken;
  // 使用 actions 命名空间来存放所有的 action
  actions: {
    setUserInfo: (userInfo: UserInfo) => void;
    setUserToken: (token: UserToken) => void;
    clearUserInfoAndToken: () => void;
  };
};


const useUserStore = create<UserStore>((set)=> ({
  userInfo: getItem<UserInfo>(StorageEnum.User) || {},
  userToken: getItem<UserToken>(StorageEnum.Token) || {},
  actions: {
    setUserInfo: (userInfo) => {
      set({ userInfo });
      setItem(StorageEnum.User, userInfo);
    },
    setUserToken: (userToken) => {
      set({ userToken });
      setItem(StorageEnum.Token, userToken);
    },
    clearUserInfoAndToken() {
      set({ userInfo: {}, userToken: {} });
      removeItem(StorageEnum.User);
      removeItem(StorageEnum.Token);
    },
  },
}))



// 这里将获取分解成单个函数.那在外部使用的时候就不需要去使用hook了,直接调用对应函数就可以拿到
export const useUserInfo = () => useUserStore((state) => state.userInfo);
export const useUserToken = () => useUserStore((state) => state.userToken);
export const useUserPermission = () => useUserStore((state) => state.userInfo.permissions);
export const useUserActions = () => useUserStore((state) => state.actions);
//
// type UserStore = {
//   userInfo: Partial<UserInfo>;
//   userToken: UserToken;
//   // 使用 actions 命名空间来存放所有的 action
//   actions: {
//     setUserInfo: (userInfo: UserInfo) => void;
//     setUserToken: (token: UserToken) => void;
//     clearUserInfoAndToken: () => void;
//   };
// };

export const useSignIn = () => {

  // 这里封装了用户登录的整体逻辑
  const { t } = useTranslation();
  const navigatge = useNavigate();
  const { notification, message } = App.useApp();
  const { setUserToken, setUserInfo } = useUserActions();

  // 将登录接口使用useMutation使用包装
  const signInMutation = useMutation(userService.signin);

  const signIn = async (data: SignInReq) => {
    try {
      const res = await signInMutation.mutateAsync(data);
      const { user, accessToken, refreshToken } = res;
      setUserToken({ accessToken, refreshToken });
      setUserInfo(user);
      navigatge(HOMEPAGE, { replace: true });

      notification.success({
        message: t('sys.login.loginSuccessTitle'),
        description: `${t('sys.login.loginSuccessDesc')}: ${data.username}`,
        duration: 3,
      });
    } catch (err) {
      message.warning({
        content: err.message,
        duration: 3,
      });
    }
  };

  // eslint-disable-next-line react-hooks/exhaustive-deps
  // 这个useCallback的写法,这个函数将一直缓存
  return useCallback(signIn, []);
};

这里个人感觉比较好的地方在于将hook直接做成了函数导出使用,在外部使用到某个数据的时候直接使用对应的函数即可

现在来看AuthGurad这个组件当中引入的useRouter这个hooks文件,作者对应路由的使用也进行了封装

import { useRouter } from '../hooks';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';

export function useRouter() {
  const navigate = useNavigate();

  const router = useMemo(
    () => ({
      back: () => navigate(-1),
      forward: () => navigate(1),
      reload: () => window.location.reload(),
      push: (href: string) => navigate(href),
      replace: (href: string) => navigate(href, { replace: true }),
    }),
    [navigate],
  );

  return router;
}

能看到这里作者是将useNavigate的hook的封装了一下,感觉这样封装之后的用法更加像vue-router的用法,不知道是不是我的错觉

我们再看看router/index.tsx中引用的usePermissionRoutes,看一下大佬使用的动态生成路由的方式


import { isEmpty } from 'ramda';// 工具函数库
import { Suspense, lazy, useMemo } from 'react';
import { Navigate, Outlet } from 'react-router-dom';

import { Iconify } from '@/components/icon';
import { CircleLoading } from '@/components/loading';
import { useUserPermission } from '@/store/userStore';
import ProTag from '@/theme/antd/components/tag';
import { flattenTrees } from '@/utils/tree';

import { Permission } from '#/entity';
import { BasicStatus, PermissionType } from '#/enum';
import { AppRouteObject } from '#/router';

// 使用 import.meta.glob 获取所有路由组件
const pages = import.meta.glob('/src/pages/**/*.tsx');

// 构建绝对路径的函数
function resolveComponent(path: string) {
  return pages[`/src/pages${path}`];
}

/**
 * return routes about permission
 */
export function usePermissionRoutes() {
  const permissions = useUserPermission();

  return useMemo(() => {
    // 这里之所以把数组进行拍平处理,是后面生成数组的时候需要用于判断父级,方便组成一个树状结构
    const flattenedPermissions = flattenTrees(permissions!);
    const permissionRoutes = transformPermissionToMenuRoutes(
      permissions || [],
      flattenedPermissions,
    );
    return [...permissionRoutes];
  }, [permissions]);
}

/**
 * transform Permission[] to  AppRouteObject[]
 * @param permissions
 * @param parent
 */
function transformPermissionToMenuRoutes(
  permissions: Permission[],
  flattenedPermissions: Permission[],
) {
  return permissions.map((permission) => {
    const {
      route,
      type,
      label,
      icon,
      order,
      hide,
      hideTab,
      status,
      frameSrc,
      newFeature,
      component,
      parentId,
      children = [],
    } = permission;

    const appRoute: AppRouteObject = {
      path: route,
      meta: {
        label,
        key: getCompleteRoute(permission, flattenedPermissions),
        hideMenu: !!hide,
        hideTab,
        disabled: status === BasicStatus.DISABLE,
      },
    };

    if (order) appRoute.order = order;
    if (icon) appRoute.meta!.icon = icon;
    if (frameSrc) appRoute.meta!.frameSrc = frameSrc;
    if (newFeature)
      appRoute.meta!.suffix = (
        <ProTag color="cyan" icon={<Iconify icon="solar:bell-bing-bold-duotone" size={14} />}>
          NEW
        </ProTag>
      );

    if (type === PermissionType.CATALOGUE) {
      appRoute.meta!.hideTab = true;
      if (!parentId) {
        // 如果是目录,并且没有父级ID的话,那它的element就是一个Outlet.代表它是最顶级的目录,Outlet这占位符会让它的子路由都加载到这个组件上
        appRoute.element = (
          <Suspense fallback={<CircleLoading />}>
            <Outlet />
          </Suspense>
        );
      }
      appRoute.children = transformPermissionToMenuRoutes(children, flattenedPermissions);
      if (!isEmpty(children)) {
        appRoute.children.unshift({
          index: true,
          element: <Navigate to={children[0].route} replace />,
        });
      }
    } else if (type === PermissionType.MENU) {
      // 如果是菜单的话,就使用lazy来加载对应的组件
      const Element = lazy(resolveComponent(component!) as any);
      if (frameSrc) {
        appRoute.element = <Element src={frameSrc} />;
      } else {
        appRoute.element = <Element />;
      }
    }

    return appRoute;
  });
}

/**
 * Splicing from the root permission route to the current permission route
 * @param {Permission} permission - current permission
 * @param {Permission[]} flattenedPermissions - flattened permission array
 * @param {string} route - parent permission route
 * @returns {string} - The complete route after splicing
 */
function getCompleteRoute(permission: Permission, flattenedPermissions: Permission[], route = '') {
  const currentRoute = route ? `/${permission.route}${route}` : `/${permission.route}`;

  if (permission.parentId) {
    const parentPermission = flattenedPermissions.find((p) => p.id === permission.parentId)!;
    return getCompleteRoute(parentPermission, flattenedPermissions, currentRoute);
  }

  return currentRoute;
}

通过上面的代码我们可以看出来,核心在于作者使用的是import.meta.glob('/src/pages/**/*.tsx')加载page下文件夹下的.tsx的组件,然后通过后台配置的路径,使用resolveComponent來获取到对象的组件,使用使用lazy来异步加载这些路由组件

这里额外说一下并非基础功能的一个组件,个人觉的写法很有启发的/pages/sys/login/Login这个文件

import { PropsWithChildren, createContext, useContext, useMemo, useState } from 'react';

export enum LoginStateEnum {
  LOGIN,
  REGISTER,
  RESET_PASSWORD,
  MOBILE,
  QR_CODE,
}

interface LoginStateContextType {
  loginState: LoginStateEnum;
  setLoginState: (loginState: LoginStateEnum) => void;
  backToLogin: () => void;
}
// 这里使用createContext创建上下文来让每个表单都进行共有状态的共享
const LoginStateContext = createContext<LoginStateContextType>({
  loginState: LoginStateEnum.LOGIN,
  setLoginState: () => {},
  backToLogin: () => {},
});

// 这个是搭配上面的createContext进行使用的,目的是让所有的子组件可以共享一份数据,免去了传递的麻烦
export function useLoginStateContext() {
  const context = useContext(LoginStateContext);
  return context;
}

export function LoginStateProvider({ children }: PropsWithChildren) {
  const [loginState, setLoginState] = useState(LoginStateEnum.LOGIN);

  function backToLogin() {
    setLoginState(LoginStateEnum.LOGIN);
  }

  const value: LoginStateContextType = useMemo(
    () => ({ loginState, setLoginState, backToLogin }),
    [loginState],
  );
  // Provider提供数据之后,被这个组件所包含的子组件就可以通过useContext(LoginStateContext)来获取到共享的数据
  return <LoginStateContext.Provider value={value}>{children}</LoginStateContext.Provider>;
}

这里的关键点在于createContext,useContext的使用,用createContext和useContext来实现父子组件中的数据共享,免去了传递来回传递的麻烦

面包屑导航

github.com/MinjieChang… 作者大佬,已经总结出来了,我就不班门弄斧了

多任务导航,src/layouts/dashboard/multi-tabs.tsx

import { Dropdown, MenuProps, Tabs, TabsProps } from 'antd';
import Color from 'color';
import { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DragDropContext, Draggable, Droppable, OnDragEndResponder } from 'react-beautiful-dnd';
import { useTranslation } from 'react-i18next';
import { useToggle, useFullscreen } from 'react-use';
import styled from 'styled-components';

import { Iconify } from '@/components/icon';
import useKeepAlive, { KeepAliveTab } from '@/hooks/web/use-keepalive';
import { useRouter } from '@/router/hooks';
import { useSettings } from '@/store/settingStore';
import { useResponsive, useThemeToken } from '@/theme/hooks';

import {
  NAV_WIDTH,
  NAV_COLLAPSED_WIDTH,
  HEADER_HEIGHT,
  OFFSET_HEADER_HEIGHT,
  MULTI_TABS_HEIGHT,
  NAV_HORIZONTAL_HEIGHT,
} from './config';

import { MultiTabOperation, ThemeLayout } from '#/enum';

type Props = {
  offsetTop?: boolean;
};
export default function MultiTabs({ offsetTop = false }: Props) {
  const { t } = useTranslation();
  const { push } = useRouter();
  const scrollContainer = useRef<HTMLDivElement>(null);
  const [hoveringTabKey, setHoveringTabKey] = useState('');
  const [openDropdownTabKey, setopenDropdownTabKey] = useState('');
  const themeToken = useThemeToken();

  const tabContentRef = useRef(null);
  const [fullScreen, toggleFullScreen] = useToggle(false);
  useFullscreen(tabContentRef, fullScreen, { onClose: () => toggleFullScreen(false) });

  const {
    tabs,
    activeTabRoutePath,
    setTabs,
    closeTab,
    refreshTab,
    closeOthersTab,
    closeAll,
    closeLeft,
    closeRight,
  } = useKeepAlive();

  /**
   * tab dropdown下拉选
   */
  const menuItems = useMemo<MenuProps['items']>(
    () => [
      {
        label: t(`sys.tab.${MultiTabOperation.FULLSCREEN}`),
        key: MultiTabOperation.FULLSCREEN,
        icon: <Iconify icon="material-symbols:fullscreen" size={18} />,
      },
      {
        label: t(`sys.tab.${MultiTabOperation.REFRESH}`),
        key: MultiTabOperation.REFRESH,
        icon: <Iconify icon="mdi:reload" size={18} />,
      },
      {
        label: t(`sys.tab.${MultiTabOperation.CLOSE}`),
        key: MultiTabOperation.CLOSE,
        icon: <Iconify icon="material-symbols:close" size={18} />,
        disabled: tabs.length === 1,
      },
      {
        type: 'divider',
      },
      {
        label: t(`sys.tab.${MultiTabOperation.CLOSELEFT}`),
        key: MultiTabOperation.CLOSELEFT,
        icon: (
          <Iconify
            icon="material-symbols:tab-close-right-outline"
            size={18}
            className="rotate-180"
          />
        ),
        disabled: tabs.findIndex((tab) => tab.key === openDropdownTabKey) === 0,
      },
      {
        label: t(`sys.tab.${MultiTabOperation.CLOSERIGHT}`),
        key: MultiTabOperation.CLOSERIGHT,
        icon: <Iconify icon="material-symbols:tab-close-right-outline" size={18} />,
        disabled: tabs.findIndex((tab) => tab.key === openDropdownTabKey) === tabs.length - 1,
      },
      {
        type: 'divider',
      },
      {
        label: t(`sys.tab.${MultiTabOperation.CLOSEOTHERS}`),
        key: MultiTabOperation.CLOSEOTHERS,
        icon: <Iconify icon="material-symbols:tab-close-outline" size={18} />,
        disabled: tabs.length === 1,
      },
      {
        label: t(`sys.tab.${MultiTabOperation.CLOSEALL}`),
        key: MultiTabOperation.CLOSEALL,
        icon: <Iconify icon="mdi:collapse-all-outline" size={18} />,
      },
    ],
    [openDropdownTabKey, t, tabs],
  );

  /**
   * tab dropdown click
   */
  const menuClick = useCallback(
    (menuInfo: any, tab: KeepAliveTab) => {
      const { key, domEvent } = menuInfo;
      domEvent.stopPropagation();
      switch (key) {
        case MultiTabOperation.REFRESH:
          refreshTab(tab.key);
          break;
        case MultiTabOperation.CLOSE:
          closeTab(tab.key);
          break;
        case MultiTabOperation.CLOSEOTHERS:
          closeOthersTab(tab.key);
          break;
        case MultiTabOperation.CLOSELEFT:
          closeLeft(tab.key);
          break;
        case MultiTabOperation.CLOSERIGHT:
          closeRight(tab.key);
          break;
        case MultiTabOperation.CLOSEALL:
          closeAll();
          break;
        case MultiTabOperation.FULLSCREEN:
          toggleFullScreen();
          break;
        default:
          break;
      }
    },
    [refreshTab, closeTab, closeOthersTab, closeLeft, closeRight, closeAll, toggleFullScreen],
  );

  /**
   * 当前显示dorpdown的tab
   */
  const onOpenChange = (open: boolean, tab: KeepAliveTab) => {
    if (open) {
      setopenDropdownTabKey(tab.key);
    } else {
      setopenDropdownTabKey('');
    }
  };

  /**
   * tab样式
   */
  const calcTabStyle: (tab: KeepAliveTab) => CSSProperties = useCallback(
    (tab) => {
      const isActive = tab.key === activeTabRoutePath || tab.key === hoveringTabKey;
      const result: CSSProperties = {
        borderRadius: '8px 8px 0 0',
        borderWidth: '1px',
        borderStyle: 'solid',
        borderColor: themeToken.colorBorderSecondary,
        backgroundColor: themeToken.colorBgLayout,
        transition:
          'color 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms, background 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
      };

      if (isActive) {
        result.backgroundColor = themeToken.colorBgContainer;
        result.color = themeToken.colorPrimaryText;
      }
      return result;
    },
    [activeTabRoutePath, hoveringTabKey, themeToken],
  );

  /**
   * 渲染单个tab
   */
  const renderTabLabel = useCallback(
    (tab: KeepAliveTab) => {
      if (tab.hideTab) return null;
      return (
        <Dropdown
          trigger={['contextMenu']}
          menu={{ items: menuItems, onClick: (menuInfo) => menuClick(menuInfo, tab) }}
          onOpenChange={(open) => onOpenChange(open, tab)}
        >
          <div
            className="relative mx-px flex select-none items-center px-4 py-1"
            style={calcTabStyle(tab)}
            onMouseEnter={() => {
              if (tab.key === activeTabRoutePath) return;
              setHoveringTabKey(tab.key);
            }}
            onMouseLeave={() => setHoveringTabKey('')}
          >
            <div>{t(tab.label)}</div>
            <Iconify
              icon="ion:close-outline"
              size={18}
              className="cursor-pointer opacity-50"
              onClick={(e) => {
                e.stopPropagation();
                closeTab(tab.key);
              }}
              style={{
                visibility:
                  (tab.key !== activeTabRoutePath && tab.key !== hoveringTabKey) ||
                  tabs.length === 1
                    ? 'hidden'
                    : 'visible',
              }}
            />
          </div>
        </Dropdown>
      );
    },
    [
      t,
      menuItems,
      activeTabRoutePath,
      hoveringTabKey,
      tabs.length,
      menuClick,
      closeTab,
      calcTabStyle,
    ],
  );

  /**
   * 所有tab
   */

  const tabItems = useMemo(() => {
    return tabs?.map((tab) => ({
      label: renderTabLabel(tab),
      key: tab.key,
      closable: tabs.length > 1, // 保留一个
      children: (
        <div ref={tabContentRef} key={tab.timeStamp}>
          {tab.children}
        </div>
      ),
    }));
  }, [tabs, renderTabLabel]);

  /**
   * 拖拽结束事件
   */
  const onDragEnd: OnDragEndResponder = ({ destination, source }) => {
    // 拖拽到非法非 droppable区域
    if (!destination) {
      return;
    }
    // 原地放下
    if (destination.droppableId === source.droppableId && destination.index === source.index) {
      return;
    }

    const newTabs = Array.from(tabs);
    const [movedTab] = newTabs.splice(source.index, 1);
    newTabs.splice(destination.index, 0, movedTab);
    setTabs(newTabs);
  };

  /**
   * 渲染 tabbar
   */
  const { themeLayout } = useSettings();
  const { colorBorder, colorBgElevated } = useThemeToken();
  const { screenMap } = useResponsive();

  const multiTabsStyle: CSSProperties = {
    position: 'fixed',
    top: offsetTop ? OFFSET_HEADER_HEIGHT - 2 : HEADER_HEIGHT,
    left: 0,
    height: MULTI_TABS_HEIGHT,
    backgroundColor: Color(colorBgElevated).alpha(1).toString(),
    borderBottom: `1px dashed ${Color(colorBorder).alpha(0.6).toString()}`,
    transition: 'top 200ms cubic-bezier(0.4, 0, 0.2, 1) 0ms',
  };

  if (themeLayout === ThemeLayout.Horizontal) {
    multiTabsStyle.top = HEADER_HEIGHT + NAV_HORIZONTAL_HEIGHT - 2;
  } else if (screenMap.md) {
    multiTabsStyle.right = '0px';
    multiTabsStyle.left = 'auto';
    multiTabsStyle.width = `calc(100% - ${
      themeLayout === ThemeLayout.Vertical ? NAV_WIDTH : NAV_COLLAPSED_WIDTH
    }px`;
  } else {
    multiTabsStyle.width = '100vw';
  }
  const renderTabBar: TabsProps['renderTabBar'] = () => {
    return (
      <div style={multiTabsStyle} className="z-20 w-full">
        <DragDropContext onDragEnd={onDragEnd}>
          <Droppable droppableId="tabsDroppable" direction="horizontal">
            {(provided) => (
              <div ref={provided.innerRef} {...provided.droppableProps} className="flex w-full">
                <div ref={scrollContainer} className="hide-scrollbar flex w-full px-2">
                  {tabs.map((tab, index) => (
                    <div
                      id={`tab-${index}`}
                      className="flex-shrink-0"
                      key={tab.key}
                      onClick={() => push(tab.key)}
                    >
                      <Draggable key={tab.key} draggableId={tab.key} index={index}>
                        {(provided) => (
                          <div
                            ref={provided.innerRef}
                            {...provided.draggableProps}
                            {...provided.dragHandleProps}
                            className="w-auto"
                          >
                            {renderTabLabel(tab)}
                          </div>
                        )}
                      </Draggable>
                    </div>
                  ))}
                </div>
                {provided.placeholder}
              </div>
            )}
          </Droppable>
        </DragDropContext>
      </div>
    );
  };

  /**
   * 路由变化时,滚动到指定tab
   */
  useEffect(() => {
    if (!scrollContainer || !scrollContainer.current) {
      return;
    }
    const index = tabs.findIndex((tab) => tab.key === activeTabRoutePath);
    const currentTabElement = scrollContainer.current.querySelector(`#tab-${index}`);
    if (currentTabElement) {
      currentTabElement.scrollIntoView({
        block: 'nearest',
        behavior: 'smooth',
      });
    }
  }, [activeTabRoutePath, tabs]);

  /**
   * scrollContainer 监听wheel事件
   */
  useEffect(() => {
    function handleMouseWheel(event: WheelEvent) {
      event.preventDefault();
      scrollContainer.current!.scrollLeft += event.deltaY;
    }

    scrollContainer.current!.addEventListener('mouseenter', () => {
      scrollContainer.current!.addEventListener('wheel', handleMouseWheel);
    });
    scrollContainer.current!.addEventListener('mouseleave', () => {
      scrollContainer.current!.removeEventListener('wheel', handleMouseWheel);
    });
  }, []);

  return (
    <StyledMultiTabs>
      <Tabs
        size="small"
        type="card"
        tabBarGutter={4}
        activeKey={activeTabRoutePath}
        items={tabItems}
        renderTabBar={renderTabBar}
      />
    </StyledMultiTabs>
  );
}

const StyledMultiTabs = styled.div`
  height: 100%;
  margin-top: 2px;
  .anticon {
    margin: 0px !important;
  }
  .ant-tabs {
    height: 100%;
    .ant-tabs-content {
      height: 100%;
    }
    .ant-tabs-tabpane {
      height: 100%;
      & > div {
        height: 100%;
      }
    }
  }

  /* 隐藏滚动条 */
  .hide-scrollbar {
    overflow: scroll;
    flex-shrink: 0;
    scrollbar-width: none; /* 隐藏滚动条 Firefox */
    -ms-overflow-style: none; /* 隐藏滚动条 IE/Edge */
  }

  .hide-scrollbar::-webkit-scrollbar {
    display: none; /* 隐藏滚动条 Chrome/Safari/Opera */
  }
`;

核心部分是组件当中引入的useKeepAlive

useKeepAlive

import { useCallback, useEffect, useState } from 'react';

import { useMatchRouteMeta, useRouter } from '@/router/hooks';

import { RouteMeta } from '#/router';

export type KeepAliveTab = RouteMeta & {
  children: any;
};
export default function useKeepAlive() {
  const { VITE_APP_HOMEPAGE: HOMEPAGE } = import.meta.env;
  const { push } = useRouter();
  // tabs
  const [tabs, setTabs] = useState<KeepAliveTab[]>([]);

  // active tab
  const [activeTabRoutePath, setActiveTabRoutePath] = useState<string>('');

  // current route meta
  const currentRouteMeta = useMatchRouteMeta();

  /**
   * Close specified tab
   */
  const closeTab = useCallback(
    (path = activeTabRoutePath) => {
      if (tabs.length === 1) return;
      const deleteTabIndex = tabs.findIndex((item) => item.key === path);
      if (deleteTabIndex > 0) {
        push(tabs[deleteTabIndex - 1].key);
      } else {
        push(tabs[deleteTabIndex + 1].key);
      }

      tabs.splice(deleteTabIndex, 1);
      setTabs([...tabs]);
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [activeTabRoutePath],
  );

  /**
   * Close other tabs besides the specified tab
   */
  const closeOthersTab = useCallback(
    (path = activeTabRoutePath) => {
      setTabs((prev) => prev.filter((item) => item.key === path));
      if (path !== activeTabRoutePath) {
        push(path);
      }
    },
    [activeTabRoutePath, push],
  );

  /**
   * Close all tabs then navigate to the home page
   */
  const closeAll = useCallback(() => {
    // setTabs([tabHomePage]);
    setTabs([]);
    push(HOMEPAGE);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [push]);

  /**
   * Close all tabs in the left of specified tab
   */
  const closeLeft = useCallback(
    (path: string) => {
      const currentTabIndex = tabs.findIndex((item) => item.key === path);
      const newTabs = tabs.slice(currentTabIndex);
      setTabs(newTabs);
      push(path);
    },
    [push, tabs],
  );

  /**
   * Close all tabs in the right of specified tab
   */
  const closeRight = useCallback(
    (path: string) => {
      const currentTabIndex = tabs.findIndex((item) => item.key === path);
      const newTabs = tabs.slice(0, currentTabIndex + 1);
      setTabs(newTabs);
      push(path);
    },
    [push, tabs],
  );

  /**
   * Refresh specified tab
   */
  const refreshTab = useCallback(
    (path = activeTabRoutePath) => {
      setTabs((prev) => {
        const index = prev.findIndex((item) => item.key === path);

        if (index >= 0) {
          prev[index].timeStamp = getKey();
        }

        return [...prev];
      });
    },
    [activeTabRoutePath],
  );

  useEffect(() => {
    if (!currentRouteMeta) return;
    const existed = tabs.find((item) => item.key === currentRouteMeta.key);
    if (!existed) {
      setTabs((prev) => [
        ...prev,
        { ...currentRouteMeta, children: currentRouteMeta.outlet, timeStamp: getKey() },
      ]);
    }

    setActiveTabRoutePath(currentRouteMeta.key);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [currentRouteMeta]);

  return {
    tabs,
    activeTabRoutePath,
    setTabs,
    closeTab,
    closeOthersTab,
    refreshTab,
    closeAll,
    closeLeft,
    closeRight,
  };
}

function getKey() {
  return new Date().getTime().toString();
}

这个的核心的部分是通过监听当前的路由信息来更新tabs并且返回出去使用,也就是多任务上面的标签让multi-tabs这个组件来监听tabs来渲染任务标签即可

至此一些通用的功能已经解析完了,剩下的就是一些业务上的界面了.后面我列两个项目中有用到,个人觉的很通用很好用的插件

  1. husky--git提交规范插件
  2. tailwindcss--css原子化的插件.省去了很多写一些简单样式的时候起类名的麻烦
转载自:https://juejin.cn/post/7362722064070033445
评论
请登录