第 6 篇:SchemaForm 渲染器核心实现
本篇目标
写出本系列的心脏:一个能消费第 5 篇 Schema、自动渲染出表单的 SchemaForm.vue。
约 200 行,复制进项目即可用。
0. 先确认组件都装好了
第 2 篇已装:input textarea select radio-group checkbox switch label button card。
导入路径以 shadcn-vue 生成的 src/components/ui/* 为准,例如:
ts
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
1. 渲染器:SchemaForm.vue
创建 src/components/form/SchemaForm.vue:
vue
<script setup lang="ts">
import { computed } from 'vue'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import type { FieldSchema } from './schema'
const props = defineProps<{
fields: FieldSchema[]
modelValue: Record<string, any>
columns?: number
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: Record<string, any>): void
}>()
const gridStyle = computed(() => ({
gridTemplateColumns: `repeat(${props.columns ?? 2}, minmax(0, 1fr))`,
}))
// 更新单个字段,触发父级 v-model
function setField(field: FieldSchema, value: any) {
emit('update:modelValue', { ...props.modelValue, [field.name]: value })
}
// 统一入口:number 转数字,其余原样
function onUpdate(field: FieldSchema, value: any) {
setField(field, field.type === 'number' ? Number(value) : value)
}
// 联动显隐(第 7 篇详述)
function visible(field: FieldSchema): boolean {
const sw = field.showWhen
if (!sw) return true
return props.modelValue[sw.field] === sw.equals
}
// 基础校验:必填 + 正则
function errorOf(field: FieldSchema): string {
const v = props.modelValue[field.name]
if (field.required && (v === undefined || v === null || v === '')) {
return `${field.label}为必填`
}
if (field.pattern && typeof v === 'string' && v && !new RegExp(field.pattern).test(v)) {
return `${field.label}格式不正确`
}
return ''
}
</script>
<template>
<div class="grid gap-[var(--spacing-field)]" :style="gridStyle">
<template v-for="field in fields" :key="field.name">
<div
v-if="visible(field)"
class="min-w-0"
:style="field.span === 2 ? { gridColumn: '1 / -1' } : undefined"
>
<Label :for="`f-${field.name}`" class="mb-1.5 block text-foreground">
{{ field.label }}<span v-if="field.required" class="ml-0.5 text-destructive">*</span>
</Label>
<!-- 单行文本 / 数字 -->
<Input
v-if="field.type === 'input' || field.type === 'number'"
:id="`f-${field.name}`"
:type="field.type === 'number' ? 'number' : 'text'"
:model-value="modelValue[field.name] ?? ''"
:placeholder="field.placeholder"
:disabled="field.disabled"
@update:model-value="onUpdate(field, $event)"
/>
<!-- 多行文本 -->
<Textarea
v-else-if="field.type === 'textarea'"
:id="`f-${field.name}`"
:model-value="modelValue[field.name] ?? ''"
:placeholder="field.placeholder"
:disabled="field.disabled"
@update:model-value="onUpdate(field, $event)"
/>
<!-- 日期:先用原生 input 保证稳定,想升级见第 7 篇 -->
<Input
v-else-if="field.type === 'date'"
:id="`f-${field.name}`"
type="date"
:model-value="modelValue[field.name] ?? ''"
:disabled="field.disabled"
@update:model-value="onUpdate(field, $event)"
/>
<!-- 下拉 -->
<Select
v-else-if="field.type === 'select'"
:model-value="modelValue[field.name] ?? ''"
:disabled="field.disabled"
@update:model-value="onUpdate(field, $event)"
>
<SelectTrigger :id="`f-${field.name}`" class="w-full">
<SelectValue :placeholder="field.placeholder ?? '请选择'" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="o in field.options ?? []" :key="o.value" :value="o.value">
{{ o.label }}
</SelectItem>
</SelectContent>
</Select>
<!-- 单选组 -->
<RadioGroup
v-else-if="field.type === 'radio'"
:model-value="modelValue[field.name] ?? ''"
:disabled="field.disabled"
@update:model-value="onUpdate(field, $event)"
class="flex flex-wrap gap-4"
>
<div v-for="o in field.options ?? []" :key="o.value" class="flex items-center gap-2">
<RadioGroupItem :id="`${field.name}-${o.value}`" :value="o.value" />
<Label :for="`${field.name}-${o.value}`">{{ o.label }}</Label>
</div>
</RadioGroup>
<!-- 复选 -->
<Checkbox
v-else-if="field.type === 'checkbox'"
:id="`f-${field.name}`"
:checked="!!modelValue[field.name]"
:disabled="field.disabled"
@update:checked="onUpdate(field, $event)"
/>
<!-- 开关 -->
<Switch
v-else-if="field.type === 'switch'"
:id="`f-${field.name}`"
:checked="!!modelValue[field.name]"
:disabled="field.disabled"
@update:checked="onUpdate(field, $event)"
/>
<p v-if="field.help" class="mt-1 text-[var(--text-caption)] text-muted-foreground">
{{ field.help }}
</p>
<p v-if="errorOf(field)" class="mt-1 text-[var(--text-caption)] text-destructive">
{{ errorOf(field) }}
</p>
</div>
</template>
</div>
</template>
说明:
@update:checked是 shadcn-vue 的 Checkbox / Switch 的真实事件名(对应checkedprop)。若你的版本事件名有出入,以
src/components/ui/checkbox、switch源码里的defineEmits为准。
2. 一个可直接运行的表单页
创建 src/pages/CustomerPage.vue:
vue
<script setup lang="ts">
import { reactive, ref } from 'vue'
import PageShell from '@/components/layout/PageShell.vue'
import PageHeader from '@/components/layout/PageHeader.vue'
import SectionCard from '@/components/layout/SectionCard.vue'
import SchemaForm from '@/components/form/SchemaForm.vue'
import { Button } from '@/components/ui/button'
import type { PageSchema } from '@/components/form/schema'
const schema: PageSchema = {
title: '客户资料',
columns: 2,
fields: [
{ name: 'name', label: '姓名', type: 'input', required: true, placeholder: '请输入姓名' },
{ name: 'gender', label: '性别', type: 'radio', options: [{ label: '男', value: 'male' }, { label: '女', value: 'female' }] },
{ name: 'birth', label: '出生日期', type: 'date' },
{ name: 'phone', label: '手机号', type: 'input', pattern: '^1\\d{10}$', placeholder: '11 位手机号' },
{ name: 'dept', label: '部门', type: 'select', options: [{ label: '研发', value: 'rd' }, { label: '产品', value: 'pm' }] },
{ name: 'active', label: '启用', type: 'switch', default: true },
{ name: 'remark', label: '备注', type: 'textarea', span: 2 },
],
}
// 用 default 初始化
const form = reactive<Record<string, any>>({})
for (const f of schema.fields) {
if (f.default !== undefined) form[f.name] = f.default
}
function submit() {
console.log('提交数据', form)
}
</script>
<template>
<PageShell>
<PageHeader :title="schema.title ?? ''" description="配置驱动表单示例">
<template #actions><Button @click="submit">保存</Button></template>
</PageHeader>
<SectionCard>
<SchemaForm v-model="form" :fields="schema.fields" :columns="schema.columns" />
</SectionCard>
</PageShell>
</template>
pnpm dev 打开页面,即可看到由 schema 渲染出的完整表单;必填项留空提交会提示。
3. 为什么这样设计(对应第 1 篇的两层分离)
- 渲染器只做「类型 → 组件」的机械映射,不掺业务
- 加新页面 = 复制 CustomerPage 的壳 + 换一份 schema
- AI 后续只改 schema,永远碰不到渲染器与布局
4. 对照:现成库什么时候值得用
真实存在、可替代自研的方案(均为已核实项目):
| 方案 | 特点 | 何时考虑 |
|---|---|---|
| FormKit | Vue3 原生,JSON Schema 支持(FormKitSchema),内置校验/主题 |
需要复杂校验和视觉,且接受它自带一套 UI 风格 |
| Formily (Vue) | 阿里,标准 JSON Schema 驱动,跨框架 | 需要复杂联动、跨端,能接受较重依赖 |
| vue-json-schema-form | 基于 JSON Schema 渲染表单,支持 Vue3 | 想直接用标准 JSON Schema 规范 |
本系列选自研,因为:代码量小(200 行)、样式完全受 Tokens 控制、字段体系按自用收敛。
如果你的需求开始超过自研能撑的复杂度(嵌套子表、复杂联动、可视化配置),再考虑上面三者------这是诚实边界。
5. 验证
- 页面能渲染出全部 7 种字段类型(input/textarea/number/select/radio/checkbox/switch/date)。
- 必填字段留空,控制台提交时能看到(或页面出现)错误提示。
- 改
schema里任意字段的label,刷新后文案跟着变,其余不动。 - 明暗切换后表单样式跟随 Tokens。
本篇小结
- 渲染器把 schema 变成表单,是「配置驱动」的落地点
- 自研 200 行够用;需求复杂度上去了再换现成库
- 下一步把校验、联动、列表页补上:第 7 篇:进阶