Vue——Vue 3动态表单配置实战:打造灵活的在线问卷系统
写在前面
最近在开发一个内部管理系统时,遇到了一个很有意思的需求:需要实现一个支持动态配置的表单功能,用户可以自定义题目类型、选项内容,还要支持附件上传和时间范围控制。
说实话,这个需求刚开始让我有点头大。动态表单、嵌套验证、时间联动…每一个点拿出来都够写一篇技术文章了。但经过一周的摸索和优化,最终实现的效果还不错。
今天就和大家分享一下这个过程中的思考和解决方案,希望能给遇到类似需求的朋友一些启发。
一、需求分析:我们要做什么?
先来看看具体需求:
- 支持三种题型:单选题、多选题、评分题
- 题目和选项可以动态增删(题目最多10道,选项最多5个)
- 表单提交前进行完整的校验
- 支持草稿保存和正式发布
- 时间范围需要智能限制(开始时间不能早于当前,结束时间要在开始时间之后)
- 支持附件上传(图片、文档等)
看起来是不是挺简单的?但真正动手写的时候,坑是一个接一个。
二、技术选型
| 技术 | 版本 | 用途 |
|---|---|---|
| Vue 3 | ^3.3.0 | 核心框架 |
| Ant Design Vue | ^4.0.0 | UI组件库 |
| dayjs | ^1.11.0 | 时间处理 |
| Vite | ^4.0.0 | 构建工具 |
三、核心难点及解决方案
难点1:动态题目的数据结构设计
问题描述:不同类型题目的数据结构差异很大。单选题需要选项数组,评分题需要分数范围。怎么设计一个既能统一管理又能灵活扩展的数据结构?
解决方案:采用「基础字段 + 类型专属字段」的设计模式。
javascript
// 题目数据结构 const questionStructure = { // 公共字段 id: '', // 唯一标识 type: 1, // 1-单选 2-多选 3-评分 title: '', // 题目标题 required: true, // 是否必填 // 类型专属字段 options: [], // 单选/多选使用 maxScore: 5, // 评分题使用 minScore: 1 // 评分题使用 } // 动态添加题目 const addQuestion = () => { formData.questions.push({ id: generateId(), type: 1, title: '', required: true, options: ['', ''], maxScore: 5, minScore: 1 }) }要点:
- 使用
v-if根据题型条件渲染不同的表单控件 - 评分题的范围可以动态调整(比如1-5分或1-10分)
难点2:嵌套表单的验证
问题描述:题目列表是动态的,每个题目内部还有选项列表。Ant Design Vue 的表单验证如何处理这种嵌套结构?
解决方案:利用a-form-model的嵌套路径验证能力。
vue
<template> <a-form ref="formRef" :model="formData" :rules="formRules"> <!-- 动态题目列表 --> <div v-for="(question, idx) in formData.questions" :key="question.id"> <!-- 使用路径绑定验证规则 --> <a-form-item :name="['questions', idx, 'title']" :rules="[{ required: true, message: '请输入题目' }]" > <a-input v-model:value="question.title" /> </a-form-item> <!-- 动态选项验证 --> <div v-for="(opt, optIdx) in question.options"> <a-form-item :name="['questions', idx, 'options', optIdx]" :rules="[{ required: true, message: '选项不能为空' }]" > <a-input v-model:value="question.options[optIdx]" /> </a-form-item> </div> </div> </a-form> </template>关键点:name属性使用数组路径['questions', idx, 'title'],框架会自动处理嵌套验证。
难点3:复杂的时间范围限制
问题描述:开始时间不能早于当前时间,结束时间必须在开始时间之后。如果选择同一天,还要精确到分钟级别。
解决方案:封装时间禁用函数,实现三级联动(日期→小时→分钟)。
javascript
import dayjs from 'dayjs' // 开始时间限制 const disabledStartTime = (selectedDate) => { // 非新建模式不限制 if (isEditMode.value) return {} const now = dayjs() const isToday = selectedDate && selectedDate.isSame(now, 'day') if (isToday) { return { disabledHours: () => range(0, now.hour()), disabledMinutes: (hour) => { if (hour === now.hour()) { return range(0, now.minute()) } return [] } } } return {} } // 辅助函数:生成数字范围 const range = (start, end) => { const result = [] for (let i = start; i < end; i++) { result.push(i) } return result }难点4:附件上传与管理
问题描述:需要支持多种文件格式,限制单个文件50MB,还要能预览和删除。
解决方案:封装通用上传组件,统一处理上传逻辑。
javascript
// 上传配置 const uploadConfig = { maxSize: 50, // MB acceptTypes: ['.jpg', '.png', '.pdf', '.doc', '.docx'], // 上传前校验 beforeUpload(file) { const isValidSize = file.size / 1024 / 1024 < this.maxSize if (!isValidSize) { message.error(`文件大小不能超过${this.maxSize}MB`) return false } const isValidType = this.acceptTypes.some(type => file.name.endsWith(type) ) if (!isValidType) { message.error(`只支持${this.acceptTypes.join(', ')}格式`) return false } return true } }难点5:草稿保存与数据回填
问题描述:草稿保存时不需要严格验证,正式提交时需要完整验证。编辑时还要能正确回填已有数据。
解决方案:区分两种提交模式,做好数据的序列化和反序列化。
javascript
// 提交处理 const handleSubmit = async (isDraft = false) => { if (!isDraft) { // 正式提交:完整验证 const isValid = await validateAll() if (!isValid) return } const submitData = isDraft ? buildDraftData() // 草稿:只保存已填内容 : buildFullData() // 正式:保存完整数据 await api.save(submitData) } // 数据回填 const fillFormData = (apiData) => { // 处理时间字段 formData.startTime = apiData.startTime ? dayjs(apiData.startTime) : null // 处理题目数据 formData.questions = apiData.questions.map(q => ({ id: q.id, type: q.type, title: q.title, options: q.options || ['', ''], maxScore: q.maxScore || 5 })) }四、完整代码示例
下面是一个简化但完整的实现:
vue
<template> <div class="dynamic-form"> <!-- 基础信息 --> <a-card title="基本信息" class="mb-4"> <a-form-item label="表单标题" required> <a-input v-model:value="formData.title" placeholder="请输入表单标题" :maxlength="50" show-count /> </a-form-item> <a-form-item label="表单说明"> <a-textarea v-model:value="formData.description" placeholder="请输入表单说明" :rows="3" :maxlength="500" /> </a-form-item> </a-card> <!-- 时间设置 --> <a-card title="时间设置" class="mb-4"> <a-row :gutter="16"> <a-col :span="12"> <a-form-item label="开始时间" required> <a-date-picker v-model:value="formData.startTime" show-time :disabled-date="disabledStartDate" :disabled-time="disabledStartTime" format="YYYY-MM-DD HH:mm" style="width: 100%" /> </a-form-item> </a-col> <a-col :span="12"> <a-form-item label="结束时间" required> <a-date-picker v-model:value="formData.endTime" show-time :disabled-date="disabledEndDate" :disabled-time="disabledEndTime" format="YYYY-MM-DD HH:mm" style="width: 100%" /> </a-form-item> </a-col> </a-row> </a-card> <!-- 题目配置 --> <a-card title="题目配置" class="mb-4"> <div v-for="(question, idx) in formData.questions" :key="question.id" class="question-item" > <a-card size="small" class="mb-3"> <template #extra> <a-popconfirm title="确定删除这道题目吗?" @confirm="removeQuestion(idx)" > <a-button type="link" danger size="small">删除</a-button> </a-popconfirm> </template> <!-- 题目基本信息 --> <a-row :gutter="16"> <a-col :span="16"> <a-form-item label="题目" :name="['questions', idx, 'title']" :rules="[{ required: true, message: '请输入题目' }]" > <a-input v-model:value="question.title" placeholder="请输入题目" :maxlength="50" /> </a-form-item> </a-col> <a-col :span="8"> <a-form-item label="题型"> <a-select v-model:value="question.type"> <a-select-option :value="1">单选题</a-select-option> <a-select-option :value="2">多选题</a-select-option> <a-select-option :value="3">评分题</a-select-option> </a-select> </a-form-item> </a-col> </a-row> <!-- 选项配置(单选/多选) --> <template v-if="question.type === 1 || question.type === 2"> <div class="options-container"> <div v-for="(opt, optIdx) in question.options" :key="optIdx" class="option-row" > <span class="option-index">{{ String.fromCharCode(65 + optIdx) }}.</span> <a-input v-model:value="question.options[optIdx]" :placeholder="`选项${optIdx + 1}`" class="option-input" /> <a-button v-if="question.options.length > 2" type="link" danger size="small" @click="removeOption(idx, optIdx)" > 删除 </a-button> </div> <a-button v-if="question.options.length < 5" type="dashed" size="small" @click="addOption(idx)" > + 添加选项 </a-button> </div> </template> <!-- 评分配置 --> <template v-if="question.type === 3"> <a-row :gutter="16"> <a-col :span="12"> <a-form-item label="最低分"> <a-input-number v-model:value="question.minScore" :min="0" :max="question.maxScore - 1" style="width: 100%" /> </a-form-item> </a-col> <a-col :span="12"> <a-form-item label="最高分"> <a-input-number v-model:value="question.maxScore" :min="question.minScore + 1" :max="10" style="width: 100%" /> </a-form-item> </a-col> </a-row> </template> </a-card> </div> <div class="add-question"> <a-button type="dashed" @click="addQuestion" :disabled="formData.questions.length >= 10" block > + 添加题目({{ formData.questions.length }}/10) </a-button> </div> </a-card> <!-- 底部按钮 --> <div class="form-actions"> <a-space> <a-button @click="resetForm">重置</a-button> <a-button @click="saveDraft">保存草稿</a-button> <a-button type="primary" @click="submitForm">提交表单</a-button> </a-space> </div> </div> </template> <script setup> import { ref, reactive } from 'vue' import { message, Modal } from 'ant-design-vue' import dayjs from 'dayjs' // 生成唯一ID const generateId = () => { return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}` } // 表单数据 const formData = reactive({ title: '', description: '', startTime: null, endTime: null, questions: [] }) // 表单引用 const formRef = ref() // 添加题目 const addQuestion = () => { if (formData.questions.length >= 10) { message.warning('最多添加10道题目') return } formData.questions.push({ id: generateId(), type: 1, title: '', required: true, options: ['', ''], minScore: 1, maxScore: 5 }) } // 删除题目 const removeQuestion = (index) => { formData.questions.splice(index, 1) } // 添加选项 const addOption = (questionIndex) => { const question = formData.questions[questionIndex] if (question.options.length >= 5) { message.warning('最多添加5个选项') return } question.options.push('') } // 删除选项 const removeOption = (questionIndex, optionIndex) => { const question = formData.questions[questionIndex] if (question.options.length <= 2) { message.warning('至少保留2个选项') return } question.options.splice(optionIndex, 1) } // 时间限制函数 const disabledStartDate = (current) => { if (!current) return false // 新建模式下禁用今天之前的日期 return current && current < dayjs().startOf('day') } const disabledStartTime = (selectedDate) => { const now = dayjs() const isToday = selectedDate && selectedDate.isSame(now, 'day') if (isToday) { return { disabledHours: () => { const hours = [] for (let i = 0; i < now.hour(); i++) { hours.push(i) } return hours }, disabledMinutes: (hour) => { if (hour === now.hour()) { const minutes = [] for (let i = 0; i < now.minute(); i++) { minutes.push(i) } return minutes } return [] } } } return {} } const disabledEndDate = (current) => { if (!current || !formData.startTime) return false return current && current < dayjs(formData.startTime).startOf('day') } const disabledEndTime = (selectedDate) => { if (!formData.startTime) return {} const start = dayjs(formData.startTime) const isSameDay = selectedDate && selectedDate.isSame(start, 'day') if (isSameDay) { return { disabledHours: () => { const hours = [] for (let i = 0; i < start.hour(); i++) { hours.push(i) } return hours }, disabledMinutes: (hour) => { if (hour === start.hour()) { const minutes = [] for (let i = 0; i <= start.minute(); i++) { minutes.push(i) } return minutes } return [] } } } return {} } // 完整验证 const validateAll = async () => { try { // 验证基础表单 await formRef.value.validate() // 验证题目 if (formData.questions.length === 0) { message.warning('请至少添加一道题目') return false } for (let i = 0; i < formData.questions.length; i++) { const q = formData.questions[i] if (!q.title) { message.warning(`请填写第${i + 1}题的题目`) return false } if (q.type === 1 || q.type === 2) { const validOptions = q.options.filter(opt => opt && opt.trim()) if (validOptions.length < 2) { message.warning(`第${i + 1}题至少需要2个有效选项`) return false } } } // 验证时间 if (!formData.startTime) { message.warning('请选择开始时间') return false } if (!formData.endTime) { message.warning('请选择结束时间') return false } if (dayjs(formData.endTime).isBefore(dayjs(formData.startTime))) { message.warning('结束时间不能早于开始时间') return false } return true } catch (error) { message.warning('请完善表单信息') return false } } // 构建提交数据 const buildSubmitData = () => { return { title: formData.title, description: formData.description, startTime: formData.startTime ? dayjs(formData.startTime).format('YYYY-MM-DD HH:mm:ss') : null, endTime: formData.endTime ? dayjs(formData.endTime).format('YYYY-MM-DD HH:mm:ss') : null, questions: formData.questions.map((q, idx) => ({ sortOrder: idx + 1, title: q.title, type: q.type, options: q.type === 3 ? { minScore: q.minScore, maxScore: q.maxScore } : q.options.filter(opt => opt && opt.trim()) })) } } // 保存草稿 const saveDraft = async () => { const draftData = buildSubmitData() draftData.isDraft = true try { // 调用保存接口 console.log('保存草稿:', draftData) message.success('草稿保存成功') } catch (error) { message.error('保存失败') } } // 提交表单 const submitForm = async () => { const isValid = await validateAll() if (!isValid) return const submitData = buildSubmitData() submitData.isDraft = false try { // 调用提交接口 console.log('提交表单:', submitData) message.success('提交成功') } catch (error) { message.error('提交失败') } } // 重置表单 const resetForm = () => { Modal.confirm({ title: '确认重置', content: '重置后所有未保存的内容将丢失,确定继续吗?', onOk: () => { formData.title = '' formData.description = '' formData.startTime = null formData.endTime = null formData.questions = [] message.success('已重置') } }) } // 数据回填(编辑时使用) const setFormData = (data) => { formData.title = data.title || '' formData.description = data.description || '' formData.startTime = data.startTime ? dayjs(data.startTime) : null formData.endTime = data.endTime ? dayjs(data.endTime) : null if (data.questions && data.questions.length) { formData.questions = data.questions.map(q => ({ id: generateId(), type: q.type, title: q.title, required: true, options: q.options || ['', ''], minScore: q.minScore || 1, maxScore: q.maxScore || 5 })) } else { // 默认添加一道题目 addQuestion() } } // 暴露方法给父组件 defineExpose({ setFormData, resetForm }) </script> <style scoped> .dynamic-form { max-width: 800px; margin: 0 auto; padding: 20px; } .mb-4 { margin-bottom: 16px; } .mb-3 { margin-bottom: 12px; } .question-item { margin-bottom: 16px; } .options-container { padding-left: 80px; } .option-row { display: flex; align-items: center; margin-bottom: 8px; } .option-index { width: 30px; font-weight: 500; } .option-input { flex: 1; margin-right: 8px; } .add-question { margin-top: 16px; } .form-actions { margin-top: 24px; text-align: center; padding: 16px; border-top: 1px solid #f0f0f0; } </style>五、踩坑总结
在实际开发中,我遇到了几个容易忽略的问题:
1. v-for 的 key 问题
动态列表的 key 不能用 index,否则删除中间项会导致渲染错误。一定要用唯一ID。
vue
<!-- ❌ 错误 --> <div v-for="(item, index) in list" :key="index"> <!-- ✅ 正确 --> <div v-for="item in list" :key="item.id">2. 响应式丢失问题
直接修改数组长度或使用索引修改数组元素,Vue 3 虽然能检测到,但最好使用数组方法:
javascript
// 推荐 questions.splice(index, 1) questions.push(newItem) // 避免 questions.length = 0 questions[index] = newValue3. 表单验证时机
嵌套表单的验证触发时机需要注意,建议使用validate方法手动触发,而不是完全依赖自动验证。
4. 时间格式处理
前后端时间格式要统一,建议统一使用YYYY-MM-DD HH:mm:ss格式传递,前端展示时再格式化。
六、性能优化建议
如果题目数量较多(比如超过20道),可以考虑以下优化:
- 虚拟滚动:使用
vue-virtual-scroller优化长列表渲染 - 懒验证:只在提交时进行完整验证,编辑时只验证当前修改的字段
- 防抖处理:对输入框的验证逻辑添加防抖
七、扩展思考
这个动态表单的思路还可以扩展到更多场景:
- 条件跳转:根据某题的回答,决定下一题是否显示
- 题目复制:一键复制已有题目,快速创建相似题目
- 模板功能:预设几种常用表单模板,一键应用
- 导出功能:支持导出为 PDF 或 Excel 格式
总结
动态表单配置是一个看似简单但实际很复杂的功能。通过合理的数据结构设计、巧妙的验证机制和细心的边界处理,完全可以构建出体验良好的动态表单系统。
核心要点回顾:
- ✅ 数据结构设计要兼顾统一性和扩展性
- ✅ 嵌套验证利用框架的路径绑定能力
- ✅ 时间限制要实现三级联动
- ✅ 草稿和正式提交要区分验证逻辑
- ✅ 注意响应式和列表渲染的坑
希望这篇文章能帮助到正在做类似需求的朋友。如果你有更好的方案或者遇到了其他问题,欢迎在评论区交流讨论!
