首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >用 Vue/React 搭建简易低代码编辑器:组件拖拽原理

用 Vue/React 搭建简易低代码编辑器:组件拖拽原理

作者头像
fruge365
发布2025-12-15 13:38:52
发布2025-12-15 13:38:52
2190
举报

用 Vue/React 搭建简易低代码编辑器:组件拖拽原理

目标:实现一个基础的低代码拖拽搭建能力,包含组件面板、画布、落点占位、网格吸附、选中与属性配置、JSON Schema 持久化。本文聚焦拖拽交互与数据结构设计,分别提供 Vue 与 React 的最小实现示例。

架构与数据模型

  • 核心模块
    • 组件面板:可拖拽的物料
    • 画布:接收拖入与移动的区域
    • 选中与属性面板:编辑节点属性
    • 存储层:以 JSON Schema 持久化
  • 基础数据结构
代码语言:javascript
复制
{
  "nodes": [
    {
      "id": "btn_1",
      "type": "Button",
      "props": { "text": "提交", "style": { "width": 120 } },
      "layout": { "x": 80, "y": 120, "w": 120, "h": 40, "z": 1 }
    }
  ],
  "meta": { "grid": 8, "version": 1 },
  "selection": ["btn_1"]
}
  • 关键点
    • layout 是渲染位置与尺寸的唯一来源
    • selection 与 hover 独立管理,便于多选与框选
    • grid 控制吸附步长

拖拽交互设计

  • 拖拽来源
    • 面板拖入:创建新节点并定位到落点
    • 画布内拖动:更新已存在节点的 layout
  • 坐标系
    • 基于画布的本地坐标计算 x,y
    • 吸附到网格:x = round(x / grid) * grid
  • 占位与落点
    • 拖拽中显示占位框与对齐参考线
  • 性能与体验
    • 使用 transform: translate3d 更新位置
    • 监听 pointer 事件,并在捕获阶段阻止不必要的选择行为

Vue 最小实现示例

代码语言:javascript
复制
<script setup>
import { ref } from 'vue'

const grid = 8
const schema = ref({ nodes: [], selection: [], meta: { grid } })
const canvasRef = ref()

function addNode(type, x, y) {
  const id = type + '_' + Date.now()
  const snap = v => Math.round(v / grid) * grid
  schema.value.nodes.push({
    id,
    type,
    props: {},
    layout: { x: snap(x), y: snap(y), w: 120, h: 40, z: 1 }
  })
}

function onPanelDragStart(e, type) {
  e.dataTransfer.setData('type', type)
}

function onCanvasDrop(e) {
  const type = e.dataTransfer.getData('type')
  const rect = canvasRef.value.getBoundingClientRect()
  const x = e.clientX - rect.left
  const y = e.clientY - rect.top
  addNode(type, x, y)
}

function onCanvasDragOver(e) {
  e.preventDefault()
}

function onNodePointerDown(e, node) {
  const startX = e.clientX
  const startY = e.clientY
  const base = { x: node.layout.x, y: node.layout.y }
  const move = ev => {
    const dx = ev.clientX - startX
    const dy = ev.clientY - startY
    const snap = v => Math.round(v / grid) * grid
    node.layout.x = snap(base.x + dx)
    node.layout.y = snap(base.y + dy)
  }
  const up = () => {
    window.removeEventListener('pointermove', move, true)
    window.removeEventListener('pointerup', up, true)
  }
  window.addEventListener('pointermove', move, true)
  window.addEventListener('pointerup', up, true)
}
</script>

<template>
  <div class="editor">
    <div class="panel">
      <div draggable="true" @dragstart="e => onPanelDragStart(e, 'Button')">Button</div>
      <div draggable="true" @dragstart="e => onPanelDragStart(e, 'Text')">Text</div>
    </div>
    <div class="canvas" ref="canvasRef" @drop="onCanvasDrop" @dragover="onCanvasDragOver">
      <div v-for="n in schema.nodes" :key="n.id" class="node"
           :style="{ transform: `translate3d(${n.layout.x}px, ${n.layout.y}px, 0)`, width: n.layout.w + 'px', height: n.layout.h + 'px' }"
           @pointerdown="e => onNodePointerDown(e, n)">
        <span v-if="n.type==='Button'">按钮</span>
        <span v-else>文本</span>
      </div>
    </div>
  </div>
</template>

<style scoped>
.editor { display: flex; height: 70vh }
.panel { width: 200px; border-right: 1px solid #eee; padding: 8px }
.canvas { flex: 1; position: relative; background: repeating-linear-gradient(0deg, #fafafa, #fafafa 7px, #f1f1f1 8px) }
.node { position: absolute; box-sizing: border-box; border: 1px dashed #999; background: #fff }
</style>

要点:用原生 HTML5 Drag 拖入,画布内采用 Pointer 事件移动;用 transform 提升性能;通过网格步长实现吸附。

React 最小实现示例

代码语言:javascript
复制
import { useRef, useState } from 'react'

const grid = 8
const snap = v => Math.round(v / grid) * grid

export default function Editor() {
  const [nodes, setNodes] = useState([])
  const canvasRef = useRef(null)

  function addNode(type, x, y) {
    setNodes(prev => prev.concat({
      id: type + '_' + Date.now(),
      type,
      props: {},
      layout: { x: snap(x), y: snap(y), w: 120, h: 40, z: 1 }
    }))
  }

  function onPanelDragStart(e, type) {
    e.dataTransfer.setData('type', type)
  }

  function onCanvasDrop(e) {
    const type = e.dataTransfer.getData('type')
    const rect = canvasRef.current.getBoundingClientRect()
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    addNode(type, x, y)
  }

  function onCanvasDragOver(e) { e.preventDefault() }

  function onNodePointerDown(e, id) {
    const startX = e.clientX
    const startY = e.clientY
    const target = nodes.find(n => n.id === id)
    const base = { x: target.layout.x, y: target.layout.y }
    function move(ev) {
      const dx = ev.clientX - startX
      const dy = ev.clientY - startY
      const nx = snap(base.x + dx)
      const ny = snap(base.y + dy)
      setNodes(prev => prev.map(n => n.id === id ? { ...n, layout: { ...n.layout, x: nx, y: ny } } : n))
    }
    function up() {
      window.removeEventListener('pointermove', move, true)
      window.removeEventListener('pointerup', up, true)
    }
    window.addEventListener('pointermove', move, true)
    window.addEventListener('pointerup', up, true)
  }

  return (
    <div style={{ display: 'flex', height: '70vh' }}>
      <div style={{ width: 200, borderRight: '1px solid #eee', padding: 8 }}>
        <div draggable onDragStart={e => onPanelDragStart(e, 'Button')}>Button</div>
        <div draggable onDragStart={e => onPanelDragStart(e, 'Text')}>Text</div>
      </div>
      <div ref={canvasRef}
           onDrop={onCanvasDrop}
           onDragOver={onCanvasDragOver}
           style={{ flex: 1, position: 'relative', background: 'repeating-linear-gradient(0deg,#fafafa,#fafafa 7px,#f1f1f1 8px)' }}>
        {nodes.map(n => (
          <div key={n.id}
               onPointerDown={e => onNodePointerDown(e, n.id)}
               style={{ position: 'absolute', transform: `translate3d(${n.layout.x}px, ${n.layout.y}px, 0)`, width: n.layout.w, height: n.layout.h, border: '1px dashed #999', background: '#fff' }}>
            {n.type === 'Button' ? '按钮' : '文本'}
          </div>
        ))}
      </div>
    </div>
  )
}

要点:与 Vue 思路一致;通过状态更新驱动渲染;移动过程只更新 transform。

拖拽中的关键细节

  • 占位与对齐参考线
    • 计算当前节点与其他节点的边界对齐位置,显示参考线
  • 网格与磁吸
    • 支持切换不同步长,或在按住辅助键时禁用吸附
  • 边界与滚动
    • 拖拽到画布边缘时自动滚动
  • 框选与多选
    • 记录拖拽框的起点终点,选出覆盖的节点
  • 历史与撤销
    • 以快照或操作命令的方式记录历史,实现撤销重做

序列化与渲染

  • 存储为 JSON Schema
    • 节点类型、属性、布局分离
  • 运行时渲染
    • 依据节点类型映射到组件库并传入 props

示例:运行时渲染映射

代码语言:javascript
复制
const registry = {
  Button: (p) => <button style={p.style}>{p.text || '按钮'}</button>,
  Text: (p) => <span style={p.style}>{p.text || '文本'}</span>
}
function Render({ schema }) {
  return schema.nodes.map(n => (
    <div key={n.id} style={{ position: 'absolute', transform: `translate3d(${n.layout.x}px, ${n.layout.y}px, 0)`, width: n.layout.w, height: n.layout.h }}>
      {registry[n.type]?.(n.props)}
    </div>
  ))
}

性能与工程实践

  • 事件
    • 优先使用 Pointer 统一鼠标与触控
    • 在捕获阶段监听,避免文本选中与滚动抖动
  • 渲染
    • transform/opacity 优先,避免频繁触发布局
    • 大量节点时考虑虚拟化与分层渲染
  • 状态
    • React 使用局部状态或 Zustand/Redux 管理
    • Vue 使用 Pinia/组合式 API 管理
  • 辅助
    • 快照限制深度与频率,压缩历史
    • 持久化存储方案与导入导出

常见问题与对策

  • 拖入与移动坐标错位
    • 始终以画布局部坐标计算,不使用页面滚动偏移
  • 触控设备拖拽困难
    • 使用 Pointer 代替 Drag API,或启用长按再拖拽
  • 选区与节点交互冲突
    • 优先级区分:拖拽捕获优先于点击选择
  • 组合对齐与分布
    • 多选后提供对齐与分布动作,批量更新 layout

总结

实现一个简易低代码编辑器的核心在拖拽坐标、吸附与占位的正确性,以及用统一事件模型与高性能渲染支撑顺畅的体验。在此基础上扩展对齐与多选、历史管理与属性编辑,即可搭建可用的原型并渐进演化为工程能力。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-11-15,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 用 Vue/React 搭建简易低代码编辑器:组件拖拽原理
    • 架构与数据模型
    • 拖拽交互设计
    • Vue 最小实现示例
    • React 最小实现示例
    • 拖拽中的关键细节
    • 序列化与渲染
    • 性能与工程实践
    • 常见问题与对策
    • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档