可视化拖拽组件库一些技术要点原理分析
编辑器 自定义组件 拖拽 删除组件、调整图层层级 放大缩小 撤消、重做 组件属性设置 吸附 预览、保存代码 绑定事件 绑定动画 导入 PSD 手机模式
为了让本文更加容易理解,我将以上技术要点结合在一起写了一个可视化拖拽组件库 DEMO:
github 项目地址 在线预览
建议结合源码一起阅读,效果更好(这个 DEMO 使用的是 Vue 技术栈)。
1.编辑器
先来看一下页面的整体结构。

这一节要讲的编辑器其实就是中间的画布。它的作用是:当从左边组件列表拖拽出一个组件放到画布中时,画布要把这个组件渲染出来。
这个编辑器的实现思路是:
用一个数组 componentData
维护编辑器中的数据。把组件拖拽到画布中时,使用 push()
方法将新的组件数据添加到componentData
。编辑器使用 v-for
指令遍历componentData
,将每个组件逐个渲染到画布(也可以使用 JSX 语法结合render()
方法代替)。
编辑器渲染的核心代码如下所示:
v-for="item in componentData"
:key="item.id"
:is="item.component"
:style="item.style"
:propValue="item.propValue"
/>
每个组件数据大概是这样:
{
component: 'v-text',
label: '文字',
propValue: '文字',
icon: 'el-icon-edit',
animations: [],
events: {},
style: {
width: 200,
height: 33,
fontSize: 14,
fontWeight: 500,
lineHeight: '',
letterSpacing: 0,
textAlign: '',
color: '',
},
}
在遍历 componentData
组件数据时,主要靠 is
属性来识别出真正要渲染的是哪个组件。
例如要渲染的组件数据是 { component: 'v-text' }
,则
会被转换为
。当然,你这个组件也要提前注册到 Vue 中。
如果你想了解更多 is
属性的资料,请查看官方文档。
2. 自定义组件
原则上使用第三方组件也是可以的,但建议你最好封装一下。不管是第三方组件还是自定义组件,每个组件所需的属性可能都不一样,所以每个组件数据可以暴露出一个属性 propValue
用于传递值。
例如 a 组件只需要一个属性,你的 propValue
可以这样写:propValue: 'aaa'
。如果需要多个属性,propValue
则可以是一个对象:
propValue: {
a: 1,
b: 'text'
}
在这个 DEMO 组件库中我定义了三个组件。
图片组件 Picture
:
"propValue">
按钮组件 VButton
:
文本组件 VText
:
3. 拖拽
从组件列表到画布
一个元素如果要设为可拖拽,必须给它添加一个 draggable
属性。另外,在将组件列表中的组件拖拽到画布中,还有两个事件是起到关键作用的:
dragstart
事件,在拖拽刚开始时触发。它主要用于将拖拽的组件信息传递给画布。drop
事件,在拖拽结束时触发。主要用于接收拖拽的组件信息。
先来看一下左侧组件列表的代码:
"handleDragStart">
"(item, index) in componentList" :key="index" draggable :data-index="index">
"item.icon">
{{ item.label }}
handleDragStart(e) {
e.dataTransfer.setData('index', e.target.dataset.index)
}
可以看到给列表中的每一个组件都设置了 draggable
属性。另外,在触发 dragstart
事件时,使用 dataTransfer.setData()
传输数据。再来看一下接收数据的代码:
"handleDrop" @dragover="handleDragOver" @click="deselectCurComponent">
handleDrop(e) {
e.preventDefault()
e.stopPropagation()
const component = deepCopy(componentList[e.dataTransfer.getData('index')])
this.$store.commit('addComponent', component)
}
触发 drop
事件时,使用 dataTransfer.getData()
接收传输过来的索引数据,然后根据索引找到对应的组件数据,再添加到画布,从而渲染组件。

组件在画布中移动
首先需要将画布设为相对定位 position: relative
,然后将每个组件设为绝对定位 position: absolute
。除了这一点外,还要通过监听三个事件来进行移动:
mousedown
事件,在组件上按下鼠标时,记录组件当前的位置,即 xy 坐标(为了方便讲解,这里使用的坐标轴,实际上 xy 对应的是 css 中的left
和top
。mousemove
事件,每次鼠标移动时,都用当前最新的 xy 坐标减去最开始的 xy 坐标,从而计算出移动距离,再改变组件位置。mouseup
事件,鼠标抬起时结束移动。
handleMouseDown(e) {
e.stopPropagation()
this.$store.commit('setCurComponent', { component: this.element, zIndex: this.zIndex })
const pos = { ...this.defaultStyle }
const startY = e.clientY
const startX = e.clientX
const startTop = Number(pos.top)
const startLeft = Number(pos.left)
const move = (moveEvent) => {
const currX = moveEvent.clientX
const currY = moveEvent.clientY
pos.top = currY - startY + startTop
pos.left = currX - startX + startLeft
this.$store.commit('setShapeStyle', pos)
}
const up = () => {
document.removeEventListener('mousemove', move)
document.removeEventListener('mouseup', up)
}
document.addEventListener('mousemove', move)
document.addEventListener('mouseup', up)
}

4. 删除组件、调整图层层级
改变图层层级
由于拖拽组件到画布中是有先后顺序的,所以可以按照数据顺序来分配图层层级。
例如画布新增了五个组件 abcde,那它们在画布数据中的顺序为 [a, b, c, d, e]
,图层层级和索引一一对应,即它们的 z-index
属性值是 01234(后来居上)。用代码表示如下:
"(item, index) in componentData" :zIndex="index">
如果不了解 z-index
属性的,请看一下 MDN 文档。
理解了这一点之后,改变图层层级就很容易做到了。改变图层层级,即是改变组件数据在 componentData
数组中的顺序。例如有 [a, b, c]
三个组件,它们的图层层级从低到高顺序为 abc(索引越大,层级越高)。
如果要将 b 组件上移,只需将它和 c 调换顺序即可:
const temp = componentData[1]
componentData[1] = componentData[2]
componentData[2] = temp
同理,置顶置底也是一样,例如我要将 a 组件置顶,只需将 a 和最后一个组件调换顺序即可:
const temp = componentData[0]
componentData[0] = componentData[componentData.lenght - 1]
componentData[componentData.lenght - 1] = temp

删除组件
删除组件非常简单,一行代码搞定:componentData.splice(index, 1)
。

5. 放大缩小
细心的网友可能会发现,点击画布上的组件时,组件上会出现 8 个小圆点。这 8 个小圆点就是用来放大缩小用的。实现原理如下:
1. 在每个组件外面包一层 Shape
组件,Shape
组件里包含 8 个小圆点和一个
插槽,用于放置组件。
"(item, index) in componentData"
:defaultStyle="item.style"
:style="getShapeStyle(item.style, index)"
:key="item.id"
:active="item === curComponent"
:element="item"
:zIndex="index"
>
:is="item.component"
:style="getComponentStyle(item.style)"
:propValue="item.propValue"
/>
Shape
组件内部结构:
"{ active: this.active }" @click="selectCurComponent" @mousedown="handleMouseDown"
@contextmenu="handleContextMenu">
v-for="(item, index) in (active? pointList : [])"
@mousedown="handleMouseDownOnPoint(item)"
:key="index"
:style="getPointStyle(item)">
2. 点击组件时,将 8 个小圆点显示出来。
起作用的是这行代码 :active="item === curComponent"
。
3. 计算每个小圆点的位置。
先来看一下计算小圆点位置的代码:
const pointList = ['t', 'r', 'b', 'l', 'lt', 'rt', 'lb', 'rb']
getPointStyle(point) {
const { width, height } = this.defaultStyle
const hasT = /t/.test(point)
const hasB = /b/.test(point)
const hasL = /l/.test(point)
const hasR = /r/.test(point)
let newLeft = 0
let newTop = 0
if (point.length === 2) {
newLeft = hasL? 0 : width
newTop = hasT? 0 : height
} else {
if (hasT || hasB) {
newLeft = width / 2
newTop = hasT? 0 : height
}
if (hasL || hasR) {
newLeft = hasL? 0 : width
newTop = Math.floor(height / 2)
}
}
const style = {
marginLeft: hasR? '-4px' : '-3px',
marginTop: '-3px',
left: `${newLeft}px`,
top: `${newTop}px`,
cursor: point.split('').reverse().map(m => this.directionKey[m]).join('') + '-resize',
}
return style
}
计算小圆点的位置需要获取一些信息:
组件的高度 height
、宽度width
注意,小圆点也是绝对定位的,相对于 Shape
组件。所以有四个小圆点的位置很好确定:
左上角的小圆点,坐标 left: 0, top: 0
右上角的小圆点,坐标 left: width, top: 0
左下角的小圆点,坐标 left: 0, top: height
右下角的小圆点,坐标 left: width, top: height

另外的四个小圆点需要通过计算间接算出来。例如左边中间的小圆点,计算公式为 left: 0, top: height / 2
,其他小圆点同理。

4. 点击小圆点时,可以进行放大缩小操作。
handleMouseDownOnPoint(point) {
const downEvent = window.event
downEvent.stopPropagation()
downEvent.preventDefault()
const pos = { ...this.defaultStyle }
const height = Number(pos.height)
const width = Number(pos.width)
const top = Number(pos.top)
const left = Number(pos.left)
const startX = downEvent.clientX
const startY = downEvent.clientY
let needSave = false
const move = (moveEvent) => {
needSave = true
const currX = moveEvent.clientX
const currY = moveEvent.clientY
const disY = currY - startY
const disX = currX - startX
const hasT = /t/.test(point)
const hasB = /b/.test(point)
const hasL = /l/.test(point)
const hasR = /r/.test(point)
const newHeight = height + (hasT? -disY : hasB? disY : 0)
const newWidth = width + (hasL? -disX : hasR? disX : 0)
pos.height = newHeight > 0? newHeight : 0
pos.width = newWidth > 0? newWidth : 0
pos.left = left + (hasL? disX : 0)
pos.top = top + (hasT? disY : 0)
this.$store.commit('setShapeStyle', pos)
}
const up = () => {
document.removeEventListener('mousemove', move)
document.removeEventListener('mouseup', up)
needSave && this.$store.commit('recordSnapshot')
}
document.addEventListener('mousemove', move)
document.addEventListener('mouseup', up)
}
它的原理是这样的:
点击小圆点时,记录点击的坐标 xy。 假设我们现在向下拖动,那么 y 坐标就会增大。 用新的 y 坐标减去原来的 y 坐标,就可以知道在纵轴方向的移动距离是多少。 最后再将移动距离加上原来组件的高度,就可以得出新的组件高度。 如果是正数,说明是往下拉,组件的高度在增加。如果是负数,说明是往上拉,组件的高度在减少。

6. 撤消、重做
撤销重做的实现原理其实挺简单的,先看一下代码:
snapshotData: [],
snapshotIndex: -1,
undo(state) {
if (state.snapshotIndex >= 0) {
state.snapshotIndex--
store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
}
},
redo(state) {
if (state.snapshotIndex < state.snapshotData.length - 1) {
state.snapshotIndex++
store.commit('setComponentData', deepCopy(state.snapshotData[state.snapshotIndex]))
}
},
setComponentData(state, componentData = []) {
Vue.set(state, 'componentData', componentData)
},
recordSnapshot(state) {
state.snapshotData[++state.snapshotIndex] = deepCopy(state.componentData)
if (state.snapshotIndex < state.snapshotData.length - 1) {
state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
}
},
用一个数组来保存编辑器的快照数据。保存快照就是不停地执行 push()
操作,将当前的编辑器数据推入 snapshotData
数组,并增加快照索引 snapshotIndex
。目前以下几个动作会触发保存快照操作:
新增组件 删除组件 改变图层层级 拖动组件结束时
...
撤销
假设现在 snapshotData
保存了 4 个快照。即 [a, b, c, d]
,对应的快照索引为 3。如果这时进行了撤销操作,我们需要将快照索引减 1,然后将对应的快照数据赋值给画布。
例如当前画布数据是 d,进行撤销后,索引 -1,现在画布的数据是 c。
重做
明白了撤销,那重做就很好理解了,就是将快照索引加 1,然后将对应的快照数据赋值给画布。
不过还有一点要注意,就是在撤销操作中进行了新的操作,要怎么办呢?有两种解决方案:
新操作替换当前快照索引后面所有的数据。还是用刚才的数据 [a, b, c, d]
举例,假设现在进行了两次撤销操作,快照索引变为 1,对应的快照数据为 b,如果这时进行了新的操作,对应的快照数据为 e。那 e 会把 cd 顶掉,现在的快照数据为[a, b, e]
。不顶掉数据,在原来的快照中新增一条记录。用刚才的例子举例,e 不会把 cd 顶掉,而是在 cd 之前插入,即快照数据变为 [a, b, e, c, d]
。
我采用的是第一种方案。

7. 吸附
什么是吸附?就是在拖拽组件时,如果它和另一个组件的距离比较接近,就会自动吸附在一起。

吸附的代码大概在 300 行左右,建议自己打开源码文件看(文件路径:src\components\Editor\MarkLine.vue
)。这里不贴代码了,主要说说原理是怎么实现的。
标线
在页面上创建 6 条线,分别是三横三竖。这 6 条线的作用是对齐,它们什么时候会出现呢?
上下方向的两个组件左边、中间、右边对齐时会出现竖线 左右方向的两个组件上边、中间、下边对齐时会出现横线
具体的计算公式主要是根据每个组件的 xy 坐标和宽度高度进行计算的。例如要判断 ab 两个组件的左边是否对齐,则要知道它们每个组件的 x 坐标;如果要知道它们右边是否对齐,除了要知道 x 坐标,还要知道它们各自的宽度。
a.x == b.x
a.x + a.width == b.x + b.width
在对齐的时候,显示标线。
另外还要判断 ab 两个组件是否 “足够” 近。如果足够近,就吸附在一起。是否足够近要靠一个变量来判断:
diff: 3,
小于等于 diff
像素则自动吸附。
吸附
吸附效果是怎么实现的呢?
假设现在有 ab 组件,a 组件坐标 xy 都是 0,宽高都是 100。现在假设 a 组件不动,我们正在拖拽 b 组件。当把 b 组件拖到坐标为 x: 0, y: 103
时,由于 103 - 100 <= 3(diff)
,所以可以判定它们已经接近得足够近。这时需要手动将 b 组件的 y 坐标值设为 100,这样就将 ab 组件吸附在一起了。

优化
在拖拽时如果 6 条标线都显示出来会不太美观。所以我们可以做一下优化,在纵横方向上最多只同时显示一条线。实现原理如下:
a 组件在左边不动,我们拖着 b 组件往 a 组件靠近。 这时它们最先对齐的是 a 的右边和 b 的左边,所以只需要一条线就够了。 如果 ab 组件已经靠近,并且 b 组件继续往左边移动,这时就要判断它们俩的中间是否对齐。 b 组件继续拖动,这时需要判断 a 组件的左边和 b 组件的右边是否对齐,也是只需要一条线。
可以发现,关键的地方是我们要知道两个组件的方向。即 ab 两个组件靠近,我们要知道到底 b 是在 a 的左边还是右边。
这一点可以通过鼠标移动事件来判断,之前在讲解拖拽的时候说过,mousedown
事件触发时会记录起点坐标。所以每次触发 mousemove
事件时,用当前坐标减去原来的坐标,就可以判断组件方向。例如 x 方向上,如果 b.x - a.x
的差值为正,说明是 b 在 a 右边,否则为左边。
eventBus.$emit('move', this.$el, currY - startY > 0, currX - startX > 0)

8. 组件属性设置
每个组件都有一些通用属性和独有的属性,我们需要提供一个能显示和修改属性的地方。
{
component: 'v-text',
label: '文字',
propValue: '文字',
icon: 'el-icon-edit',
animations: [],
events: {},
style: {
width: 200,
height: 33,
fontSize: 14,
fontWeight: 500,
lineHeight: '',
letterSpacing: 0,
textAlign: '',
color: '',
},
}

我定义了一个 AttrList
组件,用于显示每个组件的属性。
"(key, index) in styleKeys" :key="index" :label="map[key]">
"key == 'borderColor'" v-model="curComponent.style[key]">
"key == 'color'" v-model="curComponent.style[key]">
"key == 'backgroundColor'" v-model="curComponent.style[key]">
"key == 'textAlign'" v-model="curComponent.style[key]">
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
>
type="number" v-else v-model="curComponent.style[key]" />
"内容" v-if="curComponent && curComponent.propValue && !excludes.includes(curComponent.component)">
type="textarea" v-model="curComponent.propValue" />
代码逻辑很简单,就是遍历组件的 style
对象,将每一个属性遍历出来。并且需要根据具体的属性用不同的组件显示出来,例如颜色属性,需要用颜色选择器显示;数值类的属性需要用 type=number
的 input 组件显示等等。
为了方便用户修改属性值,我使用 v-model
将组件和值绑定在一起。

9. 预览、保存代码
预览和编辑的渲染原理是一样的,区别是不需要编辑功能。所以只需要将原先渲染组件的代码稍微改一下就可以了。
"(item, index) in componentData"
:defaultStyle="item.style"
:style="getShapeStyle(item.style, index)"
:key="item.id"
:active="item === curComponent"
:element="item"
:zIndex="index"
>
:is="item.component"
:style="getComponentStyle(item.style)"
:propValue="item.propValue"
/>
经过刚才的介绍,我们知道 Shape
组件具备了拖拽、放大缩小的功能。现在只需要将 Shape
组件去掉,外面改成套一个普通的 DIV 就可以了(其实不用这个 DIV 也行,但为了绑定事件这个功能,所以需要加上)。
"(item, index) in componentData" :key="item.id">
:is="item.component"
:style="getComponentStyle(item.style)"
:propValue="item.propValue"
/>