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 版本控制:表单发布后,如果修改了 Schema(比如删掉一个问题),已经提交的 Response 怎么办?常见做法是创建新版本而不是修改原版本,或者 soft delete 问题但保留历史回答。
- 条件逻辑:现实中的表单经常有"如果选了 A,则显示问题 X"这样的逻辑。这需要在 Schema 里加一个
visibility_rules字段,渲染器根据当前回答动态显示/隐藏问题。这是复杂度的主要来源。 - 性能:大表单(100+ 问题)渲染时,每个 Question 组件的 re-render 会成为瓶颈。解决方案:
React.memo+ 把 onChange 回调用useCallback包起来,确保只有值变化的 Question 才重新渲染。 - 文件上传:文件类型的问题需要特殊处理——先上传到 OSS 拿到 URL,再把 URL 作为 value 存储。这意味着文件上传和表单提交是两个独立的流程。
- 导出和分析:表单数据最终需要导出成 Excel 或做统计分析。因为 Value 是 string 类型,导出时需要根据 Question Type 做格式化(日期转 Date 格式、多选转逗号分隔等)。
和市面上方案的对比
| 方案 | 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 即合约:FormSpec 是前后端的唯一合约。后端不需要知道前端怎么渲染,前端不需要知道后端怎么存储。只要 Schema 的格式不变,两边可以独立演进。
- 代码生成 vs 手写:用 Ent + gqlgen 这样的代码生成工具,从 Schema 定义到 API 端点几乎是全自动的。手写 CRUD 代码没有任何价值——让机器生成,把精力放在业务逻辑上。
- Switch 语句的美学:渲染器里的大 switch 看起来很"原始",但它是正确的抽象层次。不要过度抽象成"注册器模式"或"插件系统"——除非你真的需要第三方开发者来扩展问题类型。大多数时候,9 种类型就够了。
- 关于 Value 的 string 存储:这是一个经典的"灵活性 vs 查询能力"的 trade-off。如果你的场景需要对回答做复杂查询("找出所有选了 A 选项的用户"),可以考虑用 PostgreSQL 的 JSONB 类型,或者在提交时额外写入一张宽表做分析。
总结
Schema 驱动表单的本质是一个古老的软件工程思想的具体应用:数据和行为分离。表单的结构(数据)用 Schema 描述,表单的渲染和校验(行为)用通用代码处理。这让非开发者可以创建和修改表单,让开发者可以用一套代码服务所有表单场景。
这个模式不仅限于表单。配置页面、审批流程、数据看板——任何"结构可变但行为固定"的场景,都可以用 Schema 驱动的方式来实现。核心永远是:定义一个好的 Schema,写一个好的渲染器。
但到目前为止,我们的 Schema 还只能描述扁平的表单字段。如果需要条件渲染、动态表达式、布局嵌套、交互动作呢?下一篇我们来把 Schema 的能力扩展到整个 UI 树。