一个复杂的动态表单是如何实现的

两年半前写过一篇《动态处理复杂数据结构的表单》,第一版只会两件事:把配置渲染成表单、实现双向绑定。

现在它跑在我的简历项目上,撑起整个编辑器几十个字段:个人信息、教育、工作、项目、技能、证书等,每条都能加、能删、能拖。显隐联动、可选字段、结构化写入、撤销、AI------这些之前要是散在各组件里各写各的,维护困难,所以全部收进了一个分层引擎。

先看整体:七层

配置层只描述字段,状态层管绑定和规则,渲染层把字段变界面,交互层管增删排序,数据层管按路径读写,写入层收六个语义操作,历史层管快照回退。整个引擎里不碰业务,只认配置和路径,业务全写在配置里。

graph TD A[配置层<br/>解析 formConfig] --> B[状态层<br/>算绑定和显隐] B --> C[渲染层<br/>查表渲染字段] C --> D[交互层<br/>增删 拖拽排序] D --> E[数据层<br/>按路径读写] E --> G[历史层<br/>快照 撤销重做] E -->|结构化变更| F[写入层<br/>六个语义操作] F --> G

配置层

字段形态

字段只用于描述自己有值、组织别的字段、同构记录的重复,对应三种形态。

object 普通字段,声明组件和数据绑定:

less 复制代码
// 一条字段描述 = 显示 + 绑定 + 校验 + 组件
{
  key: "content",              // 字段标识,同时是数据路径首段
  label: "工作内容", span: 24,
  component: "richTextEditor", // 渲染组件,渲染层按名字查表
  model: { source: ["data", "content"], prop: "modelValue", required: true }, // 值从简历的 data.content 读写
}

group 分组容器,递归渲染 fields,管组织和布局(头像和校徽并排就靠它),还支持 context 声明路径,子字段用相对路径。

array 一组记录,itemSchema 渲染每一条,运行时拼下标:

kotlin 复制代码
// 同一份 itemSchema 渲染所有记录,下标由 basePath + index 拼出来
root.data.work[2].content

工厂函数

配置可以通过一个函数管理,formConfig.ts 开头是 createUserField、createMoreField 这些工厂:状态绑定、addable、required 这些结构一致的部分统一注入,业务只传差异项:

php 复制代码
// 工厂调用:只传差异项,标签状态绑定、包裹组件由工厂注入
createMoreField({
  key: "birthday",            // 字段标识,同时是数据路径首段
  label: "年龄",
  category: "常用",           // 编辑器「更多」分区归类
  component: "datePicker",    // 内层渲染组件
  iconKey: "personal-birthday",
  addable: true,              // 数据存在才渲染,可手动添加
  defaultAdded: true,         // 空白简历创建时默认带上
  displayOptions: [           // 展示形态,预览端按选中项渲染
    { label: "年龄", value: "age" },
    { label: "日期", value: "date" },
  ],
  rules: [MONTH_FORMAT_RULE], // 月份格式校验
})

状态层

绑定

model 是数组,一个字段可以同时声明多条绑定:

arduino 复制代码
// 一个字段的多条绑定:数据一条、UI 状态三条,走同一套路径机制
model: [
  { source: ["data", key], prop: "modelValue" },                          // 简历内容
  { source: ["ui", key, "hidden"], prop: "hidden", defaultValue: false }, // 用户开关的显隐
  { source: ["ui", key, "icon"], prop: "icon", defaultValue: iconKey },   // 字段图标
  { source: ["ui", key, "display"], prop: "display" },                    // 展示形态
]

data.* 是简历内容,ui.* 是用户的界面选择。年龄字段可以在"年龄"和"日期"两种形态间切换,预览端直接读 ui.display 按选中形态渲染------配置驱动的不只是表单,还有预览。快照里本来就包含 ui,撤销时显隐和形态偏好跟着一起回退,不用单独做一套偏好历史。

可选字段

语言、技能这类模块用户不一定填,配置里标 addable: true:数据路径存在才渲染。可选性成了配置的一部分,业务组件不用维护字段清单。defaultAdded 控制空白简历是否默认带上;addField / removeField 负责添加和清空,清空时模板保留、数据移除,想加回来还在原位置。

校验和 ATS

rules 里装两种东西:表单校验(required / pattern,失焦标红)和 ATS 分析(wechat、url、salaryRange、projectLink 这些真实类型,severity: "warning",建议级)。混在一个数组里是有意的:一个字段的所有规则写在同一处。区分靠开关 useRules:开着做表单项错误提示,关了规则还在、只是不标红,AI 分析场景就关它。后果不同:校验拦输入,ATS 只提醒、不进保存流程。

渲染层

组件名到真组件,走一个组件注册中心,按优先级四级查找:业务实例注入 → 同步注册 → 异步注册 → 目录约定按需加载。都没命中只在该字段位置 warn,其他字段照常渲染。业务想覆盖默认组件,注入一份集合就行,不用改引擎。

字段各自等待自己的异步内容,普通字段不等整张表单------配置里总有几个加载慢的组件,整张表单等最慢那个,左栏就白屏很久了。

交互层

单条记录用对象容器,一组记录用数组容器。增删走三个纯函数 addArrayRecord / removeArrayRecord / moveArrayRecord,容器组件只负责把按钮点击翻译成调用。

字段组件自己也能参与增删,靠引擎 provide 的 FormContext(removeCurrent / addItem / addField / removeField 这些),业务组件经 useFormContext 读取,全部不需要传路径:想删自己所在那条记录,调 removeCurrent(),引擎按所在容器解析好路径。坚持不传路径,是因为路径只在配置里声明一次,调用方再拼一次就有两个真相来源。

排序也是纯函数,拖拽和外部排序入口共用同一套规则:moveFieldByKey 按 key 移动(固定字段不参与、也不许被插到固定字段前面),applyVisibleOrder 只回填可见项、隐藏项留在原槽位,keepFixedFirst 让固定项始终排最前。用 key 不用下标:删掉第 2 条后,第 3 条的下标会变成 2,按下标排会把内容挪错位。

拖拽实例按需创建。Sortable 会一直观察列表 DOM,编辑期挂着不销毁,用户每敲一个字都多付一份观察开销。所以按下把手才建实例、手势结束就销毁,销毁延后一拍,等 Sortable 把顺序回写完再销,不然这次排序结果会被吞掉。

数据层

绑定里是路径,界面上要一个能读能写的值,中间靠数据代理:

ini 复制代码
// 代理写值:按路径逐层下钻,缺什么补什么
// 缺中间层就补容器:下一层是数字说明是数组
current[key] = typeof nextKey === "number" ? [] : {};
// 最后一层没这个字段,先放默认值再写
if (!current.hasOwnProperty(lastKey)) current[lastKey] = defaultValue;

字段级读取必须补出"可写的位置",否则输入框的 v-model 没处落。raw: true 绑定是例外:只从外部字典取值、不写回,比如城市列表、性别选项。几百条城市数据要是写进简历,等于每次保存塞一份没人要的东西。

还有一条相反的规则:容器级读取不补数据。

scss 复制代码
// 同一个"读",两种语义
getArrayRecords()      // 容器级只读:路径不存在返回 undefined,不创建
ensureArrayRecords()   // 新增记录时才用:缺哪层补哪层

补一个空数组等于替用户承认"他加过内容",完成度统计、空简历判断全跟着失真,还不报错。同一个"读",字段级补位置、容器级不补数据,因为一个回答"值写在哪",一个回答"有几条记录"。初始化同理:hasOwnProperty 判断,只补缺失、不覆盖已有,用户填的空字符串也是他的选择。

写入层

敲字这种高频写入,代理直接落数据,一秒好几次不适合做成"操作"。一次动多处、要记录重放的变更,走六个语义操作:

arduino 复制代码
// 结构化写入的唯一入口 executeResumeOperations
// 四种来源共用:AI 写入、附件导入、模板套用、撤销重放
updateModule / updateModuleTitle / updateRecord
addRecord / deleteRecord / moveRecord

updateModuleTitle 单独占一个:模块标题有时是主题或翻译改的,不是用户填的,拆细了翻日志才能定位"谁在什么时候改的标题"。

AI 内嵌在字段里(生成、续写、更专业、更凝练、更详细),流式结果先进预览面板,用户确认后才走语义操作写入。不让 AI 直接落盘,原因很实际:草稿没确认就入库,撤销历史会堆出一堆没人要的版本。确认这道闸把"AI 的建议"和"用户的数据"隔开了。

哪种变更走哪层,判据就一条:要不要被记录、被重放。敲字不用,删记录从交互层直接调数据层就够,AI 写入要进日志,所以走写入层。

历史层

快照每步存一份序列化后的简历,麻烦在图片:头像 base64 一张几百 KB,存十二步等于把同一张图复制十二遍。所以图片抽出来进图片池,快照只留占位标识;空值也留占位,撤销"删除头像"才知道当时是"没有图";运行时 id 序列化时剔掉,还原后重新生成,不然撤销两次会造出两份同 id 记录。

触发分两级防抖:输入停下 300ms 取快照、和上一版比对,一样就丢;再过 100ms 提交版本、清重做栈。第一级防内容相同的重复版本,第二级防碎片快照清掉重做栈,加起来 400ms,换版本列表干净。

写在最后

这套表单跑在 qzcv.net,欢迎上手玩玩。有更简洁的分层办法,欢迎评论区交流。下一篇想讲撤销重做和 AI 写入怎么共处。

相关推荐
陈随易18 分钟前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马29 分钟前
前沿视界
前端
kyriewen33 分钟前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易1 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员
IT_陈寒1 小时前
Vue的这个响应式陷阱,差点让我加班到天亮
前端·人工智能·后端
Flynt2 小时前
别写拖拽行为,只声明"能拖到哪":UIST 最佳论文的拖拽方案我实测了
前端·svg·preact
Moment3 小时前
代码都能让 Agent 写了,我们为什么还要学前端?
前端·后端·面试
Hilaku3 小时前
MCP 协议会成为前端 AI 工程的标准接口吗?
前端·javascript·程序员
爱你真是太好了3 小时前
上下文组成以及如何压缩
设计模式