从零实现一个低代码 H5 页面编辑器(Vue3 + 拖拽)

从零实现一个低代码 H5 页面编辑器(Vue3 + 拖拽)

本文带你手把手实现一个类似有赞、微盟的 H5 营销页面编辑器,支持拖拽搭建、实时预览、属性配置。

前言

低代码平台这几年很火,但很多人觉得很复杂不敢下手。其实核心原理并不难,今天我们就用 Vue3 + vuedraggable 实现一个麻雀虽小五脏俱全的 H5 页面编辑器。

最终效果:

技术栈:

  • Vue 3 + Composition API
  • Pinia 状态管理
  • Element Plus
  • vuedraggable 拖拽
  • Tailwind CSS

一、整体架构设计

低代码编辑器的核心是 三栏布局 + 统一数据源

scss 复制代码
┌──────────────────────────────────────────────────────┐
│                      App.vue                          │
│  ┌──────────┐  ┌────────────────┐  ┌──────────────┐  │
│  │LeftPanel │  │  CenterCanvas  │  │  RightPanel  │  │
│  │  组件库   │ → │    画布预览    │ ← │   属性配置   │  │
│  └──────────┘  └────────────────┘  └──────────────┘  │
│                        ↑↓                             │
│                   Pinia Store                         │
│                  (全局状态管理)                        │
└──────────────────────────────────────────────────────┘

数据流向:

  1. 左侧组件库 → 拖拽克隆 → 画布
  2. 点击画布组件 → 选中 → 右侧显示配置
  3. 修改配置 → 更新 Store → 画布实时刷新

二、定义组件配置

首先定义组件库,每个组件包含类型、名称、图标和默认属性:

js 复制代码
// src/config/components.js
export const componentLibrary = {
  basic: {
    name: '基础组件',
    icon: 'Grid',
    components: [
      {
        type: 'text',
        name: '文本',
        icon: 'Document',
        props: {
          content: '请输入文本内容',
          fontSize: 14,
          color: '#333333',
          textAlign: 'left',
          fontWeight: 'normal',
          padding: 10
        }
      },
      {
        type: 'image',
        name: '图片',
        icon: 'Picture',
        props: {
          src: 'https://via.placeholder.com/350x150',
          width: '100%',
          borderRadius: 0
        }
      },
      // ... 更多组件
    ]
  },
  marketing: {
    name: '营销组件',
    components: [
      {
        type: 'banner',
        name: '轮播图',
        props: {
          images: ['banner1.jpg', 'banner2.jpg'],
          height: 200,
          autoplay: true
        }
      },
      {
        type: 'coupon',
        name: '优惠券',
        props: {
          title: '满减优惠券',
          value: 50,
          condition: '满200可用'
        }
      }
    ]
  }
}

设计要点:

  • type 用于匹配渲染组件
  • props 是默认属性,拖入画布后可独立修改
  • 分类管理,便于扩展

三、Pinia 状态管理

这是整个编辑器的核心,管理画布状态:

js 复制代码
// src/stores/editor.js
import { defineStore } from 'pinia'

export const useEditorStore = defineStore('editor', {
  state: () => ({
    components: [],      // 画布中的组件列表
    selectedIndex: -1,   // 当前选中的组件索引
    idCounter: 0         // 组件ID计数器
  }),

  getters: {
    // 获取当前选中的组件
    selectedComponent: (state) => {
      if (state.selectedIndex >= 0) {
        return state.components[state.selectedIndex]
      }
      return null
    }
  },

  actions: {
    // 添加组件(拖入时调用)
    addComponent(component) {
      const newComponent = {
        ...JSON.parse(JSON.stringify(component)), // 深拷贝
        id: ++this.idCounter
      }
      this.components.push(newComponent)
      this.selectedIndex = this.components.length - 1
    },

    // 选中组件
    selectComponent(index) {
      this.selectedIndex = index
    },

    // 删除组件
    deleteComponent(index) {
      this.components.splice(index, 1)
      if (this.selectedIndex >= this.components.length) {
        this.selectedIndex = this.components.length - 1
      }
    },

    // 上移/下移
    moveUp(index) {
      if (index > 0) {
        [this.components[index], this.components[index - 1]] = 
        [this.components[index - 1], this.components[index]]
        this.selectedIndex = index - 1
      }
    },

    // 更新组件属性
    updateComponentProps(index, props) {
      if (this.components[index]) {
        this.components[index].props = { 
          ...this.components[index].props, 
          ...props 
        }
      }
    },

    // 导出数据
    getSaveData() {
      return JSON.stringify(this.components)
    }
  }
})

四、左侧组件库(拖拽源)

使用 vuedraggable 实现拖拽,关键是 group 配置:

vue 复制代码
<!-- src/components/LeftPanel.vue -->
<template>
  <div class="left-panel">
    <div class="p-3 border-b font-bold">组件库</div>
    
    <el-collapse v-model="activeNames">
      <el-collapse-item 
        v-for="(category, key) in componentLibrary" 
        :key="key"
        :name="key"
      >
        <template #title>
          <el-icon class="mr-2"><component :is="category.icon" /></el-icon>
          {{ category.name }}
        </template>
        
        <!-- 🔥 核心:draggable 配置 -->
        <draggable
          :list="category.components"
          :group="{ name: 'components', pull: 'clone', put: false }"
          :clone="cloneComponent"
          :sort="false"
          item-key="type"
        >
          <template #item="{ element }">
            <div class="component-item">
              <el-icon><component :is="element.icon" /></el-icon>
              <span>{{ element.name }}</span>
            </div>
          </template>
        </draggable>
      </el-collapse-item>
    </el-collapse>
  </div>
</template>

<script setup>
import draggable from 'vuedraggable'
import { componentLibrary } from '@/config/components'

// 深拷贝,确保每个实例独立
const cloneComponent = (item) => {
  return JSON.parse(JSON.stringify(item))
}
</script>

🔥 group 配置详解:

属性 含义
name 'components' 分组名,同名才能互相拖拽
pull 'clone' 拖出时克隆,原列表不变
put false 禁止放入,只能拖出

这样设计的好处:

  • 组件库是"模板",拖多少次都不会消失
  • 防止用户把画布组件拖回组件库

五、中间画布(拖拽目标)

vue 复制代码
<!-- src/components/CenterCanvas.vue -->
<template>
  <div class="center-canvas">
    <!-- 工具栏 -->
    <div class="toolbar">
      <el-button type="primary" @click="handleSave">保存</el-button>
      <el-button @click="store.reset()">重置</el-button>
    </div>
    
    <!-- 手机预览框 -->
    <div class="phone-frame">
      <draggable
        v-model="store.components"
        group="components"
        item-key="id"
        ghost-class="ghost"
        @add="onAdd"
      >
        <template #item="{ element, index }">
          <div 
            class="component-wrapper"
            :class="{ selected: store.selectedIndex === index }"
            @click="store.selectComponent(index)"
          >
            <!-- 动态渲染组件 -->
            <component 
              :is="previewComponents[element.type]" 
              :props="element.props"
            />
          </div>
        </template>
      </draggable>
      
      <!-- 空状态 -->
      <div v-if="store.components.length === 0" class="empty-tip">
        拖拽组件到此处
      </div>
    </div>
    
    <!-- 操作按钮 -->
    <div v-if="store.selectedComponent" class="action-btns">
      <el-button @click="store.moveUp(store.selectedIndex)">上移</el-button>
      <el-button @click="store.moveDown(store.selectedIndex)">下移</el-button>
      <el-button type="danger" @click="store.deleteComponent(store.selectedIndex)">
        删除
      </el-button>
    </div>
  </div>
</template>

<script setup>
import draggable from 'vuedraggable'
import { useEditorStore } from '@/stores/editor'
import { previewComponents } from './preview'

const store = useEditorStore()

const onAdd = (evt) => {
  store.selectComponent(evt.newIndex)
}
</script>

动态组件渲染:

js 复制代码
// src/components/preview/index.js
import TextPreview from './TextPreview.vue'
import ImagePreview from './ImagePreview.vue'
import BannerPreview from './BannerPreview.vue'
// ...

export const previewComponents = {
  'text': TextPreview,
  'image': ImagePreview,
  'banner': BannerPreview,
  // ...
}

通过 element.type 匹配对应的预览组件,实现动态渲染。

六、右侧属性配置

vue 复制代码
<!-- src/components/RightPanel.vue -->
<template>
  <div class="right-panel">
    <div class="p-3 border-b font-bold">属性配置</div>
    
    <div v-if="!store.selectedComponent" class="empty">
      请选择组件
    </div>

    <div v-else class="p-4">
      <!-- 文本组件配置 -->
      <template v-if="store.selectedComponent.type === 'text'">
        <el-form label-position="top">
          <el-form-item label="文本内容">
            <el-input 
              v-model="props.content" 
              type="textarea" 
              @input="updateProps" 
            />
          </el-form-item>
          <el-form-item label="字体大小">
            <el-slider 
              v-model="props.fontSize" 
              :min="12" :max="36" 
              @change="updateProps" 
            />
          </el-form-item>
          <el-form-item label="文字颜色">
            <el-color-picker 
              v-model="props.color" 
              @change="updateProps" 
            />
          </el-form-item>
        </el-form>
      </template>
      
      <!-- 其他组件配置... -->
    </div>
  </div>
</template>

<script setup>
import { reactive, watch } from 'vue'
import { useEditorStore } from '@/stores/editor'

const store = useEditorStore()
const props = reactive({})

// 监听选中组件变化,同步属性到本地
watch(() => store.selectedComponent, (comp) => {
  if (comp) {
    Object.assign(props, JSON.parse(JSON.stringify(comp.props)))
  }
}, { immediate: true, deep: true })

// 更新属性到 Store
const updateProps = () => {
  if (store.selectedIndex >= 0) {
    store.updateComponentProps(store.selectedIndex, { ...props })
  }
}
</script>

为什么需要本地 props + updateProps

scss 复制代码
Store.selectedComponent.props  →  深拷贝  →  本地 props (v-model 绑定)
                                              ↓
                                         用户修改
                                              ↓
                                         updateProps()
                                              ↓
Store.updateComponentProps()  ←  同步回 Store  ←  画布更新

如果直接 v-model 绑定 Store 数据,虽然也能工作,但不够规范。使用本地副本可以:

  • 更好地控制更新时机
  • 方便做表单校验
  • 支持"取消修改"功能

七、预览组件示例

vue 复制代码
<!-- src/components/preview/TextPreview.vue -->
<template>
  <div 
    :style="{
      fontSize: props.fontSize + 'px',
      color: props.color,
      textAlign: props.textAlign,
      fontWeight: props.fontWeight,
      padding: props.padding + 'px'
    }"
  >
    {{ props.content }}
  </div>
</template>

<script setup>
defineProps({
  props: { type: Object, required: true }
})
</script>

每个预览组件接收 props 对象,根据属性渲染样式。

八、总结

核心要点:

  1. vuedraggable 的 group 配置 - pull: 'clone' 实现克隆拖拽
  2. Pinia 统一状态管理 - 三个面板共享数据源
  3. 动态组件 - <component :is=""> 根据类型渲染
  4. 深拷贝 - 确保每个组件实例独立

扩展方向:

  • 撤销/重做(操作历史栈)
  • 组件嵌套(容器组件)
  • 模板保存/加载
  • 真实预览(生成 H5 页面)
  • 更多组件类型

最后源码地址

gitee: gitee.com/live1906/lo...

如果对你有帮助,欢迎 Star ⭐ 支持一下!


我是文强,专注前端开发,欢迎关注交流 👋

相关推荐
码界奇点2 小时前
基于Django REST framework与Vue的前后端分离后台管理系统设计与实现
vue.js·后端·python·django·毕业设计·源代码管理
黑臂麒麟2 小时前
华为云 DevUI初体验:如何快速入门项目搭建
前端·ui·华为云·devui
翔云 OCR API2 小时前
企业工商信息查验API-快速核验企业信息-营业执照文字识别接口
前端·数据库·人工智能·python·mysql
小明记账簿_微信小程序2 小时前
js实现页面全屏展示
前端
wordbaby2 小时前
秒懂 Headless:为什么现在的软件都要“去头”?
前端
茄汁面2 小时前
实现紧贴边框的高亮流光动画效果(长方形适配)
前端·javascript·css
松莫莫2 小时前
Vue 3 项目搭建完整流程(Windows 版 · 避坑指南)
前端·vue.js·windows
涔溪2 小时前
深入理解 Vue Router 中 Hash 模式和 History 模式 的核心区别、底层原理、使用场景及部署注意事项
vue.js·哈希算法·history
San302 小时前
破茧成蝶:Web 前端开发的三次革命与架构演进史
javascript·vue.js·ecmascript 6