首页 / 文章 / 基于模式驱动的 React 表单:从 JSON 模式进行渲染与验证

基于模式驱动的 React 表单:从 JSON 模式进行渲染与验证

如何直接从 JSON Schema 渲染经过验证的 React 表单,处理 $ref、oneOf 以及 if/then 分支,插入自定义组件,并避免常见的验证陷阱。

2345 词

手写的 React 表单通常会复制已存在的合同内容。API 的请求规范规定 email 是必填项且必须符合电子邮件地址格式,age 为非负整数,而 role 则只能是三个指定值之一。如果在 JSX 中再次编写这些规则、在验证库中再写一次、又在错误信息中重复表述,就会为同一数据结构创建三个不同的规则来源,一旦后端发生变化,这些规则就会出现不一致。

本指南则将表单视为基于 JSON Schema 构建的,并以开源的 react-simple-schema-form 包作为具体实现方案。您将了解到如何通过 $ref、allOf、oneOf 以及 if/then 创建动态字段,如何插入自定义组件,以及哪些验证机制能让生成的表单具有手工设计的质感。

为何应让模式掌控表单

问题很容易预测:新的后端字段永远不会出现在表单中,而像“仅显示发票付款的账单地址”这样的需求最终会变成一个useState标志、条件渲染逻辑以及验证分支,几个月后这些组件就会出现不同步的情况。

JSON Schema已经能够表达所有这些规则:数据类型、约束条件、必填字段以及条件逻辑。它通常就是后端用于验证请求的文档,同时也会被嵌入到OpenAPI规范中。如果表单是根据该模式生成的,那么只需修改模式即可同时更新用户界面及其验证规则。

最简化的生成表单

react-simple-schema-form 可以接收遵循 draft-07 标准编写的 JSON Schema,并渲染经过验证的表单。根据其文档说明,除了 React 18 外它没有其他运行时依赖,自带 TypeScript 类型定义,并提供可选的样式表。安装只需一个包即可:

npm install react-simple-schema-form

下面的示例描述了一个简单的用户对象:姓名、格式为 format: 'email' 的电子邮件地址、最小值为零的整数年龄,以及通过 enum 限制的角色。name 和 email 被标记为必填项。该组件仅接收 schema 和 onSubmit 回调函数,没有其他输入。

import { SchemaForm } from 'react-simple-schema-form';
import 'react-simple-schema-form/styles.css';

const schema = {
  type: 'object',
  properties: {
    name:  { type: 'string', title: 'Name' },
    email: { type: 'string', format: 'email', title: 'Email' },
    age:   { type: 'integer', minimum: 0, title: 'Age' },
    role:  { type: 'string', enum: ['Admin', 'Editor', 'Viewer'], title: 'Role' },
  },
  required: ['name', 'email'],
};

<SchemaForm schema={schema} onSubmit={(data) => save(data)} />

根据该架构,该库会渲染一个文本输入框、电子邮件输入框、数字输入框以及用于选择枚举值的下拉菜单,标记必填字段,显示内联错误,并且仅在数据有效时才会调用onSubmit方法。该组件可在两种React模式下使用:通过传递value和onChange来控制它,或者通过传递defaultValue让它自行管理状态。

任何生成器都能处理这样的扁平对象;真正的考验在于嵌套和分支结构的架构。

处理非扁平结构的数据架构

实际应用中的数据架构会重复使用定义、组合不同片段,并根据数据进行分支处理。该库会在每次渲染之前根据当前表单数据解析所有这些结构,因此每个字段看到的始终是扁平化的架构。

使用$ref和allOf重复使用定义

像 address 这样的共享定义可以在两个位置被引用,从而生成两个独立的板块。置于 $ref 旁边的关键字会覆盖被引用的定义,因此 { "$ref": "#/definitions/address", "title": "Shipping address" } 会生成一个标有“收货地址”字样的地址板块。使用 allOf 时,各部分会进行深度合并:嵌套属性会被递归合并,而 required 数组则会被合并为它们的并集。

将 oneOf 视为区分式联合类型

许多生成器在处理 oneOf 时遇到困难。有效的解决方案是采用区分式联合类型:每个分支都使用 const 将共享字段固定为特定值,表单则通过该字段来选择当前有效的分支。

在下面的支付方案中,method 是 card 或 bank 的枚举值。第一个分支将 method 设为 card,并要求提供 number;第二个分支则将其设为 bank,并要求提供 iban。

{
  "type": "object",
  "properties": { "method": { "type": "string", "enum": ["card", "bank"] } },
  "required": ["method"],
  "oneOf": [
    { "title": "Card", "properties": { "method": { "const": "card" }, "number": { "type": "string" } }, "required": ["number"] },
    { "title": "Bank", "properties": { "method": { "const": "bank" }, "iban":   { "type": "string" } }, "required": ["iban"] }
  ]
}

将 method 从 card 更改为 bank 时,需要用 IBAN 字段替换卡号字段。你的代码中既没有组件状态,也没有条件 JSX,变化完全由方案决定。那些分支中仅包含 const 的 oneOf 会以带标签的下拉选择框形式呈现。

使用 if/then/else 和 dependencies 的条件部分

每当数据发生变化时,包括每次按键时,条件关键字都会被重新评估。一个实用的方法是设置一个可选部分,仅在用户启用它时才进行验证。下面的代码片段定义了一个包含布尔型enabled标志(默认值为false)以及两个日期字段的schedule对象。当enabled的值为true时,if条件成立,此时monday和tuesday字段变为必填项。

"schedule": {
  "type": "object",
  "properties": {
    "enabled": { "type": "boolean", "title": "Enable schedule", "default": false },
    "monday":  { "type": "string", "title": "Monday" },
    "tuesday": { "type": "string", "title": "Tuesday" }
  },
  "if":   { "properties": { "enabled": { "const": true } }, "required": ["enabled"] },
  "then": { "required": ["monday", "tuesday"] }
}

当该开关处于关闭状态时,该区域内的内容无需填写,也不会阻止表单提交。而当开关处于开启状态时,两个日期字段都会出现必填标记,只有填写完这些字段后才能提交表单。由于该开关属于数据的一部分而非本地用户界面状态,服务器可以使用相同的架构对相同的数据内容进行验证,并得出一致的判断结果。

if语句中的"required": ["enabled"]这一行很容易被忽略,但却至关重要。在JSON Schema中,properties仅能约束实际存在的键。因此,没有enabled键的对象会符合{ "properties": { "enabled": { "const": true } } }的要求,此时then分支会被触发,即便该部分从未被启用,这些字段也会变为必填项。在条件中要求存在该键就能弥补这一漏洞。

选择与定制小部件

只有当您能够控制每个字段使用何种输入类型时,生成的表单才有实际意义。该库维护着内置组件的注册表,包括text、email、number、select、radio、checkboxes、textarea和date,并提供三种分配方式:

  1. 通过路径键定的uiSchema属性,支持通配符。tags.*可匹配数组中的所有项,而**.postalCode则能匹配任意深度的所有邮政编码,即便这些邮政编码出现在两个$ref引用中也是如此。当多个键匹配时,最具体的那个键优先生效。
  • 嵌入架构的提示。节点可以拥有自己的ui:*关键字,而父节点则可以包含通过子节点名称引用的嵌套uiSchema,这样任何引用共享定义的人都可以重新设置其子节点的样式。
  • 一个resolveWidget函数,用于基于规则的决策,例如“所有具有format: epoch格式的整数都使用epoch组件”。该函数接收已完全解析的架构,可以返回组件名称或组件本身。
  • 优先级是固定的:应用程序的uiSchema优先于嵌入在架构中的提示,提示又优先于resolveWidget规则,规则则优先于默认设置。当其他团队提供架构时,这种可预测性非常重要:客户端始终可以覆盖其提示设置。

    编写自定义组件

    小部件是一种组件,它会接收当前值和onChange回调函数,以及id、required、disabled和onBlur等属性。下面的示例以Unix秒数形式存储时间戳,但向用户展示的是原生的datetime-local选择器。它会将秒数转换为日期字符串进行显示,而在值发生变化时又会将其解析回来,同时将毫秒除以1000;如果输入为空或无效,则传递undefined。该小部件以epoch为名称注册,并通过uiSchema被分配到startsAt字段中。

    import type { Widget } from 'react-simple-schema-form';
    
    const EpochWidget: Widget<number | undefined> = ({ id, value, onChange, onBlur, required, disabled }) => (
      <input
        type="datetime-local"
        id={id}
        required={required}
        disabled={disabled}
        value={value === undefined ? '' : new Date(value * 1000).toISOString().slice(0, 16)}
        onBlur={onBlur}
        onChange={(e) => {
          const ms = new Date(e.target.value).getTime();
          onChange(Number.isNaN(ms) ? undefined : Math.floor(ms / 1000));
        }}
      />
    );
    
    <SchemaForm schema={schema} widgets={{ epoch: EpochWidget }} uiSchema={{ startsAt: { widget: 'epoch' } }} />
    

    架构中规定使用integer类型,但用户看到的却是选择器,且实际存储的数据为Unix时间戳。需要注意一点:toISOString()返回的是UTC时间,而datetime-local类型的输入以及new Date(e.target.value)则均以用户的本地时区为准。在UTC时区之外,显示的时间会因时区偏移而发生变化,每次修改都会改变存储的值。因此应直接根据本地日期部分来格式化显示值,这样双向数据才能保持一致。

    某个组件还可以拥有整个对象或数组,从而获取全部数据以及其中的所有嵌套错误信息,并通过导出的<Field>组件来渲染子元素。正是这种方式让日程安排部分具备了切换显示/隐藏的功能,而无需库方知晓具体的日程信息。

    如果架构引用的小部件从未被注册,库会记录一条警告信息并回退到默认输入方式。由其他团队提供的架构中出现拼写错误时,应能平滑降级而非导致页面崩溃。

    符合用户预期的验证机制

    内置的验证器体积小且无依赖项,其设计重点在于何时报告错误,而非判断错误是否存在。

    在恰当时机显示错误

    错误会在用户离开某个字段后出现,或在尝试提交时一次性显示,绝不会在首次渲染时就出现。当提交失败时,焦点会移至第一个无效字段。

    将未被修改的可选对象视为不存在

    为嵌套对象生成输入字段时,表单会使用{}来初始化它们。如果采用简单的验证机制,对于用户从未触碰的可选地址字段,它仍会要求填写street和city。解决方法是把所有值都为空的可选对象视为不存在,这样就不会产生错误。必填对象则始终会进行验证,并显示具体缺少哪些子字段的错误信息,而非模糊的“地址为必填项”提示。

    属性oneOf在当前分支中引发的错误

    当没有oneOf分支通过验证时,那种通用的“数据必须完全符合某一架构”提示对用户来说毫无用处。相反,验证器会通过判别条件和类型来确定数据属于哪个分支,同时忽略required属性,并报告该分支对应的字段级错误。例如,对于method: card但缺少卡号的支付请求,错误会显示在卡号字段处,这正是用户会查看的位置。

    切勿让隐藏字段阻碍提交

    在已被关闭的区域内,那些残留的、未输入完整的值不应导致用户无法看到的pattern校验失败。规则存在于架构中,但解决方案在于组件本身:当该区域被禁用时,组件应清除相关内容,而errors属性则用于告知组件其隐藏部分存在哪些错误。

    在React之外重用这些规则

    该验证器也可单独导出。validate(schema, data)会返回一个包含{ path, keyword, message }条记录的列表,因此相同的规则既可在Node.js服务中运行,也可用于单元测试,或在任何内容渲染之前执行。如需以TypeScript为优先的替代方案,请参阅在React与Node之间共享同一个Zod架构。

    面向代码辅助工具的文档

    表单通常是通过AI代码辅助工具来生成的,因此该包同时提供了面向机器和人类的文档:

    • 在 npm 包内的 skills/react-simple-schema-form/SKILL.md 处有一个 Agent Skills 文件,支持该格式的工具可以从 node_modules 中加载它。该文件涵盖了 API、组件优先级、上述示例以及已知问题,撰写时大小约为 7 kB。
    • 演示网站上的 llms.txt 和 llms-full.txt 将 README、技能说明以及所有示例架构整合到一个文件中,可直接粘贴到聊天界面或被文档 MCP 服务器索引。
    • 每个导出函数都配有 JSDoc 示例,编辑器将光标悬停在类型声明上即可查看用法说明。
    • 还有一个 context7.json 文件,便于该仓库在 Context7 中实现整洁的索引。

    这不会让模型直接选择某个库,但能提高助手首次尝试就成功的概率,这种做法也值得在内部库中采用。

    试用体验

    在线演示在生成的表单旁提供了架构编辑器,下方还会显示实时数据与错误信息。其中包含了关于$ref、allOf、oneOf、if/then/else、dependencies以及组件选择的示例。该包在npm上发布,其源代码和问题追踪系统则在GitHub上。由于这还是一个较新的项目,建议在依赖它之前先用自己的架构进行测试。

    核心要点

    • 如果 API 已经发布了 JSON Schema,那么根据其生成表单可以去除重复规则,同时确保用户界面验证与服务器端验证保持一致。
    • 针对实时数据解析 $ref、allOf、oneOf 以及条件逻辑,从而使每个字段都能看到扁平化的架构。
    • 将不同版本的表单建模为使用 const 定义的区分联合体,并始终在 if 子句中添加 required 属性。
    • 应通过明确的优先级顺序来保留组件选择的可覆盖性,尤其是对于由其他团队管理的架构。
    • 优质的生成表单取决于验证时机:在输入框失去焦点或提交时进行验证,忽略未被修改的可选对象,并将 oneOf 引起的错误指向当前有效的分支。