第七章 第三节:Ant Design 数据录入篇 —— 深挖表单设计逻辑,教你用 Form/Input 组件高效解决录入场景问题


第七章 第三节:Ant Design 数据录入篇 —— 深挖表单设计逻辑,教你用 Form/Input 组件高效解决录入场景问题

7.9. 数据录入(一):基础输入组件

从本节开始,我们正式进入 Ant Design 的核心——数据录入。我们将遵循“按难度分级”的原则,首先从最基础、最高频的输入组件入手。

本节核心目标:通过一个独立的“用户注册”场景,掌握处理文本 (Input)、数字 (InputNumber)、布尔值 (Switch) 和单选/多选 (Radio/Checkbox) 这五类基础组件,并彻底理解它们背后共通的“受控模式”。

img

文件路径: 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 = () => {
// 1. 为每个录入项创建一个独立的 state
// 姓名
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;

本节核心知识点

  • 万能的“受控模式”: 我们通过实践证明,无论是 Inputvalue,还是 Switchchecked,所有基础录入组件都共享同一个核心模式:useState 定义状态,通过 value/checked 绑定状态,通过 onChange 更新状态。掌握这一点,比记住几十个 API 都重要。
  • 组件选型:
    • 需要用户输入任意文本/数字时,使用 Input/InputNumber
    • 需要在 多个互斥选项中选择一个 时,使用 Radio.Group
    • 需要在 多个选项中选择零个或多个 时,使用 Checkbox.Group
    • 需要用户进行 是/否 的二元选择时,使用 Switch

🤔 思考与探索:让 Switch 更具表现力

在我们的 Demo 中,Switch 只是一个简单的开关。但在很多场景下,我们希望开关在“开”和“关”的状态下,能显示不同的文字或图标,例如“开/关”、“启用/禁用”、“✓/✕”。

问题:查阅 Ant Design 的 Switch 组件文档,您能找到是哪个属性可以实现这个功能吗?请尝试修改上面的 Demo,让 Switch 在开启时显示“同意”,关闭时显示“未同意”。

答案是使用 checkedChildrenunCheckedChildren 属性。

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)组件

在上一节中,我们掌握了处理文本、数字和布尔值的基础输入组件。现在,我们将“难度”升级,来学习“选择器”家族。这类组件的核心场景是:当用户的输入不是自由的文本,而是需要从一个预设的、结构化的数据集中进行选择时,例如选择一个城市、一个日期或一个评分。

本节核心目标:通过一个独立的“创建活动”场景,掌握 SelectDatePickerTimePickerSliderRateColorPicker 的用法。我们将重点观察,上一节学到的“受控模式”,是如何无缝适配这些组件更丰富的数据类型的。

文件路径: src/components/demos/CreateEventForm.tsx

img

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 = () => {
// 1. 为每个选择器创建 state,注意它们的数据类型各不相同
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';
// DatePicker/TimePicker 使用 dayjs,需确保全局国际化配置正确
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;

本节核心知识点

  • “受控模式”的万能适应性: 我们再次验证了“受控模式”的强大。尽管 Selectvalue 是字符串数组,DatePickervalueDayjs 对象,但“用 state 控制 value,用 onChange 更新 state”的核心思想完全不变。
  • 处理 Dayjs 对象: DatePickerTimePickervalueonChange 回调参数都是 Dayjs 对象,而不是字符串。Dayjs 是一个功能强大的日期库,我们可以用它进行格式化 (.format('YYYY-MM-DD'))、计算等各种操作。
  • 数据驱动的 options: 对于 Select 这类选项繁多的组件,始终推荐使用 options 属性传入一个数组来生成选项,这让代码更清晰,也便于动态从服务器获取选项数据。

🤔 思考与探索:如何实现“远程搜索”功能?

在我们的 Demo 中,“参会人员”列表是硬编码的。在一个真实的应用中,用户列表可能非常庞大(成千上万),一次性加载所有用户到前端会让页面崩溃。

问题:我们如何改造 Select 组件,让它在用户输入文字时,才动态地向服务器发送请求,获取并展示匹配的用户列表?

答案在于 Select 组件的几个关键“搜索”属性的组合使用:

  1. showSearch: 必须设置为 true,以启用搜索功能。
  2. onSearch: 提供一个回调函数。当用户在搜索框中输入时,此函数会被触发,并携带用户输入的文本。这是我们 发起 API 请求 的入口。
  3. filterOption: 将其设置为 false。因为我们的选项是动态从远程获取的,需要禁用 Ant Design 的默认前端筛选逻辑。
  4. loading: 在我们等待 API 返回数据的期间,将此属性设为 trueSelect 会显示一个加载指示器,提升用户体验。
  5. 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);
// 模拟 API 请求
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(重点)

在前两级中,我们掌握了处理基础数据和预设选项的组件。现在,我们将面临企业级开发中最真实的挑战:处理层级嵌套的复杂数据,并从服务器 异步加载 选项。

本节将是一次全方位的升级,我们将依次完成:

  1. 环境升级:为项目集成 Tailwind CSS,告别行内样式。
  2. 工具引入:学习使用 json-serverfaker-js 搭建专业级的 Mock API 服务器。
  3. 实战演练:通过“增量构建”的方式,一步步完成一个集成了多个高级选择器的“内容发布”表单。

7.11.1. AutoComplete:从零搭建远程搜索

img

第一步:为何选择 AutoComplete?(何时使用)

当我们需要一个 输入框,但又希望在用户输入时,能根据输入内容提供 建议列表 以供选择时,AutoComplete 就是最佳选择。它完美结合了 Input 的自由输入和 Select 的选择辅助。

  • 核心场景:搜索引擎、文章标签输入、收件人邮箱联想等。
  • Select 的区别Select 的核心是“选择”,用户通常只能在 限定的选项 中选择;而 AutoComplete 的核心是“辅助输入”,用户 可以输入任意内容,选项只是建议。

第二步:环境升级 - 集成 Tailwind CSS

为了让我们的组件样式更专业、可维护,我们首先为项目集成 Tailwind CSS

  1. 安装依赖

    1
    pnpm add -D tailwindcss @tailwindcss/vite
  2. 配置 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(),
    ],
    })
  3. 在主 CSS 文件中引入 Tailwind 指令

    文件路径: src/index.css

    1
    @import "tailwindcss";

    现在,我们的项目已成功集成 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';

// 1. 只创建生成标签数据的函数
const generateTags = (count) => {
const tags = [];
for (let i = 0; i < count; i++) {
tags.push({
id: i + 1,
name: faker.food.adjective()
});
}
return tags;
}

// 2. db.json 中暂时也只包含 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 服务器就启动了!

第四步:前置知识 - useCallbackdebounce

在编写核心逻辑之前,我们必须先理解两个关键的工具: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 (
// antd 国际化配置
<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 的“基因”——一个由 valuelabelchildren 构成的递归嵌套结构。

目标:创建一个独立的“商品分类”选择器。

文件路径: src/components/demos/StaticCascaderDemo.tsx (新建文件)

img

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;

// 1. 定义符合 Cascader 要求的嵌套数据结构
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) => {
// `value` 是一个路径数组, e.g., ['electronics', 'phones', 'iphone-15']
console.log('Selected Path:', value);
// `selectedOptions` 是包含完整节点对象的路径数组
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,且字段名几乎不可能是 valuelabel。接下来,我们将解决这个核心痛点。

第二步:实战进阶 - 适配 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;

// 1. 定义与 API 返回结构完全一致的 TypeScript 接口
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 当作 valuecatName 当作 labelsubCategories 当作 children”。这让我们无需在前端手动遍历和转换整个数据树,极大简化了代码。

7.11.4. Mentions: 在文本中实现智能提及

在现代协作和社交应用中,我们经常需要在文本输入框中 @ 某个用户或 # 某个话题。这种功能不仅能精准地通知相关人员,还能将非结构化的文本与应用内的实体(用户、项目、标签)关联起来。Ant Design 的 Mentions 组件就是为实现这一功能而生的专业工具。

img

核心价值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"]>([]);
// 1. 核心搜索逻辑,使用 debounce 防止高频请求
const loadUser = async (search: string) => {
setLoading(true);

try {
// 如果没有搜索词,获取所有用户;否则使用 q 参数进行全文搜索
const url = !search
? `http://localhost:3001/users`
: `http://localhost:3001/users?q=${search}`;

const res = await fetch(url);
const users = await res.json();

// 2. 将 API 返回的数据映射为 Mentions 需要的格式
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);
};

// 3. 使用 useCallback 和 debounce 包装搜索函数
const debouncedLoadUsers = useCallback(debounce(loadUser, 800), []);

const onSearch: MentionProps["onSearch"] = (search) => {
// onSearch 在用户输入 @ 后触发,我们将调用防抖后的搜索函数
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 (新建文件)

img

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;

// 1. 定义我们的权限树数据结构
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:

img

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';

// ... (permissionTreeData 保持不变)

const PermissionSelectorDemo: React.FC = () => {
// 1. 将 state 初始化为数组,以支持多选
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
// ... 其他 props
showCheckedStrategy={TreeSelect.SHOW_CHILD}
/>

效果: 勾选“用户管理”后,选择框中显示的是 [用户列表, 角色分配]

策略: 优先显示 父节点。只有当一个父节点下的所有子节点都被选中时,才将它们合并为父节点进行显示。如果只选中了部分子节点,则依然显示这些子节点。

场景: 当您希望权限表示更概括、更简洁时。例如,如果用户拥有了用户管理下的所有权限,直接显示一个 “用户管理” 标签,比显示一长串子权限更清晰。

代码:

1
2
3
4
<TreeSelect
// ... 其他 props
showCheckedStrategy={TreeSelect.SHOW_PARENT}
/>

效果: 勾选“用户管理”后,选择框中只显示 [用户管理]

策略: 显示 所有 被选中的节点,无论父子。

场景: 极为少见,通常只在需要完整回溯所有选中状态时使用。大部分情况下会造成信息冗余。

代码:

1
2
3
4
<TreeSelect
// ... 其他 props
showCheckedStrategy={TreeSelect.SHOW_ALL}
/>

效果: 勾选“用户管理”后,选择框中会显示 [用户管理, 用户列表, 角色分配]

第四步:终极解耦 - 严格模式 (treeCheckStrictly)

默认的父子联动勾选行为,在某些严格的权限场景下会成为阻碍。例如:

痛点:我想给某个角色“用户管理”这个模块的 访问权限(即勾选父节点),但不给他下面任何具体的 操作权限(即不勾选任何子节点)。

在默认模式下,这是不可能的。一旦勾选父节点,子节点也会被勾选。treeCheckStrictly 就是为了解决这个问题而生的。

修改 PermissionSelectorDemo.tsx:

img

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
// 1. 需要从 antd 导入类型
import type { DefaultOptionType } from "antd/es/select";

// ... (permissionTreeData 保持不变)

const PermissionSelectorDemo: React.FC = () => {
// 2. 启用 treeCheckStrictly 后,value 的类型会变为对象数组
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 的行为会发生根本性改变:

  1. 父子解绑: 勾选父节点不再影响子节点,反之亦然。每个节点的勾选状态都是完全独立的。
  2. 强制 labelInValue: 组件会自动将 labelInValue 设为 true。这意味着 valueonChange 的值不再是简单的 string[],而是 [{ value: string, label: ReactNode }] 格式的对象数组。您的 useState 和类型定义必须做出相应调整。

通过这四步的层层递进,我们不仅学会了 TreeSelect 的基础用法,更深入理解了其在多选场景下的核心配置 showCheckedStrategytreeCheckStrictly。掌握了它们,您就能够从容应对任何复杂的树形数据选择需求。


7.11.6. Upload: 实现专业级文件上传

至此,我们已经征服了各类“选择”型输入组件。现在,我们将进入数据录入的另一大核心领域:文件上传。无论是发布文章的封面、上传用户头像,还是提交附件,Upload 组件都是任何现代应用不可或缺的一环。它将复杂的文件处理流程——从选择、预览到发送 HTTP 请求——封装成了一个优雅、可高度定制的交互界面。

第一步:备好“弹药” - 搭建 Mock 服务 (CLI 模式)

Upload 组件需要一个后端接收文件。为了模拟这个过程,我们将使用 json-server 的命令行工具,并为其提供一个“中间件”脚本来专门处理文件上传。这种方式比编写完整的服务器脚本更简单。

1. 安装依赖
我们需要 multer 来帮助我们解析上传的文件。

1
pnpm add -D 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');

// 确保 uploads 文件夹存在
const uploadDir = 'uploads';
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir);
}

// 配置 multer 用于文件存储
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) => {
// 1. 检查请求是否是我们的上传请求
if (req.method === 'POST' && req.path === '/api/upload') {
// 2. 如果是,使用 multer 处理它
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.' });
}
// 3. 返回成功的响应,注意 URL 路径
res.status(200).json({
status: 'success',
message: 'File uploaded successfully!',
// CLI 模式下,--static 会将文件服务到根路径
url: `http://localhost:3001/${req.file.filename}`,
});
});
} else {
// 4. 如果不是上传请求,交给 json-server 继续处理
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 生命周期

  1. 文件选择:用户通过点击或拖拽选择了文件。
  2. beforeUpload 拦截:在客户端进行文件类型、大小的校验。
  3. 发起请求:组件向 action URL (http://localhost:3001/api/upload) 发起 POST 请求。
  4. onChange 状态更新file.status 依次变为 uploading -> doneerror
  5. 服务端响应:我们的 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', // 必须与后端 multer 的 'file' 字段名一致
action: 'http://localhost:3001/api/upload', // 指向我们自己的 Mock API
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} 文件上传成功`);
// 服务端返回的 URL 在 info.file.response.url
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 功能扩展的最佳实践之一。


第四步:体验升级 - 实现用户头像上传

基础的文件列表样式显然不适合“用户头像”这种场景。我们需要一个方形或圆形的区域,点击后上传,成功后直接显示图片预览。

痛点:如何隐藏默认的文件列表,并在上传成功后将组件本身变为图片预览区?如何在前端就拦截掉不符合要求的文件?

解决方案

  1. 使用 listType="picture-card"listType="picture-circle" 改变组件外观。
  2. 设置 showUploadList={false} 来完全接管 UI 展示。
  3. 利用 beforeUpload 钩子在客户端进行文件类型和大小的校验。
  4. (核心变更)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";

// antd 的工具类型,用于精确获取 `beforeUpload` 的 file 类型
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") {
// **核心变更**: 不再使用 getBase64 本地预览
// 直接从服务器的响应中获取 URL 并更新 imageUrl state
const serverUrl = info.file.response?.url;
if (serverUrl) {
setImageUrl(serverUrl);
message.success(`${info.file.name} 上传成功`);
} else {
// 如果服务器没有返回 url,则认为上传失败
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 BasicUploadDemo from './components/demos/BasicUploadDemo';
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, beforeUploadonChange,我们完全掌控了组件的外观和行为,并实现了从客户端校验到服务端存储、再到真实 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 AvatarUploadDemo from './components/demos/AvatarUploadDemo';
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;

UploadDragger 的切换成本极低。你只需要将 <Upload> 标签换成 <Dragger>,并传入完全相同的 props,即可瞬间为你的应用赋予专业级的拖拽上传能力。这正是 Ant Design 组件化设计思想的魅力所在。

通过以上几步,我们已经掌握了 Upload 组件从基础到高级的核心用法。无论是简单的文件提交,还是定制化的头像上传,亦或是现代化的拖拽交互,Upload 都为我们提供了强大而灵活的武器。