Skip to content

JSONSchema动态表单生成总结

· 9 min

做过几个后台系统后,我有个直觉:手写表单是个“有手就行”的活儿,但越写越想哭。字段多、校验多、场景多,重复代码像打地鼠,打一只冒三只。直到我拉上 JSON Schema,当了次“架构上嫁妆”,整个表单系统就顺了:既省心又省时,扩展起来还带劲。

这篇就聊聊我在 Vue 3 + ant-design-vue 上,怎么用 JSON Schema 动态生成 form 表单的,附上核心思路和示例代码,保准下饭。


背景与目标:别再重复造按钮了#

一句话总结:让“写页面”变“写配置”。


基本思路:Schema → 中间层 → 渲染#

你可以把它想象成“翻译官”:把“数据的语言”翻译成“组件的语言”。


我们的 Schema 长什么样(简化示例)#

这里我用“数据 schema + UI schema 合体”的方式,生产可读性更高:

{
"title": "用户信息",
"type": "object",
"required": ["name", "gender", "birthday"],
"properties": {
"name": {
"type": "string",
"title": "姓名",
"minLength": 2,
"ui:widget": "input",
"ui:props": { "placeholder": "请输入姓名", "allowClear": true }
},
"gender": {
"type": "string",
"title": "性别",
"enum": ["male", "female"],
"ui:widget": "select",
"ui:options": [
{ "label": "", "value": "male" },
{ "label": "", "value": "female" }
]
},
"age": {
"type": "number",
"title": "年龄",
"minimum": 0,
"maximum": 150,
"ui:widget": "input-number",
"ui:props": { "min": 0, "max": 150, "style": "width: 100%" }
},
"isMarried": {
"type": "boolean",
"title": "已婚",
"ui:widget": "switch"
},
"birthday": {
"type": "string",
"format": "date",
"title": "生日",
"ui:widget": "date-picker"
}
}
}

约定俗成的映射(可按需拓展):


基于 ant-design-vue 的封装要点#

组件注册表示例(极简):

const widgetMap = {
input: 'a-input',
'input-number': 'a-input-number',
select: 'a-select',
'radio-group': 'a-radio-group',
switch: 'a-switch',
'date-picker': 'a-date-picker',
textarea: 'a-textarea'
}

dynamic-form.vue 的核心实现(精简版)#

下面是一个可以跑通的思路示例,细节可按项目打磨:

<script setup lang="ts">
import { computed, reactive, watch, toRaw } from 'vue'
import { Form } from 'ant-design-vue'
// props
const props = defineProps<{
schema: any, // JSON Schema(含 ui 配置)
modelValue?: Record<string, any>,
layout?: 'horizontal' | 'vertical' | 'inline'
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: any): void
(e: 'submit', v: any): void
(e: 'change', v: any): void
}>()
// 内部模型
const innerModel = reactive({ ...(props.modelValue || {}) })
watch(() => props.modelValue, (v) => {
if (!v) return
Object.assign(innerModel, v)
}, { deep: true })
watch(innerModel, (v) => {
emit('update:modelValue', toRaw(v))
emit('change', toRaw(v))
}, { deep: true })
// 解析 schema → 节点数组(这里只做最基础的铺平)
const fields = computed(() => {
const { properties = {}, required = [] } = props.schema || {}
return Object.keys(properties).map((key) => {
const node = properties[key]
return {
field: key,
label: node.title || key,
required: required.includes(key),
widget: node['ui:widget'] || guessWidgetByType(node),
rules: buildAntdRules(node), // 把 JSON Schema 校验映射到 antd 规则
props: node['ui:props'] || {},
options: node['ui:options'] || [],
schema: node
}
})
})
function guessWidgetByType(node: any) {
if (node.enum) return 'select'
if (node.type === 'boolean') return 'switch'
if (node.type === 'number' || node.type === 'integer') return 'input-number'
if (node.format === 'date') return 'date-picker'
return 'input'
}
// 把常见 JSON Schema 关键字映射到 antd 规则
function buildAntdRules(node: any) {
const rules: any[] = []
if (node.minLength != null) rules.push({ min: node.minLength, message: `至少 ${node.minLength} 个字符` })
if (node.maxLength != null) rules.push({ max: node.maxLength, message: `最多 ${node.maxLength} 个字符` })
if (node.pattern) rules.push({ pattern: new RegExp(node.pattern), message: '格式不正确' })
if (node.minimum != null) rules.push({ type: 'number', min: node.minimum, message: `不能小于 ${node.minimum}` })
if (node.maximum != null) rules.push({ type: 'number', max: node.maximum, message: `不能大于 ${node.maximum}` })
return rules
}
// 绑定 v-model 属性名适配
function modelBinding(fieldNode: any, fieldKey: string) {
const widget = fieldNode.widget
if (widget === 'a-switch') {
return { 'v-model:checked': innerModel[fieldKey] }
}
return { 'v-model:value': innerModel[fieldKey] }
}
// 提交
const [form] = Form.useForm(innerModel)
async function onSubmit() {
try {
await form.validate()
emit('submit', toRaw(innerModel))
} catch (e) { /* 校验不通过,antd 已提示 */ }
}
</script>
<template>
<a-form :model="innerModel" :layout="layout || 'horizontal'">
<a-row :gutter="12">
<a-col :span="24" v-for="node in fields" :key="node.field">
<a-form-item :name="node.field" :label="node.label" :rules="node.rules" :required="node.required">
<component
:is="widgetMap[node.widget] || 'a-input'"
v-bind="node.props"
v-on="node.props?.on"
v-model:value="innerModel[node.field]"
v-if="node.widget !== 'a-switch'"
/>
<component
:is="widgetMap[node.widget] || 'a-input'"
v-bind="node.props"
v-on="node.props?.on"
v-model:checked="innerModel[node.field]"
v-else
/>
<!-- 选项型组件 -->
<template v-if="node.widget === 'select'">
<a-select-option v-for="opt in node.options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-select-option>
</template>
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<a-space>
<a-button type="primary" @click="onSubmit">提交</a-button>
<a-button @click="form.resetFields()">重置</a-button>
</a-space>
</a-form-item>
</a-form>
</template>

说明:


使用方式(两行起飞)#

<script setup lang="ts">
import DynamicForm from '@/components/dynamic-form.vue'
import schema from './user.schema.json'
import { ref } from 'vue'
const formData = ref({})
function handleSubmit(val: any) {
console.log('提交数据:', val)
}
</script>
<template>
<dynamic-form :schema="schema" v-model="formData" @submit="handleSubmit" />
</template>

校验策略:两条路,随你挑#


动态联动:表单最“会整活”的地方#

常见需求:选中“公司”才显示“公司名称”,或者“证件类型=护照”时“号码校验”变更。

几种做法:

示例(简版条件):

"companyName": {
"type": "string",
"title": "公司名称",
"ui:widget": "input",
"ui:visibleIf": { "workType": "company" }
}

自定义组件:给高级玩法留条活路#


小结:省的不是几行代码,是一整套心智负担#

用 JSON Schema 驱动表单,最香的地方不是“少写几行 input”,而是:

如果你们项目也正被表单“反复横跳”折磨,不妨试试这套方案。