第 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/checkboxswitch 源码里的 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 篇:进阶
相关推荐
故七月1 小时前
优胜劣汰·动态赋能——锦邻创享OPC社区的考核管理与退出机制
大数据·人工智能
CoderYanger1 小时前
前端基础——JavaScript(基础语法)(下篇)
java·开发语言·前端·javascript·程序人生·面试·职场和发展
枫叶林FYL1 小时前
【群体智能集群控制工程实践】第10章 无人舰队核心功能实现
大数据·人工智能·算法
XLYcmy1 小时前
Self-Adapting Language Models论文分享
自然语言处理·llm·微调·sft·论文笔记·强化学习·自进化
还是大剑师兰特1 小时前
vue项目浏览器版本判别,低于IE11跳转到新页面
前端·javascript·vue.js
昵称画1 小时前
POC验证怎么设计用例?不走过程的实操要点
大数据·数据库·人工智能·低代码·excel
2601_967212721 小时前
新一代电源轨道系统技术甄别维度与行业技术路线分析
大数据·网络·人工智能
Lucas_coding1 小时前
【Codex Remote】 Codex App通过SSH连接远程Linux开发环境
人工智能
EQUINOX11 小时前
【论文精读】| MiniGPT-4精读
论文阅读·人工智能·深度学习