用 Vue/React 搭建简易低代码编辑器:组件拖拽原理
用 Vue/React 搭建简易低代码编辑器:组件拖拽原理
·
用 Vue/React 搭建简易低代码编辑器:组件拖拽原理
目标:实现一个基础的低代码拖拽搭建能力,包含组件面板、画布、落点占位、网格吸附、选中与属性配置、JSON Schema 持久化。本文聚焦拖拽交互与数据结构设计,分别提供 Vue 与 React 的最小实现示例。
架构与数据模型
- 核心模块
- 组件面板:可拖拽的物料
- 画布:接收拖入与移动的区域
- 选中与属性面板:编辑节点属性
- 存储层:以 JSON Schema 持久化
- 基础数据结构
{
"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 最小实现示例
<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 最小实现示例
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
示例:运行时渲染映射
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
总结
实现一个简易低代码编辑器的核心在拖拽坐标、吸附与占位的正确性,以及用统一事件模型与高性能渲染支撑顺畅的体验。在此基础上扩展对齐与多选、历史管理与属性编辑,即可搭建可用的原型并渐进演化为工程能力。
更多推荐



所有评论(0)