使用 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-checker
,eslint
校验插件
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
全局提示
因为 antd
的message
静态方法无法消费上下文,所以需要通过 message.useMessage
创建支持读取 context
的 contextHolder
通过顶层注册的方式代替 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
组件来简化全局消费 message
、modal
、notification
静态方法,具体看官方文档,我这就懒得改了
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[];
};
路由配置都会引入具体页面,通用方法就是使用react
的lazy
方法实现懒加载,但是自定义路由上的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.ts
为mock/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();
},
});
修改services
中src/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;
相关文档
懒得列了,自己去百度谷歌吧