背景
最近在做低代码项目,想使用现成的拖拽库做低代码的编辑器区域,因为采用的技术栈是 Vue,所以看了 VueDraggable
,这个库底层是基于 Sortable
的,在使用的时候发现跨 Iframe 拖拽
时有点小问题,并且定制化程度比较低,只暴露了一些 class 可以自定义,并且同时要维护 2 份数据,所以便想自己造一个
拖拽的库来满足需求(PS: 原生 draggable=true + draggable的相关事件也踩了不少的坑)
设计
基于以上的一些痛点,我们要开发的库需要满足以下要求
- 扩展性要高
- 要足够灵活
- 上手要简单
我们用人话
拆解一下上面的要求
- 首先
扩展性要高
,我们可以使用目前主流的插件化架构
满足,即插即用 - 其次
要足够灵活
,可以随意组合应用,我们可以使用CompositionApi
的方式来自由组织代码 - 最后
上手要简单
,可以用最少的代码实现基本功能,用插件
对功能进行增强
一个人的RFC
拖拽的核心要素我个人认为不是记录坐标
、维护数据
,因为你永远不知道用户的诉求
举个例子:假设我们只处理了坐标
,那其实是可以实现自由拖拽的效果
,但是比如我的诉求是元素不能自由拖拽,要实现一个跟随鼠标移动的小元素
,类似于下面的这种效果
可以看到,这里并没有对元素实现拖拽
,而是做了一个类似鼠标跟随的效果
回到开头,我个人认为拖拽的核心要素是暴露整个拖拽流程
,这些流程类似于生命周期的概念
,可以基于这些流程
实现各种想要的效果
我们先不急着去实现代码,而是用伪代码的方式
看看如何使用
1. 基本使用
import { useReactionDrag } from 'xxxx'
useReactionDrag()
如果一行代码,可以实现基本功能,那也不是不可以,先不要感到疑惑,继续往下看
其实我们使用事件代理
的方式,来对全局做一些拖拽动作的监听,比如以上的一行代码,实际上等同于这样
useReactionDrag()
useReactionDrag({
proxyTarget:document
})
我们对document
进行拖拽动作的监听就可以实现我们想要的效果,但是有一些场景,比如用户只想要特定的一些元素才有拖拽效果
,那我们可以继续扩展参数,如以下代码
useReactionDrag({
proxyTarget:document
canDraggable:element => !!element.getAttribute('id')
})
这里我们就扩展了canDraggable
参数,这个参数的作用就可以实现特定元素的拖拽,同理可得,对于拖拽元素的放置
也可能有一些要求,照葫芦画瓢
,如以下代码
useReactionDrag({
proxyTarget:document
canDraggable:element => !!element.getAttribute('id')
canDropable:element => !!element.getAttribute('id')
})
这里我们又扩展了canDropable
参数,用于对放置区域有诉求
的用户
好了,我们的基本使用类似就这样了,接下来我们继续用伪代码的方式
来实现插件化的架构
2. 插件化架构
在实现插件架构
前,我们要先想清楚我们的插件可以干什么?
- 可以自定义渲染元素,比如我们可以
监听拖拽过程
,实现鼠标跟随的效果
- 可以使用
响应式Api
,因为我们可以基于响应式Api
封装各种Hooks
- 插件可以
单独更新,挂载
,比如参数变化了,只更新依赖的相关插件即可 - 插件可以被
暂停
、恢复
,因为有可能某些插件和用户的逻辑同时使用时,会有一些事件冲突 - 插件接收一些统一的参数,提高插件的灵活性
可以看到,以上的功能,其实我们Vue
中的组件就已经满足了,意思就是我们插件就是一个组件
?
如果是组件
,那么我们要如何应用呢,类似这样么?
const context = useReactionDrag()
context.use(<DemoPlugin>)
也不是不行,但是我们进一步简化,而且为了可以统一管理,我们可以将插件
定义成函数
,再内部在将函数
定义成组件
,那函数
和组件
有什么关系?
我们知道Vue 组件
有一个 setup
函数,setup
函数可以返回一个render
函数,这个render
函数返回的虚拟节点
供Vue
来渲染,那我们用户写的函数
就是setup
函数,是不是简化了一步,并且也同样可以实现以上的功能要求呢,那我们的插件就可以这么定义
const context = useReactionDrag()
context.use(DemoPlugin1)
context.use(DemoPlugin2)
context.use(DemoPlugin3)
3. 暴露拖拽流程钩子
我们可以在内部将拖拽的流程全部暴露出来,这样的话,用户想在每个阶段做什么事情,用户可以自己接管,那我们继续用伪代码的方式
来定义需要哪些拖拽流程钩子吧
onStart
:拖拽开始的钩子onMove
:拖拽中的钩子onEnd
:拖拽结束的钩子onDragging
:在拖拽中的钩子
我们暂且定义这些拖拽钩子
我们一个人的RFC
说的差不多了,接下来看我们如何组合这些流程,实现各种效果
实现鼠标跟随的效果
先看效果图
这种效果我们可以轻松的实现,我们先实现一下页面的布局
<template>
<div
v-for="item in 3"
:key="item"
:class="[`draggable-${item}`]"
:style="{ width: '100px', height: '100px', border: '1px solid #ccc', margin: '10px' }"
>
{{ item }}
</div>
</template>
接下来我们用伪代码
实现以上的效果,上面的效果其实分为4步
- 记录当前拖拽的坐标
- 记录拖拽的元素内容
- 渲染一个
元素
,将坐标
和内容
渲染到元素上 - 将插件应用上去
针对记录当前拖拽的坐标
这一步,我们可以利用onDragging
钩子来记录,伪代码如下
function MouseFollowPlugin(context: UseReactionDragContext){
const positionRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
context.onDragging((event) => {
positionRef.value.x = event.clientX
positionRef.value.y = event.clientY
})
}
这样我们就很轻松的记录了拖拽位置的坐标
针对记录拖拽的元素内容
这一步,我们可以利用onStart
钩子轻松实现,伪代码如下
function MouseFollowPlugin(context: UseReactionDragContext){
const textRef = ref('')
context.onStart((event) => {
const element = event.target as HTMLElement
textRef.value = element.textContent ?? ''
})
}
针对渲染一个元素
,将坐标
和内容
渲染到元素上,我们可以利用render
函数,伪代码如下
function MouseFollowPlugin(context: UseReactionDragContext) {
const textRef = ref('')
const positionRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
...
return () => {
const { x, y } = unref(positionRef)
return <div
v-show={context.isDragging()}
style={{
position: 'fixed',
top: `${y}px`,
left: `${x}px`,
zIndex: 1,
color: '#fff',
padding: '4px 8px',
background: '#0000f6',
whiteSpace: 'nowrap',
pointerEvents: 'none',
}}>{ unref(textRef) }</div>
}
}
最后将插件应用
,伪代码如下
const context = useReactionDrag({
canDraggable: element => element.className.startsWith('draggable-'),
})
function MouseFollowPlugin(context: UseReactionDragContext) {
...
}
context.use(MouseFollowPlugin)
以上四步就完成了鼠标跟随
的效果
实现元素交换
还是先看效果图
实现以上效果很简单,我们拆解成以下几步
- 记录原始的
元素坐标索引
- 记录目标的
元素坐标索引
- 交换
元素坐标索引
- 应用插件
- 应用动画
记录原始的元素坐标索引
,我们可以利用onStart
钩子,因为上一种交互效果
已经实现了一遍了,所以这里我们不再重复编写
记录目标的元素坐标索引
,我们可以利用onDragging
钩子,上面同样也实现了一遍了
交换元素坐标索引
,伪代码如下
function SwapElementPlugin(context: UseReactionDragContext) {
const positionRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
const currentDragElementRef = context.useDragElement()
function getIndex(element: HTMLElement) {
const content = element.textContent
const list = unref(listRef)
return list.findIndex(item => item.name === content)
}
function swapIndex(startIndex: number, endIndex: number) {
const list = unref(listRef)
const tempItem = list[startIndex]
list[startIndex] = list[endIndex]
list[endIndex] = tempItem
}
watch(() => positionRef.value, () => {
const { x, y } = positionRef.value
const element = document.elementFromPoint(x, y)
if (element && canDraggable(element as any)) {
const currentIndex = getIndex(currentDragElementRef.value!)
const moveToIndex = getIndex(element as any)
if (currentIndex !== moveToIndex) {
swapIndex(currentIndex, moveToIndex)
}
}
}, { deep: true })
}
这里我们关注几个重点
getIndex
方法用来获取元素所在列表数据中的索引swapIndex
方法用来交互列表中的数据document.elementFromPoint(x, y)
是原生的Api
,根据坐标获取元素
应用插件也很简单,使用use
即可,代码如下
const listRef = ref([
{ name: '11' },
{ name: '22' },
{ name: '33' },
])
function MouseFollowPlugin(context: UseReactionDragContext) {
...
}
function SwapElementPlugin(context: UseReactionDragContext) {
...
}
const context = useReactionDrag({
canDraggable:element => element.className.includes('draggable-'),
})
context.use(MouseFollowPlugin) // 鼠标跟随插件
context.use(SwapElementPlugin) // 数据交换插件
动画效果咱们使用Vue
的TransitionGroup
可以轻松实现,因为Vue
内部已经实现了FLIP
动画的思想
<template>
<TransitionGroup tag="ul" name="fade" class="container">
<div
v-for="item in listRef"
:key="item.name"
class="item"
:class="[`draggable-${item.name}`]"
:style="{ width: '100px', height: '100px', border: '1px solid #ccc', margin: '10px' }"
>
{{ item.name }}
</div>
</TransitionGroup>
</template>
<style scoped>
.container {
position: relative;
padding: 0;
}
.item{
user-select: none;
}
/* 1. 声明过渡效果 */
.fade-move,
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s cubic-bezier(0.55, 0, 0.1, 1);
}
/* 2. 声明进入和离开的状态 */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scaleY(0.01) translate(0px, 0);
}
/* 3. 确保离开的项目被移除出了布局流 以便正确地计算移动时的动画效果。 */
.fade-leave-active {
position: absolute;
}
</style>
实现自由拖拽效果
先看效果图
其实这个效果是最容易实现的,我们可以和vueuse
中的useDraggable
这个hook
结合一下即可
import { useDraggable } from '@vueuse/core'
const context = useReactionDrag({
canDraggable: element.className.startsWith('draggable-')
})
function draggablePlugin(context: UseReactionDragContext) {
const activeElementRef = useActiveElement({ trigger: 'hover' })
const { x, y } = useDraggable(activeElementRef)
watch([x, y], () => {
if (activeElementRef.value && canDraggable(activeElementRef.value)) {
activeElementRef.value.style.position = 'fixed'
activeElementRef.value.style.left = `${x.value}px`
activeElementRef.value.style.top = `${y.value}px`
}
})
}
context.use(draggablePlugin)
这样就可以轻松实现自由拖拽效果
,useActiveElement
这个hook
是用来实时的获取鼠标移入的元素是哪一个
低代码中的 Outline 效果
先看效果图
这个效果大部分的低代码平台都会有,在拖拽时给可以放置物料的区域设定Outline
边框,增强视觉交互
我们来看下这个效果在我们的插件中应该如何去实现吧
- 记录拖拽鼠标的
x,y
- 使用
document.elementFromPoint
获取元素 - 获取到了元素后,在获取元素的
getBoundingClientRect
- 绘制一个元素,将
坐标
和宽高
应用到元素上 - 应用插件
记录拖拽鼠标的x,y
,使用onDragging
钩子即可,伪代码如下
function DropOutlinePlugin(context: UseReactionDragContext) {
const positionRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
context.onDragging((event) => {
positionRef.value.x = event.clientX
positionRef.value.y = event.clientY
})
}
使用document.elementFromPoint
获取元素,在获取元素的getBoundingClientRect
,伪代码如下
function DropOutlinePlugin(context: UseReactionDragContext) {
const positionRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
...
const computedBoundingRect = computed(() => {
const { x, y } = positionRef.value
const element = document.elementFromPoint(x, y)
if (element && canDraggable(element as any)) {
return element.getBoundingClientRect()
}
return {
left: 0,
top: 0,
width: 0,
height: 0,
}
})
}
绘制一个元素,将坐标
和宽高
应用到元素上,伪代码如下
function DropOutlinePlugin(context: UseReactionDragContext) {
const positionRef = ref<{ x: number; y: number }>({ x: 0, y: 0 })
const computedBoundingRect = computed(() => {
...
})
return () => {
const { left, top, width, height } = unref(computedBoundingRect)
return <div v-show={context.isDragging()} style={{
position: 'fixed',
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
pointerEvents: 'none',
zIndex: 'auto',
transition: 'width 0.1s ease-in,height 0.1s ease-in,left 0.1s ease-in,top 0.1s ease-in',
outline: 'dashed 1px #0071e7',
background: 'rgba(0, 0, 0, 0.1)',
}}></div>
}
}
最后我们使用use
应用这个插件即可,伪代码如下
function MouseFollowPlugin(context: UseReactionDragContext) {
...
}
function DropOutlinePlugin(context: UseReactionDragContext) {
...
}
function canDraggable(element: HTMLElement) {
return element.className.includes('draggable-')
}
const context = useReactionDrag({
canDraggable,
})
context.use(MouseFollowPlugin)
context.use(DropOutlinePlugin)
结语
总结一下我们的内容
- 我们基于目前一些拖拽库的痛点,开发了一个
插件化架构的拖拽库
- 我们分析了整个拖拽库的
流程设计
、插件设计
- 我们使用伪代码实现了
4个拖拽效果
如果大家比较感兴趣,下期可以分享整个拖拽库的内部实现
如有错误之处,请指正,谢谢大家~