用插件化 + CompositionApi 的方式实现一个可扩展的拖拽通用库

背景

最近在做低代码项目,想使用现成的拖拽库做低代码的编辑器区域,因为采用的技术栈是 Vue,所以看了 VueDraggable,这个库底层是基于 Sortable 的,在使用的时候发现跨 Iframe 拖拽时有点小问题,并且定制化程度比较低,只暴露了一些 class 可以自定义,并且同时要维护 2 份数据,所以便想自己造一个
拖拽的库来满足需求(PS: 原生 draggable=true + draggable的相关事件也踩了不少的坑)

设计

基于以上的一些痛点,我们要开发的库需要满足以下要求

  1. 扩展性要高
  2. 要足够灵活
  3. 上手要简单

我们用人话拆解一下上面的要求

  1. 首先扩展性要高,我们可以使用目前主流的插件化架构满足,即插即用
  2. 其次要足够灵活,可以随意组合应用,我们可以使用CompositionApi的方式来自由组织代码
  3. 最后上手要简单,可以用最少的代码实现基本功能,用插件对功能进行增强

一个人的RFC

拖拽的核心要素我个人认为不是记录坐标维护数据,因为你永远不知道用户的诉求

举个例子:假设我们只处理了坐标,那其实是可以实现自由拖拽的效果,但是比如我的诉求是元素不能自由拖拽,要实现一个跟随鼠标移动的小元素,类似于下面的这种效果

t.gif

可以看到,这里并没有对元素实现拖拽,而是做了一个类似鼠标跟随的效果

回到开头,我个人认为拖拽的核心要素是暴露整个拖拽流程,这些流程类似于生命周期的概念,可以基于这些流程实现各种想要的效果

我们先不急着去实现代码,而是用伪代码的方式看看如何使用

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. 插件化架构

在实现插件架构前,我们要先想清楚我们的插件可以干什么?

  1. 可以自定义渲染元素,比如我们可以监听拖拽过程,实现鼠标跟随的效果
  2. 可以使用响应式Api,因为我们可以基于响应式Api封装各种Hooks
  3. 插件可以单独更新,挂载,比如参数变化了,只更新依赖的相关插件即可
  4. 插件可以被暂停恢复,因为有可能某些插件和用户的逻辑同时使用时,会有一些事件冲突
  5. 插件接收一些统一的参数,提高插件的灵活性

可以看到,以上的功能,其实我们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. 暴露拖拽流程钩子

我们可以在内部将拖拽的流程全部暴露出来,这样的话,用户想在每个阶段做什么事情,用户可以自己接管,那我们继续用伪代码的方式来定义需要哪些拖拽流程钩子吧

  1. onStart:拖拽开始的钩子
  2. onMove:拖拽中的钩子
  3. onEnd:拖拽结束的钩子
  4. onDragging:在拖拽中的钩子

我们暂且定义这些拖拽钩子

我们一个人的RFC说的差不多了,接下来看我们如何组合这些流程,实现各种效果

实现鼠标跟随的效果

先看效果图

2.gif

这种效果我们可以轻松的实现,我们先实现一下页面的布局

<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步

  1. 记录当前拖拽的坐标
  2. 记录拖拽的元素内容
  3. 渲染一个元素,将坐标内容渲染到元素上
  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)

以上四步就完成了鼠标跟随的效果

实现元素交换

还是先看效果图

3.gif

实现以上效果很简单,我们拆解成以下几步

  1. 记录原始的元素坐标索引
  2. 记录目标的元素坐标索引
  3. 交换元素坐标索引
  4. 应用插件
  5. 应用动画

记录原始的元素坐标索引,我们可以利用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 })
}

这里我们关注几个重点

  1. getIndex 方法用来获取元素所在列表数据中的索引
  2. swapIndex 方法用来交互列表中的数据
  3. 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) // 数据交换插件

动画效果咱们使用VueTransitionGroup可以轻松实现,因为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>

实现自由拖拽效果

先看效果图

1.gif

其实这个效果是最容易实现的,我们可以和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 效果

先看效果图

4.gif

这个效果大部分的低代码平台都会有,在拖拽时给可以放置物料的区域设定Outline边框,增强视觉交互

我们来看下这个效果在我们的插件中应该如何去实现吧

  1. 记录拖拽鼠标的x,y
  2. 使用document.elementFromPoint获取元素
  3. 获取到了元素后,在获取元素的getBoundingClientRect
  4. 绘制一个元素,将坐标宽高应用到元素上
  5. 应用插件

记录拖拽鼠标的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)

结语

总结一下我们的内容

  1. 我们基于目前一些拖拽库的痛点,开发了一个插件化架构的拖拽库
  2. 我们分析了整个拖拽库的流程设计插件设计
  3. 我们使用伪代码实现了4个拖拽效果

如果大家比较感兴趣,下期可以分享整个拖拽库的内部实现

如有错误之处,请指正,谢谢大家~

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MY1nhbjK' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片