
目标:实现一个基础的低代码拖拽搭建能力,包含组件面板、画布、落点占位、网格吸附、选中与属性配置、JSON Schema 持久化。本文聚焦拖拽交互与数据结构设计,分别提供 Vue 与 React 的最小实现示例。
{
"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"]
}x,yx = round(x / grid) * gridtransform: translate3d 更新位置pointer 事件,并在捕获阶段阻止不必要的选择行为<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 提升性能;通过网格步长实现吸附。
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。
示例:运行时渲染映射
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>
))
}实现一个简易低代码编辑器的核心在拖拽坐标、吸附与占位的正确性,以及用统一事件模型与高性能渲染支撑顺畅的体验。在此基础上扩展对齐与多选、历史管理与属性编辑,即可搭建可用的原型并渐进演化为工程能力。