背景(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 }}><Selectvalue={value?.id}onChange={(val) => {const selected = options.find((item) => item.id === val);onChange?.(selected);}}loading={loading}style={{ flex: 1 }}placeholder="請選擇活動"allowClearshowSearchoptionFilterProp="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><Modaltitle={editItem ? '編輯活動' : '新增活動'}open={modalVisible}onCancel={() => setModalVisible(false)}onOk={handleOk}destroyOnClose><Form form={form} layout="vertical" preserve={false}><Form.Itemlabel="活動名稱"name="name"rules={[{ required: true, message: '請輸入活動名稱' }]}><Input /></Form.Item><Form.Itemlabel="活動 ID"name="id"rules={[{ required: true, message: '請輸入活動 ID' }]}><Input /></Form.Item><Form.Itemlabel="版本號"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><ActivitySelectorvalue={selected}onChange={setSelected}fetchOptions={fetchOptions}onCreate={handleCreate}onEdit={handleEdit}disabledIds={['act002']}defaultVersion="v2.0"/><Button onClick={() => openModal()}>🔧 外部控制打開彈窗</Button></div>);
};export default ParentPage;
如你有后續需求(如:分頁、遠程搜索、多選、自定義彈窗樣式或 MUI 替換),可以繼續在這個封裝基礎上擴展,我也可以幫你一步步完成。需要我繼續用 STAR 法則
補充筆記內容嗎?