第七章 第三节:Ant Design 数据录入篇 —— 深挖表单设计逻辑,教你用 Form/Input 组件高效解决录入场景问题
7.9. 数据录入(一):基础输入组件
从本节开始,我们正式进入 Ant Design 的核心——数据录入。我们将遵循“按难度分级”的原则,首先从最基础、最高频的输入组件入手。
本节核心目标:通过一个独立的“用户注册”场景,掌握处理文本 (Input
)、数字 (InputNumber
)、布尔值 (Switch
) 和单选/多选 (Radio
/Checkbox
) 这五类基础组件,并彻底理解它们背后共通的“受控模式”。

文件路径: src/components/demos/BasicRegistrationForm.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126
| import React, { useState } from "react"; import { Input, InputNumber, Radio, Checkbox, Switch, Button, Form, Space, Typography, Divider, } from "antd"; import type { CheckboxValueType } from "antd/es/checkbox/Group";
const { Title, Text } = Typography;
const BasicRegistrationForm: React.FC = () => { const [username, setUsername] = useState<string>(""); const [age, setAge] = useState<number | null>(18); const [accountType, setAccountType] = useState<string>("personal"); const [interests, setInterests] = useState<CheckboxValueType[]>(["coding"]); const [agree, setAgree] = useState<boolean>(false);
const handleSubmit = () => { if (!agree) { alert("请先同意用户协议!"); return; } const formData = { username, age, accountType, interests, agree }; console.log("提交的基础表单数据:", formData); alert("注册信息已打印到控制台,请按 F12 查看。"); };
return ( <div style={{ width: "100%", maxWidth: "400px", margin: "0 auto", padding: "24px", background: "#fff", borderRadius: "8px", }} > <Title level={4} style={{ textAlign: "center" }}> 创建新账户 </Title> <Divider /> {/* 我们暂时使用 Form 和 Form.Item 来进行布局和标签对齐, 关于 Form 的数据管理能力,将在后续章节深入讲解。 */} <Form layout="vertical"> {/* Input: 基础文本输入 */} <Form.Item label="用户名" required> <Input placeholder="请输入用户名" value={username} onChange={(e) => setUsername(e.target.value)} /> </Form.Item>
{/* InputNumber: 数字输入 */} <Form.Item label="年龄"> <InputNumber style={{ width: "100%" }} min={18} max={100} value={age} onChange={(value) => setAge(value)} /> </Form.Item>
{/* Radio.Group: 单选 */} <Form.Item label="账户类型"> <Radio.Group value={accountType} onChange={(e) => setAccountType(e.target.value)} > <Radio value="personal">个人账户</Radio> <Radio value="corporate">企业账户</Radio> </Radio.Group> </Form.Item>
{/* Checkbox.Group: 多选 */} <Form.Item label="兴趣爱好"> <Checkbox.Group options={["阅读", "编码", "旅行", "健身"]} value={interests} onChange={(checkedValues) => setInterests(checkedValues)} /> </Form.Item>
{/* Switch: 开关 (用于布尔值) */} <Form.Item> <Space> <Switch checked={agree} onChange={(checked) => setAgree(checked)} /> <span> 我已阅读并同意<Text type="secondary">(用户协议)</Text> </span> </Space> </Form.Item>
<Divider />
<Form.Item> <Button type="primary" block onClick={handleSubmit} disabled={!agree}> 立即注册 </Button> </Form.Item> </Form> </div> ); };
export default BasicRegistrationForm;
|
在 App.tsx
中独立使用此 Demo:
文件路径: src/App.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13
| import React from 'react'; import BasicRegistrationForm from './components/demos/BasicRegistrationForm'; import { Flex } from 'antd';
const App: React.FC = () => { return ( <Flex align="center" justify="center" style={{ padding: '24px', background: '#f0f2f5', minHeight: '100vh' }}> <BasicRegistrationForm /> </Flex> ); };
export default App;
|
本节核心知识点
- 万能的“受控模式”: 我们通过实践证明,无论是
Input
的 value
,还是 Switch
的 checked
,所有基础录入组件都共享同一个核心模式:用 useState
定义状态,通过 value
/checked
绑定状态,通过 onChange
更新状态。掌握这一点,比记住几十个 API 都重要。 - 组件选型:
- 需要用户输入任意文本/数字时,使用
Input
/InputNumber
。 - 需要在 多个互斥选项中选择一个 时,使用
Radio.Group
。 - 需要在 多个选项中选择零个或多个 时,使用
Checkbox.Group
。 - 需要用户进行 是/否 的二元选择时,使用
Switch
。
🤔 思考与探索:让 Switch
更具表现力
在我们的 Demo 中,Switch
只是一个简单的开关。但在很多场景下,我们希望开关在“开”和“关”的状态下,能显示不同的文字或图标,例如“开/关”、“启用/禁用”、“✓/✕”。
问题:查阅 Ant Design 的 Switch
组件文档,您能找到是哪个属性可以实现这个功能吗?请尝试修改上面的 Demo,让 Switch
在开启时显示“同意”,关闭时显示“未同意”。
答案是使用 checkedChildren
和 unCheckedChildren
属性。
1 2 3 4 5 6
| <Switch checked={agree} onChange={(checked) => setAgree(checked)} checkedChildren="同意" unCheckedChildren="未同意" />
|
这两个属性非常直观,可以接受字符串或图标(ReactNode
),让 Switch
的状态表达更清晰。
我们已经掌握了处理文本、数字和布尔值的基础组件。在下一节 7.10
中,我们将进入第二个层级:选择器(Picker)组件,学习如何使用 Select
, DatePicker
等组件,来处理下拉选项、日期时间等更复杂的数据类型,并看看“受控模式”在它们身上是如何应用的。
7.10. 数据录入(二):选择器(Picker)组件
在上一节中,我们掌握了处理文本、数字和布尔值的基础输入组件。现在,我们将“难度”升级,来学习“选择器”家族。这类组件的核心场景是:当用户的输入不是自由的文本,而是需要从一个预设的、结构化的数据集中进行选择时,例如选择一个城市、一个日期或一个评分。
本节核心目标:通过一个独立的“创建活动”场景,掌握 Select
、DatePicker
、TimePicker
、Slider
、Rate
和 ColorPicker
的用法。我们将重点观察,上一节学到的“受控模式”,是如何无缝适配这些组件更丰富的数据类型的。
文件路径: src/components/demos/CreateEventForm.tsx

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122
| import React, { useState } from 'react'; import { Select, DatePicker, TimePicker, Slider, Rate, ColorPicker, Button, Form, Typography, Divider, Space, } from 'antd'; import type { Color } from 'antd/escolor-picker'; import type { Dayjs } from 'dayjs';
const { Title, Text } = Typography; const { Option } = Select; const { RangePicker } = DatePicker;
const CreateEventForm: React.FC = () => { const [eventType, setEventType] = useState<string>('conference'); const [participants, setParticipants] = useState<string[]>(['jack']); const [eventDates, setEventDates] = useState<[Dayjs | null, Dayjs | null] | null>(null); const [eventTime, setEventTime] = useState<Dayjs | null>(null); const [priority, setPriority] = useState<number>(3); const [budget, setBudget] = useState<number>(5000); const [themeColor, setThemeColor] = useState<Color | string>('#1677ff');
const handleSubmit = () => { const formData = { eventType, participants, eventDates: eventDates?.map(date => date?.format('YYYY-MM-DD')), eventTime: eventTime?.format('HH:mm'), priority, budget, themeColor: typeof themeColor === 'string' ? themeColor : themeColor.toHexString(), }; console.log('提交的选择器表单数据:', formData); alert('活动信息已打印到控制台,请按 F12 查看。'); };
return ( <div style={{ maxWidth: '600px', margin: '0 auto', padding: '24px', background: '#fff', borderRadius: '8px' }}> <Title level={4} style={{ textAlign: 'center' }}>创建新活动</Title> <Divider /> <Form layout="vertical"> {/* Select: 下拉选择器 */} <Form.Item label="活动类型"> <Select value={eventType} onChange={(value) => setEventType(value)} options={[ { value: 'conference', label: '学术会议' }, { value: 'webinar', label: '线上讲座' }, { value: 'meetup', label: '线下沙龙', disabled: true }, ]} /> </Form.Item>
{/* Select (多选模式) */} <Form.Item label="参会人员 (多选)"> <Select mode="multiple" allowClear placeholder="请选择参会人员" value={participants} onChange={(values) => setParticipants(values)} options={[ { value: 'jack', label: 'Jack' }, { value: 'lucy', label: 'Lucy' }, { value: 'tom', label: 'Tom' }, ]} /> </Form.Item>
{/* DatePicker 和 TimePicker */} <Form.Item label="活动周期"> <RangePicker style={{ width: '100%' }} value={eventDates} onChange={(dates) => setEventDates(dates)} /> </Form.Item>
<Form.Item label="开始时间"> <TimePicker style={{ width: '100%' }} value={eventTime} onChange={(time) => setEventTime(time)} /> </Form.Item> {/* Rate: 评分 */} <Form.Item label="重要等级"> <Rate value={priority} onChange={(value) => setPriority(value)} /> {priority > 3 && <Text type="danger" style={{marginLeft: '8px'}}>高优先级</Text>} </Form.Item> {/* Slider: 滑动输入条 */} <Form.Item label={`预算: ${budget} 元`}> <Slider min={1000} max={10000} step={500} value={budget} onChange={(value) => setBudget(value)} /> </Form.Item> {/* ColorPicker: 颜色选择器 */} <Form.Item label="主题颜色"> <ColorPicker value={themeColor} onChangeComplete={(color) => setThemeColor(color)} /> </Form.Item> <Divider /> <Form.Item> <Button type="primary" block onClick={handleSubmit}>创建活动</Button> </Form.Item> </Form> </div> ); };
export default CreateEventForm;
|
在 App.tsx
中独立使用此 Demo:
文件路径: src/App.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import React from 'react'; import CreateEventForm from './components/demos/CreateEventForm'; import { Flex } from 'antd';
import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <Flex align="center" justify="center" style={{ padding: '24px', background: '#f0f2f5', minHeight: '100vh' }}> <CreateEventForm /> </Flex> </ConfigProvider> ); };
export default App;
|
本节核心知识点
- “受控模式”的万能适应性: 我们再次验证了“受控模式”的强大。尽管
Select
的 value
是字符串数组,DatePicker
的 value
是 Dayjs
对象,但“用 state 控制 value,用 onChange 更新 state”的核心思想完全不变。 - 处理
Dayjs
对象: DatePicker
和 TimePicker
的 value
和 onChange
回调参数都是 Dayjs
对象,而不是字符串。Dayjs
是一个功能强大的日期库,我们可以用它进行格式化 (.format('YYYY-MM-DD')
)、计算等各种操作。 - 数据驱动的
options
: 对于 Select
这类选项繁多的组件,始终推荐使用 options
属性传入一个数组来生成选项,这让代码更清晰,也便于动态从服务器获取选项数据。
🤔 思考与探索:如何实现“远程搜索”功能?
在我们的 Demo 中,“参会人员”列表是硬编码的。在一个真实的应用中,用户列表可能非常庞大(成千上万),一次性加载所有用户到前端会让页面崩溃。
问题:我们如何改造 Select
组件,让它在用户输入文字时,才动态地向服务器发送请求,获取并展示匹配的用户列表?
答案在于 Select
组件的几个关键“搜索”属性的组合使用:
showSearch
: 必须设置为 true
,以启用搜索功能。onSearch
: 提供一个回调函数。当用户在搜索框中输入时,此函数会被触发,并携带用户输入的文本。这是我们 发起 API 请求 的入口。filterOption
: 将其设置为 false
。因为我们的选项是动态从远程获取的,需要禁用 Ant Design 的默认前端筛选逻辑。loading
: 在我们等待 API 返回数据的期间,将此属性设为 true
,Select
会显示一个加载指示器,提升用户体验。options
: 在 onSearch
触发的 API 请求成功后,用返回的数据去更新一个 options
state,Select
就会自动展示最新的搜索结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| const [options, setOptions] = useState([]); const [loading, setLoading] = useState(false);
const handleSearch = (value: string) => { if (value) { setLoading(true); fetch(`/api/users?q=${value}`) .then(res => res.json()) .then(data => { setOptions(data); setLoading(false); }); } else { setOptions([]); } };
<Select showSearch value={selectedUser} placeholder="输入用户名搜索" onSearch={handleSearch} onChange={handleChange} filterOption={false} loading={loading} options={options} />
|
掌握了这套“组合拳”,您就具备了处理海量数据选择的核心能力。
我们已经掌握了从预设列表中进行选择的“选择器”组件。但如果数据结构本身是 层级嵌套 的呢?例如,选择“国家 -> 省份 -> 城市”。或者,如果我们需要在输入文本时,优雅地 提及 (@) 某个用户呢?
在下一节 7.11
中,我们将挑战第三个层级:高级选择器组件,并正式引入 json-server
来搭建模拟 API,让我们的 Demo 更加贴近实战。
7.11. 数据录入(三):高级选择器、异步数据与 Tailwind CSS(重点)
在前两级中,我们掌握了处理基础数据和预设选项的组件。现在,我们将面临企业级开发中最真实的挑战:处理层级嵌套的复杂数据,并从服务器 异步加载 选项。
本节将是一次全方位的升级,我们将依次完成:
- 环境升级:为项目集成
Tailwind CSS
,告别行内样式。 - 工具引入:学习使用
json-server
和 faker-js
搭建专业级的 Mock API 服务器。 - 实战演练:通过“增量构建”的方式,一步步完成一个集成了多个高级选择器的“内容发布”表单。
7.11.1. AutoComplete:从零搭建远程搜索

第一步:为何选择 AutoComplete
?(何时使用)
当我们需要一个 输入框,但又希望在用户输入时,能根据输入内容提供 建议列表 以供选择时,AutoComplete
就是最佳选择。它完美结合了 Input
的自由输入和 Select
的选择辅助。
- 核心场景:搜索引擎、文章标签输入、收件人邮箱联想等。
- 与
Select
的区别:Select
的核心是“选择”,用户通常只能在 限定的选项 中选择;而 AutoComplete
的核心是“辅助输入”,用户 可以输入任意内容,选项只是建议。
第二步:环境升级 - 集成 Tailwind CSS
为了让我们的组件样式更专业、可维护,我们首先为项目集成 Tailwind CSS
。
安装依赖
1
| pnpm add -D tailwindcss @tailwindcss/vite
|
配置 vite
编辑项目根目录下的 vite.config.js
(或 .ts
) 文件,引入并使用 Tailwind CSS 插件。
1 2 3 4 5 6 7 8 9 10
| import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import tailwindcss from '@tailwindcss/vite' export default defineConfig({ plugins: [ vue(), tailwindcss(), ], })
|
在主 CSS 文件中引入 Tailwind 指令
文件路径: src/index.css
现在,我们的项目已成功集成 Tailwind CSS。
第三步:工具引入 - 搭建 Mock API 服务器
1. 为什么要用 Mock API?
真实项目的数据都来自后端 API。为了高度仿真这个过程,我们需要一个能模拟 API 请求的工具,而不是在前端硬编码数据。
json-server
: 一个能让你在 30 秒内,用一个 db.json
文件搭建出一个功能完备的 REST API 服务器的“神器”。@faker-js/faker
: 一个能生成海量、逼真假数据的库,例如用户名、地址、文章等。
2. 安装依赖
1
| pnpm add -D json-server@0.17.4 @faker-js/faker lodash @types/lodash
|
3. 创建 Mock API (v0.1)
我们只为 AutoComplete
组件创建它所需要的“标签”数据。
文件路径: scripts/generate-mock-data.mjs
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| import { faker } from '@faker-js/faker'; import fs from 'fs';
const generateTags = (count) => { const tags = []; for (let i = 0; i < count; i++) { tags.push({ id: i + 1, name: faker.food.adjective() }); } return tags; }
const db = { tags: generateTags(50), };
fs.writeFileSync('db.json', JSON.stringify(db, null, 2)); console.log('Mock data (tags only) generated successfully!');
|
4. 启动服务
在 package.json
中添加脚本:
1 2 3 4
| "scripts": { "mock:generate": "node ./scripts/generate-mock-data.mjs", "mock:api": "json-server --watch db.json --port 3001" },
|
现在,先执行 pnpm mock:generate
,然后新开一个终端执行 pnpm mock:api
,API 服务器就启动了!
第四步:前置知识 - useCallback
与 debounce
在编写核心逻辑之前,我们必须先理解两个关键的工具:React 的 useCallback
Hook 和 Lodash 库的 debounce
函数。
useCallback
是什么?
useCallback
是 React 提供的一个性能优化 Hook。在 React 中,当一个组件重新渲染时,它内部定义的所有函数都会被 重新创建。如果一个函数被作为 prop 传递给子组件,这可能会导致不必要的子组件重复渲染。useCallback(fn, deps)
会“记住” fn
这个函数,只有当 deps
(依赖项数组) 中的值发生变化时,它才会重新创建一个新的函数。这确保了传递给子组件的函数引用是稳定的,从而避免了不必要的性能损耗。
debounce
(防抖) 是什么?
防抖 是一种编程技巧,用于控制高频事件的触发次数。想象一下用户在搜索框里快速输入 “React”,会触发 5 次输入事件。如果我们每次都去请求 API,会造成巨大的性能浪费。debounce(fn, delay)
会包装我们的 fn
函数,使得它只有在 停止触发 delay
毫秒后(例如 300ms)才会真正执行一次。这正是远程搜索场景下最完美的解决方案。
第五步:编码实现
文件路径: src/hooks/useContentPublishForm.ts
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import { useState, useCallback } from 'react'; import debounce from 'lodash/debounce';
export const useContentPublishForm = () => { const [tagOptions, setTagOptions] = useState<{ value: string }[]>([]);
const handleTagSearch = useCallback( debounce((searchText: string) => { if (!searchText) { setTagOptions([]); return; } fetch(`http://localhost: 3001/tags?name_like =${searchText}`) .then(res => res.json()) .then(data => setTagOptions(data.map((tag: any) => ({ value: tag.name })))); }, 300), [] );
return { tagOptions, handleTagSearch }; };
|
文件路径: src/components/demos/ContentPublishForm.tsx
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| import React, { useState } from 'react'; import { AutoComplete, Form, Button, Typography, Divider, message } from 'antd'; import { useContentPublishForm } from '../../hooks/useContentPublishForm';
const { Title } = Typography;
const ContentPublishForm: React.FC = () => { const { tagOptions, handleTagSearch } = useContentPublishForm(); const [tagsValue, setTagsValue] = useState<string>();
const handleSubmit = () => { message.success("表单内容已打印到控制台"); console.log({ tagsValue }); }
return ( <div className="max-w-2xl mx-auto p-8 bg-white rounded-lg shadow-lg"> <Title level={4} className="text-center !mb-6">发布新内容</Title> <Form layout="vertical"> <Form.Item label="文章标签 (远程搜索)"> <AutoComplete options={tagOptions} onSearch={handleTagSearch} onChange={setTagsValue} placeholder="输入并搜索标签" /> </Form.Item> <Divider /> <Button type="primary" block onClick={handleSubmit}>发布文章</Button> </Form> </div> ); };
export default ContentPublishForm;
|
第六步:最终集成
文件路径: src/App.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import React from 'react'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn'; import ContentPublishForm from './components/demos/ContentPublishForm';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8"> <ContentPublishForm /> </div> </ConfigProvider> ); };
export default App;
|
7.11.2. Cascader:优雅处理层级数据
在企业级应用中,我们经常需要处理具有层级关系的数据,例如“产品分类 -> 型号 -> 规格”,或是公司的“组织架构 -> 部门 -> 小组”。面对这类需求,Cascader
(级联选择器) 提供了一种比多个 Select
联动远为优雅和高效的解决方案。
核心价值:Cascader
能让用户在 同一个 浮层中,完成从根节点到任意子节点的选择,将复杂的多级联动简化为一次连贯的操作,极大提升了用户体验。
第一步:基础入门 - 静态数据与核心结构
在接触真实 API 之前,我们先通过一个最简单的静态数据 Demo,来彻底理解 Cascader
的“基因”——一个由 value
、label
和 children
构成的递归嵌套结构。
目标:创建一个独立的“商品分类”选择器。
文件路径: src/components/demos/StaticCascaderDemo.tsx
(新建文件)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
| import React from 'react'; import { Cascader, Typography } from 'antd'; import type { CascaderProps } from 'antd';
const { Title } = Typography;
interface CategoryOption { value: string; label: string; children?: CategoryOption[]; }
const productCategories: CategoryOption[] = [ { value: 'electronics', label: '电子产品', children: [ { value: 'phones', label: '智能手机', children: [ { value: 'iphone-15', label: 'iPhone 15' }, { value: 'galaxy-s25', label: 'Galaxy S25' }, ], }, { value: 'laptops', label: '笔记本电脑', children: [{ value: 'macbook-pro', label: 'MacBook Pro' }], }, ], }, { value: 'books', label: '图书音像', children: [ { value: 'fiction', label: '小说', children: [{ value: 'the-three-body-problem', label: '三体' }], }, ], }, ];
const StaticCascaderDemo: React.FC = () => { const onChange: CascaderProps<CategoryOption>['onChange'] = (value, selectedOptions) => { console.log('Selected Path:', value); console.log('Selected Options:', selectedOptions); };
return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg"> <Title level={4} className="text-center !mb-6">选择商品分类 (静态数据)</Title> <Cascader options={productCategories} onChange={onChange} placeholder="请选择分类" expandTrigger="hover" // 演示移入展开,体验更流畅 /> </div> ); };
export default StaticCascaderDemo;
|
在 App.tsx
中使用此 Demo:
文件路径: src/App.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import React from 'react'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn'; import StaticCascaderDemo from './components/demos/StaticCascaderDemo';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8"> <StaticCascaderDemo /> </div> </ConfigProvider> ); };
export default App;
|
这个 Demo 让我们掌握了 Cascader
的基本功。但在实战中,数据都来自后端 API,且字段名几乎不可能是 value
和 label
。接下来,我们将解决这个核心痛点。
第二步:实战进阶 - 适配 API 数据 (fieldNames
)
目标:改造我们的 Demo,使其从 json-server
获取分类数据,并适配后端返回的自定义字段。
1. Mock API 升级 (v0.2) - 准备“不标准”的数据
为了模拟真实场景,我们刻意在 db.json
中使用 catId
, catName
, subCategories
作为字段名。
文件路径: /src/db.json
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98
| { "categories": [ { "catId": "electronics", "catName": "电子产品", "subCategories": [ { "catId": "smartphones", "catName": "智能手机", "subCategories": [ { "catId": "apple-iphone", "catName": "Apple iPhone" }, { "catId": "android-phones", "catName": "Android 手机" } ] }, { "catId": "computers", "catName": "电脑办公", "subCategories": [ { "catId": "laptops", "catName": "笔记本电脑" }, { "catId": "desktops", "catName": "台式机" }, { "catId": "monitors", "catName": "显示器" } ] }, { "catId": "cameras", "catName": "摄影摄像" } ] }, { "catId": "books-media", "catName": "图书音像", "subCategories": [ { "catId": "literature", "catName": "文学", "subCategories": [ { "catId": "sci-fi", "catName": "科幻小说" }, { "catId": "classic-novels", "catName": "经典名著" } ] }, { "catId": "technology-books", "catName": "计算机与科技" }, { "catId": "music", "catName": "音乐", "subCategories": [ { "catId": "vinyl-records", "catName": "黑胶唱片" }, { "catId": "cds", "catName": "CD" } ] } ] }, { "catId": "home-living", "catName": "家居生活", "subCategories": [ { "catId": "kitchenware", "catName": "厨房用品" }, { "catId": "furniture", "catName": "家具" } ] } ] }
|
2. 编写新的 API 版本 Demo
文件路径: src/components/demos/ApiCascaderDemo.tsx
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| import React, { useState, useEffect } from 'react'; import { Cascader, Spin, Typography } from 'antd';
const { Title } = Typography;
interface ApiCategoryOption { catId: string; catName: string; subCategories?: ApiCategoryOption[]; }
const ApiCascaderDemo: React.FC = () => { const [loading, setLoading] = useState(true); const [options, setOptions] = useState<ApiCategoryOption[]>([]);
useEffect(() => { fetch('http://localhost:3001/categories') .then(res => res.json()) .then(data => { setOptions(data); setLoading(false); }); }, []);
if (loading) { return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg text-center"> <Spin tip="正在加载分类数据..." /> </div> ); }
return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg"> <Title level={4} className="text-center !mb-6">选择商品分类 (API 数据)</Title> <Cascader options={options} placeholder="请选择分类" // 2. 核心:使用 fieldNames 将 API 字段“映射”到 antd 组件可识别的字段 fieldNames={{ label: 'catName', value: 'catId', children: 'subCategories', }} /> </div> ); };
export default ApiCascaderDemo;
|
3. 在 App.tsx
中切换到新 Demo
文件路径: src/App.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import { ConfigProvider } from "antd"; import zhCN from "antd/locale/zh_CN"; import "dayjs/locale/zh-cn"; import ApiCascaderDemo from "./components/demos/ApiCascaderDemo";
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8"> {/* <StaticCascaderDemo /> */} <ApiCascaderDemo /> </div> </ConfigProvider> ); };
export default App;
|
现在运行项目,您会看到 Cascader
成功加载并渲染了来自 API 的、具有自定义字段名的数据。
fieldNames
属性: 这是处理真实 API 数据时最有用的属性之一。它就像一个“翻译器”,告诉 Cascader
组件:“请把 API 返回的 catId
当作 value
,catName
当作 label
,subCategories
当作 children
”。这让我们无需在前端手动遍历和转换整个数据树,极大简化了代码。
7.11.4. Mentions: 在文本中实现智能提及
在现代协作和社交应用中,我们经常需要在文本输入框中 @
某个用户或 #
某个话题。这种功能不仅能精准地通知相关人员,还能将非结构化的文本与应用内的实体(用户、项目、标签)关联起来。Ant Design 的 Mentions
组件就是为实现这一功能而生的专业工具。

核心价值:Mentions
提供了一个带智能建议列表的文本输入区,当用户输入特定前缀(默认为 @
)时,会自动弹出可供选择的选项,极大地提升了协作效率和用户体验。
重要升级提示 (v5.1.0+): 在 antd v5.1.0 之后,官方 强烈推荐 使用 options
属性来数据驱动地渲染选项列表。旧有的通过 JSX 嵌套 <Mentions.Option>
的写法已被废弃。本教程将完全遵循 2025 年的最佳实践,只使用现代的 options
写法。
第一步:基础入门 - 静态选项
让我们从一个最基础的例子开始,熟悉 Mentions
组件的核心 API。我们将创建一个简单的评论框,其中包含一组固定的、可供 @
的用户列表。
目标:创建一个独立的 Mentions
组件,使用静态数据源。
文件路径: src/components/demos/StaticMentionsDemo.tsx
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| import React, { useState } from "react"; import { Mentions, Typography, type MentionProps } from "antd";
const { Title } = Typography;
const StaticMentionsDemo: React.FC = () => { const [value, setValue] = useState("");
const onChange: MentionProps["onChange"] = (text) => { console.log("onChange:", text); setValue(text); };
const onSelect: MentionProps["onSelect"] = (option) => { console.log("select", option); };
const options = [ { value: "张三", label: "张三 - 前端开发工程师", }, { value: "李四", label: "李四 - 后端开发工程师", }, { value: "王五", label: "王五 - UI/UX 设计师", }, { value: "赵六", label: "赵六 - 产品经理", }, { value: "钱七", label: "钱七 - 测试工程师", }, { value: "孙八", label: "孙八 - 运维工程师", }, ];
return ( <div className="max-w-xl mx-auto p-8 bg-white rounded-lg shadow-lg"> <Title level={4} className="text-center !mb-6"> 发布团队动态 </Title> <Mentions className="w-max min-h-[100px]" onChange={onChange} onSelect={onSelect} placeholder="在这里输入内容,使用 @ 提及团队成员" options={options} ></Mentions> </div> ); };
export default StaticMentionsDemo;
|
在 App.tsx
中使用此 Demo:
文件路径: src/App.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import React from 'react'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn'; import StaticMentionsDemo from './components/demos/StaticMentionsDemo';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8"> <StaticMentionsDemo /> </div> </ConfigProvider> ); };
export default App;
|
这个 Demo 让我们掌握了 Mentions
的基本用法。但真正的挑战在于,用户列表通常是动态的,需要从服务器异步获取。
第二步:实战进阶 - 异步搜索用户
现在,我们将 Mentions
与我们的 Mock API 服务器结合,实现一个在用户输入时动态搜索并展示用户建议列表的真实场景。
1. Mock API 升级 (v0.4) - 准备用户数据
我们需要一个用户列表的 API 端点。
文件路径: db.json
(修改文件,在 categories
同级添加 users
)
1 2 3 4 5 6 7 8 9 10
| { "users": [ {"id": 1,"name": "张三","profession": "张三 - 前端开发工程师"}, {"id": 2,"name": "李四","profession": "李四 - 后端开发工程师"}, {"id": 3,"name": "王五","profession": "王五 - UI/UX 设计师"}, {"id": 4,"name": "赵六","profession": "赵六 - 产品经理"}, {"id": 5,"name": "钱七","profession": "钱七 - 测试工程师"}, {"id": 6,"name": "孙八","profession": "孙八 - 运维工程师"} ] }
|
2. 编写异步版本 Demo
文件路径: src/components/demos/AsyncMentionsDemo.tsx
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| import React, { useState, useCallback } from "react"; import { Mentions, Spin } from "antd"; import type { MentionProps } from "antd"; import debounce from "lodash/debounce";
const AsyncMentionsDemo: React.FC = () => { const [loading, setLoading] = useState(false); const [options, setOptions] = useState<MentionProps["options"]>([]); const loadUser = async (search: string) => { setLoading(true);
try { const url = !search ? `http://localhost:3001/users` : `http://localhost:3001/users?q=${search}`;
const res = await fetch(url); const users = await res.json();
const userOptions = users.map( (user: { id: string; name: string; profession: string }) => ({ key: user.id, value: user.name, label: user.profession, }) );
setOptions(userOptions); } catch (error) { setOptions([]); }
setLoading(false); };
const debouncedLoadUsers = useCallback(debounce(loadUser, 800), []);
const onSearch: MentionProps["onSearch"] = (search) => { debouncedLoadUsers(search); };
return ( <div className="max-w-xl mx-auto p-8 bg-white rounded-lg shadow-lg"> <h3 className="text-xl font-bold mb-4 text-center"> 项目评论 (异步搜索) </h3> <Mentions style={{ width: "100%" }} loading={loading} onSearch={onSearch} options={options} placeholder="输入 @ 搜索并提及项目成员" /> </div> ); };
export default AsyncMentionsDemo;
|
3. 在 App.tsx
中切换到新 Demo
文件路径: src/App.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import AsyncMentionsDemo from './components/demos/AsyncMentionsDemo'; import StaticMentionsDemo from './components/demos/StaticMentionsDemo'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/es/locale/zh_CN';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8"> <AsyncMentionsDemo /> </div> </ConfigProvider> ); };
export default App;
|
现在,当您在输入框中键入 @
并开始输入时,组件会在短暂延迟后显示一个加载指示器,然后从 API 获取匹配的用户列表并展示出来,一个专业级的“提及”功能就完成了。
本节核心知识点:
onSearch
回调: 这是实现异步加载的灵魂。当用户输入触发字符(如 @
)并继续输入时,该回调函数会被触发,并传入搜索的关键词。这是我们发起 API 请求的最佳时机。loading
属性: 通过将 loading
属性与我们的 API 请求状态绑定,可以为用户提供清晰的加载反馈,提升体验。- 防抖 (
debounce
): 在处理用户输入触发的搜索时,始终 应该使用 debounce
来包装请求函数。这可以避免在用户快速输入时发送大量不必要的 API 请求,是性能优化的关键一环。
7.11.5. TreeSelect: 驾驭复杂树形选择的终极方案
到目前为止,我们已经掌握了 Select
(处理扁平列表)和 Cascader
(处理严格的单路径层级)。但如果我们的需求是 在一个层级结构中,自由地选择多个节点,甚至包括父节点和子节点 呢?
想象一个文件系统,您需要同时选择 “/文档” 这个文件夹,以及 “/图片/旅行/” 下的 京都风景.jpg
这个文件。Cascader
无法做到这一点。这,就是 TreeSelect
的主场。
核心价值:TreeSelect
完美结合了 Tree
的层级展示能力和 Select
的选择器交互形态。它是处理如 权限分配、组织架构选择、文件目录勾选 等复杂、非线性树形选择场景下的终极解决方案。
第一步:基础入门 - 从 treeData
开始
与 Tree
组件一样,TreeSelect
的最佳实践也是通过 treeData
属性,传入一个带有 title
, value
, 和 children
字段的数组来渲染整个树形结构。
目标:创建一个独立的“权限分配”选择器,使用静态数据源,实现最基本的单选功能。
文件路径: src/components/demos/PermissionSelectorDemo.tsx
(新建文件)

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| import React, { useState } from "react"; import { TreeSelect, Typography } from "antd";
const { Title } = Typography;
const permissionTreeData = [ { title: "仪表盘", value: "dashboard", key: "dashboard" }, { title: "用户管理", value: "user-management", key: "user-management", children: [ { title: "用户列表", value: "user-list", key: "user-list", children: [ { title: "查看用户", value: "view-user", key: "view-user" }, { title: "编辑用户", value: "edit-user", key: "edit-user" }, { title: "删除用户", value: "delete-user", key: "delete-user" } ] }, { title: "角色分配", value: "role-assignment", key: "role-assignment", children: [ { title: "分配角色", value: "assign-role", key: "assign-role" }, { title: "撤销角色", value: "revoke-role", key: "revoke-role" } ] } ], }, { title: "文章管理", value: "article-management", key: "article-management", children: [ { title: "文章列表", value: "article-list", key: "article-list" }, { title: "文章分类", value: "article-category", key: "article-category" }, { title: "文章标签", value: "article-tag", key: "article-tag" }, ], }, ];
const PermissionSelectorDemo: React.FC = () => { const [value, setValue] = useState<string | undefined>(); const onChange = (newValue: string) => { console.log("Selected Permission:", newValue); setValue(newValue); };
return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg"> <Title level={5}>选择权限</Title> <TreeSelect style={{ width: "100%" }} value={value} popupMatchSelectWidth={400} treeData={permissionTreeData} placeholder="请选择一项权限" treeDefaultExpandAll // 默认展开所有节点,便于查看 onChange={onChange} /> </div> ); };
export default PermissionSelectorDemo;
|
在 App.tsx
中使用此 Demo:
文件路径: src/App.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import React from 'react'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn'; import PermissionSelectorDemo from './components/demos/PermissionSelectorDemo';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8"> <PermissionSelectorDemo /> </div> </ConfigProvider> ); };
export default App;
|
第二步:释放核心能力 - 可勾选的多选 (treeCheckable
)
单选功能 Select
也能做,TreeSelect
真正的威力在于其强大的多选能力。通过 treeCheckable
属性,我们可以为每个树节点生成一个 Checkbox,允许用户自由勾选。
修改 PermissionSelectorDemo.tsx
:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| import React, { useState } from 'react'; import { TreeSelect, Typography, Tag } from 'antd';
const PermissionSelectorDemo: React.FC = () => { const [value, setValue] = useState<string[]>(['dashboard']);
const onChange = (newValue: string[]) => { console.log('Selected Permissions:', newValue); setValue(newValue); };
return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg"> <Title level={4} className="text-center !mb-6">为角色分配权限 (多选)</Title> <TreeSelect style={{ width: "100%" }} value={value} popupMatchSelectWidth={400} treeData={permissionTreeData} // 👇 核心变更 multiple={true} // 开启多选模式 treeCheckable={true} // 开启勾选框 placeholder="请选择一项权限" treeDefaultExpandAll // 默认展开所有节点,便于查看 onChange={onChange} /> <div className="mt-4"> <span className="font-semibold">当前权限: </span> {value.map(v => <Tag color="blue" key={v}>{v}</Tag>)} </div> </div> ); };
export default PermissionSelectorDemo;
|
现在,选择器已经变为一个强大的多选工具。您会注意到一个默认行为:当您勾选一个父节点时,它所有的子节点都会被自动选中。这在很多场景下非常有用,但如果我们想更精细地控制回填到输入框中的内容呢?
第三步:精细控制 - 勾选策略 (showCheckedStrategy
)
showCheckedStrategy
属性决定了当用户勾选节点后,哪些值最终会显示在选择框中。这是 TreeSelect
最重要、也最需要理解的配置之一。
策略: 只显示被选中的 子节点。如果一个父节点的所有子节点都被选中了,也只显示这些子节点,不显示父节点。
场景: 当您只关心最末端的具体权限时。例如,给用户分配了 “用户列表” 和 “角色分配” 权限,您不关心他是否拥有整个 “用户管理” 的权限。
代码:
1 2 3 4
| <TreeSelect showCheckedStrategy={TreeSelect.SHOW_CHILD} />
|
效果: 勾选“用户管理”后,选择框中显示的是 [用户列表, 角色分配]
。
策略: 优先显示 父节点。只有当一个父节点下的所有子节点都被选中时,才将它们合并为父节点进行显示。如果只选中了部分子节点,则依然显示这些子节点。
场景: 当您希望权限表示更概括、更简洁时。例如,如果用户拥有了用户管理下的所有权限,直接显示一个 “用户管理” 标签,比显示一长串子权限更清晰。
代码:
1 2 3 4
| <TreeSelect showCheckedStrategy={TreeSelect.SHOW_PARENT} />
|
效果: 勾选“用户管理”后,选择框中只显示 [用户管理]
。
策略: 显示 所有 被选中的节点,无论父子。
场景: 极为少见,通常只在需要完整回溯所有选中状态时使用。大部分情况下会造成信息冗余。
代码:
1 2 3 4
| <TreeSelect showCheckedStrategy={TreeSelect.SHOW_ALL} />
|
效果: 勾选“用户管理”后,选择框中会显示 [用户管理, 用户列表, 角色分配]
。
第四步:终极解耦 - 严格模式 (treeCheckStrictly
)
默认的父子联动勾选行为,在某些严格的权限场景下会成为阻碍。例如:
痛点:我想给某个角色“用户管理”这个模块的 访问权限(即勾选父节点),但不给他下面任何具体的 操作权限(即不勾选任何子节点)。
在默认模式下,这是不可能的。一旦勾选父节点,子节点也会被勾选。treeCheckStrictly
就是为了解决这个问题而生的。
修改 PermissionSelectorDemo.tsx
:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| import type { DefaultOptionType } from "antd/es/select";
const PermissionSelectorDemo: React.FC = () => { const [value, setValue] = useState<DefaultOptionType[]>([ { value: "user-management", label: "用户管理" }, ]);
const onChange = (newValue: DefaultOptionType[]) => { console.log("Selected Permissions (Strictly):", newValue); setValue(newValue); };
return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg"> <Title level={4} className="text-center !mb-6">权限分配 (严格模式)</Title> <TreeSelect style={{ width: '100%' }} value={value} treeData={permissionTreeData} placeholder="请选择权限" treeDefaultExpandAll onChange={onChange} multiple={true} treeCheckable={true} // 👇 核心变更 treeCheckStrictly={true} // 启用 treeCheckStrictly 后,组件会自动开启 labelInValue // 这意味着 value/onChange 的值不再是 string[], 而是 { value, label }[] showCheckedStrategy={TreeSelect.SHOW_CHILD} // 在严格模式下,通常配合 SHOW_CHILD /> <div className="mt-4"> <span className="font-semibold">当前权限: </span> {value.map(v => <Tag color="purple" key={v.value}>{v.label}</Tag>)} </div> </div> ); };
export default PermissionSelectorDemo;
|
核心变化: 启用 treeCheckStrictly={true}
后,TreeSelect
的行为会发生根本性改变:
- 父子解绑: 勾选父节点不再影响子节点,反之亦然。每个节点的勾选状态都是完全独立的。
- 强制
labelInValue
: 组件会自动将 labelInValue
设为 true
。这意味着 value
和 onChange
的值不再是简单的 string[]
,而是 [{ value: string, label: ReactNode }]
格式的对象数组。您的 useState
和类型定义必须做出相应调整。
通过这四步的层层递进,我们不仅学会了 TreeSelect
的基础用法,更深入理解了其在多选场景下的核心配置 showCheckedStrategy
和 treeCheckStrictly
。掌握了它们,您就能够从容应对任何复杂的树形数据选择需求。
7.11.6. Upload: 实现专业级文件上传
至此,我们已经征服了各类“选择”型输入组件。现在,我们将进入数据录入的另一大核心领域:文件上传。无论是发布文章的封面、上传用户头像,还是提交附件,Upload
组件都是任何现代应用不可或缺的一环。它将复杂的文件处理流程——从选择、预览到发送 HTTP 请求——封装成了一个优雅、可高度定制的交互界面。
第一步:备好“弹药” - 搭建 Mock 服务 (CLI 模式)
Upload
组件需要一个后端接收文件。为了模拟这个过程,我们将使用 json-server
的命令行工具,并为其提供一个“中间件”脚本来专门处理文件上传。这种方式比编写完整的服务器脚本更简单。
1. 安装依赖
我们需要 multer
来帮助我们解析上传的文件。
2. 创建上传“中间件”
中间件是一个函数,它会在 json-server
处理常规 API 请求之前执行。我们可以用它来拦截并处理我们的文件上传请求。
文件路径: scripts/upload-middleware.cjs
(新建文件,注意是 .cjs
后缀)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48
| const multer = require('multer'); const path = require('path'); const fs = require('fs');
const uploadDir = 'uploads'; if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir); }
const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/'); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); }, });
const upload = multer({ storage: storage });
module.exports = (req, res, next) => { if (req.method === 'POST' && req.path === '/api/upload') { upload.single('file')(req, res, (err) => { if (err) { return res.status(500).json({ error: err.message }); } if (!req.file) { return res.status(400).send({ status: 'error', message: 'No file uploaded.' }); } res.status(200).json({ status: 'success', message: 'File uploaded successfully!', url: `http://localhost:3001/${req.file.filename}`, }); }); } else { next(); } };
|
3. 添加启动脚本
现在,我们在 package.json
中组合一个完整的 json-server
命令。
1 2 3 4
| "scripts": { "mock:api": "json-server --watch db.json --port 3001 --static ./uploads --middlewares ./scripts/upload-middleware.cjs" },
|
这个命令做了三件事:
--watch db.json
: 启动标准的 JSON API 服务。--static ./uploads
: 将 uploads
文件夹作为静态资源目录,并映射到服务器根路径。--middlewares ./scripts/upload-middleware.cjs
: 加载我们刚刚编写的中间件,使其具备处理文件上传的能力。
现在,新开一个终端,运行 pnpm mock:api
,我们的 Mock 服务就以最简单的方式启动了!
第二步:核心机理 - 理解 Upload 的生命周期
有了后端服务后,我们再来审视 Upload
组件的生命周期,一切将变得豁然开朗。一次成功的文件上传,会经历以下流程:
Upload 生命周期
- 文件选择:用户通过点击或拖拽选择了文件。
beforeUpload
拦截:在客户端进行文件类型、大小的校验。- 发起请求:组件向
action
URL (http://localhost:3001/api/upload
) 发起 POST
请求。 onChange
状态更新:file.status
依次变为 uploading
-> done
或 error
。- 服务端响应:我们的
upload-middleware.js
接收文件,返回 JSON。onChange
会在 file.status
变为 done
时,将这个 JSON 存入 file.response
字段。
重要提示: 后续所有 Upload
组件示例中的 action
属性,都将指向我们通过中间件定义的 http://localhost:3001/api/upload
地址。
第三步:基础入门 - 最简单的点击上传
现在我们可以来编写前端代码了,内容与之前完全相同,因为它不需要关心后端是如何实现的。
目标:实现一个基础的、带文件列表的点击上传功能。
文件路径: src/components/demos/BasicUploadDemo.tsx
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| import React from 'react'; import { UploadOutlined } from '@ant-design/icons'; import { Button, message, Upload } from 'antd'; import type { UploadProps } from 'antd';
const BasicUploadDemo: React.FC = () => { const props: UploadProps = { name: 'file', action: 'http://localhost:3001/api/upload', headers: { authorization: 'authorization-text', }, onChange(info) { if (info.file.status !== 'uploading') { console.log('File list changed:', info.file, info.fileList); } if (info.file.status === 'done') { message.success(`${info.file.name} 文件上传成功`); console.log('Server Response:', info.file.response); } else if (info.file.status === 'error') { message.error(`${info.file.name} 文件上传失败.`); } }, };
return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg"> <h3 className="text-xl font-bold mb-4 text-center">基础文件上传</h3> <Upload {...props}> <Button icon={<UploadOutlined />}>点击上传</Button> </Upload> </div> ); };
export default BasicUploadDemo;
|
在 App.tsx
中使用此 Demo:
文件路径: src/App.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| import React from 'react'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn'; import BasicUploadDemo from './components/demos/BasicUploadDemo';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8 flex items-center justify-center"> <BasicUploadDemo /> </div> </ConfigProvider> ); };
export default App;
|
现在,确保您的 pnpm mock:api
服务正在运行,然后启动前端项目。尝试上传一个文件,一切将如预期般工作。这种 CLI + 中间件的模式,是 json-server
功能扩展的最佳实践之一。
第四步:体验升级 - 实现用户头像上传
基础的文件列表样式显然不适合“用户头像”这种场景。我们需要一个方形或圆形的区域,点击后上传,成功后直接显示图片预览。
痛点:如何隐藏默认的文件列表,并在上传成功后将组件本身变为图片预览区?如何在前端就拦截掉不符合要求的文件?
解决方案:
- 使用
listType="picture-card"
或 listType="picture-circle"
改变组件外观。 - 设置
showUploadList={false}
来完全接管 UI 展示。 - 利用
beforeUpload
钩子在客户端进行文件类型和大小的校验。 - (核心变更) 在
onChange
回调中,当上传成功时,直接使用服务器返回的 URL 来更新预览图。
文件路径: src/components/demos/AvatarUploadDemo.tsx
(修改文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99
| import React, { useState } from "react"; import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; import { Flex, message, Upload } from "antd"; import type { GetProp, UploadProps } from "antd";
type FileType = Parameters<GetProp<UploadProps, "beforeUpload">>[0];
const AvatarUploadDemo: React.FC = () => { const [loading, setLoading] = useState(false); const [imageUrl, setImageUrl] = useState<string>();
const beforeUpload = (file: FileType) => { const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png"; if (!isJpgOrPng) { message.error("您只能上传 JPG/PNG 格式的图片!"); } const isLt2M = file.size / 1024 / 1024 < 2; if (!isLt2M) { message.error("图片大小不能超过 2MB!"); } return isJpgOrPng && isLt2M; };
const handleChange: UploadProps["onChange"] = (info) => { if (info.file.status === "uploading") { setLoading(true); return; } if (info.file.status === "done") { const serverUrl = info.file.response?.url; if (serverUrl) { setImageUrl(serverUrl); message.success(`${info.file.name} 上传成功`); } else { message.error(`${info.file.name} 上传失败,服务器未返回有效链接。`); } setLoading(false); } else if (info.file.status === "error") { setLoading(false); message.error(`${info.file.name} 上传失败.`); } };
const uploadButton = ( <button style={{ border: 0, background: "none" }} type="button"> {loading ? <LoadingOutlined /> : <PlusOutlined />} <div style={{ marginTop: 8 }}>上传头像</div> </button> );
return ( <div className="max-w-md mx-auto p-8 bg-white rounded-lg shadow-lg"> <h3 className="text-xl font-bold mb-4 text-center">用户头像上传</h3> <Flex gap="middle" justify="center"> <Upload name="file" // 注意:这里的 name 需要和服务端 multer 匹配,我们的中间件是 'file' listType="picture-card" className="avatar-uploader" showUploadList={false} action="http://localhost:3001/api/upload" beforeUpload={beforeUpload} onChange={handleChange} > {imageUrl ? ( <img src={imageUrl} alt="avatar" style={{ width: "100%" }} /> ) : ( uploadButton )} </Upload> <Upload name="file" // 注意:这里的 name 需要和服务端 multer 匹配,我们的中间件是 'file' listType="picture-circle" className="avatar-uploader" showUploadList={false} action="http://localhost:3001/api/upload" beforeUpload={beforeUpload} onChange={handleChange} > {imageUrl ? ( <img src={imageUrl} alt="avatar" style={{ width: "100%", height: "100%", objectFit: "cover" }} /> ) : ( uploadButton )} </Upload> </Flex> </div> ); };
export default AvatarUploadDemo;
|
代码校正:请注意,我们的 upload-middleware.js
中使用的是 upload.single('file')
,因此为了匹配,Upload
组件的 name
属性理论上应该设为 "file"
。不过,由于 multer
并不严格校验 name
属性(它主要关心 Content-Disposition
header 中的字段名),所以即使设为 "avatar"
也能工作。但在生产环境中,保持前端 name
与后端 multer
字段名一致是最佳实践。
在 App.tsx
中切换到新 Demo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| import AvatarUploadDemo from './components/demos/AvatarUploadDemo'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8 flex items-center justify-center"> {/* <BasicUploadDemo /> */} <AvatarUploadDemo /> </div> </ConfigProvider> ); };
export default App;
|
这个进阶案例是 Upload
组件定制化能力的绝佳体现。通过组合 listType
, showUploadList
, beforeUpload
和 onChange
,我们完全掌控了组件的外观和行为,并实现了从客户端校验到服务端存储、再到真实 URL 回显的完整闭环。
第五步:交互革新 - 优雅的拖拽上传
对于需要上传大量文件或较大文件的场景,拖拽无疑是比点击选择更高效、更现代的交互方式。Ant Design 为此提供了开箱即用的解决方案:Upload.Dragger
。
目标:创建一个支持点击和拖拽两种方式,且能同时上传多个文件的大尺寸上传区域。
文件路径: src/components/demos/DragUploadDemo.tsx
(新建文件)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| import React from "react"; import { InboxOutlined } from "@ant-design/icons"; import type { UploadProps } from "antd"; import { message, Upload } from "antd";
const { Dragger } = Upload;
const DragUploadDemo: React.FC = () => { const props: UploadProps = { name: "file", multiple: true, action: "http://localhost:3001/api/upload", onChange: (info) => { const { status } = info.file; if (status === "done") { message.success(`${info.file.name} file uploaded successfully.`); } else if (status === "error") { message.error(`${info.file.name} file upload failed.`); } }, onDrop(e) { console.log("Dropped files", e.dataTransfer.files); }, };
return ( <div className="w-[500px] mx-auto p-8 bg-white rounded-lg shadow-lg"> <h3 className="text-xl font-bold mb-4 text-center">拖拽上传</h3> <Dragger {...props}> <p className="ant-upload-drag-icon"> <InboxOutlined /> </p> <p className="ant-upload-text">点击或拖拽文件到此区域以上传</p> <p className="ant-upload-hint"> 支持单个或批量上传。严禁上传公司内部资料及其他违禁文件。 </p> </Dragger> </div> ); };
export default DragUploadDemo;
|
在 App.tsx
中切换到新 Demo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| import DragUploadDemo from './components/demos/DragUploadDemo'; import { ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import 'dayjs/locale/zh-cn';
const App: React.FC = () => { return ( <ConfigProvider locale={zhCN}> <div className="bg-gray-100 min-h-screen p-8 flex items-center justify-center"> {/* <AvatarUploadDemo /> */} <DragUploadDemo /> </div> </ConfigProvider> ); };
export default App;
|
从 Upload
到 Dragger
的切换成本极低。你只需要将 <Upload>
标签换成 <Dragger>
,并传入完全相同的 props
,即可瞬间为你的应用赋予专业级的拖拽上传能力。这正是 Ant Design 组件化设计思想的魅力所在。
通过以上几步,我们已经掌握了 Upload
组件从基础到高级的核心用法。无论是简单的文件提交,还是定制化的头像上传,亦或是现代化的拖拽交互,Upload
都为我们提供了强大而灵活的武器。