首页
/ Dify UI 表单契约深度解析:原生提交边界、值所有权与 Field/Fieldset 组合规范

Dify UI 表单契约深度解析:原生提交边界、值所有权与 Field/Fieldset 组合规范

2026-09-06 17:05:56作者:袁立春Spencer

本文基于 Dify 的独立 UI 包 @langgenius/dify-ui 中的表单契约文档 forms.md,系统讲解 Dify 前端表单原语的核心设计约束:提交边界(submit boundary)、值与状态所有权(value/state ownership)、字段与标签组合规则,以及分组控件(Fieldset)的正确写法。读完你可以掌握在 Dify 工作区中编写表单时的完整决策依据——何时用 Form、何时用原生 <form>、受控与非受控如何选型、每类控件该配哪种 Label 原语,并能直接对照源码(form/index.tsxfield/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、聚合错误、actionsRefvalidationMode 等上游能力:
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 值、聚合错误、actionsRefvalidationMode 时,使用 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 小节的同款约定。此外 loadingdisabled 是两种不同的事实:前者表示"动作已触发、正在处理中"(内部映射为 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> 关系,但常规表单行应优先使用可见 labelFieldDescriptionFieldError 提供对应的无障碍消息关系。

Field 原语族的实现

field/index.tsx 导出了 FieldFieldItemFieldLabelFieldDescriptionFieldErrorFieldValidity 六个原语,全部基于 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}
    />
  )
}

其中 FieldLabelFieldError 的样式分别来自 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 的 ComboboxAutocomplete、单个 Checkbox、每个 Radio 选项、SwitchNumberField FieldLabel
基于 trigger 的 Select SelectLabel
Slider SliderLabel;仅多滑块(multi-thumb)需要为每个 thumb 增加 aria-label 以区分
弹层内容内的选项分组 SelectGroupLabelAutocompleteGroupLabel——它们是选项组标签,不是字段标签

select/index.tsx 中可以看到这一区分的实现:SelectLabel 复用 formLabelClassName(字段级标签样式),而 SelectGroupLabel 使用弹层专用的 floatingGroupLabelClassName,两者类名完全不同,混用会导致视觉语义错位。

InputGroup:前缀/后缀/动作共享输入表面

当 prefix、suffix 或 action 需要与输入框共享视觉表面时使用 InputGroup;没有共享内容时用独立 Inputinput-group/README.md 补充了更细的契约:

  • 组合恰好一个直接 InputGroupInput 加一个或多个 InputGroupAddon,且 InputGroupInput 必须位于 DOM 中所有 addon 之前;视觉定位用 align="inline-start" / align="inline-end",不要靠 DOM 顺序调整;
  • InputGroup 拥有边框、背景、焦点态与非交互指针表面;把需要共享 name/label/校验/错误状态的 group 包进 Field,字段状态会传播到 InputGroupInput不要在 addon 上复制这些状态;
  • addon 中的交互内容要放进语义化的 ButtonIconButton 或链接;点击组的非交互表面会聚焦其直接 input,也可在 InputGrouponMouseDown 中调用 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>

要点拆解:

  1. Fieldset 拥有分组语义与 legend 关系,不拥有交互状态disabledvaluedefaultValue 和 change handler 要传给 group 原语本身(本例通过 render={<CheckboxGroup />}Fieldset 渲染为 Base UI CheckboxGroup)。fieldset/index.tsx 的实现印证了这一点:Fieldset.Root 仅叠加 'm-0 min-w-0 border-0 p-0' 这类重置类名,交互全部来自 render 出的 group 原语;checkbox-group/index.tsx 则是 Base UI CheckboxGroup 的直通封装。
  2. 每个 radio 都必须属于某个 RadioGroup:用 FieldsetLegend 命名分组、FieldLabel 命名每个选项,不要渲染独立的 Radio
  3. 表单状态、schema、服务端校验与重置行为保持在原语内部之外——放在需要该生命周期的最近应用拥有者中,通过公开 field/control props 传入,而不是替换语义结构。

在仓库中验证与扩展

小结

Dify UI 的表单契约可以浓缩为四条决策规则:

  1. 一起提交就加 <form>:需要 Base UI 结构化提交值时用 Form,外部表单库负责时用原生 <form>,绝不嵌套表单拥有者;提交按钮显式 type="submit",其余按钮保持 type="button"
  2. Form 管边界,owner 管草稿:应用代码在生命周期匹配的最窄组件内拥有值,默认非受控(defaultValue),仅当渲染/协调必须拥有值时才受控。
  3. label 按控件选原语:文本类/Checkbox/Radio/Switch/NumberField 用 FieldLabel,trigger 型 SelectSelectLabelSliderSliderLabel,弹层内选项组才用 SelectGroupLabel/AutocompleteGroupLabel
  4. Fieldset 管语义,group 原语管状态disabled/value/change 一律传给 group 原语,选项用 FieldItem + 独立 label 包裹,radio 必须成组。

这套"薄封装 + 语义结构外置"的设计让表单逻辑始终留在可测试的应用层,而原语只负责稳定的无障碍与视觉契约——这正是 docs/forms.md 作为跨组件契约文档(而非单组件说明)存在的意义。

登录后查看全文
热门项目推荐
相关项目推荐