尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

antd Form 自定义表单控件接入指南:value/onChange/ref 三大约定与源码实现剖析

antd Form 自定义表单控件接入指南:value/onChange/ref 三大约定与源码实现剖析 antd Form 自定义表单控件接入指南value/onChange/ref 三大约定与源码实现剖析【免费下载链接】ant-designAn enterprise-class UI design language and React UI library项目地址: https://gitcode.com/gh_mirrors/ant/ant-design自定义或第三方的表单控件如价格输入框、带单位的组合输入、时间选择器等也可以无缝接入 Ant Designantd的 Form 组件享受数据绑定、校验、联动等完整能力。本文以 antd 仓库中的自定义表单控件示例为蓝本从约定、实现、源码三个层面讲透「让任意 React 控件成为 Form 字段」的完整方案读完后你将能独立封装任何符合约定的第三方控件并理解其背后的绑定机制。一、接入三大约定value、onChange 与 ref 转发在 antd Form 官方文档 对应的自定义表单控件 demo 说明中给出了一个自定义控件接入 Form 必须遵循的三大约定这也是 rc-field-form 这类受控组件体系的通用接口提供受控属性value或其它与valuePropName的值同名的属性。提供onChange事件或trigger的值同名的事件。转发 ref 或者传递 id 属性到 dom 以支持scrollToField方法。逐条拆解如下受控值属性value/valuePropName控件必须能接收一个受控的「当前值」属性。默认情况下 Form.Item 会向子组件注入value如果控件使用的不是value例如 Switch、Checkbox 用的是checked则需要通过valuePropName指定实际属性名否则 Form 无法获取该组件的值。变更事件onChange/trigger控件必须在自己内部状态发生变化时调用一个事件把新值抛给 Form。默认事件名是onChange若控件使用其他事件名如onSelect、onInput可通过trigger指定。ref 转发 / id 传递为了支持表单的scrollToField滚动到指定字段与字段定位能力控件需要把 ref 转发到真实 DOM或在最外层 DOM 上接受并透传id属性。二、从零实现一个自定义控件PriceInput 完整源码解析customized-form-controls.tsx 给出了一个「价格输入」组合控件左侧是一个数字输入框右侧是一个币种下拉选择RMB / Dollar两者共同构成一个{ number, currency }对象作为字段值。它是演示三大约定的教科书式案例完整源码如下import React, { useState } from react; import { Button, Form, Input, Select } from antd; const { Option } Select; type Currency rmb | dollar; interface PriceValue { number?: number; currency?: Currency; } interface PriceInputProps { id?: string; value?: PriceValue; onChange?: (value: PriceValue) void; } const PriceInput: React.FCPriceInputProps (props) { const { id, value {}, onChange } props; const [number, setNumber] useState(0); const [currency, setCurrency] useStateCurrency(rmb); const triggerChange (changedValue: { number?: number; currency?: Currency }) { onChange?.({ number, currency, ...value, ...changedValue }); }; const onNumberChange (e: React.ChangeEventHTMLInputElement) { const newNumber parseInt(e.target.value || 0, 10); if (Number.isNaN(number)) { return; } if (!(number in value)) { setNumber(newNumber); } triggerChange({ number: newNumber }); }; const onCurrencyChange (newCurrency: Currency) { if (!(currency in value)) { setCurrency(newCurrency); } triggerChange({ currency: newCurrency }); }; return ( span id{id} Input typetext value{value.number || number} onChange{onNumberChange} style{{ width: 100 }} / Select value{value.currency || currency} style{{ width: 80, margin: 0 8px }} onChange{onCurrencyChange} Option valuermbRMB/Option Option valuedollarDollar/Option /Select /span ); };1. 受控属性定义PriceInputProps中定义了value?: PriceValue与onChange?: (value: PriceValue) void这正是 Form 注入受控状态所需的两个接口。注意这里的id?: string—— 它会被透传到最外层span id{id}从而满足第三条约定的「传递 id 属性到 DOM」要求。2. 内部状态与外部值的合并策略PriceInput内部用useState维护number和currency两个本地状态作为非受控兜底同时在渲染时优先使用外部传入的value输入框显示value.number || number外部有值用外部值否则用本地状态下拉框显示value.currency || currency同理。这里体现了受控组件封装的一个关键细节外部value可能只包含部分字段例如只改了币种时value里只有currency因此需要在triggerChange中做字段合并const triggerChange (changedValue: { number?: number; currency?: Currency }) { onChange?.({ number, currency, ...value, ...changedValue }); };即{ ...本地兜底值, ...外部最新值, ...本次变更值 }这样既能保留 Form 中已有的字段又不会丢失本地未受控部分的状态。3. 变更事件的向上抛送onNumberChange解析输入框字符串为数字后调用triggerChange({ number: newNumber })onCurrencyChange下拉框选中后调用triggerChange({ currency: newCurrency })。这两个内部事件最终都会汇聚到onChange由 Form 统一收集从而满足第二条约定。三、将自定义控件挂进 Form完整表单示例控件封装好后即可像使用内置组件一样把它放进Form.Itemconst App: React.FC () { const onFinish (values: any) { console.log(Received values from form: , values); }; const checkPrice (_: any, value: { number: number }) { if (value.number 0) { return Promise.resolve(); } return Promise.reject(new Error(Price must be greater than zero!)); }; return ( Form namecustomized_form_controls layoutinline onFinish{onFinish} initialValues{{ price: { number: 0, currency: rmb, }, }} Form.Item nameprice labelPrice rules{[{ validator: checkPrice }]} PriceInput / /Form.Item Form.Item Button typeprimary htmlTypesubmit Submit /Button /Form.Item /Form ); }; export default App;几个值得注意的要点initialValues注入初始值price字段的初始值是一个{ number: 0, currency: rmb }对象Form 会把它作为value传给PriceInput。在 Form 文档中明确说明被设置了name的Form.Item包裹后表单控件的默认值应使用initialValues设置而不是控件自身的defaultValuedefaultValue在受控 Field 上不生效。rules自定义校验这里用validator校验价格必须大于 0校验失败时错误信息会展示在Form.Item下方与内置组件行为完全一致。数据收集方式提交时onFinish收到的values中price即为PriceInput通过onChange抛出的完整PriceValue对象。四、绑定机制源码剖析Form.Item 如何注入受控 props要真正理解三大约定为什么有效需要看 FormItem/index.tsx 的实现。在InternalFormItem中trigger onChange,trigger的默认值就是onChangeFormItem/index.tsx与文档表格中的默认值一致。随后组件把 props 透传给底层FieldField {...props} messageVariables{variables} trigger{trigger} validateTrigger{mergedValidateTrigger} onMetaChange{onMetaChange} 在渲染子元素时Form 会做两件事合并受控 propsconst childProps { ...mergedChildren.props, ...mergedControl };FormItem/index.tsx其中mergedControl就是 rc-field-form 注入的value/onChange等受控属性会被克隆到子元素上。保留用户自定义事件并做转发Form 会把trigger与validateTrigger对应的事件名收集起来统一包装FormItem/index.tsxconst triggers new Setstring([ ...toArray(trigger), ...toArray(mergedValidateTrigger), ]); triggers.forEach((eventName) { childProps[eventName] (...args: any[]) { mergedControl[eventName]?.(...args); mergedChildren.props[eventName]?.(...args); }; });这意味着 Form不会覆盖你自定义控件上原有的onChange处理函数而是先调用 Form 的收集逻辑再调用你原有的处理器两者共存。id 与 ref 的注入当子元素没有id时Form 会补上fieldIdchildProps.id fieldId见 FormItem/index.tsx当子元素支持 ref 时Form 会注入childProps.ref getItemRef(...)FormItem/index.tsx为scrollToField提供 DOM 定位能力。五、灵活配置valuePropName、trigger 与 getValueProps/normalize三大约定中的两个「或」对应着Form.Item的两个常用配置项完整参数表见 Form 文档参数说明类型默认值valuePropName子节点的值的属性。注意Switch、Checkbox 的valuePropName应该是checked否则无法获取这两个组件的值。该属性为getValueProps的封装自定义getValueProps后会失效stringvaluetrigger设置收集字段值变更的时机stringonChangegetValueProps为子元素添加额外的属性不建议通过getValueProps生成动态函数 prop请直接将其传递给子组件(value: any) Recordstring, any-normalize组件获取值后进行转换再放入 Form 中。不支持异步(value, prevValue, prevValues) any-getValueFromEvent设置如何将 event 的值转换成字段值(..args: any[]) any-场景一控件值属性不是value如 Switch / CheckboxSwitch、Checkbox 这类组件的受控属性是checked而不是value直接放入Form.Item无法取到值需要显式声明Form.Item namefieldA valuePropNamechecked Switch / /Form.Item这也是Form.Item文档中特别强调的注意事项。场景二控件变更事件不叫onChange如果第三方控件的变更事件是onChange之外的名字例如onSelect、onPick通过trigger指定即可Form 会改为监听该事件收集值Form.Item namefieldB triggeronSelect ThirdPartyPicker / /Form.Itemtrigger的默认值在源码中被定义为onChangeFormItem/index.tsx这也是三大约定第二条的默认形态。场景三值需要转换后再入库normalize子组件把值抛给 Form 之前先做转换如把「大写城市名」统一转成小写存储getValueFromEvent把事件对象转换成字段值如从e.target.value取值getValueProps给子元素额外注入属性注意它会覆盖valuePropName的默认行为。这三种能力与本文的PriceInput组合控件并不冲突PriceInput自行完成了「内部多子控件 → 单一对象值」的聚合而normalize/getValueProps则适合在「值进出 Form 的边界」做统一加工。仓库中另有 getValueProps-normalize 示例 可参考。六、被 Form 接管后三个行为约束当控件被设置了name的Form.Item包裹后数据同步将完全由 Form 接管见 Form 文档这会带来三个直接影响自定义控件使用方式的行为不再需要也不应该用onChange做数据收集同步收集交给 Form但你可以继续监听onChange事件源码中 Form 会保留原有事件处理器见上文 triggers 包装逻辑。不能用控件的value或defaultValue设置表单域的值默认值用 Form 的initialValues设置注意initialValues不能被setState动态更新需要更新时用form.setFieldsValue。不应该用setState改表单值应使用form.setFieldsValue等 Form 实例方法。因此自定义控件的正确姿势是控件只负责「展示外部传入的 value 把内部变化通过 onChange 抛出去」状态的持有、默认值的下发、值的修改全部交给 Form 层。这也解释了为什么PriceInput内部虽然用了useState兜底但真正的数据源永远是 Form 注入的value。七、常见问题与排查思路1. 自定义控件值取不到 / 提交时字段为 undefined优先检查三点控件是否接收了value并把它渲染出来若控件用checked等属性需设置valuePropName控件内部状态变化时是否调用了onChange并把新值作为参数抛出Form.Item是否设置了name没有name的Form.Item只做布局不做数据绑定。2.scrollToField不生效scrollToField依赖字段 DOM 的定位见 Form 文档 API 表 中scrollToField条目。若自定义控件未转发 ref 也未透传idForm 无法找到目标 DOM。解决方式是让控件接受id并挂到最外层元素如PriceInput中的span id{id}或使用React.forwardRef把 ref 转发到真实 DOM。3. 校验不触发或事件不更新检查trigger与validateTrigger若控件的事件名不是onChange且 Form.Item 未设置triggerForm 将监听不到变更。同理校验时机默认也是onChange可通过validateTrigger调整。八、小结自定义表单控件接入 antd Form 的本质是遵循「受控值属性 变更事件 ref/id 定位」三大约定让任意组件成为 Form 数据域中的一个标准字段约定 1受控属性由valuePropName兜底适配非标准属性名约定 2变更事件由trigger兜底适配非标准事件名约定 3ref/id支撑scrollToField等 DOM 定位能力。从源码看FormItem/index.tsx 通过mergedControl合并受控 props、包装 trigger 事件、注入 id 与 ref 三步完成了从「任意 React 控件」到「受控表单字段」的桥接。掌握这套机制后无论第三方控件内部多复杂如本文的多输入组合控件都能用同样的模式快速接入并完整获得校验、联动、提交等 Form 的全部能力。【免费下载链接】ant-designAn enterprise-class UI design language and React UI library项目地址: https://gitcode.com/gh_mirrors/ant/ant-design创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表