预览

前言
本文将介绍低代码表单设计器的实现方式,该项目基于 Vue 3、Element Plus、Codemirror 和 SortableJS 构建,支持组件拖拽、可视化配置、JSON回显、源码生成等能力。
项目地址
项目架构概览
- 使用
Vue3
作为主框架,采用组合式API
提高模块化能力。 Element Plus
提供 UI 支持。Codemirror
用于源码展示与编辑。SortableJS
实现拖拽功能。
功能介绍
-
拖拽式表单构建
- 支持无限层级嵌套
- 画布元素选中
- 组件动态删除与更新
-
可视化配置系统
- 属性面板修改组件属性实时响应
JSON
数据导出JSON
数据回显- 根据拖拽生成的组件来生成代码
目录结构
css
src/
├── assets
│ └── svg(存放SVG文件)
│ └── templates (存放模板文件)
│ └── components(vue 模板组件)
│
├── components (vue 组件)
│
├── demo
│
├── store (跨文件数据共享)
│
├── utils (存放封装的函数)
│
└── view (存放vue文件)
核心模块实现
1. 初始容器
使用 SortableJS
实现组件拖拽功能,结合 Element-Plus
的渲染能力构建可视化编辑器。
1.1 组件列表容器(只读拖出)
-
功能:提供可拖拽的组件列表,支持克隆元素到画布
-
关键配置:
group: { pull: 'clone', put: false }
确保拖出时克隆元素且不删除原元素onEnd
回调处理,拖到目标区域后删除克隆元素
js
Sortable.create(componentListRef.value, {
group: {
name: 'shared',
pull: 'clone',
put: false
},
ghostClass: '.item',
sort: false,
preventOnFilter: true,
animation: 150,
onEnd(evt) {
if (evt.originalEvent.dataTransfer.getData('type') && !evt.to.classList.contains('component-box')) {
evt.item.remove()
}
}
}
})
1.2 画布容器(可排序接收)
-
功能:接收拖入的组件并支持画布内排序
-
关键配置:
group: { name: 'shared' }
与组件列表共享拖拽组onAdd
回调处理新组件初始化逻辑onUpdate
拖拽改变顺序时的处理
js
sortableMap[id] = Sortable.create(element, {
group: {name: 'shared'},
animation: 150,
setData: setData,
onAdd: (event) => {},
onUpdate:(event)=>{}
});
1.3 应用集成
采用单实例模式创建 Vue 应用,通过响应式数据驱动画布渲染:
js
const renderComponent = createRenderer()
const app = createApp({
mounted() {
nextTick(() => {
Sortable.create(componentListRef.value, {
group: {
name: 'shared',
pull: 'clone',
put: false
},
ghostClass: '.item',
sort: false,
preventOnFilter: true,
animation: 150,
setData: setData,
onStart(evt) {
if (inline.value) evt.item.classList.add("inline-block");
evt.item.classList.remove("item-component");
},
onEnd(evt) {
if (evt.originalEvent.dataTransfer.getData('type') && !evt.to.classList.contains('component-box')) {
evt.item.remove()
} else {
evt.item.classList.add("item-component");
}
}
});
nextTick(() => {
initSortable({el: canvasRef.value})
})
})
},
updated() {
console.log(isExecuted,'updated')
if (isExecuted) {
creatInitSortable(schema.value.components, initSortable)
isExecuted = false;
}
},
render() {
const list = schema.value.components // 响应式依赖,用于触发 updated 生命周期
return h(
ElementPlus['ElForm'],
{
ref: formRef,
inline: inline.value,
model: formData,
rules: rules,
labelWidth: labelWidth.value,
labelPosition: labelPosition.value
},
{
default: () => [
h('div', {
ref: canvasRef,
class: 'drop-zone-box',
onClick: handleClick
}, {
default: () => list.map(item => {
componentDataStore.SET_COMPONENT_DATA_MAP(item.id, item)
return renderComponent(item)
})
})
]
}
)
}
}).use(ElementPlus, {locale: zhCn});
app.mount('#canvas-drop')
由于schema
变量是响应式的每次更新数据都会触发render()
,所以只需要操作schema
变量即可。
createApp
最好只创建一个,创建多个createApp
会导致每次调用都会创建一个完全独立的 Vue 应用实例。
2. 容器组件与基础组件
当前支持的容器类组件,均具备无限嵌套 和动态内容管理能力:
- 基础容器:DivComponent(通用块元素)
- 布局容器:GridComponent(栅格系统)、ElCard(卡片容器)
- 导航容器:ElTabs(标签页容器)
- 基础组件:包括输入框、下拉框、日期选择器、单选框等等
2.1 核心能力
- 嵌套管理:支持容器间无限层级嵌套
- 内容操作 :
- 子组件拖出容器
- 容器内元素重新排序
- 动态内容更新
2.2 Sortable.create
封装策略
将 Sortable.create
封装为独立方法,提供以下特性:
- 统一管理 :通过
sortableMap
维护所有实例 - 智能销毁:重复初始化时自动清理旧实例
- 动态适配:自动识别不同容器类型的DOM结构
ini
/**
* 初始化可排序组件
*
* @param {Object} options - 初始化参数
* @param {string} [options.id=''] - 组件ID
* @param {HTMLElement} [options.el=null] - DOM元素
* @param {Object} [options.componentJSON=null] - 组件JSON对象
*/
function initSortable({id = '', el = null, componentJSON = null}) {
if (!el && !id) {
console.error('必须提供 id 或 el');
return;
}
// 如果已存在,先销毁旧的 Sortable 实例
if (sortableMap[id]) {
sortableMap[id].destroy();
delete sortableMap[id];
}
let element = el || document.querySelector(`[data-id='${id}']`);
if (element?.dataset?.component === 'ElCard') {
element = element.querySelector('.el-card__body');
if (!element) return;
} else if (element?.dataset?.component === 'GridComponent' || element?.dataset?.component === 'DivComponent') {
element = element.querySelector('.element');
if (!element) return;
}
sortableMap[id] = Sortable.create(element, {
group: {name: 'shared'},
animation: 150,
setData: setData,
onAdd: (event) => {
if (!event.originalEvent.dataTransfer.getData('type')) {
event.item.remove()
return
}
handleDragDropElement(event)
},
onMove: () => {
teleportStore.SET_TELEPORT_TO(null);
// toolbarRef.value.style.display = 'none';
},
onUpdate: handleSortChange
});
}
2.3 处理schema
变量
handleDragDropElement
函数负责处理拖拽事件与 schema
数据模型的同步,实现以下能力:
- 动态创建组件配置对象
- 维护父子组件嵌套关系
- 自动初始化嵌套容器的排序功能
- 表单组件自动包装(FormItem 处理)
js
function handleDragDropElement(event) {
const type = event.originalEvent.dataTransfer.getData('type');
const dropTarget = event.to.closest('[data-id]');
if (!event.item.classList.contains('item')) {
handleSortChange(event);
return;
}
const itemId = event.originalEvent.dataTransfer.getData('itemId');
let compConfig = JSON.parse(JSON.stringify(componentMapStore.componentMap[type]));
if (!compConfig) return;
let newComp = createComponent(compConfig, itemId); // 创建新组件配置对象
if (!newComp.noUseForm) {
const name = newComp.componentName
const id = newComp.id
newComp = {
componentName: 'ElFormItem',
props: {label: newComp.text, prop: `field${id}`},
on: {
onClick: (e) => {
if (name !== 'ElRadioGroup' && name !== 'ElCheckboxGroup') e.preventDefault();
}
},
id: generateRandomId(),
children: [newComp]
}
}
if (dropTarget) { // 如果存在目标元素,则添加到子组件列表中
const parentId = dropTarget.dataset.id;
const parentNode = findComponentById(schema.value.components, parentId);
if (parentNode) {
parentNode.children.splice(event.newIndex, 0, newComp);
}
} else {
if (schema.value.components[event.newIndex]) { // 画布上已有元素,新拖拽的元素插入到最上层触发
schema.value.components.splice(event.newIndex, 0, newComp);
} else {
schema.value.components.push(newComp);
}
}
if (type === "ElCard" || type === 'ElTabs' || type === 'GridComponent' || type === 'DivComponent') {
nextTick(() => {
if (newComp.children?.length) { // ElTabs 执行
newComp.children.forEach(item => initSortable({id: item.id}));
} else { // ElCard GridComponent DivComponent执行
initSortable({id: newComp.id});
}
});
}
}
3. 可视化配置面板
3.1 核心功能
实现组件属性的可视化配置系统,具备以下特性:
- 动态属性加载:根据选中组件类型自动加载对应配置项
- 集中式管理:通过配置文件统一维护所有组件属性定义
- 响应式更新:属性修改实时同步到画布组件

3.2 配置文件规范
采用模块化设计,每个组件独立配置,示例如下:
js
export const ElSelectConfigProps = [
{key: 'placeholder', value: '请选择', name: '占位文本'},
{key: 'clearable', value: false, name: '清除按钮'},
{key: 'multiple', value: false, name: '是否多选'},
{key: 'multiple-limit', value: 0, name: '可选项目数'},
{key: 'collapse-tags', value: false, name: '超出合并'},
{key: 'filterable', value: false, name: '是否可筛选'},
];
数据结构与 JSON 回显
- 组件的 JSON 格式:
js
export default () => {
return {
"componentName": "ElSelect",
"text": "选择器",
"icon": select,
"props": {
"style": "min-width: 196px",
placeholder: '请选择'
},
"children": [
{
"componentName": "ElOption",
"props": {
"label": "First Option",
"value": "val1"
}
},
{
"componentName": "ElOption",
"props": {
"label": "Second Option",
"value": "val2"
}
}
]
}
}
- 实现动态组件树的构建与渲染,支持特殊组件处理和包装逻辑:
js
// 处理子节点
let defaultData
if (value.children) {
defaultData = () => value.children.map(child => renderComponent(child))
} else if (value.label) {
defaultData = () => value.label
}
// 特殊处理
if (value.componentName === 'ElTabPane') {
props['data-id'] = value.id
}
// 特殊处理
if (value.componentName === 'ElCard') {
return h('div', {
key: value.id,
class: 'drop-item drop-item-card',
'data-id': value.id,
'data-component': value.componentName
}, [h(ElementPlus[value.componentName], props, {
default: defaultData, header: () => renderStaticChildren(value.staticChildren)
})])
}
const componentName = value.componentName === 'ElFormItem' ? value.children?.[0]?.componentName : value.componentName
if (value.componentName === 'GridComponent') {
return h(DropItemComponent, {componentData: {...value, componentName}, key: value.id}, {
default: () => h(gridComponent, value.props, defaultData)
})
}
let wrappedComponentChild;
if (value.componentName === 'DivComponent') {
wrappedComponentChild = {
default: () => h(DivComponent, value.props, defaultData)
}
} else {
wrappedComponentChild = {
default: () => h(ElementPlus[value.componentName], {
...props, ...(labelWidth ? {'label-width': labelWidth || 'auto'} : {}), ...events
}, defaultData)
}
}
if (useWrappedNames.indexOf(value.componentName) > -1) {
return h(DropItemComponent, {
componentData: {...value, componentName},
key: value.id
}, wrappedComponentChild)
} else {
return h(ElementPlus[value.componentName], {...props, ...events}, defaultData)
}
代码生成与预览
- 基于 CodeMirror 实现动态代码编辑与实时预览功能,支持 JSON 数据到 Vue 模板的转换
js
// 处理组件名称,如果是 Element Plus 组件则替换为 el- 开头的形式
let componentName = item.componentName
if (item.componentName.indexOf("El") > -1) {
componentName = toKebabCase(item.componentName);
} else if (item.componentName === 'DivComponent' || item.componentName === 'GridComponent') {
componentName = 'div'
}
// 生成最终的标签字符串
if (componentName) {
if (allChildrenStr) {
return `<${componentName} ${vModelString} ${propsString} ${onString} ${slotString}>\n${allChildrenStr}\n</${componentName}>`
} else if (item.label) {
return `<${componentName} ${vModelString} ${propsString} ${onString} ${slotString}>\n${item.label}\n</${componentName}>`
} else {
return `<${componentName} ${vModelString} ${propsString} ${onString}/>`
}
} else if (item.text) {
return item.text
}
项目难点
在实现过程中,遇到了一些具有挑战性的技术难点,主要包括以下几个方面:
1. createApp
渲染陷阱
最初是使用创建多个createApp
来创建组件,会导致每次调用都会创建一个完全独立的 Vue 应用实例。这意味着它们之间的状态、指令、插件等都是彼此隔离的,不会互相影响。如el-form
和el-form-item
使用不同的createApp
所创建,就会导致el-form
设置属性,el-form-item
不会生效的情况。这只是举例了一种问题,因为创建多个createApp
还间接引发了很多问题,导致我把项目的主要逻辑重构了一遍。所以createApp
最好只创建一个。
2. 结构管理复杂
-
拖拽组件不仅需要控制视觉交互,还要保证组件在数据结构中的正确插入、移动与嵌套。
-
需要解决:
- 拖拽过程中的插槽定位与层级嵌套。
- 拖拽状态同步与实时更新。
-
解决方法:
- 如果是拖入画布并且
schema
无数据则push
到schema
。有数据的情况则用splice
插入,如schema.value.components.splice(event.newIndex, 0, newComp);
。 - 如果拖入到容器则递归查询
schema
数据,并插入到数据的子级。 - 移动节点则需要查找到新父级和旧父级,旧父级使用
splice
移除子节点,新的父级则插入子节点,还需要调用creatInitSortable
更新可拖拽元素,因为每次拖拽都会触发render()
的更新,导致DOM
的映射关系改变。 - 实时更新,因为
schema
变量是响应式的每次更新数据都会触发render()
重新渲染,能很好的解决实时更新的问题。
- 如果是拖入画布并且
3. render
排序问题
-
改变数据顺序后,触发
render()
更新,出现渲染的元素还是以前的顺序,但是数据的顺序却是变了。 -
难点在于:
- 如何才能找到原因并解决掉。
-
解决方法:由于 Vue 进行虚拟 DOM diff 的时候,它默认是通过"类型"来识别组件的。如果数组修改顺序,比如
splice()
或重新赋值,Vue 会默认认为组件是同一个,只是内容不同,于是它会复用旧组件实例。加上
key
后,Vue 会以key
为唯一标识,严格根据key
来判断组件身份是否一致。比如有两个组件:[组件A, 组件B]换了顺序[组件B, 组件A]。如果没
key
,它会直接把第一个组件复用一改个内容就行。这样它会把第二组数据的 A 当成 B 渲染,导致看到的顺序、样式、数据都可能乱了。由于diff算法没有去比对数组的顺序,引发了以上问题,加上key就可以判断出
总结
本项目通过 Vue 3 + Element Plus 构建了一个支持组件拖拽、可视化配置、JSON 回显与源码生成的低代码表单设计器,涵盖了从组件渲染、数据结构管理到属性配置与代码生成的完整流程。在开发过程中,解决了多实例渲染、嵌套结构同步、属性双向绑定等多个关键问题,最终实现了一个低代码表单项目。
后续仍有修改空间,还有多个功能未实现,如支持更多组件类型、更丰富的配置项、更强的导出能力等。希望本文的实践经验能为低代码平台开发提供实用参考。