引言
表单是低代码平台里最高频、最基础、也最容易被低估的组件。几乎每一个后台系统、审批流程、数据采集场景,最终都落在「渲染一张表单、收集一份数据、校验一遍」上。但表单一旦动态化,复杂度会指数上升:字段要按条件显隐、必填要随其他字段变化、选项要从接口异步加载、子表要能增删行、校验要前后端一致。
Schema 驱动是应对这种复杂度的唯一现实路径:把「表单长什么样」抽成一份结构化数据,渲染器负责把它变成视图,求值器负责处理联动,校验器负责把关数据。三者解耦后,任何一处变化都不会污染另外两处。
本文从渲染器的最小实现讲起,逐步补上字段注册表、状态管理、联动依赖图、校验管线、复杂字段与提交副作用,最后讨论前后端一致性这一最容易出事的地方。读完后你应当能判断:一个表单引擎的复杂度究竟花在哪里,以及哪些设计决策会在半年后让你痛苦。
目录
- 表单引擎的职责边界
- Schema 到视图的映射
- 字段注册表与组件协议
- 受控状态与数据流
- 联动与依赖图
- 校验管线
- 布局与栅格
- 复杂字段:引用、子表、富文本
- 提交与副作用
- 前后端一致性
- 可访问性与国际化
1. 表单引擎的职责边界
先把边界划清,否则表单引擎会膨胀成一个小型框架。
表单引擎负责:
- 把 Schema 渲染为视图
- 维护表单状态(值、错误、脏标记)
- 执行联动(显隐、必填、选项)
- 执行校验并汇总错误
- 组织提交与重置
表单引擎不负责:
- 业务数据的持久化(那是数据层)
- 权限判断(那是权限引擎)
- 复杂算法(那是后端)
- 路由与导航(那是应用壳)
边界清晰后,引擎只需暴露三个接口:render(schema, value)、validate()、getValue()。其余能力通过事件与插槽外挂。
2. Schema 到视图的映射
渲染器的核心是一个递归的 switch:根据字段类型选择组件,递归处理容器字段。
function renderField(field: FieldSchema, ctx: FormContext) {
// 1. 显隐判断
if (field.visibleWhen && !evaluate(field.visibleWhen, ctx.values)) {
return null;
}
// 2. 查找组件
const Component = registry.get(field.type);
if (!Component) return renderUnknown(field);
// 3. 渲染
return (
<FieldWrapper key={field.id} field={field} ctx={ctx}>
<Component
value={ctx.values[field.name]}
disabled={isDisabled(field, ctx)}
onChange={(v) => ctx.setValue(field.name, v)}
options={resolveOptions(field, ctx)}
/>
</FieldWrapper>
);
}
这个函数包含了表单引擎的全部核心动作:显隐求值、组件解析、值绑定、变更回调、选项解析。其余都是它的变体与扩展。
2.1 容器字段的递归
分组、栅格、子表都是「容器字段」,它们自己不产生值,只是渲染子字段。
function renderContainer(field: ContainerSchema, ctx: FormContext) {
return (
<div className={layoutClass(field)}>
{field.children.map((child) => renderNode(child, ctx))}
</div>
);
}
容器与叶子字段统一抽象为「节点」,渲染器只认节点,不关心它是容器还是叶子。
3. 字段注册表与组件协议
字段注册表是引擎的可扩展点:平台内置一批,插件注册更多。
interface FieldComponentProps {
value: unknown;
disabled?: boolean;
readOnly?: boolean;
options?: Option[];
field: FieldSchema;
onChange: (v: unknown) => void;
onBlur?: () => void;
}
type FieldComponent = React.ComponentType<FieldComponentProps>;
class FieldRegistry {
private map = new Map<string, FieldComponent>();
register(type: string, comp: FieldComponent) {
if (this.map.has(type)) {
console.warn(`field type ${type} overridden`);
}
this.map.set(type, comp);
}
get(type: string): FieldComponent | undefined {
return this.map.get(type);
}
}
组件协议(props 契约)是引擎与组件的接口。只要遵守它,任何组件都能插入;不遵守就会破坏联动与校验。协议要保持最小:值、变更、禁用、选项、字段定义,五样足够。
3.1 未知类型的降级
插件卸载或 Schema 版本不匹配时,会遇到未知字段类型。引擎必须降级而不是崩溃:渲染成只读文本并记录告警,例如 renderUnknown 返回一段标注了字段类型的占位文本。
4. 受控状态与数据流
表单状态应该集中在引擎里,组件保持受控(controlled)。这是联动与校验能工作的前提。
状态结构:
{
values: { [fieldName]: unknown },
errors: { [fieldName]: string | null },
touched: { [fieldName]: boolean },
dirty: boolean,
meta: { [fieldName]: { loading?: boolean } }
}
function useFormState(initial: Record<string, unknown>) {
const [values, setValues] = useState(initial);
const [errors, setErrors] = useState<Record<string, string | null>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const setValue = useCallback((name: string, v: unknown) => {
setValues((prev) => ({ ...prev, [name]: v }));
}, []);
return { values, errors, touched, setValue, setErrors, setTouched };
}
不要让组件自己维护值——一旦值分散在各组件内部,引擎就无法做联动与整体校验。
5. 联动与依赖图
联动是表单引擎的灵魂,也是性能最容易崩的地方。正确做法是构建依赖图,精确订阅。
依赖图:
days → reason.visible, reason.required
status → approveBtn.disabled
dept → approver.options
字段 days 变化时:
只重算依赖 days 的规则,其余不动
function buildDependencyGraph(fields: FieldSchema[]) {
const graph = new Map<string, Set<string>>(); // field -> dependents
for (const f of fields) {
const exprs = [f.visibleWhen, f.editableWhen, f.requiredWhen].filter(Boolean);
for (const e of exprs) {
for (const dep of extractDeps(parse(e!))) {
if (!graph.has(dep)) graph.set(dep, new Set());
graph.get(dep)!.add(f.id);
}
}
}
return graph;
}
有了依赖图,字段变更时只重算受影响节点,复杂度从 O(n) 降到 O(受影响节点数)。
5.1 联动的收敛性
联动可能形成环(A 影响 B,B 影响 A)。引擎必须检测环并给出明确报错,否则会出现「抖动」或死循环。简单做法是限制联动传播轮数(如 10 轮)并告警。
6. 校验管线
校验分三级,顺序执行,遇到错误即停。
第一级:字段级
必填、类型、范围、正则、自定义
第二级:跨字段
结束日期 > 开始日期、密码一致性
第三级:异步/服务端
唯一性、余额充足性
async function validateAll(
fields: FieldSchema[],
values: Record<string, unknown>
): Promise<Record<string, string | null>> {
const errors: Record<string, string | null> = {};
for (const f of fields) {
if (!isVisible(f, values)) continue; // 隐藏字段不校验
const err = validateField(f, values[f.name]);
if (err) { errors[f.name] = err; continue; }
}
// 跨字段规则
for (const rule of crossFieldRules) {
const err = rule(values);
if (err) errors[rule.field] = err;
}
return errors;
}
注意「隐藏字段不校验」这条:字段被联动隐藏后,其旧值与错误都应被清除,否则会提交脏数据。
7. 布局与栅格
布局信息也应该在 Schema 里,但不要和字段混在一起。
{
"layout": "grid",
"columns": 24,
"fields": [
{ "id": "f_1", "name": "name", "label": "姓名", "type": "string", "span": 12 },
{ "id": "f_2", "name": "age", "label": "年龄", "type": "number", "span": 12 },
{ "id": "f_3", "name": "addr", "label": "地址", "type": "string", "span": 24 }
]
}
24 栅格是行业惯例,span 表示占多少列。响应式场景下再叠加断点规则(xs/sm/md/lg)。
7.1 布局与渲染解耦
布局只影响外层容器,不影响字段组件本身。这样换布局(栅格 ↔ 流式)不需要改任何字段组件,反之亦然。
8. 复杂字段:引用、子表、富文本
这三类字段是表单引擎的「硬骨头」,各自有独立的复杂度。
| 字段 | 复杂度来源 | 关键设计 |
|---|---|---|
| 引用(ref) | 异步选项、远程搜索、回显 | 值存 id,显示名单独缓存 |
| 子表(array) | 行内校验、增删排序、汇总 | 每行独立状态 + 行级错误 |
| 富文本 | XSS、粘贴、图片上传 | 白名单净化、受控同步 |
// 引用字段:值存 id,显示名单独缓存避免二次请求
interface RefValue { id: string; label: string }
function RefField({ value, onChange, field }: FieldComponentProps) {
const [options, setOptions] = useState<Option[]>([]);
useEffect(() => {
searchRef(field.options!.source, "").then(setOptions);
}, [field.options?.source]);
return <Select value={(value as RefValue)?.id} options={options}
onChange={(id) => onChange({ id, label: findLabel(options, id) })} />;
}
8.1 子表的行级校验
子表的校验结果是「行 × 字段」的二维矩阵,不能只用一个 errors[name] 表达。需要把错误路径写成 items[2].amount 这样的路径式键。
9. 提交与副作用
提交不是简单的 fetch,要处理并发、幂等、部分成功与回滚。
async function submit(ctx: FormContext) {
const errors = await validateAll(ctx.fields, ctx.values);
if (Object.keys(errors).length) {
ctx.setErrors(errors);
ctx.setTouched(allFields(ctx.fields)); // 全部标为已触碰,暴露错误
return;
}
ctx.setSubmitting(true);
try {
const res = await api.save(ctx.values, {
idempotencyKey: ctx.idempotencyKey, // 幂等
version: ctx.recordVersion, // 乐观锁
});
ctx.onSuccess(res);
} catch (e) {
handleSubmitError(e, ctx); // 字段级错误回填
} finally {
ctx.setSubmitting(false);
}
}
9.1 幂等键
提交按钮被连点、网络重试,都会导致重复提交。客户端生成幂等键(如 UUID),服务端用唯一约束去重,是标准解法。
10. 前后端一致性
表单引擎最大的坑是「前端过了后端拒」。根因是校验规则被写了两遍。
正确做法:
同一份 Schema → 前端渲染 + 前端校验
→ 后端加载同一份 Schema → 后端校验
错误做法:
前端写一套校验
后端手写另一套 if/else
→ 两者必然漂移
后端加载同一份 Schema 的代价是需要一套与前端等价的表达式求值器。这可以通过把表达式编译为受限的 AST 求值(前后端各实现一份求值器,共享 AST 格式)来实现。这也是 元数据驱动架构设计 中「一次描述、多处消费」的直接体现。
11. 可访问性与国际化
动态表单很容易忽略这两点,但它们是生产可用的门槛。
可访问性(a11y):
- 每个字段有 label 且通过 htmlFor 关联
- 错误通过 aria-describedby 关联到输入
- 键盘可达、焦点管理(提交失败聚焦第一个错误字段)
国际化(i18n):
- label 存 i18n key 而非字面量
- 错误信息模板化,按 locale 渲染
- 数字/日期按 locale 格式化
{
"name": "leaveDays",
"label": { "key": "form.leave.days", "default": "请假天数" }
}
label 用 key 而非字面量,是后续支持多语言的前提。相关实践可参考 前端可访问性与国际化 。
权衡取舍
| 决策点 | 选项 A | 选项 B | 建议 |
|---|---|---|---|
| 状态位置 | 组件自持 | 引擎集中 | 集中,否则无法联动 |
| 联动策略 | 全量重算 | 依赖图 | 依赖图,字段多时必选 |
| 校验时机 | 仅提交 | 实时 | 失焦校验 + 提交全量 |
| 布局表达 | CSS 硬编码 | Schema 栅格 | Schema,便于换肤 |
| 表达式执行 | eval | AST 求值 | AST,安全且可依赖分析 |
常见坑清单
- 组件自持状态:值散落各处,联动与整体校验无法实现。
- 隐藏字段不清值:被隐藏的必填字段旧值仍被提交,数据脏。
- 全量重算联动:字段上百后每次输入都卡,必须建依赖图。
- 联动成环无检测:出现抖动或死循环,需限制传播轮数并告警。
- 校验规则前后端各写一遍:必然漂移,必须共用 Schema。
- 子表用扁平错误键:无法定位到具体行,需路径式错误键。
- 未知字段类型直接崩溃:插件卸载即白屏,必须降级渲染。
- 提交无幂等键:连点或重试导致重复数据。
- label 写字面量:后续多语言改造要全量返工。
- 错误不聚焦:提交失败后用户不知错在哪,需自动聚焦首个错误字段。
小结
Schema 驱动的表单引擎,骨架是「渲染映射 → 字段注册表 → 受控状态 → 依赖图 → 校验管线 → 提交副作用」。其中真正决定成败的是三件事:状态是否集中、联动是否精确、校验是否单一来源。
实现时最常见的错觉是「先简单做,后面再优化联动」。但联动方案决定了状态结构与渲染粒度,事后重做的代价极高。建议第一版就建依赖图,即使字段很少。
表单引擎处理的是「单个表单内部」的复杂度。当表单需要被放进可自由拖拽的页面、与其他组件组合时,复杂度就上升到了「页面级」,这属于 可视化页面搭建器实现 的范畴。而表单数据的落点与建模,则见 数据模型设计器 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。