Schema 驱动表单(下):表达式引擎与通用 UI 渲染

Patchwork Quilt, 1870-1900, Metropolitan Museum of Art
拼布被(Patchwork Quilt),1870–1900 — 每一块独立的布片按照规则拼接,组成完整的图案。Schema 驱动 UI 也是如此:独立的组件通过结构化描述组合成完整界面
这篇文章的价值:上一篇解决了"用 JSON 描述表单"的问题,但表单只是 UI 的子集。这篇把 Schema 的能力扩展到描述整个界面——布局、条件渲染、表达式引擎、动作系统。最终实现一个通用的 SchemaRenderer:给它一份 JSON,它能渲染出包含条件逻辑和交互行为的完整页面。这套方案适用于需要"低代码配置页面"的场景。

从表单到界面

上一篇讨论了用 JSON Schema 驱动表单渲染——数据模型、渲染器、校验策略。但如果仔细想想,表单不过是 UI 的一个子集。一个表单里不只有输入框——还有布局(分栏、卡片、Tab 切换)、展示组件(文字、图片、Badge)、交互逻辑(点击按钮触发动作、根据选项显示/隐藏字段)。

如果我们把 Schema 的能力从"描述表单字段"扩展到"描述整个 UI 树",就得到了一个更强大的系统:Schema 驱动 UI。一份 JSON 不仅定义数据输入,还定义布局、条件渲染、校验规则、交互动作——前端只需要一个通用的 SchemaRenderer,就能渲染出完整的页面。

核心类型设计

整个系统的类型定义:

// 一个组件的完整描述
interface ComponentSchema {
  type: string;              // 组件类型:"Input" | "Card" | "Row" | ...
  name?: string;             // 字段名(表单组件才有)
  props?: Record<string, unknown>;  // 传给组件的 props
  validation?: ValidationSchema;     // 校验规则
  conditional?: ConditionalSchema;   // 条件渲染
  actions?: Record<string, ActionSchema>;  // 事件处理
  children?: ComponentSchema[];      // 子组件(布局容器用)
}

// 顶层 Schema
interface FormSchema {
  id: string;
  title?: string;
  description?: string;
  form?: {
    initialValues?: Record<string, unknown>;  // 默认值
    values?: Record<string, unknown>;         // 预填值(优先级最高)
    validateOn?: "change" | "blur" | "submit";
  };
  components: ComponentSchema[];  // 组件树
}

关键设计决策:ComponentSchema 是递归的。一个 Cardchildren 里可以放 RowRow 里放 InputButton。这意味着 Schema 可以描述任意深度的 UI 树,不局限于扁平的字段列表。

一个真实的 Schema 示例

{
  "id": "employee-onboarding",
  "title": "新员工入职登记",
  "form": {
    "initialValues": { "accountType": "individual" }
  },
  "components": [
    {
      "type": "Card",
      "props": { "title": "基本信息" },
      "children": [
        {
          "type": "Row",
          "props": { "gap": 16 },
          "children": [
            {
              "type": "Input",
              "name": "name",
              "props": { "label": "姓名", "placeholder": "请输入姓名" },
              "validation": {
                "rule": "LEN(@name) >= 2",
                "message": "姓名至少 2 个字符"
              }
            },
            {
              "type": "DatePicker",
              "name": "startDate",
              "props": { "label": "入职日期" }
            }
          ]
        },
        {
          "type": "Selector",
          "name": "department",
          "props": {
            "label": "部门",
            "options": [
              { "value": "eng", "label": "工程" },
              { "value": "product", "label": "产品" },
              { "value": "design", "label": "设计" }
            ]
          }
        },
        {
          "type": "RadioGroup",
          "name": "accountType",
          "props": {
            "label": "账号类型",
            "options": [
              { "value": "individual", "label": "个人" },
              { "value": "business", "label": "企业" }
            ]
          }
        },
        {
          "type": "Input",
          "name": "companyName",
          "props": { "label": "公司名称" },
          "conditional": {
            "when": "@accountType == 'business'"
          }
        }
      ]
    },
    {
      "type": "Button",
      "props": { "label": "提交", "type": "submit" },
      "actions": {
        "onClick": { "type": "submit" }
      }
    }
  ]
}

这份 JSON 描述了:布局(Card 包裹、Row 分栏)、表单字段(Input、DatePicker、Selector、RadioGroup)、校验规则(姓名长度 ≥ 2)、条件渲染(选择"企业"才显示公司名称)、交互动作(按钮提交)。前端不需要写任何组件代码,只需要一个通用渲染器。

表达式引擎

条件渲染和校验规则的核心是一个表达式引擎。Schema 里的字符串表达式(如 @accountType == 'business')需要在运行时求值。

@ 前缀引用表单字段的当前值,表达式支持比较、逻辑运算和内置函数:

// 校验规则示例
"LEN(@name) >= 2"                              // 字符串长度
"@age >= 18 AND @age <= 120"                   // 范围校验
"LEN(@email) > 5 AND FIND(\"@\", @email) > 0" // 邮箱格式

// 条件渲染示例
"@accountType == 'business'"                    // 等值判断
"@role == 'admin' OR @role == 'super_admin'"   // 多条件
"@items > 0"                                    // 数值比较

表达式引擎的实现不需要很复杂。一个简单的递归下降 parser 就够了——tokenize 表达式字符串,解析成 AST,然后递归求值。关键是:表达式的求值上下文必须和表单状态同步。每次表单值变化时,表达式引擎能拿到最新的值来重新求值。

实现方式是用 React Context 把表单值注入到表达式引擎:

function FormContent({ schema, registry }) {
  const formContext = useFormSchema();
  const evalContext = useLibExprEvaluation();

  // 表单值变化时,同步到表达式引擎
  useEffect(() => {
    evalContext.setValues(formContext.values);
  }, [formContext.values]);

  return (
    <form>
      {schema.components.map((comp, i) => (
        <SchemaField key={comp.name || i} schema={comp} registry={registry} />
      ))}
    </form>
  );
}

条件渲染

条件渲染的 Schema 定义:

interface ConditionalSchema {
  when: string;                     // 表达式,决定是否可见
  then?: Partial<ComponentSchema>;  // 条件为 true 时合并的 props
  else?: Partial<ComponentSchema>;  // 条件为 false 时合并的 props
}

不仅能控制显示/隐藏,还能根据条件修改组件的 props。比如:

{
  "type": "Input",
  "name": "budget",
  "props": { "label": "预算" },
  "conditional": {
    "when": "@role == 'manager'",
    "then": { "props": { "label": "部门预算(管理员)" } },
    "else": { "props": { "disabled": true } }
  }
}

渲染器里的实现很直接:

function SchemaField({ schema, registry }) {
  const evalContext = useLibExprEvaluation();
  const { conditional } = schema;

  const isVisible = useMemo(() => {
    if (!conditional?.when) return true;
    try {
      return evalContext.validate(conditional.when);
    } catch {
      return true;  // 表达式出错时默认显示
    }
  }, [conditional?.when, evalContext]);

  if (!isVisible) return null;

  // ... 渲染组件
}

组件注册表

Schema 里的 type: "Input" 怎么变成真正的 React 组件?通过一个组件注册表:

interface ComponentRegistry {
  [key: string]: React.ComponentType<any>;
}

const defaultRegistry: ComponentRegistry = {
  // 表单输入
  Input: XDSInput,
  TextArea: XDSTextArea,
  NumberInput: XDSNumberInput,
  Checkbox: XDSCheckbox,
  RadioGroup: XDSRadioGroup,
  Selector: XDSSelector,
  Switch: XDSSwitch,
  DatePicker: XDSDatePicker,
  TimePicker: XDSTimePicker,
  FileUpload: XDSFileUpload,
  Slider: XDSSlider,
  Rating: XDSRating,
  ColorPicker: XDSColorPicker,

  // 布局
  Card: XDSCard,
  Row: XDSFlexbox,     // direction: row
  Column: XDSFlexbox,  // direction: column
  Tabs: XDSTabs,
  Accordion: XDSAccordion,
  Divider: XDSDivider,

  // 展示
  Text: XDSText,
  Badge: XDSBadge,
  Alert: XDSAlert,
  Image: XDSImage,
  Table: XDSTable,

  // 交互
  Button: XDSButton,
  Dropdown: XDSDropdown,
};

使用时可以注入自定义组件覆盖默认的,或者扩展新类型:

<SchemaRenderer
  schema={schema}
  components={{
    RichTextEditor: MyRichTextEditor,  // 新增
    Input: MyCustomInput,              // 覆盖默认
  }}
/>

这个设计的好处是:Schema 和组件实现完全解耦。同一份 Schema 可以用不同的组件库渲染——桌面端用 Ant Design 的注册表,移动端用 React Native 的注册表,甚至可以渲染成 Flutter Widget。

Action 系统

表单不只是收集数据,还有交互。Action Schema 描述用户触发的行为:

type ActionType = "submit" | "setValue" | "navigate" | "toast" | "api" | "custom";

interface ActionSchema {
  type: ActionType;
  field?: string;    // setValue: 目标字段
  value?: unknown;   // setValue: 新值
  to?: string;       // navigate: 目标路径
  message?: string;  // toast: 提示内容
  url?: string;      // api: 请求 URL
  method?: string;   // api: HTTP 方法
  handler?: string;  // custom: 处理函数名
}

在 Schema 里这样用:

// 点击按钮提交表单
{
  "type": "Button",
  "props": { "label": "提交" },
  "actions": {
    "onClick": { "type": "submit" }
  }
}

// 点击按钮设置另一个字段的值
{
  "type": "Button",
  "props": { "label": "填入默认地址" },
  "actions": {
    "onClick": {
      "type": "setValue",
      "field": "address",
      "value": "上海市浦东新区"
    }
  }
}

// 调用自定义处理函数
{
  "type": "Button",
  "props": { "label": "导出 PDF" },
  "actions": {
    "onClick": {
      "type": "custom",
      "handler": "exportPDF"
    }
  }
}

渲染器根据 Action 类型分发到对应的处理逻辑:

const buildActionHandler = (action: ActionSchema) => () => {
  switch (action.type) {
    case "setValue":
      formContext.setValue(action.field, action.value);
      break;
    case "submit":
      formContext.submit();
      break;
    case "custom":
      actionHandlers?.[action.handler]?.(action);
      break;
  }
};

状态管理与持久化

表单状态管理用 React Context 实现,提供一组 hooks:

// 完整的表单上下文
const { values, setValue, reset, isDirty, submit, getSchema } = useFormSchema();

// 单个字段绑定
const [email, setEmail, { touched, setTouched }] = useFormField<string>("email");

// 只读值
const values = useFormValues();

// 监听特定字段变化
const { name, age } = useWatchFields(["name", "age"]);

状态持久化是一个巧妙的设计:getSchema() 方法返回当前 Schema 并把用户填写的值嵌入到 form.values 里。保存这份 JSON,下次加载时表单自动恢复到之前的状态:

// 保存当前进度
const schemaWithValues = getSchema();
localStorage.setItem("draft", JSON.stringify(schemaWithValues));

// 恢复进度——直接渲染保存的 Schema,表单自动预填
const saved = JSON.parse(localStorage.getItem("draft"));
<SchemaRenderer schema={saved} />

值的优先级设计也很讲究:form.values(预填/恢复)> props.initialValues(外部传入)> form.initialValues(Schema 默认值)。这保证了持久化恢复总能覆盖默认值。

Props 中的动态表达式

Schema 里的 props 不仅可以是静态值,还可以包含表达式插值:

{
  "type": "Text",
  "props": {
    "content": "${@name ? '你好,' + @name + '!' : '请输入姓名'}"
  }
}

渲染器递归遍历 props,遇到 ${...} 就调用表达式引擎求值:

function evaluateProps(props, evaluate) {
  const result = {};
  for (const [key, value] of Object.entries(props)) {
    if (typeof value === "string" && value.includes("${")) {
      result[key] = safeEvaluate(value, evaluate);
    } else if (typeof value === "object" && value !== null) {
      result[key] = evaluateProps(value, evaluate);  // 递归
    } else {
      result[key] = value;
    }
  }
  return result;
}

这让 Schema 具备了"数据绑定"的能力——展示组件的内容可以跟着表单输入实时变化,不需要写任何 JavaScript。

渲染器的 Switch 策略

不同类型的组件需要不同的 props 绑定方式。这是渲染器里最"脏"但最关键的部分:

// Input 用 onValueChange
case "Input":
  componentProps.onValueChange = handleChange;
  break;

// TextArea 用 event-based onChange
case "TextArea":
  componentProps.onChange = (e) => handleChange(e.target.value);
  break;

// Checkbox/Switch 用 checked + event
case "Checkbox":
case "Switch":
  componentProps.checked = Boolean(value);
  componentProps.onChange = (e) => handleChange(e.target.checked);
  break;

// DatePicker 需要 string ↔ Date 转换
case "DatePicker":
  componentProps.value = toDate(value);
  componentProps.onChange = handleChange;
  break;

// 布局组件不需要 value/name
case "Card":
case "Row":
case "Divider":
  delete componentProps.value;
  delete componentProps.name;
  break;

这段 switch 是不可避免的"适配层"。每个 UI 组件库对 onChange 的约定都不一样——有的传 value,有的传 event,有的传 Date 对象。Schema 渲染器的职责就是抹平这些差异,让 Schema 作者不需要关心底层组件的 API 细节。

选择模式:给可视化编辑器铺路

如果你要做一个拖拽式的表单构建器,需要让用户能点击页面上的组件来选中它、编辑它的属性。这需要一个"选择模式":

<SchemaRenderer
  schema={schema}
  selectionMode={true}
  onComponentSelect={(info) => {
    console.log(info.path);  // "components[0].children[2]"
    console.log(info.type);  // "Input"
    console.log(info.name);  // "email"
  }}
/>

选择模式下,每个组件外面套一层 hover overlay,点击时返回组件在 Schema 树中的路径。编辑器可以根据这个路径定位到 Schema JSON,修改 props 后重新渲染。

这就形成了一个完整的闭环:

可视化编辑器
    ↓ 拖拽 / 配置
FormSchema (JSON)
    ↓ SchemaRenderer
渲染出的 UI
    ↓ 用户交互
FormResponse (JSON)
    ↓ getSchema()
带状态的 FormSchema(可持久化)

这个方案的边界

和其他方案的关系

方案 Schema 范围 表达式能力 布局能力
react-jsonschema-form 数据结构 需要 uiSchema 补充
Formily 表单字段 + 布局 JSON Schema + reactions FormLayout / FormGrid
Retool / Appsmith 整个应用 JavaScript 拖拽画布
本文的方案 UI 组件树 自定义表达式语言 组件嵌套(Card/Row/Tabs)

本文的方案介于"表单 Schema"和"低代码平台"之间。它比 react-jsonschema-form 强大(支持布局、条件渲染、Action),但比 Retool 轻量(不涉及数据源连接、API 编排、状态机)。这个定位适合"需要非开发者配置 UI,但不需要一个完整低代码平台"的场景。

总结

Schema 驱动 UI 的核心公式:

ComponentSchema(递归的组件树描述)
  + ComponentRegistry(类型 → 组件的映射)
  + Expression Engine(动态求值)
  + FormContext(状态管理 + 持久化)
  = SchemaRenderer(通用渲染器)

四个部分各自独立,组合起来就能渲染任意复杂度的 UI。Schema 负责"是什么",Registry 负责"长什么样",Expression Engine 负责"什么时候",FormContext 负责"当前状态是什么"。

这不是一个新想法——从 WinForms 的 XML 描述到 SwiftUI 的声明式语法,"用数据描述 UI"是一个反复出现的主题。JSON Schema 驱动 UI 只是这个思想在 Web 前端、特别是 B 端产品中的一个具体实例。它的价值不在于技术上有多先进,而在于:它把 UI 的创建权从开发者转移给了业务人员

上篇:Schema 驱动表单(上):数据模型与渲染器