手把手打造高可用表单设计器的核心技术实践
先看效果,再看代码
上周接到一个需求:让用户自己拖拽组件生成表单,支持保存、预览、导出 JSON。听起来很常见,但真做起来才发现坑不少。我折腾了三天,最后用原生 JS + Vue3 搞定了一套轻量方案,核心代码不到 200 行,亲测有效。
先说效果:左侧是组件库(输入框、下拉框、开关等),中间是画布,拖过去就能放,点一下还能编辑属性。整个交互流畅,连产品经理都点头说“这次做得快”。
核心思路就两点:1. 用拖拽 API 实现组件拖入;2. 用响应式数组管理表单结构。下面直接上关键代码。
// 组件库项(简化版)
const components = [
{ type: 'input', label: '单行文本', placeholder: '请输入' },
{ type: 'select', label: '下拉选择', options: ['选项1', '选项2'] },
{ type: 'switch', label: '开关', default: false }
];
// 表单数据结构
const formSchema = ref([]);
<!-- 左侧组件库 -->
<div class="component-list">
<div
v-for="comp in components"
:key="comp.type"
draggable="true"
@dragstart="handleDragStart(comp)"
class="component-item"
>
{{ comp.label }}
</div>
</div>
<!-- 中间画布 -->
<div class="canvas" @drop="handleDrop" @dragover.prevent></div>
// 拖拽开始
function handleDragStart(comp) {
// 把组件数据存到 dataTransfer
event.dataTransfer.setData('application/json', JSON.stringify(comp));
}
// 放下组件
function handleDrop() {
const data = event.dataTransfer.getData('application/json');
const comp = JSON.parse(data);
// 注意:这里要深拷贝!否则多个实例会共享同一个 options 引用
formSchema.value.push({ ...comp, id: Date.now() });
}
这段代码跑起来基本就能拖了。但注意那个 id: Date.now() —— 我一开始没加唯一 ID,结果编辑的时候改一个,所有同类型组件全变了,折腾半天才定位到是引用问题。
踩坑提醒:这三点一定注意
表单设计器看着简单,实际开发中这几个坑我全踩过:
- 引用问题:从组件库拖出来的对象如果直接 push 到 schema,修改时会污染原始模板。一定要深拷贝,或者像上面那样用展开运算符+新 ID。
- 拖拽兼容性:
@dragover.prevent必须加,否则 drop 事件不会触发。这个在 Chrome 没问题,但在 Safari 上不加 prevent 就直接失效,血泪教训。 - 动态组件渲染:画布里怎么根据 type 渲染不同组件?别用 v-if 堆一堆,用动态组件
<component :is="item.type" />更干净。但要注意,组件名必须注册,比如:
// 在父组件中注册
import InputField from './fields/InputField.vue';
import SelectField from './fields/SelectField.vue';
export default {
components: {
input: InputField,
select: SelectField,
// 注意:这里 key 是小写,和 schema.type 保持一致
}
}
另外,不要用大驼峰命名 type,比如 InputField,因为 Vue 动态组件对大小写敏感,容易出错。统一用小写最省事。
这个场景最好用:实时预览 + 属性面板
用户拖完组件,肯定要改 label、placeholder 这些。我搞了个右侧属性面板,点击画布上的组件就高亮并显示可编辑字段。
<!-- 画布中的每个组件 -->
<div
v-for="item in formSchema"
:key="item.id"
@click="selectItem(item)"
:class="{ selected: selectedItem?.id === item.id }"
>
<component :is="item.type" :config="item" />
</div>
<!-- 属性面板 -->
<div v-if="selectedItem" class="props-panel">
<input v-model="selectedItem.label" placeholder="标签" />
<input v-model="selectedItem.placeholder" placeholder="占位符" v-if="selectedItem.type === 'input'" />
</div>
关键点:selectedItem 直接指向 formSchema 中的某个对象,所以 v-model 修改会自动同步。但这里有个隐患 —— 如果用户清空了 label,表单可能校验失败。所以我在保存前加了个校验:
function validateSchema() {
for (const item of formSchema.value) {
if (!item.label.trim()) {
alert('请填写字段标签');
return false;
}
}
return true;
}
简单粗暴,但有效。毕竟这是给内部运营用的工具,不是给终端用户,没必要搞复杂 UI 提示。
高级技巧:支持嵌套布局(比如两栏)
客户后来提了个需求:能不能放个“两栏布局”容器,里面再拖字段?这就要把 schema 改成树形结构了。
我原来的 flat 数组不行了,得改成:
// 新结构
const formSchema = ref([
{
type: 'container',
layout: 'two-column',
children: [
{ type: 'input', label: '左边字段' },
{ type: 'select', label: '右边字段' }
]
}
]);
渲染时递归处理:
<template>
<div v-for="item in items" :key="item.id">
<component
v-if="!item.children"
:is="item.type"
:config="item"
/>
<div v-else class="container-wrapper">
<div v-for="child in item.children" :key="child.id">
<FormField :item="child" />
</div>
</div>
</div>
</template>
拖拽逻辑也得改:drop 的时候要判断当前是否在容器内。我加了个 currentContainer 变量,通过冒泡事件记录路径。不过这样代码复杂度飙升,如果你项目不需要复杂布局,建议直接禁用嵌套,保持 flat 结构最稳。
导出与加载:JSON 就够了
最后一步,保存和加载。直接把 formSchema 转 JSON 存数据库就行:
// 保存
const saveData = JSON.stringify(formSchema.value);
// 发给后端
fetch('https://jztheme.com/api/save-form', {
method: 'POST',
body: JSON.stringify({ schema: saveData })
});
// 加载
const res = await fetch('https://jztheme.com/api/get-form?id=123');
formSchema.value = await res.json();
注意:JSON 里不能有函数或循环引用,但我们 schema 都是纯数据,没问题。不过 Date 对象会变成字符串,所以 ID 我改用 crypto.randomUUID() 了,更稳妥。
另外,别在 JSON 里存 DOM 信息或临时状态,比如 isFocused、isValid 这些。只存业务数据,避免后续解析出错。
结尾:这东西水很深,但 MVP 其实很简单
表单设计器可以做得很重(比如阿里云的 LowCode),但大多数内部需求,一个周末就能搞出能用的版本。我这套方案虽然简陋,但支撑了我们三个项目的运营配置,稳定跑了半年多。
当然,它还有不少问题:比如撤销/重做没做、移动端适配差、性能在 50+ 字段时有点卡。但这些都不影响核心流程,优先级不高就没动。
以上是我踩坑后的总结,希望对你有帮助。这个技术的拓展用法还有很多(比如结合 JSON Schema 自动生成表单、支持自定义组件注册等),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流 —— 毕竟谁还没被表单需求折磨过呢?

暂无评论