使用 vite 搭建 react 项目(从插件安装配置到路由请求拦截配置)

使用 vite 搭建 react 项目

一. 安装项目基础依赖并启动项目

bash 复制代码
mkdir workspace && cd workspace # 创建工作空间并进入目录
pnpm create vite # 执行vite项目安装命令,等待下载安装

# 可见以下内容
../../.pnpm-store/v3/tmp/dlx-18020       |   +1 +
../../.pnpm-store/v3/tmp/dlx-18020       | Progress: resolved 1, reused 1, downloaded 0, added 1, done
? Project name: >> vite-project # 设置项目目录名称,然后回车
? Select a framework: >> - Use arrow-keys. Return to submit. # 选择要使用的前端框架,我们这里选择react(键盘上下键开始选择),然后回车
    Vanilla
    Vue
>   React
    Preact
    Lit
    Svelte
    Solid
    Qwik
    Others

? Select a variant: >> - Use arrow-keys. Return to submit. # 选择TypeScript + SWC,打包速度更快(如果有浏览器兼容要求,建议直接选TypeScript,生态更完善,配置兼容更简单)
    TypeScript
>   TypeScript + SWC
    JavaScript
    JavaScript + SWC

Scaffolding proje
                 
Done. Now run:   
                 
  cd vite-project
  pnpm install   
  pnpm run dev   

到此处就安装成功了,然后执行上面的最后三行命令,即可安装项目基础依赖并启动项目了

复制连接在浏览器打开,见到下面内容及安装启动成功:

二. 安装配置vite项目基础依赖

1. 安装 css 预处理器 sass

bash 复制代码
pnpm add sass -D

2. 安装 vite-plugin-checkereslint 校验插件

bash 复制代码
pnpm add vite-plugin-checker -D

进入项目根目录配置 vite.config.ts

ts 复制代码
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import checker from 'vite-plugin-checker'
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    checker({
      // e.g. use TypeScript check
      typescript: true,
      eslint: {
        // for example, lint .ts and .tsx
        lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
      },
    }),
  ],
})

3. 配置环境变量

项目根目录创建 .env 文件,并添加如下代码:

.env 复制代码
// .env
VITE_APP_BASE_URL="/" # 项目打包后运行的根目录,如果项目打包后放到nginx的二级目录需要配置这个,否则会导致js等资源文件404
VITE_APP_API_PREFIX="/api" # 项目接口地址前缀,一般为 /api,根据后端协商而定
VITE_APP_API_URL="http://你自己的服务器地址" # 后端接口服务器地址
VITE_APP_WEB_TITLE="网站标题" # 项目网页标签标题,后续结合路由使用,显示网站标题信息
VITE_APP_VERSION="0.0.1" # 项目版本号
# 其他自定义环境变量,一般还会配置copyright,tel,email等网站基本信息

添加typescript 的智能提示,打开 src/vite-env.d.ts 文件,添加环境变量ts基础定义:

ts 复制代码
// src/vite-env.d.ts
interface ImportMetaEnv {
  readonly VITE_APP_BASE_URL: string;
  readonly VITE_APP_API_PREFIX: string;
  readonly VITE_APP_API_URL: string;
  readonly VITE_APP_WEB_TITLE: string;
  readonly VITE_APP_VERSION: string;
  // 更多环境变量...
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

以后项目中需要用到环境变量就可以直接使用 import.meta.env.VITE_APP_BASE_URL 等。

4. 安装 vite-plugin-legacy-swc 配置打包浏览器兼容

之前说到 typescript-swc 不支持 vite-plugin-legacy导致打包兼容配置麻烦,后来找到这个插件,提供 vite-plugin-legacy一致的功能,如此我们在typescript-swc 模式下,既能加速打包构建,又能兼容低版本浏览器了:

bash 复制代码
pnpm add vite-plugin-legacy-swc -D

进入项目根目录配置 vite.config.ts

ts 复制代码
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import checker from "vite-plugin-checker";
import legacy from "vite-plugin-legacy-swc";
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    checker({
      // e.g. use TypeScript check
      typescript: true,
      eslint: {
        // for example, lint .ts and .tsx
        lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
      },
    }),
    legacy({
      targets: ['defaults', 'not IE 11'],
    }),
  ],
});

5. 安装 vite-plugin-chunk-split 代码拆包插件

支持多种拆包策略,可避免手动操作 manualChunks 潜在的循环依赖问题。

bash 复制代码
pnpm add vite-plugin-chunk-split -D

进入项目根目录配置 vite.config.ts

ts 复制代码
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import checker from "vite-plugin-checker";
import legacy from "vite-plugin-legacy-swc";
import { chunkSplitPlugin } from "vite-plugin-chunk-split";
// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    checker({
      // e.g. use TypeScript check
      typescript: true,
      eslint: {
        // for example, lint .ts and .tsx
        lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
      },
    }),
    legacy({
      targets: ["defaults", "not IE 11"],
    }),
    chunkSplitPlugin(),
  ],
});

6. 安装 vite-plugin-progress picocolors

插件是一个在打包时展示进度条的插件, picocolors 可以给给自定义进度条加点颜色

bash 复制代码
pnpm add vite-plugin-progress picocolors -D

进入项目根目录配置 vite.config.ts

ts 复制代码
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import checker from "vite-plugin-checker";
import legacy from "vite-plugin-legacy-swc";
import { chunkSplitPlugin } from "vite-plugin-chunk-split";
import progress from "vite-plugin-progress";
import colors from 'picocolors'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    checker({
      // e.g. use TypeScript check
      typescript: true,
      eslint: {
        // for example, lint .ts and .tsx
        lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
      },
    }),
    legacy({
      targets: ["defaults", "not IE 11"],
    }),
    chunkSplitPlugin(),
    progress({
      format: `${colors.green(colors.bold('Bouilding'))} ${colors.cyan('[:bar]')} :percent`,
      total: 200,
      width: 60,
      complete: "=",
      incomplete: "",
    }),
  ],
});

7 . 安装 vite-plugin-remove-console 配置生产打包时自动移除 console.log

bash 复制代码
pnpm add vite-plugin-remove-console -D

进入项目根目录配置 vite.config.ts

ts 复制代码
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import checker from "vite-plugin-checker";
import legacy from "vite-plugin-legacy-swc";
import { chunkSplitPlugin } from "vite-plugin-chunk-split";
import progress from "vite-plugin-progress";
import colors from "picocolors";
import removeConsole from "vite-plugin-remove-console";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    checker({
      // e.g. use TypeScript check
      typescript: true,
      eslint: {
        // for example, lint .ts and .tsx
        lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
      },
    }),
    legacy({
      targets: ["defaults", "not IE 11"],
    }),
    chunkSplitPlugin(),
    progress({
      format: `${colors.green(colors.bold("Bouilding"))} ${colors.cyan(
        "[:bar]"
      )} :percent`,
      total: 200,
      width: 60,
      complete: "=",
      incomplete: "",
    }),
    removeConsole(),
  ],
});

8 . 配置路径别名

bash 复制代码
pnpm add @types/node -D

进入项目根目录配置 vite.config.ts

ts 复制代码
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import checker from "vite-plugin-checker";
import legacy from "vite-plugin-legacy-swc";
import { chunkSplitPlugin } from "vite-plugin-chunk-split";
import progress from "vite-plugin-progress";
import colors from "picocolors";
import removeConsole from "vite-plugin-remove-console";
import path from "path";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    react(),
    checker({
      // e.g. use TypeScript check
      typescript: true,
      eslint: {
        // for example, lint .ts and .tsx
        lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
      },
    }),
    legacy({
      targets: ["defaults", "not IE 11"],
    }),
    chunkSplitPlugin(),
    progress({
      format: `${colors.green(colors.bold("Bouilding"))} ${colors.cyan(
        "[:bar]"
      )} :percent`,
      total: 200,
      width: 60,
      complete: "=",
      incomplete: "",
    }),
    removeConsole(),
  ],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "src"),
    },
  },
});

进入项目根目录配置 tsconfig.json:

json 复制代码
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

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

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src"],
  "references": [{ "path": "./tsconfig.node.json" }]
}

9 . 配置项目反向代理

进入项目根目录配置 vite.config.ts

ts 复制代码
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react-swc";
import checker from "vite-plugin-checker";
import legacy from "vite-plugin-legacy-swc";
import { chunkSplitPlugin } from "vite-plugin-chunk-split";
import progress from "vite-plugin-progress";
import colors from "picocolors";
import removeConsole from "vite-plugin-remove-console";
import path from "path";

// https://vitejs.dev/config/
export default defineConfig(({mode}) => {
  // 根据开发、生产环境模式动态读取环境变量
  const configEnv = loadEnv(mode, process.cwd(), "");

  return {
    plugins: [
      react(),
      checker({
        // e.g. use TypeScript check
        typescript: true,
        eslint: {
          // for example, lint .ts and .tsx
          lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
        },
      }),
      legacy({
        targets: ["defaults", "not IE 11"],
      }),
      chunkSplitPlugin(),
      progress({
        format: `${colors.green(colors.bold("Bouilding"))} ${colors.cyan(
          "[:bar]"
        )} :percent`,
        total: 200,
        width: 60,
        complete: "=",
        incomplete: "",
      }),
      removeConsole(),
    ],
    base: configEnv.VITE_APP_BASE_URL,
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "src"),
      },
    },
    server: {
      proxy: {
        [configEnv.VITE_APP_API_PREFIX]: {
          target: configEnv.VITE_APP_API_URL,
          changeOrigin: true,
          rewrite: (path) => path.replace(configEnv.VITE_APP_API_PREFIX, '')
        }
      }
    }
  }
});

10. 安装 eslint-plugin-simple-import-sort配置 eslint

这个插件可以帮我们保存时自定对 import 行进行排序整理

bash 复制代码
pnpm add eslint-plugin-simple-import-sort eslint-plugin-import -D

进入项目根目录配置 .eslintrc.cjs

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",
  parserOptions: {
    sourceType: "module",
  },
  plugins: ["react-refresh", "simple-import-sort", "import"],
  rules: {
    "react-refresh/only-export-components": [
      "warn",
      { allowConstantExport: true },
    ],
    "sort-imports": "off",
    "import/order": "off",
    "simple-import-sort/imports": "error",
    "simple-import-sort/exports": "error",
  },
};

每次配置完了一定要重启编辑器,否则不生效

三. 项目功能开发依赖安装及配置(UI、路由、接口请求封装、布局配置)

1. 安装 antd及配置

bash 复制代码
pnpm add antd @ant-design/icons

修改 App.tsx,添加 antd动态主题配置,通过这一步可以实现动态切换主题,详细的参考官网

tsx 复制代码
import './App.css';

import { Button, ConfigProvider } from 'antd';

function App() {
  return (
    <ConfigProvider
      theme={{
        token: {
          colorPrimary: '#ee3f4d',
        },
      }}>
      <Button type='primary'>Button</Button>
    </ConfigProvider>
  );
}

export default App;

效果如图:

2. antd 样式降级

antd 使用了 :where选择器,大多数旧的浏览器都不支持,尤其是在国产机上面,所以建议做样式降级处理:

安装 @ant-design/cssinjs

bash 复制代码
pnpm add @ant-design/cssinjs

修改 App.tsx,添加样式降级配置:

tsx 复制代码
import './App.css';

import {
  legacyLogicalPropertiesTransformer,
  StyleProvider,
} from '@ant-design/cssinjs';
import { Button, ConfigProvider } from 'antd';

function App() {
  return (
    <StyleProvider
      // `hashPriority` 默认为 `low`,配置为 `high` 后,
      // 会移除 `:where` 选择器封装
      hashPriority='high'
      // `transformers` 提供预处理功能将样式进行转换
      // 为了统一 LTR 和 RTL 样式,Ant Design 使用了 CSS 逻辑属性
      // 需要兼容旧版浏览器使用transformers 将其进行转换
      transformers={[legacyLogicalPropertiesTransformer]}>
      <ConfigProvider
        theme={{
          token: {
            colorPrimary: '#ee3f4d',
          },
        }}>
        <Button type='primary'>Button</Button>
      </ConfigProvider>
    </StyleProvider>
  );
}

export default App;

3. 安装 dayjs,配置 antd 全局日期格式语言

bash 复制代码
pnpm add dayjs

修改 App.tsx,配置 antd 全局日期格式语言

tsx 复制代码
import './App.css';
import 'dayjs/locale/zh-cn';

import {
  legacyLogicalPropertiesTransformer,
  StyleProvider,
} from '@ant-design/cssinjs';
import { Button, ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';

dayjs.locale('zh-cn');

function App() {
  return (
    <StyleProvider
      // `hashPriority` 默认为 `low`,配置为 `high` 后,
      // 会移除 `:where` 选择器封装
      hashPriority='high'
      // `transformers` 提供预处理功能将样式进行转换
      // 为了统一 LTR 和 RTL 样式,Ant Design 使用了 CSS 逻辑属性
      // 需要兼容旧版浏览器使用transformers 将其进行转换
      transformers={[legacyLogicalPropertiesTransformer]}>
      <ConfigProvider
        theme={{
          token: {
            colorPrimary: '#ee3f4d',
          },
        }}
        locale={zhCN}>
        <Button type='primary'>Button</Button>
      </ConfigProvider>
    </StyleProvider>
  );
}

export default App;

4. 配置 Message 全局提示

因为 antdmessage静态方法无法消费上下文,所以需要通过 message.useMessage 创建支持读取 contextcontextHolder通过顶层注册的方式代替 message 静态方法,具体如下:

创建 /src/AppContext.ts文件,注册全局上下文:

ts 复制代码
import { MessageInstance } from 'antd/es/message/interface';
import { createContext } from 'react';

export type AppContextType = {
  message: MessageInstance;
} | null;

export const AppContext = createContext<AppContextType>(null);

修改/src/App.tsx,引入新建的全局上下文,初始化message并注册到上下文,以便项目中使用message方法:

tsx 复制代码
import './App.css';
import 'dayjs/locale/zh-cn';

import {
  legacyLogicalPropertiesTransformer,
  StyleProvider,
} from '@ant-design/cssinjs';
import { ConfigProvider, message } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';

import { AppContext } from '@/AppContext';
import ShowMessage from '@/ShowMessage';

dayjs.locale('zh-cn');

function App() {
  // 初始化 message
  const [messageApi, contextHolder] = message.useMessage();
 
  return (
    <StyleProvider
      // `hashPriority` 默认为 `low`,配置为 `high` 后,
      // 会移除 `:where` 选择器封装
      hashPriority='high'
      // `transformers` 提供预处理功能将样式进行转换
      // 为了统一 LTR 和 RTL 样式,Ant Design 使用了 CSS 逻辑属性
      // 需要兼容旧版浏览器使用transformers 将其进行转换
      transformers={[legacyLogicalPropertiesTransformer]}>
      <ConfigProvider
        theme={{
          token: {
            colorPrimary: '#ee3f4d',
          },
        }}
        locale={zhCN}>
        <AppContext.Provider value={{ message: messageApi }}>
          <ShowMessage></ShowMessage>
          {contextHolder}
        </AppContext.Provider>
      </ConfigProvider>
    </StyleProvider>
  );
}

export default App;

创建/src/ShowMessage.tsx组件,尝试使用:

tsx 复制代码
import { Button } from 'antd';
import { useContext } from 'react';

import { AppContext } from '@/AppContext';

const ShowMessage = () => {
  const appContext = useContext(AppContext);
  const onClick: React.MouseEventHandler<HTMLElement> = e => {
    e.stopPropagation();
    appContext?.message.info('我是要显示的消息');
  };
  return (
    <>
      <Button type='primary' onClick={onClick}>
        点我显示消息
      </Button>
    </>
  );
};

export default ShowMessage;

效果如下图:

5. 配置全局 modal , notification

原理和全局 message 差不多,都是为了消费 Context ,直接上代码:

修改 /src/AppContext.ts

ts 复制代码
import { MessageInstance } from 'antd/es/message/interface';
import { HookAPI as ModalInstance } from 'antd/es/modal/useModal';
import { NotificationInstance } from 'antd/es/notification/interface';
import { createContext } from 'react';

export type AppContextType = {
  message: MessageInstance;
  modal: ModalInstance;
  notification: NotificationInstance;
} | null;

export const AppContext = createContext<AppContextType>(null);

修改 /src/App.tsx

tsx 复制代码
import './App.css';
import 'dayjs/locale/zh-cn';

import {
  legacyLogicalPropertiesTransformer,
  StyleProvider,
} from '@ant-design/cssinjs';
import { ConfigProvider, message, Modal, notification } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';

import { AppContext } from '@/AppContext';
import ShowMessage from '@/ShowMessage';

dayjs.locale('zh-cn');

function App() {
  // 初始化 message
  const [messageApi, messageContextHolder] = message.useMessage();
  // 初始化 Modal
  const [modalApi, modalContextHolder] = Modal.useModal();
  const [notificationApi, notificationContextHolder] = notification.useNotification();
  return (
    <StyleProvider
      // `hashPriority` 默认为 `low`,配置为 `high` 后,
      // 会移除 `:where` 选择器封装
      hashPriority='high'
      // `transformers` 提供预处理功能将样式进行转换
      // 为了统一 LTR 和 RTL 样式,Ant Design 使用了 CSS 逻辑属性
      // 需要兼容旧版浏览器使用transformers 将其进行转换
      transformers={[legacyLogicalPropertiesTransformer]}>
      <ConfigProvider
        theme={{
          token: {
            colorPrimary: '#ee3f4d',
          },
        }}
        locale={zhCN}>
        <AppContext.Provider value={{ message: messageApi, modal: modalApi, notification: notificationApi }}>
          <ShowMessage></ShowMessage>
          {messageContextHolder}
          {modalContextHolder}
          {notificationContextHolder}
        </AppContext.Provider>
      </ConfigProvider>
    </StyleProvider>
  );
}

export default App;

ps:最新的 antd提供了 App组件来简化全局消费 messagemodalnotification 静态方法,具体看官方文档,我这就懒得改了

6. 安装路由插件并配置全局路由,实现路由拦截、全局页面布局

bash 复制代码
pnpm add react-router-dom localforage match-sorter sort-by

先在src目录下配置如下项目目录结构:

perl 复制代码
// src
├─assets # 静态资源文件目录,需要经过打包的放在这里,不需要的放在public目录下
│  ├─image # 图片资源文件目录
│  └─scss # 公共scss样式资源文件目录
├─components # 组件文件目录
├─constants # 静态常量文件目录
├─contexts # 全局上下文文件目录
│  └─modules # 模块化上下文,再统一在 index.ts中导出
├─layouts # 公共布局,后台管理布局结构文件目录
├─pages # 页面文件目录
│  ├─admin # 管理后台页面文件目录
│  │  ├─goods # 商品管理页面文件目录
│  │  ├─goodsCategories # 商品分类管理页面文件目录
│  │  └─index # 管理后台首页文件目录
│  └─front # 用户前台页面文件目录
│      ├─exceptions # 通用异常文件目录,404等页面
│      ├─index # 前台首页
│      └─login # 登录页
├─routes # 路由配置文件目录
│  ├─admin # 管理后台路由配置目录
│  └─common # 不需要登录权限的通用路由目录
├─services # api接口配置文件目录
├─stores # 状态管理文件目录
└─utils # 公共助手函数文件目录
    └─modules #模块化助手函数,再统一在 index.ts中导出

首先我们先在 src/pages/admin目录中创建几个页面,配置路由时方便引入页面路径,假设我们是做一个商城管理系统,需要对商品和分类进行管理。

6.1 定义页面及路由信息

6.1.1 定义管理后台首页
tsx 复制代码
// src/pages/admin/index/Index.tsx
// 管理后台首页
import { Card } from 'antd';
import { CSSProperties } from 'react';

const CardStyle: CSSProperties = {
  width: '100%',
  height: '100%',
};
const AdminIndex = () => {
  return (
    <Card style={CardStyle}>
      欢迎登录{import.meta.env.VITE_APP_WEB_TITLE}管理后台
    </Card>
  );
};

export default AdminIndex;
6.1.2 定义商品管理列表页面
tsx 复制代码
// src/pages/admin/goods/List.tsx
// 商品管理列表页面
import {
  Button,
  Card,
  Space,
  Statistic,
  Switch,
  Table,
  TableProps,
} from 'antd';
import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';

import { AppContext } from '@/contexts';

// 商品数据类型
type DataType = {
  id: number;
  title: string;
  description: string;
  price: number;
  status: boolean;
};
const GoodsList = () => {
  const navigate = useNavigate();
  const appContext = useContext(AppContext);
  const dataSource: DataType[] = Array(10)
    .fill(0)
    .map((_, i) => ({
      id: i + 1,
      title: `商品${i}`,
      description: `商品${i}的描述`,
      price: 1000,
      status: i % 2 === 0,
    }));
  const onEdit = (id: number) => {
    // 跳转到商品编辑页
    navigate('/admin/goods/edit', { state: { id } });
  };
  const onDetail = (id: number) => {
    // 跳转到商品详情页
    navigate('/admin/goods/detail', { state: { id } });
  };
  const onDelete = (id: number) => {
    // 删除前弹出确认提示,确认后调用删除接口,目前未作接口配置,暂时做个演示
    appContext?.modal.confirm({
      title: '删除确认',
      content: '您确定要删除该商品吗?',
      onCancel: () => {
        appContext?.message.info('已取消删除');
      },
      onOk: () => {
        appContext?.message.success(`确认删除商品${id}`);
      },
    });
  };
  // 定义商品列表展示项及操作功能
  const columns: TableProps<DataType>['columns'] = [
    {
      title: '商品名称',
      dataIndex: 'title',
      width: 500,
    },
    {
      title: '商品简介',
      dataIndex: 'description',
    },
    {
      title: '商品单价',
      dataIndex: 'price',
      render: (value: number) => {
        return (
          <Statistic
            valueStyle={{ fontSize: '16px' }}
            value={value}
            prefix='¥'
            precision={2}
          />
        );
      },
    },
    {
      title: '上架状态',
      dataIndex: 'status',
      render: (value: boolean) => {
        return (
          <Switch
            checkedChildren='上架'
            unCheckedChildren='下架'
            checked={value}
          />
        );
      },
    },
    {
      title: '操作',
      width: 150,
      render: (_, col) => {
        return (
          <Space>
            <Button type='link' onClick={() => onEdit(col.id)}>
              编辑
            </Button>
            <Button type='link' onClick={() => onDetail(col.id)}>
              详情
            </Button>
            <Button type='link' onClick={() => onDelete(col.id)} danger>
              删除
            </Button>
          </Space>
        );
      },
    },
  ];
  return (
    <Card>
      <Table rowKey={row => row.id} dataSource={dataSource} columns={columns} />
    </Card>
  );
};
export default GoodsList;
6.1.3 定义商品编辑、详情、商品分类列表页面
tsx 复制代码
// src/pages/admin/goods/Edit.tsx
// 商品编辑页面,新增和修改都在这里处理
import { useEffect } from "react";
import { useLocation, useNavigate } from "react-router-dom";

const GoodsEdit = () => {
  const { state } = useLocation();
  const navigate = useNavigate();
  useEffect(() => {
    // 在这里监听传入的商品id,调用商品详情接口,获取商品信息,如果不存在就跳转到404去,存在就展示表单
    // 当前只做路由相关开发,暂时就这样显示一下信息
    const ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    if (!state.id || !ids.includes(state.id)) {
      navigate('/404');
    }
  }, [state, navigate]);
  return <>商品{state.id}编辑页</>;
};
export default GoodsEdit;
tsx 复制代码
// src/pages/admin/goods/Detail.tsx
// 商品详情页面
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

const GoodsDetail = () => {
  const { state } = useLocation();
  const navigate = useNavigate();
  useEffect(() => {
    const ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    if (!state.id || !ids.includes(state.id)) {
      navigate('/404');
    }
  }, [state, navigate]);
  return <>商品{state.id}详情页</>;
};
export default GoodsDetail;
tsx 复制代码
// src/pages/admin/goodsCategories/List.tsx
// 商品分类列表页面
const GoodsCategories = () => {
  return <div>商品分类列表</div>;
};

export default GoodsCategories;
6.1.4 定义网站首页、登录页、通用错误页面
tsx 复制代码
// src/pages/front/index/Index.tsx
const Index = () => {
  return <>首页</>
};
export default Index
tsx 复制代码
// src/pages/front/login/Login.tsx
const Login = () => {
  return <>登录</>
};

export default Login
tsx 复制代码
// src/pages/front/exceptions/Index.tsx
import { Button, Result } from 'antd';
import { ResultProps } from 'antd/es/result';
import { useLocation, useNavigate } from 'react-router-dom';
const ExceptionIndex = () => {
  const location = useLocation();
  const navigate = useNavigate();

  const newResultProps = useMemo(() => {
    const onBackClick: React.MouseEventHandler<HTMLElement> = e => {
      e.stopPropagation();
      navigate('/index');
    };
    const getPageInformation = (pathname: string) => {
      const pageInformation: Record<string, ResultProps> = {
        '/403': {
          status: 403,
          title: 403,
          subTitle: '对不起,您无权访问此页面。',
          extra: (
            <Button type='primary' onClick={onBackClick}>
              回到首页
            </Button>
          ),
        },
        '/404': {
          status: 404,
          title: 404,
          subTitle: '对不起,页面不存在。',
          extra: (
            <Button type='primary' onClick={onBackClick}>
              回到首页
            </Button>
          ),
        },
        '/500': {
          status: 500,
          title: 500,
          subTitle: '对不起,服务器出了点问题。',
          extra: (
            <Button type='primary' onClick={onBackClick}>
              回到首页
            </Button>
          ),
        },
      };
      if (pageInformation[pathname]) {
        return pageInformation[pathname];
      }
      return pageInformation['404'];
    };
    return getPageInformation(location.pathname);
  }, [location.pathname, navigate]);
  return <Result {...newResultProps} />;
};

export default ExceptionIndex;
6.1.5 配置公共页面布局

我们管理后台一般都是顶部导航加左侧导航加右侧内容的结构,并且大多数路由都是嵌套结构,所以我们还需要创建Layout页面,并在里面引入Outlet,才能实现路由嵌套的效果:

tsx 复制代码
// src/layouts/AdminLayout.tsx
// 管理后台布局
import { Layout } from 'antd';
import { CSSProperties } from 'react';

import PageLayout from './PageLayout';

const { Header, Sider, Content, Footer } = Layout;

const LayoutStyle: CSSProperties = {
  minHeight: '100%',
};
const ContentStyle: CSSProperties = {
  overflow: 'auto',
  height: 'calc(100vh - 64px)',
};
const MainStyle: CSSProperties = {
  minHeight: 'calc(100vh - 64px - 67px)',
  padding: '20px',
};
const AdminLayout = () => {
  return (
    <Layout style={LayoutStyle}>
      <Header>页头</Header>
      <Layout>
        <Sider>侧边栏</Sider>
        <Content style={ContentStyle}>
          <div style={MainStyle}>
            <PageLayout />
          </div>
          <Footer>页尾</Footer>
        </Content>
      </Layout>
    </Layout>
  );
};

export default AdminLayout;
tsx 复制代码
// src/layouts/PageLayout.tsx
// 嵌套路由页面
import { Outlet } from 'react-router-dom';

const PageLayout = () => {
  return <Outlet></Outlet>;
};

export default PageLayout;
6.1.6 自定义路由类型及配置路由信息

一般我们切换页面后,浏览器标签都会显示当前页面的标题信息,参考vue-router,我们都会自定义一个meta在路由信息里面,用户配置当前页面标题,图标,是否登录以及权限限制信息,所以我们先声明一个自定义路由类型:

ts 复制代码
// src/routes/types.ts
import { ReactNode } from 'react';
import { RouteObject } from 'react-router-dom';
// 对原生路由信息进行扩展
export type DataRouteObject = RouteObject & {
  // 路由唯一id
  id?: string;
  // 路由扩展信息
  meta?: {
    // 路由是否需要登录
    auth?: boolean;
    // 是否是菜单,一般菜单都是和路由绑定的,通常都直接和路由信息配置在一起,再通过接口获取菜单信息,生成实际菜单
    menu?: boolean;
    // 路由名称
    title: string;
    // 菜单图标
    icon?: ReactNode;
  };
  //嵌套子路由
  children?: DataRouteObject[];
};

路由配置都会引入具体页面,通用方法就是使用reactlazy方法实现懒加载,但是自定义路由上的element接口类型和lazy方法返回的类型冲突,我们还需要基于这个方法,自己封装一个lazyLoad方法来生成一个动态页面,将这个页面和 element绑定:

tsx 复制代码
// src/utils/modules/lazyLoad.tsx;
import { Spin } from 'antd';
import { ComponentType, CSSProperties, lazy, Suspense } from 'react';

export const lazyLoad = (
  factory: () => Promise<{ default: ComponentType<unknown> }>,
) => {
  // 设置页面加载中的loading居中
  const LayoutStyle: CSSProperties = {
    width: '100%',
    height: '100%',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
  };
  const LazyComponent = lazy(factory);
  return (
    <Suspense
      fallback={
        <div style={LayoutStyle}>
          <Spin size='large' />
        </div>
      }>
      <LazyComponent />
    </Suspense>
  );
};
ts 复制代码
// src/utils/index.ts
// 导出lazyLoad方法
export * from './modules/lazyLoad'

然后我们就可以开始配置具体的路由信息了:

6.1.6.1 配置不需要登录的首页、登录及异常页面路由
tsx 复制代码
// src/routes/common/common.tsx
import { lazyLoad } from '@/utils';

import { DataRouteObject } from '../types';

const commonRoutes: DataRouteObject[] = [
  {
    path: '/index',
    id: 'index',
    meta: {
      title: '首页',
    },
    element: lazyLoad(() => import('@/pages/front/index/Index')),
  },
  {
    path: '/login',
    id: 'login',
    meta: {
      title: '登录',
    },
    element: lazyLoad(() => import('@/pages/front/login/Login')),
  },
];

export default commonRoutes;
ts 复制代码
// src/routes/common/exceptions.tsx
const exceptionRoutes: DataRouteObject[] = [
  {
    path: '/403',
    id: 'permissionDenied',
    meta: {
      auth: true,
      title: '无权访问',
    },
    element: lazyLoad(() => import('@/pages/front/exceptions/Index')),
  },
  {
    path: '/404',
    id: 'notFound',
    meta: {
      auth: true,
      title: '页面不存在',
    },
    element: lazyLoad(() => import('@/pages/front/exceptions/Index')),
  },
  {
    path: '/500',
    id: 'serverError',
    meta: {
      auth: true,
      title: '服务器错误',
    },
    element: lazyLoad(() => import('@/pages/front/exceptions/Index')),
  },
  {
    path: '*',
    element: <Navigate to='/404' replace />,
  },
];

export default exceptionRoutes;
6.1.6.2 配置管理后台首页、商品管理、商品分类管理路由
tsx 复制代码
// src/routes/admin/index.ts
import { lazyLoad } from '@/utils';

import { DataRouteObject } from '../types';

const indexRoutes: DataRouteObject[] = [
  {
    path: 'index',
    id: 'adminIndex',
    meta: {
      auth: true,
      menu: true,
      title: '首页',
    },
    element: lazyLoad(() => import('@/pages/admin/index/Index')),
  },
];

export default indexRoutes;
tsx 复制代码
// src/routes/admin/goods.tsx
import { Navigate } from 'react-router-dom';

import PageLayout from '@/layouts/PageLayout';
import { lazyLoad } from '@/utils';

import { DataRouteObject } from '../types';

const goodsRoutes: DataRouteObject[] = [
  {
    path: 'goods',
    id: 'adminGoods',
    meta: {
      auth: true,
      title: '商品管理',
      menu: true,
    },
    element: <PageLayout />,
    children: [
      {
        path: '',
        element: <Navigate to='/admin/goods/list' replace />,
      },
      {
        path: 'list',
        id: 'adminGoodsList',
        meta: {
          auth: true,
          title: '商品列表',
        },
        element: lazyLoad(() => import('@/pages/admin/goods/List')),
      },
      {
        path: 'create',
        id: 'adminGoodsCreate',
        meta: {
          auth: true,
          title: '添加商品',
        },
        element: lazyLoad(() => import('@/pages/admin/goods/Edit')),
      },
      {
        path: 'edit',
        id: 'adminGoodsEdit',
        meta: {
          auth: true,
          title: '修改商品',
        },
        element: lazyLoad(() => import('@/pages/admin/goods/Edit')),
      },
      {
        path: 'detail',
        id: 'adminGoodsDetail',
        meta: {
          auth: true,
          title: '商品详情',
        },
        element: lazyLoad(() => import('@/pages/admin/goods/Detail')),
      },
    ] as DataRouteObject[],
  },
];

export default goodsRoutes;

我们现在配置的都是按模块零散的文件,所以我们还需要一个地方汇总导出,方便具体使用:

tsx 复制代码
// src/routes/routes.tsx
import { Navigate } from 'react-router-dom';

import AdminLayout from '@/layouts/AdminLayout';
import PageLayout from '@/layouts/PageLayout';

import goodsRoutes from './admin/goods';
import indexRoutes from './admin/index';
import BeforeRouterEach from './BeforeRouterEach';
import commonRoutes from './common/common';
import exceptionRoutes from './common/exceptions';
import { DataRouteObject } from './types';

export const adminRoutes: DataRouteObject[] = [
  {
    path: '/admin',
    id: 'admin',
    meta: {
      auth: true,
      title: '管理后台',
    },
    element: <AdminLayout />,
    children: [
      {
        path: '',
        element: <Navigate to='/admin/index' replace />,
      },
      ...indexRoutes,
      ...goodsRoutes,
    ] as DataRouteObject[],
  },
];
export const frontRoutes: DataRouteObject[] = [
  {
    path: '/',
    id: 'home',
    meta: {
      title: '首页',
    },
    element: <PageLayout />,
    children: [
      {
        path: '',
        element: <Navigate to='/index' replace />,
      },
      ...commonRoutes,
    ],
  },
];
export const fullRoutes: DataRouteObject[] = [
  ...frontRoutes,
  ...adminRoutes,
  ...exceptionRoutes,
]

注意看里面有几个路由的path是空字符串,嵌套路由且需要进入后打开子路由就需要做这个配置,具体来讲就是,假设你期望输入/admin,就自动进入管理后台首页,那么就可以像上面这个配置一样,/admin路由的children的第一个配置:

ts 复制代码
{
  path: '',
  element: <Navigate to='/admin/index' replace />,
},

使用Navigate组件实现自动跳转。

6.2 使用路由配置信息,实现路由跳转

我们配置了路由信息之后需要用 useRoutes方法初始化路由信息后才能实现路由跳转切换页面的效果:

tsx 复制代码
// src/routes/Router.tsx
import { useRoutes } from 'react-router-dom';

import { fullRoutes } from './routes';

const Router = () => {
  return useRoutes(fullRoutes);
};
export default Router;

然后在src/routes/index.ts汇总导出一下:

ts 复制代码
export { default as Router } from './Router';
export * from './routes';
export * from './types';

src/main.tsx中使用BrowserRouter

tsx 复制代码
// src/main.tsx
import './index.css';

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';

import App from './App.tsx';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App></App>
    </BrowserRouter>
  </React.StrictMode>,
);

src/App.tsx中引入Router组件:

tsx 复制代码
// src/App.tsx
import './App.css';
import 'dayjs/locale/zh-cn';

import {
  legacyLogicalPropertiesTransformer,
  StyleProvider,
} from '@ant-design/cssinjs';
import { ConfigProvider, message, Modal, notification } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';

import { AppContext } from '@/contexts';

import { Router } from './routes';

dayjs.locale('zh-cn');

function App() {
  // 初始化 message
  const [messageApi, messageContextHolder] = message.useMessage();
  // 初始化 Modal
  const [modalApi, modalContextHolder] = Modal.useModal();
  const [notificationApi, notificationContextHolder] =
    notification.useNotification();
  return (
    <StyleProvider
      // `hashPriority` 默认为 `low`,配置为 `high` 后,
      // 会移除 `:where` 选择器封装
      hashPriority='high'
      // `transformers` 提供预处理功能将样式进行转换
      // 为了统一 LTR 和 RTL 样式,Ant Design 使用了 CSS 逻辑属性
      // 需要兼容旧版浏览器使用transformers 将其进行转换
      transformers={[legacyLogicalPropertiesTransformer]}>
      <ConfigProvider
        theme={{
          token: {
            colorPrimary: '#ee3f4d',
          },
        }}
        locale={zhCN}>
        <AppContext.Provider
          value={{
            message: messageApi,
            modal: modalApi,
            notification: notificationApi,
          }}>
          {/* 就是这个地方,配置之后就可以启动访问配置好的地址了*/}
          <Router></Router> 
          {messageContextHolder}
          {modalContextHolder}
          {notificationContextHolder}
        </AppContext.Provider>
      </ConfigProvider>
    </StyleProvider>
  );
}

export default App;

6.3 添加路由拦截

我们既然有管理后台,那么肯定是需要登录后的管理员才能进入对应页面,所以我们还需要添加路由全局拦截。添加拦截后对用户信息进行权限校验控制,同时顺便在用户切换页面时,动态的展示当前页面标题。

6.3.1 添加全局路由拦截器
tsx 复制代码
// src/routes/BeforeRouterEach.tsx
import { FC, ReactNode, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

import { AppContext } from '@/contexts';

import { DataRouteObject } from './types';

const BeforeRouterEach: FC<{
  route: DataRouteObject;
  children: ReactNode;
}> = ({ route, children }) => {
  const navigate = useNavigate();
  const appContext = 
  useEffect(() => {
    const { meta, path } = route
    // 判断当前页面是否需要登录才能访问
    const isAuth = meta && meta?.auth;
    // 判断是否进入的是登录页,因为已经登录就不应该再去登录页了
    const isToLogin = ['/login'].includes(path as string);
    // 判断用户是已经登录
    const isLogin = false;
    if (isLogin && isToLogin) {
      // 已经登录且还要进入登录页,则自动重定向到网站首页
      appContext?.message.error('您已经登录系统,无需重复登录!')
      navigate('/');
    } else if (isAuth && !isLogin) {
      // 需要登录才能进入的页面,但是又没有登录,重定向到登录页
      appContext?.message.error('对不起,您还未登录或登录已失效,请登录后访问!')
      navigate('/login');
    }
    if (route.meta?.title) {
      // 有标题信息,更新浏览器标题
      document.title = route.meta?.title;
    } else {
      document.title = import.meta.env.VITE_APP_WEB_TITLE
    }
  }, [route, navigate, appContext]);

  return children;
};

export default BeforeRouterEach;
6.3.2 使用拦截器

修改src/routes/routes.tsx,添加路由配置处理函数,自动向需要拦截器的页面添加拦截器:

tsx 复制代码
// src/routes/routes.tsx
import { Navigate } from 'react-router-dom';

import AdminLayout from '@/layouts/AdminLayout';
import PageLayout from '@/layouts/PageLayout';

import goodsRoutes from './admin/goods';
import indexRoutes from './admin/index';
import BeforeRouterEach from './BeforeRouterEach';
import commonRoutes from './common/common';
import exceptionRoutes from './common/exceptions';
import { DataRouteObject } from './types';

export const adminRoutes: DataRouteObject[] = [
  {
    path: '/admin',
    id: 'admin',
    meta: {
      auth: true,
      title: '管理后台',
    },
    element: <AdminLayout />,
    children: [
      {
        path: '',
        element: <Navigate to='/admin/index' replace />,
      },
      ...indexRoutes,
      ...goodsRoutes,
    ] as DataRouteObject[],
  },
];
export const frontRoutes: DataRouteObject[] = [
  {
    path: '/',
    id: 'home',
    meta: {
      title: '首页',
    },
    element: <PageLayout />,
    children: [
      {
        path: '',
        element: <Navigate to='/index' replace />,
      },
      ...commonRoutes,
    ],
  },
];
const getFullRoutes = (routes: DataRouteObject[]) => {
  return routes.map(route => {
    const newRoute: DataRouteObject = {
      ...route,
    };
    if (route.meta?.title || route.meta?.auth) {
      newRoute.element = (
        <BeforeRouterEach route={route}>{route.element}</BeforeRouterEach>
      );
    }
    if (route.children) {
      newRoute.children = getFullRoutes(route.children);
    }
    return newRoute;
  });
};
export const fullRoutes: DataRouteObject[] = getFullRoutes([
  ...frontRoutes,
  ...adminRoutes,
  ...exceptionRoutes,
]);

到此处,我们就用上路由配置及拦截器了,后续配置接口即可完善登录、登录拦截及权限拦截。

7. 接口请求封装

现在大家接口请求基本都是用 axios发送,我们这里也不例外,其实axios原生的功能已经完全足够项目使用了,不过直接用会有很多相似的代码块,比如每次都要手动处理响应异常,而axios是提供拦截器了的,为了优化代码,减少不必要的代码量,我这里也会二次封装一下。 我们这里尽量封装一个完全支持原生axios功能的东西,仅在原有的基础上做拓展。 封装axios可以直接初始化封装成一个函数,也可以封装成类的。 考虑到有多种请求拦截的情况,我们这里选择封装成一个request类,类在使用时可以new一个实例,实例化的时候可选的去修改拦截器逻辑。 比如我们有一部分接口需要登录后带上token,有一部分不需要,但是所有接口响应拦截逻辑是一致的,我们就可以取公约数,定义两种请求拦截器,定义一种响应拦截器。 然后再实例化两个request请求,分别传入定义的请求拦截器和响应拦截器,分开使用。 废话不多说,直接上代码,具体看注释:

ts 复制代码
// src/services/types.ts
// 自定义 axios 要用到的基础类型
import { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';

// 请求拦截器类型
export type AxiosResInterceptorType = (
  config: InternalAxiosRequestConfig,
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>;
// 请求拦截器错误类型
export type AxiosResInterceptorErrType = (error: AxiosError) => Promise<never>;

// 响应拦截器类型
export type AxiosReqInterceptorType = (
  response: AxiosResponse<ResponseModel<unknown>>,
) => Promise<AxiosResponse<ResponseModel<unknown>, unknown>>;

// 响应拦截器错误类型
export type AxiosReqInterceptorErrType = (error: unknown) => Promise<never>;

// axios 实例化配置参数类型
export type RequestInitType = {
  axiosConfig?: AxiosRequestConfig,
  resInterceptor?: AxiosResInterceptorType,
  resInterceptorErr?: AxiosResInterceptorErrType,
  reqInterceptor?: AxiosReqInterceptorType,
  reqInterceptorErr?: AxiosReqInterceptorErrType,
}

// 与后端约定的响应数据类型
export interface ResponseModel<T = unknown> {
  success: boolean;
  message: string | null;
  code: number | string;
  data: T;
}

// 与后端约定的列表分页数据类型
export interface Pagination<T = unknown> {
  pageNum: number;
  pageSize: number;
  total: number;
  data: T[];
}
ts 复制代码
// src/utils/modules/request.ts
// 封装基础 axios
import axios, {
  AxiosError,
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  InternalAxiosRequestConfig,
} from 'axios';

import {
  RequestInitType,
  ResponseModel,
} from '@/services/types';

// 默认请求拦截方法
const defaultResInterceptor = (config: InternalAxiosRequestConfig) => {
  return config;
};
// 默认请求错误拦截方法
const defaultResInterceptorErr = (error: AxiosError) => {
  return Promise.reject(error);
};
// 默认响应拦截方法
const defaultReqInterceptor = (
  response: AxiosResponse<ResponseModel<unknown>>,
) => {
  if (response.status === 200 && response.data.code !== 0) {
    return Promise.reject(new Error(response.data.message as string));
  }
  return Promise.resolve(response);
};
// 默认响应错误拦截方法
const defaultReqInterceptorErr = (error: unknown) => {
  return Promise.reject(error);
};
class Request {
  private instance: AxiosInstance;

  constructor({
    // axios 基础配置
    axiosConfig = {
      baseURL: import.meta.env.VITE_APP_API_PREFIX,
      timeout: 5 * 1000,
    },
    // 请求拦截器
    resInterceptor = defaultResInterceptor,
    // 请求异常拦截器
    resInterceptorErr = defaultResInterceptorErr,
    // 响应拦截器
    reqInterceptor = defaultReqInterceptor,
    // 响应异常拦截器
    reqInterceptorErr = defaultReqInterceptorErr,
  }: RequestInitType) {
    // 初始化配置
    this.instance = axios.create(axiosConfig);
    // 初始化请求拦截器
    this.instance.interceptors.request.use(
      (config: InternalAxiosRequestConfig) => resInterceptor(config),
      (error: AxiosError) => resInterceptorErr(error),
    );
    // 初始化响应拦截器
    this.instance.interceptors.response.use(
      (response: AxiosResponse<ResponseModel<unknown>>) =>
        reqInterceptor(response),
      (error: unknown) => reqInterceptorErr(error),
    );
  }

  // 导出全量axios方法
  request<T = unknown>(config: AxiosRequestConfig): Promise<ResponseModel<T>> {
    return this.instance.request<T>(config) as unknown as Promise<
      AxiosResponse<ResponseModel<T>>
    >;
  }

  // 自定义 get 方法
  get<T = unknown>(
    url: string,
    params?: Record<string, unknown>,
    config?: AxiosRequestConfig,
  ): Promise<ResponseModel<T>> {
    return this.request<T>({
      url,
      method: 'GET',
      params,
      ...(config || {}),
    });
  }

  // 自定义 post 方法
  post<T = unknown>(config: AxiosRequestConfig): Promise<AxiosResponse<ResponseModel<T>>> {
    return this.request<T>({
      method: 'POST',
      ...(config || {}),
    });
  }

  // 自定义 put 方法
  put<T = unknown>(config: AxiosRequestConfig): Promise<AxiosResponse<ResponseModel<T>>> {
    return this.request<T>({
      method: 'PUT',
      ...(config || {}),
    });
  }
  
  // 自定义 delete 方法
  delete<T>(config: AxiosRequestConfig): Promise<AxiosResponse<ResponseModel<T>>> {
    return this.request<T>({
      method: 'DELETE',
      ...(config || {}),
    });
  }

  // 自定义上传方法
  upload<T = unknown>(config: AxiosRequestConfig): Promise<AxiosResponse<ResponseModel<T>>> {
    return this.request<T>({
      method: 'POST',
      ...config,
      headers: {
        ...config?.headers,
        'Content-Type': 'multipart/form-data',
      },
    });
  }

  // 自定义 get 下载方法
  downloadGet<T = unknown>(
    config: AxiosRequestConfig,
  ): Promise<AxiosResponse<ResponseModel<T>>> {
    return this.request<T>({
      method: 'GET',
      ...config,
      responseType: 'blob',
    });
  }

  // 自定义 post 下载方法
  downloadPost<T = unknown>(
    config: AxiosRequestConfig,
  ): Promise<AxiosResponse<ResponseModel<T>>> {
    return this.request<T>({
      method: 'POST',
      ...config,
      responseType: 'blob',
    });
  }
}

export default Request;
ts 复制代码
// src/services/commonReq.ts
// 不需要登录就可以提交的接口实例
import { Request } from "@/utils";

const commonReq = new Request({
  axiosConfig: {
    baseURL: import.meta.env.VITE_APP_API_PREFIX,
    timeout: 5 * 1000,
  },
})

export default commonReq
ts 复制代码
// src/services/reqWithToken.ts
// 需要登录Token的接口实例
import { InternalAxiosRequestConfig } from 'axios';
import localforage from 'localforage';

import { Request } from '@/utils';

const reqWithToken = new Request({
  axiosConfig: {
    baseURL: import.meta.env.VITE_APP_API_PREFIX,
    timeout: 5 * 1000,
  },
  resInterceptor: async (config: InternalAxiosRequestConfig) => {
    try {
      const token = await localforage.getItem('token');
      if (!token) {
        return Promise.reject(new Error('401'));
      }
      config.headers.set('Authorization', `Bearer ${token}`);
      return config;
    } catch (error) {
      return Promise.reject(new Error('401'));
    }
  },
});

export default reqWithToken

然后我需要带登录信息的接口请求就用这个实例,不需要的就用另一个,随让开发的时候麻烦一点,创建了多个实例,但是后期维护起来就会简单些。具体使用如下:

ts 复制代码
// src/services/modules/user.ts
// 用户相关接口
import commonReq from '../commonReq';
import reqWithToken from '../reqWithToken';

export type LoginParams = {
  userName: string;
  password: string;
};
export type UserInfo = {
  id: string;
  token: string;
  username: string;
  nickname: string;
  sex: number;
  birthday: string;
};
// 登录请求
export const postLogin = (data: LoginParams) =>
  commonReq.post<UserInfo>({
    url: '/user/login',
    data,
  });

// 获取用户信息
export const getUserInfo = () => reqWithToken.get<Omit<UserInfo, 'token'>>('/user/getUserInfo');

导出接口方法:

ts 复制代码
// src/services/index.ts
export * from './modules/user';

8. 使用mockjs模拟接口请求响应数据

现在都是前后端分离开发,双方约定好接口规范就可以各行其事,完成后再联调一下就可以了。分离后前端开发时可以根据接口规范自己伪造请求响应,基本设计阶段合理的话,开发出来就没多少需要调整的了。mockjs就是我们用于伪造接口请求的插件,下面我们就详细展开开发示例:

bash 复制代码
pnpm add vite-plugin-mock-dev-server -D
pnpm add mockjs -D
pnpm add @types/mockjs

安装完成后就是配置了,考虑到项目后期会对接实际接口,所以我们最好添加个mock的环境变量,通过vite启动模式区分接口环境:

新建 /.env.mock文件,然后写入:

.env.mock 复制代码
// .env.mock
VITE_APP_BASE_URL="/" # 项目打包后运行的根目录,如果项目打包后放到nginx的二级目录需要配置这个,否则会导致js等资源文件404
VITE_APP_API_PREFIX="/api" # 项目接口地址前缀
VITE_APP_API_URL="http://你自己的服务器地址" # 后端接口服务器地址
VITE_APP_WEB_TITLE="网站标题" # 项目网页标签默认标题
VITE_APP_VERSION="0.0.1" # 项目版本号
VITE_APP_COPYRIGHT="@2024" # 项目版权信息
VITE_APP_TEL="0816-0000000" # 网站热线
VITE_APP_EMAIL="网站电子邮箱" # 网站电子邮箱
VITE_APP_USE_MOCK='true' # 是否使用mock环境

更新 src/vite-env.d.ts声明:

ts 复制代码
// src/vite-env.d.ts
/// <reference types="vite/client" />
interface ImportMetaEnv {
  readonly VITE_APP_BASE_URL: string;
  readonly VITE_APP_API_PREFIX: string;
  readonly VITE_APP_API_URL: string;
  readonly VITE_APP_WEB_TITLE: string;
  readonly VITE_APP_VERSION: string;
  readonly VITE_APP_COPYRIGHT: string;
  readonly VITE_APP_TEL: string;
  readonly VITE_APP_EMAIL: string;
  readonly VITE_APP_USE_MOCK: string;
  // 更多环境变量...
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

修改package.json文件,添加mock环境启动命令:

json 复制代码
// package.json
...
"scripts": {
    "dev": "vite",
    "mock": "vite --mode mock", // 关键是这句,添加后使用 pnpm rum mock 启动后,项目代理就会使用 mock环境的接口处理了
    "build": "tsc && vite build",
    "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },
...

修改 vite.config.ts配置,开启mock:

ts 复制代码
// vite.config.ts
import react from '@vitejs/plugin-react-swc';
import path from 'path';
import colors from 'picocolors';
import { defineConfig, loadEnv } from 'vite';
import checker from 'vite-plugin-checker';
import { chunkSplitPlugin } from 'vite-plugin-chunk-split';
import legacy from 'vite-plugin-legacy-swc';
import mockDevServerPlugin from 'vite-plugin-mock-dev-server';
import progress from 'vite-plugin-progress';
import removeConsole from 'vite-plugin-remove-console';

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
  // 根据开发、生产环境模式动态读取环境变量
  const configEnv = loadEnv(mode, process.cwd(), '');

  return {
    plugins: [
      react(),
      checker({
        // e.g. use TypeScript check
        typescript: true,
        eslint: {
          // for example, lint .ts and .tsx
          lintCommand: 'eslint "./src/**/*.{ts,tsx}"',
        },
      }),
      configEnv.VITE_APP_USE_MOCK === 'true' ? mockDevServerPlugin() : null, // 读取环境变量,动态判断是否使用 mock 数据
      legacy({
        targets: ['defaults', 'not IE 11'],
      }),
      chunkSplitPlugin(),
      progress({
        format: `${colors.green(colors.bold('Bouilding'))} ${colors.cyan(
          '[:bar]',
        )} :percent`,
        total: 200,
        width: 60,
        complete: '=',
        incomplete: '',
      }),
      removeConsole(),
    ],
    base: configEnv.VITE_APP_BASE_URL,
    resolve: {
      alias: {
        '@': path.resolve(__dirname, 'src'),
      },
    },
    server: {
      proxy: {
        [configEnv.VITE_APP_API_PREFIX]: {
          target: configEnv.VITE_APP_API_URL,
          changeOrigin: true,
          rewrite: path => path.replace(configEnv.VITE_APP_API_PREFIX, ''),
        },
      },
    },
  };
});

创建 mock/user.mock.ts文件,配置 mock接口请求:

ts 复制代码
// mock/user.mock.ts
import Mock from 'mockjs';
import { defineMock } from 'vite-plugin-mock-dev-server';

export default defineMock({
  url: '/api/user/login',
  method: 'POST',
  response(req, res) {
    let data = {
      code: 0,
      message: '登录成功!',
      success: true,
      data: {
        id: Mock.mock('@id'),
        token: Mock.mock('@guid'),
        username: req.body.username,
        nickname: Mock.mock('@cname'),
        ...Mock.mock({
          'sex|1': [0, 1, 2],
        }),
        birthday: Mock.mock('@date("yyyy-MM-dd")'),
      },
    };
    if (req.body.username !== 'admin' || req.body.password !== '123456') {
      data = {
        code: 1,
        message: '用户名或密码错误!',
        success: false,
        data: {},
      };
    }
    res.write(JSON.stringify(data));
    res.end();
  },
});

一般接口请求的时候我们都会手动添加一个loading,显示请求状态,比较麻烦,所以推荐安装ahooks插件,具体可以看文档

bash 复制代码
pnpm add ahooks

修改登录页面:

tsx 复制代码
// src/pages/front/login/Login.tsx
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useRequest } from 'ahooks';
import { Button, Card, Form, Input, Spin, Typography } from 'antd';
import { CSSProperties, useContext } from 'react';

import { AppContext } from '@/contexts';
import { postLogin, UserLoginParams } from '@/services';

const loginContent: CSSProperties = {
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'center',
  width: '100%',
  height: '100%',
};
const Login = () => {
  const appContext = useContext(AppContext);

  const { run: handleLogin, loading } = useRequest(postLogin, {
    manual: true,
    onSuccess: res => {
      console.log(res);
      // 登录成功
      appContext?.message.success('登录成功');
      // TODO: 保存token及用户信息
    },
    onError: err => {
      console.log(err);
      // 登录失败,显示错误提示
      appContext?.message.error(err.message);
    },
  });
  const onLogin = (values: UserLoginParams) => {
    if (values) {
      handleLogin(values);
    }
  };
  return (
    <div style={loginContent}>
      <Spin spinning={loading}>
        <Card style={{ width: 450 }}>
          <Form onFinish={onLogin}>
            <Typography.Title level={4}>登录</Typography.Title>
            <Form.Item
              name='username'
              rules={[{ required: true, message: '请输入用户名' }]}>
              <Input
                prefix={<UserOutlined />}
                placeholder='请输入用户名'></Input>
            </Form.Item>
            <Form.Item
              name='password'
              rules={[{ required: true, message: '请输入登录密码' }]}>
              <Input.Password
                prefix={<LockOutlined />}
                placeholder='请输入登录密码'></Input.Password>
            </Form.Item>
            <Form.Item noStyle>
              <Button htmlType='submit' type='primary' block>
                登录
              </Button>
            </Form.Item>
          </Form>
        </Card>
      </Spin>
    </div>
  );
};

export default Login;

pnpm rum mock启动项目,地址修改为 /login:

登录效果自己测试下。

9. 安装 zunstand状态管理并配置,保存全局公用数据

以前都用redux等状态管理插件,个人觉得使用起来颇为麻烦,没有zustand简洁,也可能是我水平还不够,管他的,自己觉得好用就行。之前我们配置了登录接口,也开发了登录表单,但是还没有完整的实现登录过程,下面我们完善一下,达到的效果就是输入正确的账号密码登录成功后,会保存token信息及用户基本信息,然后自动进入首页,登录后需要token才能访问的接口请求会自动带上token信息:

bash 复制代码
pnpm add zustand

定义userInfo状态管理器,新建src/stores/modules/user.store.ts:

ts 复制代码
// src/stores/modules/user.store.ts
import { create } from 'zustand';

import { UserInfo } from '@/services';

type UserInfoStore = {
  userInfo: Omit<UserInfo, 'token'> | null;
};
type UserInfoAction = {
  setUserInfo: (userInfo: UserInfoStore['userInfo']) => void;
};
const useUserInfoStore = create<UserInfoStore & UserInfoAction>()(set => ({
  userInfo: null, // 用户信息
  setUserInfo: userInfo => set(() => ({ userInfo })), // 更新用户信息方法
}));

export default useUserInfoStore;

新建src/stores/index.ts导出全部状态:

ts 复制代码
// src/stores/index.ts
export { default  as useUserInfoStore} from './modules/user.store';

修改完善mock方法,提供登录及用户信息获取接口,修改mock/user.mock.tsmock/userLogin.mock.ts:

ts 复制代码
import Mock from 'mockjs';
import { defineMock } from 'vite-plugin-mock-dev-server';

export default defineMock({
  url: '/api/user/login',
  method: 'POST',
  response(req, res) {
    let data = {
      code: 0,
      message: '登录成功!',
      success: true,
      data: {
        id: Mock.mock('@id'),
        token: Mock.mock('@guid'),
        username: req.body.username,
        nickname: Mock.mock('@cname'),
        ...Mock.mock({
          'sex|1': [0, 1, 2],
        }),
        birthday: Mock.mock('@date("yyyy-MM-dd")'),
      },
    };
    if (req.body.username !== 'admin' || req.body.password !== '123456') {
      data = {
        code: 1,
        message: '用户名或密码错误!',
        success: false,
        data: {},
      };
    }
    res.write(JSON.stringify(data));
    res.end();
  },
});

新建mock/userInfo.mock.ts:

ts 复制代码
import Mock from 'mockjs';
import { defineMock } from 'vite-plugin-mock-dev-server';

export default defineMock({
  url: '/api/user/getUserInfo',
  method: 'GET',
  response(req, res) {
    const data = {
      code: 0,
      message: '请求成功!',
      success: true,
      data: {
        id: Mock.mock('@id'),
        token: Mock.mock('@guid'),
        username: Mock.mock('@first'),
        nickname: Mock.mock('@cname'),
        ...Mock.mock({
          'sex|1': [0, 1, 2],
        }),
        birthday: Mock.mock('@date("yyyy-MM-dd")'),
      },
    };
    res.write(JSON.stringify(data));
    res.end();
  },
});

修改servicessrc/services/modules/user.ts用户登录及获取用户信息的接口定义:

ts 复制代码
import commonReq from '../commonReq';
import reqWithToken from '../reqWithToken';

export type UserLoginParams = {
  userName: string;
  password: string;
};
export type UserInfo = {
  id: string;
  token: string;
  username: string;
  nickname: string;
  sex: number;
  birthday: string;
};
// 登录请求
export const postLogin = (data: UserLoginParams) =>
  commonReq.post<UserInfo>({
    url: '/user/login',
    data,
  });

// 获取用户信息
export const getUserInfo = () => reqWithToken.get<Omit<UserInfo, 'token'>>('/user/getUserInfo');

完善登录表单逻辑:

tsx 复制代码
// src/pages/front/login/Login.tsx
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { useRequest } from 'ahooks';
import { Button, Card, Form, Input, Spin, Typography } from 'antd';
import localforage from 'localforage';
import { CSSProperties, useContext } from 'react';
import { useNavigate } from 'react-router-dom';

import { AppContext } from '@/contexts';
import { postLogin, UserLoginParams } from '@/services';
// 引入自定义 userInfo 状态管理器
import { useUserInfoStore } from '@/stores';

const loginContent: CSSProperties = {
  display: 'flex',
  justifyContent: 'center',
  alignItems: 'center',
  width: '100%',
  height: '100%',
};
const Login = () => {
  const appContext = useContext(AppContext);
  const navigate = useNavigate()
  // 结构设置用户信息方法
  const { setUserInfo } = useUserInfoStore(state => ({
    setUserInfo: state.setUserInfo,
  }));
    
  const { run: handleLogin, loading } = useRequest(postLogin, {
    manual: true,
    onSuccess: res => {
      // 登录成功
      appContext?.message.success('登录成功');
      // 结构返回的数据,获取 token及用户信息
      const { token, ...userInfo } = res.data.data;
      // 缓存token信息到indexDB
      localforage.setItem('token', token);
      // 更新用户信息到状态管理器
      setUserInfo(userInfo);
      // 登录成功后自动跳转到首页
      navigate('/')
    },
    onError: err => {
      // 登录失败,显示错误提示
      appContext?.message.error(err.message);
      // 登录失败,清理一下 token
      localforage.removeItem('token')
      // 登录失败,清理一下用户状态信息
      setUserInfo(null);
    },
  });
  const onLogin = (values: UserLoginParams) => {
    if (values) {
      handleLogin(values);
    }
  };
  return (
    <div style={loginContent}>
      <Spin spinning={loading}>
        <Card style={{ width: 450 }}>
          <Form onFinish={onLogin}>
            <Typography.Title level={4}>登录</Typography.Title>
            <Form.Item
              name='username'
              rules={[{ required: true, message: '请输入用户名' }]}>
              <Input
                prefix={<UserOutlined />}
                placeholder='请输入用户名'></Input>
            </Form.Item>
            <Form.Item
              name='password'
              rules={[{ required: true, message: '请输入登录密码' }]}>
              <Input.Password
                prefix={<LockOutlined />}
                placeholder='请输入登录密码'></Input.Password>
            </Form.Item>
            <Form.Item noStyle>
              <Button htmlType='submit' type='primary' block>
                登录
              </Button>
            </Form.Item>
          </Form>
        </Card>
      </Spin>
    </div>
  );
};

export default Login;

修改src/pages/front/index/Index.tsx,使用 userInfoState查看效果:

tsx 复制代码
import { useUserInfoStore } from '@/stores';

const Index = () => {
  const { userInfo } = useUserInfoStore(state => ({
    userInfo: state.userInfo,
  }));
  return <>首页{userInfo?.nickname}</>;
};
export default Index;

token信息缓存后,我们切换页面,刷新页面时,就可以在路由拦截器 src/routes/BeforeRouterEach.tsx中完善登录拦截逻辑了:

tsx 复制代码
// src/routes/BeforeRouterEach.tsx
import { useRequest } from 'ahooks';
import localforage from 'localforage';
import { FC, ReactNode, useContext, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

import { AppContext } from '@/contexts';
import { getUserInfo } from '@/services';
import { useUserInfoStore } from '@/stores';

import { DataRouteObject } from './types';

const BeforeRouterEach: FC<{
  route: DataRouteObject;
  children: ReactNode;
}> = ({ route, children }) => {
  const navigate = useNavigate();
  const appContext = useContext(AppContext);
  const { userInfo, setUserInfo } = useUserInfoStore(state => ({
    userInfo: state.userInfo,
    setUserInfo: state.setUserInfo,
  }));
  const { run: handleGetUserInfo } = useRequest(getUserInfo, {
    throttleWait: 300,
    debounceWait: 300,
    manual: true,
    onSuccess: res => {
      setUserInfo(res.data.data);
    },
    onError: err => {
      appContext?.message.destroy();
      appContext?.message.error(err.message);
    },
  });
  useEffect(() => {
    const { meta, path } = route;
    const isAuth = meta && meta?.auth;
    const isToLogin = ['/login'].includes(path as string);
    // 获取indexDB 中的 token
    localforage
      .getItem('token')
      .then(res => {
        const isLogin = !!res;
        // 如果已经登录并且没有用户信息,就重新发起请求获取用户信息
        if (isLogin && !userInfo) {
          handleGetUserInfo();
        }
        if (isLogin && isToLogin) {
          appContext?.message.warning('您已经登录系统,无需重复登录!');
          navigate('/');
        } else if (isAuth && !isLogin) {
          appContext?.message.error(
            '对不起,您还未登录或登录已失效,请登录后访问!',
          );
          navigate('/login');
        }
      })
      .finally(() => {
        if (route.meta?.title) {
          document.title = route.meta?.title;
        } else {
          document.title = import.meta.env.VITE_APP_WEB_TITLE;
        }
      });
  }, [route, navigate, appContext, handleGetUserInfo, userInfo]);

  return children;
};

export default BeforeRouterEach;

相关文档

懒得列了,自己去百度谷歌吧

相关推荐
Martin -Tang17 分钟前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发18 分钟前
解锁微前端的优秀库
前端
王解1 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
老码沉思录1 小时前
写给初学者的React Native 全栈开发实战班
javascript·react native·react.js
老码沉思录1 小时前
React Native 全栈开发实战班 - 第四部分:用户界面进阶之动画效果实现
react native·react.js·ui
我不当帕鲁谁当帕鲁1 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis
那一抹阳光多灿烂1 小时前
工程化实战内功修炼测试题
前端·javascript
放逐者-保持本心,方可放逐2 小时前
微信小程序=》基础=》常见问题=》性能总结
前端·微信小程序·小程序·前端框架
毋若成4 小时前
前端三大组件之CSS,三大选择器,游戏网页仿写
前端·css
红中马喽4 小时前
JS学习日记(webAPI—DOM)
开发语言·前端·javascript·笔记·vscode·学习