Dify UI 表单契约深度解析:原生提交边界、值所有权与 Field/Fieldset 组合规范
本文基于 Dify 的独立 UI 包 @langgenius/dify-ui 中的表单契约文档 forms.md,系统讲解 Dify 前端表单原语的核心设计约束:提交边界(submit boundary)、值与状态所有权(value/state ownership)、字段与标签组合规则,以及分组控件(Fieldset)的正确写法。读完你可以掌握在 Dify 工作区中编写表单时的完整决策依据——何时用 Form、何时用原生 <form>、受控与非受控如何选型、每类控件该配哪种 Label 原语,并能直接对照源码(form/index.tsx、field/index.tsx 等)验证每一条契约。
定位:Form 原语是语义封装,不是表单状态框架
forms.md 开篇明确了 Dify UI 表单原语的定位:它们组合了 Base UI 的原生表单语义、字段无障碍关系和 Dify 设计系统样式,但不是表单状态管理或 schema 框架。这一点可以从包结构得到印证:
- package.json 中 Form 类原语的公开子路径为
./form、./field、./fieldset、./input、./input-group、./textarea、./checkbox、./checkbox-group、./radio-group、./number-field、./select、./slider、./switch(见 README.md 的 Primitives 表); - form/index.tsx 的实现就是对 Base UI
Form的直接再导出,仅透传onFormSubmit、聚合错误、actionsRef、validationMode等上游能力:
import { Form as BaseForm } from '@base-ui/react/form'
const Form = BaseForm
因此表单的状态、schema、服务端校验、重置行为都应留在应用层离表单最近的拥有者(owner)中,通过公开 props 传入原语,而不是替换原语的语义结构。
提交边界:一组控件一起提交就必须有真实的 <form>
文档的第一条硬性约束是:任何一组会一起保存或提交的控件都需要一个真实的 <form> 边界,不能把 Input 和一个只做点击的 Button 随意组合成"非正式表单"。
何时用 Form,何时保留原生 <form>
- 当 Dify UI 边界需要拥有 Base UI 结构化的
onFormSubmit值、聚合错误、actionsRef或validationMode时,使用Form——它渲染原生<form>; - 当提交与校验由其他表单库负责时,继续使用原生
<form>依然是正确做法; - 不要嵌套两个表单拥有者(do not nest form owners)。
按钮的 submit 语义
文档要求:在表单内的提交按钮上显式设置 type="submit",表单内的其他所有按钮保持 type="button"。
这条约束在 button/index.tsx 中可以直接看到依据——Button 组件的 type 参数默认值就是 'button':
function Button({
className,
variant,
size,
tone,
loading,
disabled,
focusableWhenDisabled,
type = 'button', // 默认为普通按钮,提交必须显式声明
children,
...props
}: ButtonProps) {
因此提交按钮必须写 <Button type="submit">Save</Button>,这也是 button/README.md 中 Button semantics 小节的同款约定。此外 loading 与 disabled 是两种不同的事实:前者表示"动作已触发、正在处理中"(内部映射为 disabled={disabled || loading} 并保持可聚焦),后者表示"动作当前不可用",调用方不应把同一 pending 状态重复传给两者。
值与状态所有权:Form 只管提交边界,不管每个字段的草稿
文档在 "Value and state ownership" 一节给出了明确的分工:Form 拥有的是上面的提交/校验边界,而不是每个字段的草稿(draft)。受控性的选择应独立于草稿存放位置,取决于"谁是当前值的真相来源":
- 应用侧 React 代码不需要拥有当前值时,优先使用
defaultValue(非受控); - 应用侧的渲染或协调必须在编辑期间拥有该值时,才使用
value+ change handler(受控); - 仅仅监听 change 事件、追踪 dirty 状态、或做原生/原语级校验,本身并不要求受控状态。
关于草稿的存放位置,文档给出了"最窄拥有者"原则:
- 应用代码应在生命周期与草稿匹配的最窄组件内拥有草稿;
- 值可以在本地受控而不上提(lift);
- 非受控字段同样可以参与持久化工作流——只要该工作流在显式的持久化边界上捕获它的值;
- 外层界面定义挂载生命周期,而 owner 的放置位置决定了草稿状态位于该生命周期之内还是之外。
从源码结构看,这一分工是成立的:Form 只是 Base UI Form 的薄封装(见 form/index.tsx),Input 等控件也只是 Base UI 控件加 Dify 设计令牌样式(见 input/index.tsx),包内不存在任何 form state/schema 层的依赖,状态逻辑自然落在应用层。
Fields 与 Labels:每个控件配对的标签原语不同
当控件需要共享 name、label、校验、描述或错误状态时,使用 Field;单独的 Input 可以用原生 <label htmlFor> 关系,但常规表单行应优先使用可见 label。FieldDescription 与 FieldError 提供对应的无障碍消息关系。
Field 原语族的实现
field/index.tsx 导出了 Field、FieldItem、FieldLabel、FieldDescription、FieldError、FieldValidity 六个原语,全部基于 Base UI Field 的对应子部件,并统一叠加 Dify 类名:
function Field({ className, ...props }: FieldProps) {
return <BaseField.Root className={cn('group/field grid min-w-0 gap-1', className)} {...props} />
}
function FieldError({ className, ...props }: FieldErrorProps) {
return (
<BaseField.Error
className={cn('py-0.5 body-xs-regular text-text-destructive', className)}
{...props}
/>
)
}
其中 FieldLabel 与 FieldError 的样式分别来自 form-control-shared.ts 的共享常量,保证同一表单族内标签与错误文案的视觉一致性:
export const formLabelClassName =
'w-fit py-1 text-text-secondary system-sm-medium data-disabled:cursor-not-allowed'
data-disabled:cursor-not-allowed 这类类名也解释了为什么分组状态可以"穿透":标签通过 data- 属性感知所在 fieldset 的禁用态,而不是自己管理交互状态。
按控件类型选择 label 原语
文档给出了一张精确的选择表,这是最容易写错的部分:
| 控件 | 应使用的标签原语 |
|---|---|
文本类输入、Textarea、基于 input 的 Combobox 与 Autocomplete、单个 Checkbox、每个 Radio 选项、Switch、NumberField |
FieldLabel |
基于 trigger 的 Select |
SelectLabel |
Slider |
SliderLabel;仅多滑块(multi-thumb)需要为每个 thumb 增加 aria-label 以区分 |
| 弹层内容内的选项分组 | SelectGroupLabel、AutocompleteGroupLabel——它们是选项组标签,不是字段标签 |
在 select/index.tsx 中可以看到这一区分的实现:SelectLabel 复用 formLabelClassName(字段级标签样式),而 SelectGroupLabel 使用弹层专用的 floatingGroupLabelClassName,两者类名完全不同,混用会导致视觉语义错位。
InputGroup:前缀/后缀/动作共享输入表面
当 prefix、suffix 或 action 需要与输入框共享视觉表面时使用 InputGroup;没有共享内容时用独立 Input。input-group/README.md 补充了更细的契约:
- 组合恰好一个直接
InputGroupInput加一个或多个InputGroupAddon,且InputGroupInput必须位于 DOM 中所有 addon 之前;视觉定位用align="inline-start"/align="inline-end",不要靠 DOM 顺序调整; InputGroup拥有边框、背景、焦点态与非交互指针表面;把需要共享 name/label/校验/错误状态的 group 包进Field,字段状态会传播到InputGroupInput,不要在 addon 上复制这些状态;- addon 中的交互内容要放进语义化的
Button、IconButton或链接;点击组的非交互表面会聚焦其直接 input,也可在InputGroup的onMouseDown中调用preventDefault()取消该行为。
分组控件:Fieldset 拥有语义,不拥有交互状态
当一个字段包含多个相关控件(checkbox 组、radio 组、多滑块、相关输入区)时,使用 Fieldset + FieldsetLegend;每个 checkbox/radio 选项用 FieldItem 包裹并赋予自己的标签。文档给出的标准示例(可直接复制):
<Field name="allowedNetworkProtocols">
<Fieldset render={<CheckboxGroup />}>
<FieldsetLegend>Allowed network protocols</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2">
<Checkbox value="https" />
HTTPS
</FieldLabel>
</FieldItem>
</Fieldset>
</Field>
要点拆解:
Fieldset拥有分组语义与 legend 关系,不拥有交互状态。disabled、value、defaultValue和 change handler 要传给 group 原语本身(本例通过render={<CheckboxGroup />}让Fieldset渲染为 Base UICheckboxGroup)。fieldset/index.tsx 的实现印证了这一点:Fieldset.Root仅叠加'm-0 min-w-0 border-0 p-0'这类重置类名,交互全部来自render出的 group 原语;checkbox-group/index.tsx 则是 Base UICheckboxGroup的直通封装。- 每个 radio 都必须属于某个
RadioGroup:用FieldsetLegend命名分组、FieldLabel命名每个选项,不要渲染独立的Radio。 - 表单状态、schema、服务端校验与重置行为保持在原语内部之外——放在需要该生命周期的最近应用拥有者中,通过公开 field/control props 传入,而不是替换语义结构。
在仓库中验证与扩展
- 每个原语都有独立测试与 Story 文件,如 form/tests/index.spec.tsx、field/tests/index.spec.tsx、fieldset/tests/index.spec.tsx,可对照行为断言理解契约边界;
- 包命令定义在 package.json 的 scripts 中:
storybook、test(unit 项目)、type-check等,配合 Storybook stories(各原语目录下的index.stories.tsx)可直接观察组件在不同状态(normal/hover/focus/invalid/disabled)下的表现; - 跨组件契约文档同目录还包括 selection.md、accessible-names-and-descriptions.md、styling.md 等,表单开发涉及选择控件或可访问命名时应一并查阅。
小结
Dify UI 的表单契约可以浓缩为四条决策规则:
- 一起提交就加
<form>:需要 Base UI 结构化提交值时用Form,外部表单库负责时用原生<form>,绝不嵌套表单拥有者;提交按钮显式type="submit",其余按钮保持type="button"。 - Form 管边界,owner 管草稿:应用代码在生命周期匹配的最窄组件内拥有值,默认非受控(
defaultValue),仅当渲染/协调必须拥有值时才受控。 - label 按控件选原语:文本类/Checkbox/Radio/Switch/NumberField 用
FieldLabel,trigger 型Select用SelectLabel,Slider用SliderLabel,弹层内选项组才用SelectGroupLabel/AutocompleteGroupLabel。 - Fieldset 管语义,group 原语管状态:
disabled/value/change 一律传给 group 原语,选项用FieldItem+ 独立 label 包裹,radio 必须成组。
这套"薄封装 + 语义结构外置"的设计让表单逻辑始终留在可测试的应用层,而原语只负责稳定的无障碍与视觉契约——这正是 docs/forms.md 作为跨组件契约文档(而非单组件说明)存在的意义。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00