Schema 驱动的表单引擎

从零拆解 Schema 驱动的表单引擎:Schema 到视图的映射、字段注册表与组件协议、受控状态与数据流、联动依赖图、校验管线、栅格布局、复杂字段(引用/子表/富文本)、提交副作用与前后端一致性,给出可运行的渲染器实现与配置片段,回答如何用一份 Schema 渲染出可维护的生产级表单。

引言

表单是低代码平台里最高频、最基础、也最容易被低估的组件。几乎每一个后台系统、审批流程、数据采集场景,最终都落在「渲染一张表单、收集一份数据、校验一遍」上。但表单一旦动态化,复杂度会指数上升:字段要按条件显隐、必填要随其他字段变化、选项要从接口异步加载、子表要能增删行、校验要前后端一致。

Schema 驱动是应对这种复杂度的唯一现实路径:把「表单长什么样」抽成一份结构化数据,渲染器负责把它变成视图,求值器负责处理联动,校验器负责把关数据。三者解耦后,任何一处变化都不会污染另外两处。

本文从渲染器的最小实现讲起,逐步补上字段注册表、状态管理、联动依赖图、校验管线、复杂字段与提交副作用,最后讨论前后端一致性这一最容易出事的地方。读完后你应当能判断:一个表单引擎的复杂度究竟花在哪里,以及哪些设计决策会在半年后让你痛苦。

目录

  1. 表单引擎的职责边界
  2. Schema 到视图的映射
  3. 字段注册表与组件协议
  4. 受控状态与数据流
  5. 联动与依赖图
  6. 校验管线
  7. 布局与栅格
  8. 复杂字段:引用、子表、富文本
  9. 提交与副作用
  10. 前后端一致性
  11. 可访问性与国际化

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,便于换肤
表达式执行evalAST 求值AST,安全且可依赖分析

常见坑清单

  1. 组件自持状态:值散落各处,联动与整体校验无法实现。
  2. 隐藏字段不清值:被隐藏的必填字段旧值仍被提交,数据脏。
  3. 全量重算联动:字段上百后每次输入都卡,必须建依赖图。
  4. 联动成环无检测:出现抖动或死循环,需限制传播轮数并告警。
  5. 校验规则前后端各写一遍:必然漂移,必须共用 Schema。
  6. 子表用扁平错误键:无法定位到具体行,需路径式错误键。
  7. 未知字段类型直接崩溃:插件卸载即白屏,必须降级渲染。
  8. 提交无幂等键:连点或重试导致重复数据。
  9. label 写字面量:后续多语言改造要全量返工。
  10. 错误不聚焦:提交失败后用户不知错在哪,需自动聚焦首个错误字段。

小结

Schema 驱动的表单引擎,骨架是「渲染映射 → 字段注册表 → 受控状态 → 依赖图 → 校验管线 → 提交副作用」。其中真正决定成败的是三件事:状态是否集中、联动是否精确、校验是否单一来源。

实现时最常见的错觉是「先简单做,后面再优化联动」。但联动方案决定了状态结构与渲染粒度,事后重做的代价极高。建议第一版就建依赖图,即使字段很少。

表单引擎处理的是「单个表单内部」的复杂度。当表单需要被放进可自由拖拽的页面、与其他组件组合时,复杂度就上升到了「页面级」,这属于 可视化页面搭建器实现 的范畴。而表单数据的落点与建模,则见 数据模型设计器 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「低代码」更多文章

  1. 自定义代码与逃生舱
  2. 低代码应用测试与质量
  3. 连接器与 API 编排