两年半前写过一篇《动态处理复杂数据结构的表单》,第一版只会两件事:把配置渲染成表单、实现双向绑定。
现在它跑在我的简历项目上,撑起整个编辑器几十个字段:个人信息、教育、工作、项目、技能、证书等,每条都能加、能删、能拖。显隐联动、可选字段、结构化写入、撤销、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 普通字段,声明组件和数据绑定:
// 一条字段描述 = 显示 + 绑定 + 校验 + 组件
{
key: "content", // 字段标识,同时是数据路径首段
label: "工作内容", span: 24,
component: "richTextEditor", // 渲染组件,渲染层按名字查表
model: { source: ["data", "content"], prop: "modelValue", required: true }, // 值从简历的 data.content 读写
}
group 分组容器,递归渲染 fields,管组织和布局(头像和校徽并排就靠它),还支持 context 声明路径,子字段用相对路径。
array 一组记录,itemSchema 渲染每一条,运行时拼下标:
// 同一份 itemSchema 渲染所有记录,下标由 basePath + index 拼出来
root.data.work[2].content
工厂函数
配置可以通过一个函数管理,formConfig.ts 开头是 createUserField、createMoreField 这些工厂:状态绑定、addable、required 这些结构一致的部分统一注入,业务只传差异项:
// 工厂调用:只传差异项,标签状态绑定、包裹组件由工厂注入
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 是数组,一个字段可以同时声明多条绑定:
// 一个字段的多条绑定:数据一条、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 把顺序回写完再销,不然这次排序结果会被吞掉。
数据层
绑定里是路径,界面上要一个能读能写的值,中间靠数据代理:
// 代理写值:按路径逐层下钻,缺什么补什么
// 缺中间层就补容器:下一层是数字说明是数组
current[key] = typeof nextKey === "number" ? [] : {};
// 最后一层没这个字段,先放默认值再写
if (!current.hasOwnProperty(lastKey)) current[lastKey] = defaultValue;
字段级读取必须补出"可写的位置",否则输入框的 v-model 没处落。raw: true 绑定是例外:只从外部字典取值、不写回,比如城市列表、性别选项。几百条城市数据要是写进简历,等于每次保存塞一份没人要的东西。
还有一条相反的规则:容器级读取不补数据。
// 同一个"读",两种语义
getArrayRecords() // 容器级只读:路径不存在返回 undefined,不创建
ensureArrayRecords() // 新增记录时才用:缺哪层补哪层
补一个空数组等于替用户承认"他加过内容",完成度统计、空简历判断全跟着失真,还不报错。同一个"读",字段级补位置、容器级不补数据,因为一个回答"值写在哪",一个回答"有几条记录"。初始化同理:hasOwnProperty 判断,只补缺失、不覆盖已有,用户填的空字符串也是他的选择。
写入层
敲字这种高频写入,代理直接落数据,一秒好几次不适合做成"操作"。一次动多处、要记录重放的变更,走六个语义操作:
// 结构化写入的唯一入口 executeResumeOperations
// 四种来源共用:AI 写入、附件导入、模板套用、撤销重放
updateModule / updateModuleTitle / updateRecord
addRecord / deleteRecord / moveRecord
updateModuleTitle 单独占一个:模块标题有时是主题或翻译改的,不是用户填的,拆细了翻日志才能定位"谁在什么时候改的标题"。
AI 内嵌在字段里(生成、续写、更专业、更凝练、更详细),流式结果先进预览面板,用户确认后才走语义操作写入。不让 AI 直接落盘,原因很实际:草稿没确认就入库,撤销历史会堆出一堆没人要的版本。确认这道闸把"AI 的建议"和"用户的数据"隔开了。
哪种变更走哪层,判据就一条:要不要被记录、被重放。敲字不用,删记录从交互层直接调数据层就够,AI 写入要进日志,所以走写入层。
历史层
快照每步存一份序列化后的简历,麻烦在图片:头像 base64 一张几百 KB,存十二步等于把同一张图复制十二遍。所以图片抽出来进图片池,快照只留占位标识;空值也留占位,撤销"删除头像"才知道当时是"没有图";运行时 id 序列化时剔掉,还原后重新生成,不然撤销两次会造出两份同 id 记录。
触发分两级防抖:输入停下 300ms 取快照、和上一版比对,一样就丢;再过 100ms 提交版本、清重做栈。第一级防内容相同的重复版本,第二级防碎片快照清掉重做栈,加起来 400ms,换版本列表干净。
写在最后
这套表单跑在 qzcv.net,欢迎上手玩玩。有更简洁的分层办法,欢迎评论区交流。下一篇想讲撤销重做和 AI 写入怎么共处。