
1. 双层架构是被需求逼出来的不是设计出来的AutoForm 这个名字最早只是我们内部团队一个工具仓库的代号后来用的人越来越多才慢慢变成了一套可以独立维护的轻量级双层架构。所谓轻量级不是指代码少到只有几百行而是它不给使用者预设一堆必须接受的框架所谓双层就是我把表单开发里的几乎所有问题收拢到两个层面Schema 定义层和 Renderer 执行层。这篇文章不打算把架构吹得多漂亮重点讲清楚这两层各自解决什么问题、中间那条契约长什么样以及我在真实项目里踩过的坑。如果你正在维护后台管理系统、做低代码平台或者在纠结到底该不该选一个表单引擎这篇内容应该能给你一个比较务实的参考。1.1 我踩过的两条老路先说最常见的做法每个表单页面单独写 JSX字段、校验、联动逻辑全部堆在组件里。这种方式的优点是直接、没有魔法缺点是表单一多就失控。一个五十多个表单的后台系统字段改动是高频需求比如某个下拉框的选项变了、两个字段之间的联动规则改了、审核金额超过两万时要出现额外说明框。每一次改动都要定位到对应组件改完还得人工验证周边逻辑。同一份客户名称字段的定义会散落在搜索页、创建页、详情页、编辑页改一处漏三处是常态。另一条路是上低代码表单引擎可视化拖拽、自动生成代码、内置一堆高级组件。听起来很美好真用起来却经常被绑架。引擎自带的表达式语法要学数据流是引擎自己的规则组件版本跟着引擎走想嵌入一个自定义的业务组件还得看引擎给你留了多少缝隙。很多团队花了两三周接入最后又花了两三周把表单迁回普通组件因为线上报问题的速度已经赶不上需求变更的速度了。这两条路的本质矛盾是一样的表单的定义和表单的执行被绑死在了一起。要么把定义写进代码里导致处处复制要么把执行塞进引擎里导致处处受限。AutoForm 的双层架构就是为了解开这个结。1.2 双层架构的核心分界schema 是数据renderer 是代码AutoForm 的整个架构只有两个概念。上层是 schema也就是表单定义。它是一份纯数据描述有哪些字段、字段类型、布局顺序、校验规则、联动条件。schema 本身没有副作用不依赖某个 UI 组件库也不需要运行环境。它可以放在前端项目里可以从后端接口下发也可以直接存在数据库里。下层是 renderer也就是表单执行器。它是一段代码负责读取 schema、找到对应组件、管理字段值、触发校验、执行联动结果。renderer 可以有多个实现同一个 schema 在表单页、详情页、搜索页里可以渲染出完全不同的界面。中间连接两层的是一个非常简单的创建入口传入 schema指定视图类型附加运行环境。这个入口返回一个表单实例实例上只挂几个必要的方法。我把这两层之间的契约控制得很小因为契约越大使用者需要理解的东西就越多架构也就越不容易保持轻量。我经常给同事们打一个比方schema 是图纸renderer 是施工队。图纸不关心施工队用哪家的砖施工队可以根据同一个图纸盖住宅楼、盖办公室、盖临时展厅。AutoForm 做的事情就是让图纸和施工队保持独立同时约定一张图纸上每个符号的含义。层级内容典型职责是否可运行Schema 层字段、布局、校验、联动数据定义、跨端复用、后端下发否Renderer 层组件映射、值管理、事件驱动界面渲染、表单交互、校验执行是2. Schema 层只描述“长什么样、要什么”不描述“怎么实现”schema 层的设计目标不是做一个完整的领域建模语言而是提供一个够用的表单描述协议。这个协议必须让产品经理能读让后端能生成让前端能渲染让测试能理解。AutoForm 的 schema 内部统一用对象表示最外层固定包含 version 字段这样以后协议演进时有据可查。2.1 字段描述的基本协议每个字段由一个字段对象描述核心字段如下表。AutoForm 的组件注册表里有一个字符串和组件的映射关系所以 schema 里只需要写组件名不需要写任何导入路径或者组件对象。字段属性类型说明namestring字段名提交时作为值的 keylabelstring展示给用户看的标签componentstring组件注册名例如 Input、NumberInputpropsobject传给 UI 组件的额外参数rulesarray校验规则列表hiddenboolean初始是否隐藏disabledboolean初始是否禁用descriptionstring字段说明通常在详情页展示举个例子一个很常规的订单审核表单结构大概长这样{ version: 1.0, fields: [ { name: orderNo, label: 订单号, component: Input, props: { placeholder: 请输入订单号 }, rules: [ { required: true, message: 订单号不能为空 } ] }, { name: amount, label: 审核金额, component: NumberInput, props: { precision: 2 } }, { name: payType, label: 支付方式, component: Select, props: { options: [ { label: 在线支付, value: online }, { label: 线下转账, value: transfer }, { label: 退款冲抵, value: refund } ] } } ] }这里有个值得注意的设计决策schema 里的label、placeholder这类信息必须是纯数据不参与任何逻辑判断。逻辑判断只围绕name和value展开这样 schema 才能被后端安全地解析和生成不会因为某个字段被 UI 文案污染而影响业务逻辑。2.2 布局信息用二维数组表达很多表单描述协议会把布局打成树形结构比如栅格、分组、卡片层层嵌套。树形结构表达能力强但写起来重处理起来也要递归。AutoForm 选择了一个更朴素的方案layout 字段是一个二维数组。第一层是行第二层是这一行里的字段。{ layout: [ [orderNo], [amount, payType] ] }上面的 layout 表示第一行只有订单号第二行是金额和支付方式并排。约定每个字段名在 layout 里最多出现一次没出现在 layout 里的字段自动按 fields 的声明顺序追加到末尾。这个设计有意牺牲了复杂嵌套能力换来了三个好处schema 肉眼可读、后端生成逻辑简单、前端渲染只需要两层循环。如果之后确实需要分组和卡片我在实践中建议不要扩展 layout 本身而是在 fields 外面加一个sections数组section 内部再引用字段名。这样旧的解析逻辑可以保持稳定新能力只是增量不会破坏已有 schema。2.3 联动规则走声明式不走脚本表单联动是最容易把架构搞重的点。我看到不少方案在 schema 里塞 JavaScript 表达式运行的时候用eval或者一个自定义解释器去执行。这么做功能很强大但随之而来的是调试困难、安全风险、性能损耗。表达式的字符串写在 JSON 里写的时候没语法高亮线上报错了你也很难定位是哪一段表达式出了问题。AutoForm 的取舍是只支持非常有限的联动原语。一个联动规则由三部分组成触发源、触发条件、动作。比如支付方式等于退款冲抵时隐藏金额字段可以这样描述{ relations: [ { source: payType, condition: { op: eq, value: refund }, action: { type: setVisibility, target: amount, visible: false } } ] }我最初只定义了三种动作setVisibility、setDisabled、setValue。后来业务里遇到了需要重置字段值、联动清空、动态修改选项的场景又增加了resetValue和updateProps。但直到今天AutoForm 的 rules 引擎也不支持任意表达式。如果你需要A 大于 100 且 B 不等于 x这种条件就拆成两条 relation或者用一个自定义的判断函数挂进去。这个取舍让核心包常年保持在小体积也让 schema 可以安全地交给非前端团队维护。2.4 version 字段和 schema 校验schema 既然是数据它就会面临版本演进。AutoForm 在最外层强制要求 version 字段渲染器根据 version 决定用哪个解析版本。我自己见过太多因为字段定义升级导致旧数据渲染崩溃的事故所以 version 就像数据库表的版本号一样必须显式声明。校验这部分AutoForm 内置了一个轻量的 schema 校验器主要检查四类问题字段 name 是否重复、layout 是否引用了不存在的字段、component 名称是否有对应注册、rules 里的 required 和 pattern 是否合法。这层校验不查业务逻辑只查结构健康度。每次从后端拉取 schema 时先跑一遍校验能在渲染前就拦截掉 80% 的配置错误。3. Renderer 层同一份 schema三种渲染方式的复用实验如果说 schema 层是 AutoForm 的静面renderer 层就是动面。renderer 负责把同一份 schema 变成可交互的界面同时保持不同场景之间的行为一致。3.1 渲染器注册表和创建入口AutoForm 的渲染器采用注册表模式。每个 renderer 对象包含一个 view 标识和一个 render 方法通过AutoForm.register注册进去。创建表单实例时通过AutoForm.create指定 view 类型。这里你可以把 view 理解成使用者希望表单以什么形态出现的开关。AutoForm.register({ view: form, render: (schema, context) { return FormRenderer schema{schema} context{context} /; } }); const instance AutoForm.create(schema, { view: form, componentMap, value: initialValue, onChange: (nextValue) console.log(nextValue) });创建入口是两层的唯一连接点。无论 renderer 内部多复杂对外暴露的能力都收敛在一组方法上getValue、setValue、validate、reset。业务方不需要知道当前是哪个 renderer 在干活也不需要知道 schema 是怎么被解析的。3.2 form / detail / search 三种视图渲染器我实际用得最多的三个 view 分别是表单、详情、搜索。表单和详情两个 renderer 是同一个代码仓库里最互补的两个实现。表单渲染器会把 layout 摊开成栅格每个字段渲染对应的输入组件同时挂上校验规则。详情渲染器接同一份 schema把字段名映射成 label把字段值交给一个 Format 函数格式化后输出。也就是说同一个订单号字段表单里是一个输入框详情里是一行只读文本。这种一致性不是靠复制粘贴实现的而是因为两者共享同一份 schema 定义。搜索渲染器稍微特殊一点。它不会渲染完整字段而是从 schema 里挑出searchable: true的字段。我在 schema 字段协议里加了searchable这个可选标记目的是让搜索条件跟表单字段天然保持同步。订单号、客户名这种高频搜索条件在 schema 里标上searchable搜索表单就自动生成不用单独维护另一套搜索字段定义。渲染器输入组件校验规则布局支持主要用途form是是二维数组布局新增、编辑、审核detail否否label 对齐详情展示、审批预览search部分字段否简单横向排列列表条件筛选3.3 自定义组件的统一接入协议AutoForm 不内置任何 UI 组件所以组件接入协议是 renderer 层能否落地的关键。每个自定义组件只需要遵循一个简单的 props 契约接收value作为当前值调用onChange传出新值同时支持disabled、readOnly这类通用状态。至于组件内部是用什么 UI 库实现的AutoForm 完全不关心。interface FieldComponentProps { name: string; value: unknown; onChange: (value: unknown) void; disabled?: boolean; readOnly?: boolean; [key: string]: unknown; } const MyAmountInput: React.FCFieldComponentProps ({ value, onChange, disabled, ...restProps }) { return input typetext value{value ?? } disabled{disabled} onChange{(e) onChange(e.target.value)} {...restProps} /; };这个协议虽然简单但要求组件必须是受控组件也就是值由外部传入组件内部不维护数据副本。我在推广时遇到过很多组件天然是内部维护状态的改造起来有一定成本。这块没有更好的捷径只能靠统一封装层去兜底。建议团队在建组件库时就把 value/onChange 契约纳入规范否则后接 AutoForm 时要返工。4. “轻量级”是怎么取舍的我砍掉的重概念做架构时最难的往往不是加功能而是决定砍掉什么。AutoForm 被称为轻量级不是因为功能少所以代码量小而是我主动砍掉了四个容易让架构变重的设计。4.1 不内置 UI 组件库一开始有人建议直接把 Ant Design 或者 Element 封装进 AutoForm这样开箱即用。我拒绝了这条路线。内置 UI 库看起来方便实际上意味着 AutoForm 的生命周期被绑定在一个框架版本链路上。今天项目用的 AntD v4明天升级 v5AutoForm 要么跟着升级要么成为一个兼容层黑洞。AutoForm 的做法是把组件映射交出来。外界通过 componentMap 传入一个{ Input, Select, DatePicker }对象schema 里的 component 字符串在这个映射表里查找对应组件。组件属于项目不属于 AutoFormAutoForm 只提供查找规则和渲染流程。4.2 不做运行时表达式引擎表单字段之间的依赖比如金额大于某个值时显示额外字段、多选选中某项时修改另一项的 options这类需求一旦写进核心就收不住。完整的表达式引擎要处理语法解析、变量作用域、类型转换、错误堆栈哪怕只做 JS 的子集复杂度也会超过整个表单渲染流程。所以 AutoForm 选择了声明式 relations。条件判断只支持eq、neq、gt、gte、in这五个操作符动作只支持前文说的那五种。真遇到特别复杂的联动判断我会建议业务方写一个自定义 validator 或者自定义computeProps函数挂到 schema 上。这样做诚实且可控核心永远不膨胀。4.3 不接管全局状态很多表单框架会把数据状态管理做进内核比如内部维护一个 store提供 get/set 方法再和 Redux、Zustand 打通。AutoForm 没有这么做。值就是普通对象创建实例时传入 initial value交互过程中由 onChange 把最新值抛回给外界状态到底放在组件局部 state 还是全局 store 中由使用方自己决定。这个决策在很多人看来很偷懒但它帮我避开了大量数据同步问题。表单页面里的值本来就该由页面控制表单引擎适合做一个计算器不适合做账本。4.4 不做模板编译和代码生成还有一种比较重的思路是把 schema 编译成 React/Vue 代码然后交付给项目。模板编译要做代码生成、语法转换、依赖分析还要处理编译产物和手写代码混合的边界。AutoForm 只做运行时解析不在构建期碰任何代码。schema 传进来组件直接渲染出来没有中间产物因此任何环境都能用包括一些不支持编译器的低代码容器。5. 落地复盘一个订单审核页从 schema 定义到踩坑修复说了这么多设计不落地都是空谈。我带大家完整走一遍 AutoForm 在实际项目里做订单审核页的过程顺便复盘几个我印象很深的坑。5.1 第一步定义订单审核表单的 schema假设业务方提出的需求是审核员需要查看订单号、客户名称、支付方式、审核金额、退款原因备注。当支付方式选择退款冲抵时必须显示退款原因输入框审核金额超过 20000 时备注字段变为必填。这份 schema 大概长这样{ version: 1.0, layout: [ [orderNo, customerName], [payType, amount], [refundReason], [remark] ], fields: [ { name: orderNo, label: 订单号, component: Input }, { name: customerName, label: 客户名称, component: Input }, { name: payType, label: 支付方式, component: Select, props: { options: [ { label: 在线支付, value: online }, { label: 线下转账, value: transfer }, { label: 退款冲抵, value: refund } ] } }, { name: amount, label: 审核金额, component: NumberInput }, { name: refundReason, label: 退款原因, component: TextArea }, { name: remark, label: 审核备注, component: TextArea } ], relations: [ { source: payType, condition: { op: eq, value: refund }, action: { type: setVisibility, target: refundReason, visible: true } }, { source: amount, condition: { op: gt, value: 20000 }, action: { type: updateProps, target: remark, props: { rules: [ { required: true, message: 大额订单必须填写审核备注 } ] } } } ] }这里我特别强调 layout 的作用。如果后端下发接口时把 JSON 字段顺序做了排序前面的 field 数组顺序变了布局也不会乱因为渲染完全以 layout 为准。这是一层防御性设计我们在实际联调时多次从中受益。5.2 第二步注册渲染器和组件表页面侧接入代码非常短。组件表里放的是项目自己的封装组件AutoForm 只做渲染编排。const componentMap { Input: AppInput, Select: AppSelect, NumberInput: AppNumberInput, TextArea: AppTextArea, }; const Page () { const [instance, setInstance] useStateFormInstance | null(null); const [value, setValue] useState({}); useEffect(() { const inst AutoForm.create(orderSchema, { view: form, componentMap, value, onChange: setValue, }); setInstance(inst); }, []); const handleSubmit async () { const result await instance?.validate(); if (result?.pass) { // 提交 value } }; return ( div {instance?.render()} button onClick{handleSubmit}提交审核/button /div ); };这里的 FormInstance 接口是固定的renderer 换掉页面代码也不用改。比如同一个页面需要做只读审核预览时不需要新建一页只用把 view 换成detail再创建一次实例即可。5.3 现场遇到的三个坑组件 props 覆盖、联动环、id 漂移第一个坑是组件 props 覆盖。自定义组件通常有自己的 className、style、data-testid 这类属性。最开始 renderer 会把 schema 里的 props 直接展开传给组件结果项目里统一的下划线样式被 schema 里的props.style覆盖了好几个页面样式突然失真。排查链路倒是很清晰页面样式问题先看传给组件的 props打印后发现 schema 里的 props 与项目默认属性冲突。修复方案是在updateProps动作之外加了一条规则组件协议里的通用属性比如disabled、readOnly、hidden由 renderer 统一管理自定义 schema props 只允许放进extraProps命名空间。这样组件自身的约定属性不会被外部配置随意覆盖。第二个坑是联动环。某个版本里两个字段互相设置 disabled 状态一个是A 被禁用时 B 不禁用另一个是B 被禁用时 A 不禁用再加上初始值配置没做好导致渲染时联动反复触发最后栈溢出。排查时我先在 relations 里打印执行记录发现一个问题updateProps动作执行后又会触发 source 字段的 watcher进而再次执行 relation。修复方式是两个层面同时做。核心层增加执行深度限制单次事件循环内最多处理二十条联动链使用层面禁止出现两个字段互相 setDisabled 这种循环关系在 schema 校验器里增加了环检测。现在 AutoForm 遇到循环关系时会在创建阶段直接报错而不是等到运行期才表现异常。第三个坑是详情页的 id 漂移。detail 渲染器为了实现 label 和值一一对应生成了一组基于字段名的 id。当同一页面有两个相同 schema 实例时比如左右对比审核两边的 label 和值会错位。这个问题的根因是 id 没有实例化作用域。后来我在创建入口增加了一个 scopeId 参数所有内部生成的 DOM id 都以 scopeId 为前缀这个问题才彻底消掉。5.4 排查链路回顾这三个坑让我总结出一条经验双层架构的问题大多数发生在契约边缘而不是 schema 本身或 renderer 内部。所以排查问题时要先问三个问题字段名是否存在于 schema组件是否注册在 componentMap当前 view 是否有对应的 renderer 注册把这三条走完一般能找到 80% 的根因。问题表现检查顺序常见根因组件没渲染schema 字段名 - componentMapcomponent 字符串拼写错误样式错乱组件通用属性 - extraPropsprops 直接展开覆盖联动卡死relation 环检测 - 执行深度两个字段互相设置状态状态不更新onChange 是否透传 - value 是否受控自定义组件内部维护自己的 state值提交缺失字段是否 hidden 且未排除隐藏字段没配 ignore 策略6. 双层架构的适用边界以及它和轻量级工作流的关系架构没有绝对好坏只有适用边界。做完 AutoForm 之后我越来越清楚什么场景该用它什么场景最好不要硬套。6.1 我建议使用 AutoForm 的场景最典型的场景是后台管理系统尤其是表单数量多、字段相似度高、变更频繁的团队。把 schema 从代码里抽出来之后前端可以更快响应产品改字段的需求产品经理甚至可以直接改一份 JSON 文件发起 MR测试同学也能在提测前先自行校验 schema 结构。我第二个重点推荐的使用场景是动态表单。比如表单模板由用户配置或者表单内容由后端按业务类型下发这种场景天生就需要 schema 与 renderer 分离。AutoForm 的 schema 是纯 JSON后端可以基于权限、业务类型拼装不同的字段集合前端只需要处理渲染和数据回传不需要跟着每个业务类型写死组件。跨端渲染也值得提。同一份订单 schemaPC 后台是表格布局移动端审批 App 是纵向排列的卡片布局。移动端备注栏更宽详情页更紧凑。因为 schema 与 UI 组件库无关两个端可以各自注册自己的组件映射但字段名、校验规则、提交结构完全一致。6.2 不建议强行上 AutoForm 的场景如果项目里只有三五个固定表单而且几乎不会变动我不建议引入 AutoForm。加一层抽象必然带来理解成本直接写组件反而更简单。复杂的动态嵌套表单比如一个可以无限增加行、每行内部又有子表结构的矩阵场景AutoForm 的二维 layout 是撑不住的。这种场景更适合专门的数据网格方案。AutoForm 的价值是通用表单不是万能编辑器。对实时协同编辑这类极端场景也有压力。AutoForm 的 value 是整体对象多人同时编辑同一个表单时字段级合并冲突要外界自己处理。如果要做到 OT 或者 CRDT表单引擎本身不应该是核心瓶颈而是你整体架构里微不足道的一部分没必要纠结在渲染层。6.3 双层架构只是轻量级工作流的底座最后聊一个让我自己都觉得惊喜的延伸。AutoForm 最初只是做表单后来团队做审批流时发现一个简单的审核流程本质上就是多个 schema 节点的有序流转。步骤一填申请单步骤二管理员审核步骤三财务打款每个步骤对应一份 schema再加上一个状态机描述步骤之间的跳跃条件就构成了一套非常轻量级的工作流。轻量级工作流的关键在于每个节点只依赖 schema不依赖特定页面代码。工作流引擎只关心当前节点是谁、下一步是谁、需要校验什么数据具体的字段交互全部交给 AutoForm 渲染。这样审批流的调整往往只需要改配置和 schema不需要改前端代码。从双层架构到轻量级工作流中间隔着的只是规则的显式化。我现在的做法是把每一类表单的 schema 集中放在一个表单仓库目录里用 JSON 文件维护并提供版本变更记录。当需求方说这个表单需要加一个字段我改的是 schema 文件跑的是结构校验然后界面自己就跟着变了。这个流程看起来不够炫酷但它让表单开发真正回归到了数据配置本身我觉得这才是轻量级里轻字的分量。