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

Roman Mosaic Floor Panel, 2nd century CE
罗马马赛克地板,公元2世纪 — 每一块小石子按照严格的规则排列,拼出完整的图案。Schema 驱动的表单也是如此:用结构化的规则,拼出灵活的界面
这篇文章的价值:B 端产品的表单需求永远做不完——注册、审批、问卷、配置,每个场景都不同。与其为每个表单写一个组件,不如设计一套 JSON Schema 来描述表单结构,让前端自动渲染。这篇文章分享一个经过生产验证的数据模型:FormSpec → Question → QuestionResponse 三层结构,支持 15+ 字段类型、分页、校验,并且非开发者也能通过编辑 JSON 来创建表单。

问题的起点

几乎每个 B 端产品都会遇到同一个问题:表单太多了

注册表单、审批表单、调查问卷、配置页面、数据录入……每一个业务场景都需要一套表单,每套表单的字段、校验规则、布局都不一样。如果每个表单都手写一个 React 组件,代码量会爆炸式增长,而且改一个字段就要发一次版。

更糟糕的是,很多表单的创建者不是开发者——是产品经理、运营、HR。他们需要一个"拖拖拽拽就能建表单"的工具,而不是等排期。

这就是 Schema 驱动表单的用武之地:用一份 JSON 描述表单的结构,前端根据这份 JSON 自动渲染出完整的表单。改字段?改 JSON。加校验?改 JSON。换布局?还是改 JSON。不需要改代码,不需要发版。

核心思想

Schema 驱动表单的核心是一个简单的分离:

表单定义(What)  →  JSON Schema  →  存在数据库里
表单渲染(How)   →  通用渲染器   →  一套代码搞定所有表单
表单数据(Data)  →  JSON 响应    →  结构化存储和查询

这三者之间的关系:

┌─────────────────────────────────────────────────────┐
│                    Form Builder                      │
│  产品经理/运营 通过可视化界面创建表单                      │
│  输出:FormSpec(JSON Schema)                        │
└──────────────────────┬──────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────┐
│                    Form Schema                       │
│  {                                                   │
│    "title": "入职信息收集",                             │
│    "groups": [                                       │
│      { "label": "基本信息",                            │
│        "questions": [                                │
│          { "type": "short_text", "label": "姓名",     │
│            "required": true },                       │
│          { "type": "date", "label": "入职日期" },      │
│          { "type": "drop_down", "label": "部门",      │
│            "options": ["工程", "产品", "设计"] }        │
│        ] }                                           │
│    ]                                                 │
│  }                                                   │
└──────────────────────┬──────────────────────────────┘
                       │
                       ▼
┌─────────────────────────────────────────────────────┐
│                  Form Renderer                       │
│  根据 Schema 自动渲染表单 UI                           │
│  收集用户输入 → FormResponse                          │
└─────────────────────────────────────────────────────┘

数据模型设计

一个完整的 Schema 驱动表单系统需要三层数据模型:

第一层:FormSpec(表单定义)

// FormSpec —— 一张表单的元数据
type FormSpec struct {
    ID          int
    Name        string       // "入职信息收集"
    Description string       // 表单说明
    Cover       string       // 封面图 URL
    Enabled     bool         // 是否启用
    CreatedBy   int          // 创建者
    Groups      []QuestionGroup  // 问题分组
}

// QuestionGroup —— 问题分组(用于表单分页或分区)
type QuestionGroup struct {
    ID        int
    Label     string       // "基本信息" / "工作经历"
    Order     int          // 排序
    Questions []Question
}

第二层:Question(问题定义)

type QuestionType string

const (
    ShortText   QuestionType = "short_text"
    Paragraph   QuestionType = "paragraph"
    MultiChoice QuestionType = "multi_choice"
    Checkboxes  QuestionType = "checkboxes"
    DropDown    QuestionType = "drop_down"
    FileUpload  QuestionType = "file"
    LinearScale QuestionType = "linear_scale"
    Date        QuestionType = "date"
    Time        QuestionType = "time"
)

type Question struct {
    ID       int
    Label    string         // "你的姓名"
    Type     QuestionType   // "short_text"
    Required bool           // 是否必填
    Options  []string       // 选择题的选项
    Config   QuestionConfig // 额外配置
}

type QuestionConfig struct {
    Placeholder  string  // 输入提示
    MinLength    *int    // 最小长度
    MaxLength    *int    // 最大长度
    MinValue     *int    // linear_scale 最小值
    MaxValue     *int    // linear_scale 最大值
    FileTypes    []string // 允许的文件类型
}

第三层:QuestionResponse(用户回答)

type QuestionResponse struct {
    ID             int
    QuestionID     int      // 关联到 Question
    FormInstanceID int      // 关联到某次填写
    Label          string   // 冗余存储问题标签(方便查询)
    Value          string   // 用户的回答(统一用 string 存储)
}

type FormInstance struct {
    ID         int
    FormSpecID int          // 关联到 FormSpec
    SubmittedBy int         // 填写者
    SubmittedAt time.Time
    Responses  []QuestionResponse
}

这里有一个关键设计决策:QuestionResponse.Value 统一用 string 存储。不管是日期、数字、还是多选,都序列化成字符串。这样做的好处是 schema 简单,不需要为每种类型建不同的列。坏处是查询时需要反序列化,但对于表单场景,大多数查询是"导出某次填写的所有回答",而不是"找出所有年龄大于 25 的回答",所以这个 trade-off 是值得的。

前端渲染器

渲染器的核心是一个 switch 语句——根据 Question 的 type 字段渲染不同的 UI 组件:

// Question.tsx
function Question({ question, value, onChange }) {
  switch (question.type) {
    case "short_text":
      return (
        <div className="form-field">
          <label>{question.label}{question.required && " *"}</label>
          <input
            type="text"
            value={value}
            onChange={e => onChange(e.target.value)}
            placeholder={question.config?.placeholder}
            required={question.required}
          />
        </div>
      );

    case "paragraph":
      return (
        <div className="form-field">
          <label>{question.label}</label>
          <textarea
            value={value}
            onChange={e => onChange(e.target.value)}
            rows={4}
          />
        </div>
      );

    case "multi_choice":
      return (
        <div className="form-field">
          <label>{question.label}</label>
          {question.options.map(opt => (
            <label key={opt} className="radio-option">
              <input
                type="radio"
                name={`q-${question.id}`}
                value={opt}
                checked={value === opt}
                onChange={() => onChange(opt)}
              />
              {opt}
            </label>
          ))}
        </div>
      );

    case "checkboxes":
      const selected = value ? JSON.parse(value) : [];
      return (
        <div className="form-field">
          <label>{question.label}</label>
          {question.options.map(opt => (
            <label key={opt} className="checkbox-option">
              <input
                type="checkbox"
                checked={selected.includes(opt)}
                onChange={() => {
                  const next = selected.includes(opt)
                    ? selected.filter(s => s !== opt)
                    : [...selected, opt];
                  onChange(JSON.stringify(next));
                }}
              />
              {opt}
            </label>
          ))}
        </div>
      );

    case "drop_down":
      return (
        <div className="form-field">
          <label>{question.label}</label>
          <select value={value} onChange={e => onChange(e.target.value)}>
            <option value="">请选择</option>
            {question.options.map(opt => (
              <option key={opt} value={opt}>{opt}</option>
            ))}
          </select>
        </div>
      );

    case "date":
      return (
        <div className="form-field">
          <label>{question.label}</label>
          <input type="date" value={value} onChange={e => onChange(e.target.value)} />
        </div>
      );

    case "file":
      return (
        <div className="form-field">
          <label>{question.label}</label>
          <input
            type="file"
            accept={question.config?.fileTypes?.join(",")}
            onChange={e => {
              const file = e.target.files?.[0];
              if (file) uploadAndGetUrl(file).then(onChange);
            }}
          />
        </div>
      );

    default:
      return <div>Unsupported question type: {question.type}</div>;
  }
}

这个 switch 语句看起来很"笨",但这正是它的优势:每种类型的渲染逻辑完全独立,互不干扰。加一种新类型?加一个 case。改某种类型的 UI?只改那个 case。不存在"修改单选框影响了多选框"的问题。

完整的表单渲染

把 Question 组件组合起来,渲染整个表单:

function FormRenderer({ formSpec }) {
  const [responses, setResponses] = useState({});

  const handleChange = (questionId, value) => {
    setResponses(prev => ({ ...prev, [questionId]: value }));
  };

  const handleSubmit = async () => {
    // 校验必填项
    for (const group of formSpec.groups) {
      for (const q of group.questions) {
        if (q.required && !responses[q.id]) {
          alert(`请填写:${q.label}`);
          return;
        }
      }
    }

    // 构造提交数据
    const payload = Object.entries(responses).map(([qId, value]) => ({
      questionId: parseInt(qId),
      value: String(value),
    }));

    await submitFormResponse(formSpec.id, payload);
  };

  return (
    <form onSubmit={e => { e.preventDefault(); handleSubmit(); }}>
      <h2>{formSpec.name}</h2>
      <p>{formSpec.description}</p>

      {formSpec.groups.map(group => (
        <fieldset key={group.id}>
          <legend>{group.label}</legend>
          {group.questions.map(q => (
            <Question
              key={q.id}
              question={q}
              value={responses[q.id] || ""}
              onChange={v => handleChange(q.id, v)}
            />
          ))}
        </fieldset>
      ))}

      <button type="submit">提交</button>
    </form>
  );
}

后端 API 设计

用 GraphQL 来设计 API 特别自然,因为表单的数据结构本身就是嵌套的:

type FormSpec {
  id: ID!
  name: String!
  description: String
  enabled: Boolean!
  groups: [QuestionGroup!]!
}

type QuestionGroup {
  id: ID!
  label: String!
  order: Int!
  questions: [Question!]!
}

type Question {
  id: ID!
  label: String!
  type: QuestionType!
  required: Boolean!
  options: [String!]
  config: QuestionConfig
}

enum QuestionType {
  SHORT_TEXT
  PARAGRAPH
  MULTI_CHOICE
  CHECKBOXES
  DROP_DOWN
  FILE
  LINEAR_SCALE
  DATE
  TIME
}

# 查询
type Query {
  formSpec(id: ID!): FormSpec
  formInstances(formSpecId: ID!): [FormInstance!]!
}

# 提交
input QuestionResponseInput {
  questionId: ID!
  value: String!
}

type Mutation {
  submitForm(formSpecId: ID!, responses: [QuestionResponseInput!]!): FormInstance!
}

GraphQL 的嵌套查询让前端可以一次请求拿到完整的表单定义(FormSpec → Groups → Questions),不需要多次往返。配合 Relay 或 Apollo 的 fragment 机制,Question 组件可以声明自己需要的数据,框架自动合并查询。

用 ORM 代码生成简化后端

如果你用 Go + Ent ORM,可以通过 schema 定义直接生成 CRUD 代码和 GraphQL resolver:

// ent/schema/formspec.go
func (FormSpec) Fields() []ent.Field {
    return []ent.Field{
        field.String("name").NotEmpty(),
        field.String("description").Optional(),
        field.String("cover").Optional(),
        field.Bool("enabled").Default(true),
        field.Time("created_at").Default(time.Now),
        field.Int("created_by"),
    }
}

func (FormSpec) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("question_groups", QuestionGroup.Type),
    }
}

// ent/schema/question.go
func (Question) Fields() []ent.Field {
    return []ent.Field{
        field.String("label").NotEmpty(),
        field.Enum("type").Values(
            "short_text", "paragraph", "multi_choice",
            "checkboxes", "drop_down", "file",
            "linear_scale", "date", "time",
        ),
        field.Bool("required").Default(false),
        field.JSON("options", []string{}).Optional(),
    }
}

Ent 会根据这些 schema 定义自动生成:数据库 migration、Go 类型定义、CRUD 方法、GraphQL type 和 resolver(配合 entgql)。从 schema 到可用的 API,中间几乎不需要写胶水代码。

校验策略

表单校验分两层:

前端校验(即时反馈):

function validate(question, value) {
  if (question.required && !value) {
    return `${question.label} 是必填项`;
  }

  const config = question.config;
  if (!config) return null;

  switch (question.type) {
    case "short_text":
    case "paragraph":
      if (config.minLength && value.length < config.minLength)
        return `最少输入 ${config.minLength} 个字符`;
      if (config.maxLength && value.length > config.maxLength)
        return `最多输入 ${config.maxLength} 个字符`;
      break;

    case "file":
      if (config.fileTypes && !config.fileTypes.some(t => value.endsWith(t)))
        return `只支持 ${config.fileTypes.join(", ")} 格式`;
      break;
  }
  return null;
}

后端校验(安全保障):

func ValidateResponse(spec *ent.FormSpec, responses []ResponseInput) error {
    questionMap := buildQuestionMap(spec)

    for _, resp := range responses {
        q, ok := questionMap[resp.QuestionID]
        if !ok {
            return fmt.Errorf("unknown question ID: %d", resp.QuestionID)
        }
        if q.Required && resp.Value == "" {
            return fmt.Errorf("question %q is required", q.Label)
        }
        if err := validateType(q.Type, resp.Value); err != nil {
            return fmt.Errorf("question %q: %w", q.Label, err)
        }
    }

    // 检查是否所有必填项都有回答
    for id, q := range questionMap {
        if q.Required && !hasResponse(responses, id) {
            return fmt.Errorf("missing required question: %q", q.Label)
        }
    }
    return nil
}

前端校验是为了用户体验(不用等服务器往返就能看到错误),后端校验是为了数据完整性(前端校验可以被绕过)。两层都不能省。

实际中会遇到的问题

和市面上方案的对比

方案 Schema 存储 渲染 适用场景
Google Forms 私有格式 Google 托管 简单问卷
JSON Schema + react-jsonschema-form 标准 JSON Schema 自动生成 开发者工具、配置表单
Formily / XRender 自定义 DSL 可视化编辑器 + 渲染器 企业级复杂表单
自建 FormSpec 模型 数据库 + ORM 自定义渲染器 需要深度定制的产品

react-jsonschema-form 用的是标准 JSON Schema(Draft 7),优势是生态好、有现成的校验库(ajv)。但标准 JSON Schema 是为描述数据结构设计的,不是为描述表单 UI 设计的——它没有"分组"、"占位符"、"条件显示"这些概念,需要通过 uiSchema 额外补充。

自建 FormSpec 模型的优势是完全控制数据结构,可以针对业务需求做深度定制(比如加审批流、加权限控制、加模板市场)。代价是需要自己写渲染器和校验逻辑。

架构上的思考

总结

Schema 驱动表单的本质是一个古老的软件工程思想的具体应用:数据和行为分离。表单的结构(数据)用 Schema 描述,表单的渲染和校验(行为)用通用代码处理。这让非开发者可以创建和修改表单,让开发者可以用一套代码服务所有表单场景。

这个模式不仅限于表单。配置页面、审批流程、数据看板——任何"结构可变但行为固定"的场景,都可以用 Schema 驱动的方式来实现。核心永远是:定义一个好的 Schema,写一个好的渲染器。

但到目前为止,我们的 Schema 还只能描述扁平的表单字段。如果需要条件渲染、动态表达式、布局嵌套、交互动作呢?下一篇我们来把 Schema 的能力扩展到整个 UI 树。

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