DirectSurface UIDirectSurface UI
开始使用
文档/业务指南

编辑器契约与表单字段绑定

import {  createValueEditorAdapter,  FormBindingBag,  FormSession,  RenderFieldPresenter,  RenderTextBox,  type ValueEditor,} from 'ds-ui'

FormSession 保存业务草稿,输入组件负责具体交互。FormBindingBag 用一条显式声明把两者接起来:

interface PatientDraft {  name: string} const form = new FormSession<PatientDraft>({ name: '' })const bindings = new FormBindingBag(form)const nameInput = new RenderTextBox({})const nameField = new RenderFieldPresenter({  label: '姓名',  required: true,  child: nameInput,}) bindings.bindField('name', nameInput, {  presenter: nameField,  rules: [value => value.trim() ? undefined : '姓名不能为空'],})

这不是按组件层级自动生成表单,也不是扫描 Render Tree。页面仍然明确写出 name 对应 nameInput,只是不用再为每个字段重复写值同步、失焦、错误投影和提交前收尾代码。

一次绑定处理什么

bindField() 默认负责:

  • FormSession 的当前值写入编辑器。
  • 把编辑器产生的正式值变化写回草稿并标记字段 touched。
  • 按 change、blur 触发字段校验。
  • 把当前字段的 error 或 warning 投影到 RenderFieldPresenter
  • 为首错定位调用编辑器的 requestFocus()
  • 在校验和提交前提交编辑器内尚未完成的输入。
  • resetField()reset()acceptChanges() 前取消编辑器内的暂存输入。

模型写入编辑器时调用的是静默 setValue(),不会伪造一条值变化事件,因此不会形成双向通知循环。大部分正式值变化来自用户操作;候选项或精度配置使当前值失效时,编辑器也可以发布一次 userInitiated: false 的 reconciliation 变化,让表单草稿和界面保持一致,但不把字段标记为 touched。

需要自己控制校验时机时:

interface PatientDraft {  name: string} const form = new FormSession<PatientDraft>({ name: '' })const bindings = new FormBindingBag(form)const nameInput = new RenderTextBox({})const nameField = new RenderFieldPresenter({  label: '姓名',  child: nameInput,}) bindings.bindField('name', nameInput, {  presenter: nameField,  triggers: ['blur', 'submit'],})

也可以用 validateOn 单独决定绑定层是否主动发起 change 或 blur 校验。

常用选项:

选项 行为
rules 当前字段的同步或异步校验规则。
triggers 规则允许在哪些时机运行;同时作为未单独设置 validateOn 时的自动校验时机来源。
validateOn 哪些编辑器事件会主动调用 validateField(),只接受 change 和 blur。
presenter 接收当前字段的 error 或 warning 状态和文案。
mapChangeToValues 把一个编辑器事件映射成多个草稿字段的一次修改。
valueEquals 判断模型值和编辑器归一化后的值是否逻辑相等。
writeBackNormalizedValue 默认 true;编辑器规范化外部值后,把正式值回写到 FormSession
pendingCommitMessage 编辑器暂存输入不能提交时显示的字段错误。
onValidationError 处理校验规则抛出的异常,例如交给页面日志或诊断服务。

triggers 回答“这条规则能不能在这个阶段运行”,validateOn 回答“这个编辑器事件要不要主动发起一次字段校验”,两者不要混为一个概念。

关联字段一次回填

LookupEdit 的值事件除了值本身,还带有当前候选行。页面可以把一次选择映射成一组字段修改:

interface MedicationDraft {  drugCode: string  drugName: string  specification: string  unit: string} interface MedicationRow {  code: string  name: string  specification: string  unit: string} const form = new FormSession<MedicationDraft>({  drugCode: '',  drugName: '',  specification: '',  unit: '',})const bindings = new FormBindingBag(form)const drugLookup = new RenderLookupEdit<MedicationRow>({  columns: [    { key: 'code', title: '编码', width: 100 },    { key: 'name', title: '药品名称' },    { key: 'specification', title: '规格', width: 120 },  ],  rows: [],  valueKey: 'code',  labelKey: 'name',})const drugField = new RenderFieldPresenter({  label: '药品',  child: drugLookup,}) bindings.bindField('drugCode', drugLookup, {  presenter: drugField,  mapChangeToValues: change => ({    drugCode: change.detail?.code ?? '',    drugName: change.detail?.name ?? '',    specification: change.detail?.specification ?? '',    unit: change.detail?.unit ?? '',  }),})

这组值通过一个 FormSession 批次写入。组件不用知道整份医嘱对象,页面也不用把业务回填逻辑拆到多个控件回调里。

编辑器内的暂存输入

数字、日期、时间等组件在用户输入过程中可能保存尚未提交的文本或分段值。实现了 commitEdit() 的编辑器会在以下动作之前同步完成提交:

  • validateField()
  • validate()
  • runSubmit()

validateField(field) 只刷新该字段绑定的编辑器,不会打断其他字段尚未完成的输入。跨字段规则此时读取的是其他字段已经写入草稿的正式值;validate()runSubmit() 才会在整表校验前刷新全部编辑器。没有暂存输入的编辑器应让 commitEdit() 直接成功,不产生额外变化。

如果当前文本不能转换成有效值,commitEdit() 返回 false,绑定会给该字段写入 pending-editor-value 错误,表单不会把旧值当成新输入提交。

如果业务随后通过 form.setValue() 写入了一个不同的正式值,绑定会让编辑器采用该值,并清除已经过期的 pending-editor-value

commitEdit() 必须同步完成。它只把编辑器内部暂存值刷新到表单草稿,不应在这里请求服务端;异步工作仍然放在校验规则或 runSubmit() 中。

取消和恢复采用相反方向:

  • resetField(field) 只取消对应字段的暂存输入。
  • reset()acceptChanges() 取消当前表单全部编辑器的暂存输入。
  • 随后 FormSession 再把恢复后的正式值静默写回编辑器。

校验信息放在哪里

表单字段通常传入 RenderFieldPresenter

interface MedicationDraft {  dose: number} const form = new FormSession<MedicationDraft>({ dose: 0 })const bindings = new FormBindingBag(form)const doseInput = new RenderNumberInput({ value: 0 })const doseField = new RenderFieldPresenter({  label: '单次剂量',  child: doseInput,}) bindings.bindField('dose', doseInput, {  presenter: doseField,  rules: [value => value > 0 ? undefined : '单次剂量必须大于 0'],})

这样错误状态同时覆盖标签、必填标识、输入区和提示文案。没有 presenter 时,如果编辑器公开了 statushelperText,绑定会把错误直接投影到编辑器。

RenderCheckbox 等没有内置错误文案区域的控件应传 presenter,或者由页面用 form.errors 自己展示。

DataGrid 不是一个普通字段编辑器。它有多个活动行、单元格编辑器、行结构变更和自己的错误定位,应使用 DataGridEditSessionbindDataGridToForm() 作为复合组件接入,不要把整份 rows 数组塞进单个 bindField()。完整用法见编辑会话中的 DataGrid 章节

ValueEditor 契约

内置表单编辑器通过结构化 ValueEditor<TValue> 契约参与绑定。最小能力包括:

成员 含义
getValue() 读取编辑器当前正式值。
setValue(value) 静默写入模型值;不得发布用户值变化。
subscribeValueChange(listener) 订阅编辑器发布的正式值变化。配置归一化事件会标记 userInitiated: false
subscribeBlur(listener) 订阅编辑器整体失焦。
requestFocus() / blur() 进入和离开编辑焦点。
disabled / isFocused 基础可用和焦点状态。

可选能力按组件实际交互提供:

  • readonly
  • status
  • helperText
  • commitEdit()
  • cancelEdit()
  • clear()

值事件包含 valuereason 和可选 detail。例如 LookupEdit 的 detail 是选中行;NumberInput 用 reason 区分提交和步进,步进事件的 detail 还会给出方向;部分编辑器也会用 reason 标识配置归一化。业务需要区分动作来源时订阅该事件;普通字段绑定只读取正式值。

当前可直接参与绑定的内置组件包括文本、密码、搜索、ButtonEdit、MaskedTextEdit、TextArea、NumberInput、DatePicker、DateRangeEdit、TimeEdit、TimeSpanEdit、Checkbox、Switch、RadioGroup、SegmentedControl、Slider、ColorPicker、ComboBox、LookupEdit、DropTreeEdit、DropTreeGridEdit、DropCheckTreeEdit、MultiSelectDropdown、CheckedComboBox 和 TokenEdit。

ColorPicker、LookupEdit 和三种树形下拉把“触发器 + 自有 popup”视为同一次逻辑焦点会话。焦点进入 popup 不会提前发布 blur;只有离开整个编辑器、显式调用 blur(),或把控件切换为 disabled/readonly 时才发布一次 blur。这样按 blur 校验的字段不会在用户刚打开候选列表时提前显示错误。

Upload 同时管理文件对象、上传任务、进度和取消状态,不按普通值编辑器处理,页面应根据业务 DTO 显式接入。

接入自定义编辑器

自研组件可以直接实现 ValueEditor<TValue>。已有组件不适合改继承关系时,可以使用 createValueEditorAdapter()

const legacyEditor = {  value: '',  disabled: false,  isFocused: false,  onInput: undefined as ((value: string) => void) | undefined,  onBlur: undefined as (() => void) | undefined,  focus(): void {    this.isFocused = true  },  blur(): void {    if (!this.isFocused) return    this.isFocused = false    this.onBlur?.()  },}const form = new FormSession({ name: '' })const bindings = new FormBindingBag(form) const adapter = createValueEditorAdapter<string, 'input'>({  value: {    get: () => legacyEditor.value,    set: value => { legacyEditor.value = value },  },  disabled: {    get: () => legacyEditor.disabled,    set: value => { legacyEditor.disabled = value },  },  getIsFocused: () => legacyEditor.isFocused,  requestFocus: () => legacyEditor.focus(),  blur: () => legacyEditor.blur(),}) legacyEditor.onInput = value => {  adapter.emitValueChange({ value, reason: 'input' })}legacyEditor.onBlur = () => adapter.emitBlur() bindings.bindField('name', adapter.editor)

适配器不拥有原组件。页面释放时既要释放字段绑定,也要释放适配器和组件本身。

数组和对象字段

MultiSelectDropdown、CheckedComboBox 和 TokenEdit 返回新数组。绑定层默认用逐项 Object.is() 判断数组值,避免模型同步时因数组副本反复写回。

FormSession 的 dirty 判断由 EditSession 决定。如果业务要求“选中后又恢复为相同集合”立即回到未修改状态,应在创建会话时配置字段相等函数:

interface TagDraft {  tagIds: string[]} const form = new FormSession<TagDraft>(  { tagIds: [] },  {    edit: {      equals: (field, left, right) => {        if (field !== 'tagIds') return Object.is(left, right)        const leftIds = left as string[]        const rightIds = right as string[]        return leftIds.length === rightIds.length          && leftIds.every((value, index) => value === rightIds[index])      },    },  },)

绑定层会逐字段浅比较普通对象,DateRange 这类扁平值不会因防御性副本被立即写回。嵌套对象、特殊 class 实例或不同的业务等价规则应通过 valueEquals 明确告诉绑定何时是同一个逻辑值,并在 EditSession 中配置对应 dirty 相等规则。

生命周期

绑定属于页面、弹窗或工作区文档,不应放到应用级单例:

class MedicationPage {  private readonly form = new FormSession({ dose: 0 })  private readonly bindings = new FormBindingBag(this.form)   dispose(): void {    this.bindings.dispose()    this.form.dispose()  }}

先释放 FormBindingBag,再释放 FormSession。这样字段注册、编辑参与者和事件订阅会先解除,组件关闭后不会继续写入已经释放的表单。

FormBindingBag 只管理表单字段接线,不是用于 store selector 的 core BindingBag。它不拥有 FormSession 或编辑器,释放 bag 不会替页面释放这两个对象。

如果只绑定一个字段,也可以直接使用 bindFormField();返回值是幂等的释放函数。

相关文档