Vue3 配置驱动表单:JSON配置+渲染引擎,快速搭建复杂表单|配置驱动开发实战篇

【Vue3 + Element Plus + Schema 配置】×【中后台复杂 / 可复用表单】:从「字段抽象 + 统一渲染引擎」到「校验、联动、异步选项与提交闭环落地」,彻底搞懂配置驱动表单的工程化写法,避开脏数据残留、规则散落、重复开发与引擎失控等高频坑!

📑 文章目录

同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。

(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)

当你能写出规范、可维护的代码后,下一个真正的瓶颈,就是架构

面对大型项目、复杂业务,你是否也会遇到:组件越写越乱、重复开发越来越多;需求一变全链路改动;不知道怎么分层、怎么抽象、怎么设计才能支撑长期迭代;想晋升、想带项目,却缺少架构思维

这一系列《前端组件化与架构实战》,我会继续用大白话 + 真实业务场景 ,不讲玄学、不啃晦涩源码,只教你能落地、能抗复杂项目的架构思路。

帮你从「写页面的开发者」,真正升级为「能做架构、能带项目、能搞定复杂需求的前端工程师」。


一、先说人话:什么叫"配置驱动表单"?

平时我们写表单,通常是这样:

html 复制代码
<el-form>
  <el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
  <el-form-item label="年龄"><el-input-number v-model="form.age" /></el-form-item>
  <el-form-item label="城市"><el-select v-model="form.city">...</el-select></el-form-item>
</el-form>

字段少时没问题。

但一旦变成"几十个字段 + 多页面复用 + 动态联动 + 后端可配置",你会遇到:

  • 同样的字段逻辑重复写很多份
  • 改一个校验规则要改 N 个页面
  • 业务变化时改动范围大、测试成本高
  • 代码看起来像"堆模板"

[⬆ 返回目录](#⬆ 返回目录)

二、什么时候该用?什么时候别硬上?

适合用配置驱动

  • 同类表单很多(新增/编辑/审核/详情)
  • 字段变化频繁(运营、活动、B 端系统)
  • 需要后端下发部分配置
  • 需要"表单平台化"沉淀能力

[⬆ 返回目录](#⬆ 返回目录)

不建议一上来就用

  • 只有 3~5 个固定字段
  • 页面极少、生命周期短
  • 团队对 schema 认知还没建立

结论:不是所有表单都要配置驱动,但复杂和可复用场景非常值得。

[⬆ 返回目录](#⬆ 返回目录)

三、本文实战目标(你将得到什么)

我们用 Vue3 + Element Plus 做一个可跑的配置驱动表单,支持:

  1. 文本、数字、下拉、开关等基础组件
  2. 必填/长度/自定义校验
  3. 字段显示隐藏(联动)
  4. 下拉异步加载 options
  5. 表单提交前统一校验
  6. 代码结构清晰,可扩展

[⬆ 返回目录](#⬆ 返回目录)

四、项目结构建议(先立规矩,后写代码)

bash 复制代码
src/
  components/
    FormRenderer.vue        # 渲染引擎
  schema/
    userForm.schema.js      # 表单配置
  services/
    api.js                  # 模拟接口
  views/
    DemoPage.vue

[⬆ 返回目录](#⬆ 返回目录)

五、定义 Schema:先把"规则语言"设计清楚

src/schema/userForm.schema.js

js 复制代码
export const userFormSchema = [
  {
    field: 'name',
    label: '姓名',
    component: 'Input',
    defaultValue: '',
    props: {
      placeholder: '请输入姓名',
      clearable: true
    },
    rules: [
      { required: true, message: '姓名不能为空', trigger: 'blur' },
      { min: 2, max: 20, message: '姓名长度 2~20', trigger: 'blur' }
    ]
  },
  {
    field: 'age',
    label: '年龄',
    component: 'InputNumber',
    defaultValue: 18,
    props: {
      min: 0,
      max: 120
    },
    rules: [
      { required: true, message: '年龄不能为空', trigger: 'change' }
    ]
  },
  {
    field: 'jobType',
    label: '职业类型',
    component: 'Select',
    defaultValue: '',
    props: {
      placeholder: '请选择职业类型'
    },
    options: [
      { label: '前端', value: 'frontend' },
      { label: '后端', value: 'backend' },
      { label: '测试', value: 'qa' }
    ],
    rules: [{ required: true, message: '请选择职业类型', trigger: 'change' }]
  },
  {
    field: 'city',
    label: '所在城市',
    component: 'Select',
    defaultValue: '',
    props: {
      placeholder: '请选择城市',
      filterable: true
    },
    // 异步选项:由接口拉取
    asyncOptions: 'getCityOptions',
    rules: [{ required: true, message: '请选择城市', trigger: 'change' }]
  },
  {
    field: 'hasCar',
    label: '是否有车',
    component: 'Switch',
    defaultValue: false,
    props: {}
  },
  {
    field: 'carNumber',
    label: '车牌号',
    component: 'Input',
    defaultValue: '',
    props: {
      placeholder: '请输入车牌号'
    },
    // 联动显示:只有 hasCar=true 才显示
    visible: (model) => model.hasCar === true,
    rules: [
      {
        required: true,
        message: '有车时必须填写车牌号',
        trigger: 'blur'
      }
    ]
  }
]

[⬆ 返回目录](#⬆ 返回目录)

六、渲染引擎:一套模板渲染所有字段

src/components/FormRenderer.vue

html 复制代码
<template>
  <el-form
    ref="formRef"
    :model="innerModel"
    :rules="innerRules"
    label-width="100px"
    class="form-renderer"
  >
    <template v-for="item in visibleSchema" :key="item.field">
      <el-form-item :label="item.label" :prop="item.field">
        <!-- Input -->
        <el-input
          v-if="item.component === 'Input'"
          v-model="innerModel[item.field]"
          v-bind="item.props"
        />

        <!-- InputNumber -->
        <el-input-number
          v-else-if="item.component === 'InputNumber'"
          v-model="innerModel[item.field]"
          v-bind="item.props"
        />

        <!-- Select -->
        <el-select
          v-else-if="item.component === 'Select'"
          v-model="innerModel[item.field]"
          v-bind="item.props"
          style="width: 100%;"
        >
          <el-option
            v-for="opt in getOptions(item)"
            :key="opt.value"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>

        <!-- Switch -->
        <el-switch
          v-else-if="item.component === 'Switch'"
          v-model="innerModel[item.field]"
          v-bind="item.props"
        />

        <!-- fallback -->
        <span v-else style="color: red;">不支持的组件类型:{{ item.component }}</span>
      </el-form-item>
    </template>
  </el-form>
</template>

<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { getCityOptions } from '../services/api'

const props = defineProps({
  schema: {
    type: Array,
    default: () => []
  },
  modelValue: {
    type: Object,
    default: () => ({})
  }
})

const emit = defineEmits(['update:modelValue'])

const formRef = ref(null)
const innerModel = reactive({})
const optionsMap = reactive({})

// 初始化模型
function initModel() {
  props.schema.forEach((item) => {
    if (props.modelValue[item.field] !== undefined) {
      innerModel[item.field] = props.modelValue[item.field]
    } else {
      innerModel[item.field] = item.defaultValue ?? ''
    }
  })
}

// 规则映射
const innerRules = computed(() => {
  const map = {}
  props.schema.forEach((item) => {
    if (item.rules) {
      map[item.field] = item.rules
    }
  })
  return map
})

// 显示逻辑
const visibleSchema = computed(() => {
  return props.schema.filter((item) => {
    if (typeof item.visible === 'function') {
      return item.visible(innerModel)
    }
    return true
  })
})

// 获取 options(静态 + 异步)
function getOptions(item) {
  if (item.asyncOptions) {
    return optionsMap[item.field] || []
  }
  return item.options || []
}

// 加载异步 options
async function loadAsyncOptions() {
  for (const item of props.schema) {
    if (!item.asyncOptions) continue

    if (item.asyncOptions === 'getCityOptions') {
      const list = await getCityOptions()
      optionsMap[item.field] = list
    }
  }
}

// 对外暴露校验方法
async function validate() {
  return formRef.value.validate()
}

// 同步到父组件
watch(
  () => ({ ...innerModel }),
  (val) => {
    emit('update:modelValue', val)
  },
  { deep: true }
)

onMounted(async () => {
  initModel()
  await loadAsyncOptions()
})

defineExpose({
  validate,
  getFormData: () => ({ ...innerModel })
})
</script>

<style scoped>
.form-renderer {
  max-width: 600px;
}
</style>

[⬆ 返回目录](#⬆ 返回目录)

七、模拟接口层(让示例完整可跑)

src/services/api.js

js 复制代码
export function getCityOptions() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([
        { label: '北京', value: 'beijing' },
        { label: '上海', value: 'shanghai' },
        { label: '深圳', value: 'shenzhen' },
        { label: '杭州', value: 'hangzhou' }
      ])
    }, 500)
  })
}

export function submitUserForm(data) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        code: 0,
        message: '提交成功',
        data
      })
    }, 600)
  })
}

[⬆ 返回目录](#⬆ 返回目录)

八、页面使用:真正落地到业务页面

src/views/DemoPage.vue

html 复制代码
<template>
  <div class="page">
    <h2>配置驱动表单 Demo</h2>

    <FormRenderer
      ref="rendererRef"
      v-model="formData"
      :schema="userFormSchema"
    />

    <div class="actions">
      <el-button type="primary" @click="handleSubmit">提交</el-button>
      <el-button @click="handleReset">重置</el-button>
    </div>

    <pre class="preview">{{ formData }}</pre>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import FormRenderer from '../components/FormRenderer.vue'
import { userFormSchema } from '../schema/userForm.schema'
import { submitUserForm } from '../services/api'

const rendererRef = ref(null)
const formData = ref({})

async function handleSubmit() {
  try {
    await rendererRef.value.validate()
    const payload = rendererRef.value.getFormData()
    const res = await submitUserForm(payload)
    ElMessage.success(res.message)
    console.log('提交数据:', res.data)
  } catch (err) {
    ElMessage.error('请先修正表单错误')
  }
}

function handleReset() {
  formData.value = {}
  ElMessage.info('已重置(示例中直接清空模型)')
}
</script>

<style scoped>
.page {
  padding: 24px;
}
.actions {
  margin-top: 16px;
}
.preview {
  margin-top: 16px;
  background: #f7f7f7;
  padding: 12px;
  border-radius: 6px;
}
</style>

[⬆ 返回目录](#⬆ 返回目录)

九、关键设计点(这是"为什么这么选")

1)Schema 要"稳定",不要随业务乱长

建议字段统一命名:

  • field:字段名
  • component:组件类型
  • props:组件属性
  • rules:校验规则
  • visible:联动显示
  • options / asyncOptions:选项来源

规则一旦稳定,团队协作成本会大幅下降。

[⬆ 返回目录](#⬆ 返回目录)

2)渲染引擎负责"解释",业务页面只负责"使用"

业务页不关心具体 input/select 细节,只管:

  • 传 schema
  • 取数据
  • 调提交接口

这样你后面把 el-input 换成自定义组件,业务页面几乎不用动。

[⬆ 返回目录](#⬆ 返回目录)

3)联动逻辑不要写死在模板里

把显示条件写到配置里:

js 复制代码
visible: (model) => model.hasCar === true

优点是"规则和字段绑定",不会散落在页面一堆 v-if 中。

[⬆ 返回目录](#⬆ 返回目录)

4)异步 options 做缓存

下拉选项如果每次都请求,性能和体验都差。

建议在引擎内部做 optionsMap 缓存,减少重复请求。

[⬆ 返回目录](#⬆ 返回目录)

十、最常见的 8 个坑(实战高频)

  1. 字段名冲突 :多个 schema 用同一个 field,导致数据覆盖。
  2. visible 隐藏后未清值 :比如隐藏了 carNumber,旧值还在,提交脏数据。
  3. 规则触发时机乱用 :输入框常用 blur,下拉/开关常用 change
  4. 异步 options 与默认值不同步:默认值在选项加载前设置,页面显示异常。
  5. 组件 props 透传失控:建议对白名单 props 做约束。
  6. schema 过于随意:每个同学加一套字段规范,最后不可维护。
  7. 提交前未统一 validate:局部校验通过不等于整个表单可提交。
  8. 把引擎写成"大一统怪物":建议按"基础组件/高级组件/业务组件"分层扩展。

[⬆ 返回目录](#⬆ 返回目录)

十一、进阶建议(从能用到好用)

  • 支持 slot:让个别字段可定制渲染
  • 支持布局配置:colSpan、分组、折叠面板
  • 支持表单项级别权限:readonlydisabledByRole
  • 抽离组件映射表:componentMap,避免大量 v-else-if
  • 加 schema 类型定义(TypeScript):减少配置错误

[⬆ 返回目录](#⬆ 返回目录)

十二、给前端的一句实话

你会写表单,不代表你已经"设计过表单系统"。

配置驱动的价值不在于"炫技",而在于:

  • 减少重复劳动
  • 控制复杂度
  • 提升长期维护效率
  • 让团队形成统一表单规范

这才是工程化的基本功。

[⬆ 返回目录](#⬆ 返回目录)

十三、可直接复用的最小实践清单(建议收藏)

  • 先定义 schema 规范,再写引擎
  • 引擎只做解释,不做业务耦合
  • 提交前统一 validate
  • 联动字段隐藏时清理脏值
  • 异步 options 缓存 + 错误兜底
  • 给 schema 增加文档和示例,团队统一口径

[⬆ 返回目录](#⬆ 返回目录)

总结

如果你现在项目里表单已经开始"重复、臃肿、改一次痛一次",

那就可以从本文这个最小版本开始落地,先跑通,再慢慢演进。

先把基础打牢,再谈高级封装。

配置驱动它是复杂表单场景里非常稳的一把刀。

[⬆ 返回目录](#⬆ 返回目录)


🔍 系列模块导航

📝 配置驱动开发实战

持续更新中,敬请期待~

👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~

📚 系列总览

前端体系化学习完全体:基础 → 规范 → 架构 → 大厂面试

四套系列、百余篇高质量实战文,从入门到进阶,一站式补齐前端核心能力

每个系列完结后,都会整理成一篇完整导航文并附上直达链接,方便大家按顺序、体系化学习。

全套内容持续更新中,敬请期待~

[⬆ 返回目录](#⬆ 返回目录)


前端的成长路径很清晰:

会写代码 → 写规范代码 → 做可扩展架构。

每一步,都是职业晋升的关键台阶。

后续我会持续输出组件化、配置驱动、权限架构、工程化、复杂业务实战干货,帮你真正建立架构思维,在工作与面试中更有竞争力。

觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇硬核内容。

我是 Eugene,与你一起从业务走向架构,搞定复杂项目,我们下篇干货见~

相关推荐
senijusene2 小时前
IMX6ULL Linux 驱动开发:GPIO 子系统 + misc 框架实现按键输入驱动开发
linux·运维·驱动开发
敲上瘾2 小时前
大模型接入从入门到实战:API/SDK/本地部署/Claude Code 路由全解析
人工智能·深度学习·机器学习·json·aigc·claude
青桔柠薯片2 小时前
基于i.MX6ULL的字符设备驱动开发实践——以LED、蜂鸣器与按键为例
驱动开发·imx6ull
见山是山-见水是水2 小时前
鸿蒙flutter第三方库适配 - JSON格式化工具应用
flutter·华为·json·harmonyos
篮子里的玫瑰2 小时前
一个隐藏的坑:MicroLib与串口打印的关系
驱动开发·stm32·嵌入式硬件
Dontla3 小时前
JWT认证流程(JSON Web Token)
前端·数据库·json
素玥20 小时前
实训7 json文件数据用python导入数据库
数据库·python·json
LinuxRos21 小时前
I2C子系统与驱动开发:从协议到实战
linux·人工智能·驱动开发·嵌入式硬件·物联网
组合缺一1 天前
Snack JSONPath 项目架构分析
java·架构·json·jsonpath·rfc 9535