编辑器契约与表单字段绑定
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 时,如果编辑器公开了 status 和 helperText,绑定会把错误直接投影到编辑器。
RenderCheckbox 等没有内置错误文案区域的控件应传 presenter,或者由页面用 form.errors 自己展示。
DataGrid 不是一个普通字段编辑器。它有多个活动行、单元格编辑器、行结构变更和自己的错误定位,应使用 DataGridEditSession 与 bindDataGridToForm() 作为复合组件接入,不要把整份 rows 数组塞进单个 bindField()。完整用法见编辑会话中的 DataGrid 章节。
ValueEditor 契约
内置表单编辑器通过结构化 ValueEditor<TValue> 契约参与绑定。最小能力包括:
| 成员 | 含义 |
|---|---|
getValue() |
读取编辑器当前正式值。 |
setValue(value) |
静默写入模型值;不得发布用户值变化。 |
subscribeValueChange(listener) |
订阅编辑器发布的正式值变化。配置归一化事件会标记 userInitiated: false。 |
subscribeBlur(listener) |
订阅编辑器整体失焦。 |
requestFocus() / blur() |
进入和离开编辑焦点。 |
disabled / isFocused |
基础可用和焦点状态。 |
可选能力按组件实际交互提供:
readonlystatushelperTextcommitEdit()cancelEdit()clear()
值事件包含 value、reason 和可选 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();返回值是幂等的释放函数。