背景(S - Situation):在某活动管理系统中,前端页面需要支持用户选择“要配置的当前活动”,并提供「新增」「编辑」功能,操作内容包括填写活动名称、ID、版本号等字段。原始实现逻辑分散、复用性差,不利于维护和功能拓展。
目标(T - Task):
封装一个通用的 ActivitySelector
组件,支持以下功能:
✅ 异步加载活动列表,支持 loading 状态;
✅ 支持新增 / 编辑活动信息,并自动更新下拉框内容;
✅ 支持禁用某些选项、设置默认值;
✅ 弹窗使用 antd.Modal
⚠️ 实现重难点总结
1. 组件对外暴露弹窗控制器(Context 模式)
❗ 难点在于:如何在父组件中控制子组件内部的 Modal 行为
采用 React 的
createContext
+useContext
创建全局控制器;内部封装了
openModal()
方法,供外部调用;父组件通过
useActivityModal()
获取控制器,实现跨组件通信;解耦了弹窗的触发逻辑,使组件更灵活、可扩展。
👉 适用于复杂业务流程、URL 参数触发弹窗、全局快捷操作等场景。
2. 异步数据加载与状态同步
初始加载通过
fetchOptions
动态获取活动列表;新增或编辑成功后自动刷新并回填选项;
异步处理流程中加入
loading
状态,保证用户体验;使用
Form.setFieldsValue()
动态填充表单数据。
3. 新增/编辑共用同一个 Modal + 表单
通过
editItem
区分是“新增”还是“编辑”状态;弹窗标题、确认逻辑复用,简化了 UI;
校验规则、默认值、字段配置均可灵活拓展。
4. 选项禁用处理 + 名称唯一性校验
支持传入
disabledIds
数组动态禁用某些选项;在表单提交时手动校验名称重复,防止业务错误;
校验逻辑抽离出来便于维护或拓展为服务端验证。
5. 默认版本号处理
若用户未填写版本号,自动填充为指定
defaultVersion
;避免每次用户手动输入,提高使用体验。
当然可以,咱来详细解释一下这句:
✅ 「对外暴露 context 控制器,支持父组件主动打开弹窗」是什么意思?
🔧 背景场景
我们在封装一个组件(比如 <ActivitySelector />
)时,通常 弹窗的打开/关闭是组件内部控制的,比如用户点击“新增”或“编辑”按钮时,组件内部去 setModalVisible(true)
打开弹窗。
但有时候你会希望 在组件外部 主动打开弹窗,例如:
有一个按钮在父组件中,点击它时希望直接打开弹窗(比如预设一个新活动);
希望根据某个外部逻辑(例如 URL 参数)控制弹窗的显示;
在表单联动或流程中,用户完成前一步操作后触发弹窗。
📦 如何实现?
这就需要组件对外暴露一个控制器(Controller),最常见的方式就是使用 React 的 Context + useContext
+ Provider
。
✅ 具体做法举例(摘自你项目中的代码)
// 创建一个 context(上下文对象)
const ActivityModalContext = createContext(null);
// 暴露一个 Hook,让外部能使用这个控制器
export const useActivityModal = () => useContext(ActivityModalContext);
然后在组件内部:
<ActivityModalContext.Provider value={{ openModal }}>
{/* 组件内容 */}
</ActivityModalContext.Provider>
这样父组件就可以写成这样:
import { useActivityModal } from './ActivitySelector';
function ParentComponent() {
const { openModal } = useActivityModal();
return <Button onClick={() => openModal({ id: 'xxx' })}>外部打开弹窗</Button>;
}
✅ 总结:这是什么意思?
「对外暴露 context 控制器」就是指:
封装组件时,借助 React 的 Context 机制,把内部的控制方法(如打开弹窗)暴露出去,让外部组件也能调用它。
🚀 好处
好处 | 说明 |
---|---|
📦 解耦 | 父组件无需知道 Modal 是怎么实现的,只要能打开它就行 |
🧩 灵活 | 可以在任何地方调用 openModal ,比如 URL 路由、定时器、其他组件事件等 |
👨💻 易于测试与复用 | 可以单独测试控制器逻辑,也可以跨页面/组件共享 |
✅ ActivitySelector.tsx
完整封装代码
import React, { useState, useEffect, createContext, useContext } from 'react';
import { Select, Modal, Form, Input, Button, message } from 'antd';
const { Option } = Select;
// --------- Context 控制器 ---------
const ActivityModalContext = createContext(null);
export const useActivityModal = () => useContext(ActivityModalContext);
// --------- 主组件封装 ---------
const ActivitySelector = ({
value,
onChange,
fetchOptions,
onCreate,
onEdit,
disabledIds = [],
defaultVersion = 'v1.0',
}) => {
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [modalVisible, setModalVisible] = useState(false);
const [editItem, setEditItem] = useState(null);
const [form] = Form.useForm();
// 初始化加载
useEffect(() => {
loadOptions();
}, []);
const loadOptions = async () => {
setLoading(true);
const data = await fetchOptions?.();
setOptions(data || []);
setLoading(false);
};
const openModal = (item = null) => {
setEditItem(item);
form.setFieldsValue(item || { version: defaultVersion });
setModalVisible(true);
};
const handleOk = async () => {
try {
const formData = await form.validateFields();
// 校验重复名称(或其他字段)
const isRepeat = options.some(
(item) => item.name === formData.name && item.id !== editItem?.id
);
if (isRepeat) {
message.error('活动名称重复,请重新输入');
return;
}
const result = editItem
? await onEdit?.(editItem.id, formData)
: await onCreate?.(formData);
await loadOptions();
onChange?.(result); // 自动选中新项
setModalVisible(false);
} catch (err) {
console.error('表单提交失败', err);
}
};
return (
<ActivityModalContext.Provider value={{ openModal }}>
<div style={{ display: 'flex', gap: 8 }}>
<Select
value={value?.id}
onChange={(val) => {
const selected = options.find((item) => item.id === val);
onChange?.(selected);
}}
loading={loading}
style={{ flex: 1 }}
placeholder="请选择活动"
allowClear
showSearch
optionFilterProp="children"
>
{options.map((item) => (
<Option key={item.id} value={item.id} disabled={disabledIds.includes(item.id)}>
{item.name}({item.version})
</Option>
))}
</Select>
<Button onClick={() => openModal()}>新增</Button>
<Button onClick={() => openModal(value)} disabled={!value}>
编辑
</Button>
</div>
<Modal
title={editItem ? '编辑活动' : '新增活动'}
open={modalVisible}
onCancel={() => setModalVisible(false)}
onOk={handleOk}
destroyOnClose
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item
label="活动名称"
name="name"
rules={[{ required: true, message: '请输入活动名称' }]}
>
<Input />
</Form.Item>
<Form.Item
label="活动 ID"
name="id"
rules={[{ required: true, message: '请输入活动 ID' }]}
>
<Input />
</Form.Item>
<Form.Item
label="版本号"
name="version"
rules={[{ required: true, message: '请输入版本号' }]}
>
<Input placeholder="如 v1.0" />
</Form.Item>
<Form.Item label="扩展信息" name="meta">
<Input.TextArea placeholder="可以是 JSON、备注等" />
</Form.Item>
</Form>
</Modal>
</ActivityModalContext.Provider>
);
};
export default ActivitySelector;
🧪 外部调用方式(使用 Context 控制器)
import React, { useState, useEffect } from 'react';
import ActivitySelector, { useActivityModal } from './ActivitySelector';
const ParentPage = () => {
const [selected, setSelected] = useState(null);
const { openModal } = useActivityModal();
const fetchOptions = async () => {
return [
{ id: 'act001', name: '暑期促销', version: 'v1.0', meta: '' },
{ id: 'act002', name: '双11预热', version: 'v1.2', meta: '' },
];
};
const handleCreate = async (data) => {
// 模拟添加后返回新数据项
return { id: 'act003', ...data };
};
const handleEdit = async (id, data) => {
return { id, ...data };
};
return (
<div>
<h2>活动管理</h2>
<ActivitySelector
value={selected}
onChange={setSelected}
fetchOptions={fetchOptions}
onCreate={handleCreate}
onEdit={handleEdit}
disabledIds={['act002']}
defaultVersion="v2.0"
/>
<Button onClick={() => openModal()}>🔧 外部控制打开弹窗</Button>
</div>
);
};
export default ParentPage;
如你有后续需求(如:分页、远程搜索、多选、自定义弹窗样式或 MUI 替换),可以继续在这个封装基础上扩展,我也可以帮你一步步完成。需要我继续用 STAR 法则
补充笔记内容吗?