第 6 篇:SchemaForm 渲染器核心实现

第 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 的真实事件名(对应 checked prop)。

若你的版本事件名有出入,以 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. 验证

  1. 页面能渲染出全部 7 种字段类型(input/textarea/number/select/radio/checkbox/switch/date)。
  2. 必填字段留空,控制台提交时能看到(或页面出现)错误提示。
  3. 改 schema 里任意字段的 label,刷新后文案跟着变,其余不动。
  4. 明暗切换后表单样式跟随 Tokens。

本篇小结

  • 渲染器把 schema 变成表单,是「配置驱动」的落地点
  • 自研 200 行够用;需求复杂度上去了再换现成库
  • 下一步把校验、联动、列表页补上:第 7 篇:进阶
相关推荐
Light Gao6 分钟前
AI 对话助手如何实现动态交互?一文讲清 AG-UI、SSE、组件与工具调用
人工智能·ui·交互·大语言模型·sse·智能体·ag-ui
丁希希哇20 分钟前
Transformer中的注意力机制(Attention)
人工智能·机器学习·transformer·大语言模型
定栓21 分钟前
Webpack迁到Vite第五节:第三方库兼容
前端·webpack·node.js
国际学术会议-杨老师26 分钟前
2027年人文发展、城市管理与数字化社会国际会议(HUCD 2027)
大数据·人工智能·人文发展
NGINX开源社区28 分钟前
与我们交流:NGINX Gateway Fabric 与 NGINX Ingress Controller 社区会议
java·服务器·前端
田里的水稻34 分钟前
PyTorch_torchvision---数据集下载\TensorBoard可视化
人工智能·pytorch·python·深度学习·机器学习
科技每日热闻35 分钟前
电气设备综合维保服务商怎么选?从预防性维护到数字化运维,看懂五个关键能力——企业选择维保合作伙伴,不能只看“坏了能不能修”,更要看能否覆盖资产与系统的长期运营
大数据·运维·人工智能
Dotrust东信创智41 分钟前
HIL台架上的“无人夜班“:东信创智用AI重塑汽车软件测试全链路——测试不再是项目的瓶颈,而是交付的底气
人工智能·汽车
电子制造自留地1 小时前
AI服务器高多层PCB设计:阻抗控制、层间对位与孔金属化工程要点
大数据·网络·人工智能·科技·pcb工艺
QYR-分析1 小时前
汽车车窗调节器市场研究:成熟零部件赛道,模块化集成成为核心方向
人工智能·汽车