Vue3构建低代码表单设计器

预览

前言

本文将介绍低代码表单设计器的实现方式,该项目基于 Vue 3、Element Plus、Codemirror 和 SortableJS 构建,支持组件拖拽、可视化配置、JSON回显、源码生成等能力。

项目地址

github.com/SpanManX/lo...

项目架构概览

  • 使用 Vue3 作为主框架,采用组合式 API 提高模块化能力。
  • Element Plus 提供 UI 支持。
  • Codemirror 用于源码展示与编辑。
  • SortableJS 实现拖拽功能。

功能介绍

  1. 拖拽式表单构建

    • 支持无限层级嵌套
    • 画布元素选中
    • 组件动态删除与更新
  2. 可视化配置系统

    • 属性面板修改组件属性实时响应
    • 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-formel-form-item使用不同的createApp所创建,就会导致el-form设置属性,el-form-item不会生效的情况。这只是举例了一种问题,因为创建多个createApp还间接引发了很多问题,导致我把项目的主要逻辑重构了一遍。所以createApp最好只创建一个。

2. 结构管理复杂

  • 拖拽组件不仅需要控制视觉交互,还要保证组件在数据结构中的正确插入、移动与嵌套。

  • 需要解决:

    • 拖拽过程中的插槽定位与层级嵌套。
    • 拖拽状态同步与实时更新。
  • 解决方法:

    • 如果是拖入画布并且schema无数据则pushschema。有数据的情况则用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 回显与源码生成的低代码表单设计器,涵盖了从组件渲染、数据结构管理到属性配置与代码生成的完整流程。在开发过程中,解决了多实例渲染、嵌套结构同步、属性双向绑定等多个关键问题,最终实现了一个低代码表单项目。

后续仍有修改空间,还有多个功能未实现,如支持更多组件类型、更丰富的配置项、更强的导出能力等。希望本文的实践经验能为低代码平台开发提供实用参考。

相关推荐
LeeAt6 分钟前
真的!真的就一句话就能明白this指向问题
前端·javascript
阳火锅7 分钟前
都2025年了,来看看前端如何给刘亦菲加个水印吧!
前端·vue.js·面试
hahala233324 分钟前
ESLint 提交前校验技术方案
前端
夕水1 小时前
ew-vue-component:Vue 3 动态组件渲染解决方案的使用介绍
前端·vue.js
Winwin1 小时前
js基础-数据类型
javascript
Winwin1 小时前
哈?Boolean能作为回调函数?
javascript
我麻烦大了1 小时前
实现一个简单的Vue响应式
前端·vue.js
Shartin1 小时前
CPT208-Human-Centric Computing: Prototype Design Optimization原型设计优化
开发语言·javascript·原型模式
独立开阀者_FwtCoder1 小时前
你用 Cursor 写公司的代码安全吗?
前端·javascript·github
dme.1 小时前
Javascript之DOM操作
开发语言·javascript·爬虫·python·ecmascript