Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

本文作者:来自 MoonWebTeam 的 acejhli

本文编辑:kanedongliu

  1. 引言 =====

低代码编辑器主要有物料系统、配置表单、组件编排三部分组成,实现组件编排核心能力则是拖拽能力,它是编辑器的交互基础,它能极大地提升用户在使用系统时的交互体验,因为它通常意味着用户可以直观地操作界面,实现所见即所得,大大提高了使用效率。

更重要的是,这种直观的操作方式也减少了用户的学习成本,让用户在短时间内就能上手操作,从而提高了用户的满意度和系统的使用率。

除此之外,拖拽技术在各种场景中都可以得到非常有效的应用。例如,在进行项目或元素的排序时,我们可以通过拖拽来轻松地改变它们的顺序。在文件上传的过程中,拖拽能够使用户更方便地选择和提交文件。在进行逻辑编排时,拖拽技术可以帮助用户更直观地理解和操作流程。

本文将会带大家了解浏览器中拖拽相关的 API 有哪些,对比市面上常见的拖拽库都有什么区别,最后一起探索一下 moveable 这个库的实现原理,并带大家简单地实现一个 moveable able 来扩展 moveable 的能力。

  1. 浏览器中如何实现拖拽能力 ===============

2.1. 浏览器中的实现方式

如果你有仔细观察,你会发现浏览器中有些元素默认就是可拖拽的,某些元素也是默认可放置的。默认可拖拽的元素包括:选中的文本、图片、链接,而输入框默认也可以作为文本的可放置元素。

如果你想自定义拖拽元素和放置的位置,那么就可以使用到浏览器提供的拖放操作 API:Drag and Drop API.

2.1.1.HTML5 Drag and Drop API

DnD( Drag and Drop API)是在 HTML5 中新增的,提供了原生支持的拖拽功能。通过使用该 API,开发者不仅可以方便元素拖拽,也能跟操作系统交互,从其他程序中拖拽文件到浏览器中放置。

它主要有两个概念 drag source 和 drop target,允许拖动的元素 drag source 通过鼠标长按拖拽放置到到 drop target 上。

该 API 组主要通过以下事件实现相关的功能:

  • dragstart:当用户开始拖动元素时触发。

  • drag:当元素被拖动时连续触发。

  • dragenter:当拖动的元素进入放置目标时触发。

  • dragleave:当拖动的元素离开放置目标时触发。

  • dragover:当元素被拖动到放置目标上方时连续触发。默认情况下,数据 / 元素不能放置在其他元素上。要允许放置,我们必须阻止对此事件的默认处理。

  • drop:当元素被放置到放置目标时触发。

  • dragend:当拖动操作结束时(释放鼠标按钮或按下 ESC 键)触发。

2.1.2. 使用 mouseenter、mousedown 和 mouseup 等事件接口

另一种常见的实现方式是使用鼠标事件接口,如 mouseenter、mousedown 和 mouseup 等。通过监听这些事件,开发者可以捕获用户的拖拽意图, 并相应地调整元素。

这种方式相对灵活,可以根据具体需求进行定制,但可能需要更多的逻辑处理来确保拖拽的准确性和流畅性。可以不局限于拖拽 & 放置这种固定操作模式,比如直接拖动改变位置、双击改变大小等。

移动端场景,我们则需要 touchstart、touchend、touchmove 等事件来实现,同时也能通过多指操作来实现更复杂的逻辑。

2.1.3. 使用 Canvas 实现

上面说到的方式都是与 html 元素在交互,但是在更复杂的场景,我们可能会使用到 canvas。事实上在 canvas 中也是通过鼠标事件来实现拖拽能力的,这里单独提出来是因为相对于其他方式,它的使用场景更加特殊,实现起来也会复杂许多。

他通常用于高性能、大量元素、自定义要求高的场景。例如:在线版的 PS、在线版的 CAD、以及 figma 等新一代画图软件。

2.2. 各种实现方式的对比


HTML5 Drag and Drop API使用鼠标事件接口使用 Canvas 实现
实现方式使用 HTML5 标准中的 Drag and Drop API,提供原生的拖拽功能使用鼠标事件接口,通过监听这些事件,可以捕获用户的拖拽意图, 并相应地移动元素使用 Canvas API,通过在 Canvas 上绘制元素,并使用鼠标事件来监听和处理拖拽操作
难易程度相对简单,但操作模式相对固定,可能需要更多的代码来处理复杂的拖拽逻辑相对复杂,可能需要更多的逻辑处理来确保拖拽的准确性和流畅性很复杂,需要绘制元素以及自己计算元素的位置,可能需要更多的计算资源,需要处理更多的性能问题
灵活性较低、一般用于从一个容器拖动到另一个容器较高,可以根据具体需求进行定制最高,可以创建出各种复杂的拖拽交互效果
可扩展性可以实现文件拖拽、元素拖拽等功能较好,但需要处理多种鼠标事件,通过 touch 事件可以兼容移动端最好,可以实现各种复杂的自定义拖拽效果
常见的产品element-ui 的上传组件、排序组件TMagic、钉钉宜搭figma、在线 PS

2.3. 常见的拖拽库有哪些

为了更加方便快速地在项目中引入拖拽能力,我们也许会去看看业内有哪些现成的轮子。我收集了比较常见的几个拖拽的实现,供大家参考。

2.3.1 React DnD

《React DnD》(https://react-dnd.github.io/react-dnd/about) 强调的是拖动放置,实现的是把一个元素拖动到另一个元素上,这是对原生 DnD(HTML5 Drag and Drop API)接口扩展实现。

React DnD 本身是一个基于 HTML5 的拖放 API 构建的 React 高阶组件。它利用 React 组件的生命周期以及 context API 对拖放状态进行管理。React DnD 的设计理念是把 DOM 操作和事件处理交给开发者,他只负责定义接口和状态管理。

所以 React DnD 并不能单独使用,需要额外的后端模块使用 react-dnd-html5-backend,这个后端模块则是利用浏览器的 drag 和 drop 接口具体实现拖拽的交互。其他的后端模块还有 react-dnd-touch-backend,这可以允许在移动端实现拖拽交互。以及 react-dnd-test-backend 对测试的支持。

React DnD 在使用上,依赖对外暴露的 collect 接口,当被拖拽元素或放置元素的状态发生变化时,就会回调 collect,从而可以根据这个状态更新组件状态。

function DragBox() {  const [{isDragging}, dragRef] = useDrag({    type: type,    item: {type},    collect(monitor) {      return {        // 是否在拖动状态        isDragging: monitor.isDragging(),      }    },  });  return <>    <div className={classnames({      drag: true,      // 根据状态修改样式      dragging: isDragging,    })} ref={dragRef}></div>  </>}

React DnD 可以用于大多数的拖拽场景,例如拖动排序、低代码平台组件的拖拽。

2.3.2 moveable

《moveable》(https://github.com/daybrush/moveable) 强调的是对元素的操作:移动、缩放、旋转、变形等。

moveable 的实现原理主要是通过监听鼠标和触摸事件,然后根据这些事件计算元素的变换属性,进而在回调中执行对应的操作。

Moveable 支持多种交互操作,包括但不限于拖放、缩放、旋转、扭曲和调整大小等。而且能够支持批量操作,对元素操作做了高度地封装。Moveable 非常适用于那些需要进行复杂交互操作的应用场景,例如设计工具、图形编辑器等。

2.3.3 Interact.js

《Interact.js》(https://github.com/taye/interact.js) 对浏览器原生事件进行封装,通过简洁统一的 API,对多端交互做了统一处理,特别适合在移动端场景使用。

Interact.js 是一个高度灵活且模块化的 JavaScript 库,它提供了一种简洁且一致的 API,可以让你通过监听原生的鼠标和触摸事件,进而实现拖放和其他多种交互操作。

Interact.js 的特点是它提供了一种基于事件的 API,你可以自定义事件处理函数来说实现你的交互逻辑。

// 声明 drop targetinteract('.dropzone').dropzone({  // 声明 drag source  accept: '#yes-drop',  // 放置时的重叠部分  overlap: 0.75,  // 允许放置时  ondropactivate: function (event) {    event.target.classList.add('drop-active')  },  // 拖拽进入 drop target 时  ondragenter: function (event) {    var draggableElement = event.relatedTarget    var dropzoneElement = event.target    dropzoneElement.classList.add('drop-target')    draggableElement.classList.add('can-drop')    draggableElement.textContent = 'Dragged in'  },  // 拖拽离开 drop target 时  ondragleave: function (event) {    event.target.classList.remove('drop-target')    event.relatedTarget.classList.remove('can-drop')    event.relatedTarget.textContent = 'Dragged out'  },  // 已放置  ondrop: function (event) {    event.relatedTarget.textContent = 'Dropped'  },  // 当不允许放置时  ondropdeactivate: function (event) {    event.target.classList.remove('drop-active')    event.target.classList.remove('drop-target')  }})

Interact.js 对触摸、鼠标和指针事件做了统一处理,这意味着你可以在多种设备上提供一致的交互体验。Interact.js 非常适合那些需要在多种设备上提供一致交互体验的应用,例如可视化数据分析工具、缩放旋转等交互操作的实现。

  1. moveable 库介绍 ===============

上面提到的拖拽库各有所长,可以结合具体的场景是使用。在笔者参与的低代码编辑器项目中,正是使用到了 moveable 这个库的能力,所以接下来让我们一起探索一下 moveable 的实现原理吧。

3.1. 实现原理

moveable 是一个支持多 UI 框架的库,react-moveable 是它的核心实现,其他 UI 框架的支持都是直接或间接通过 react-moveable 实现的。

个人猜测这么做的原因主要有两个:一个是 react 的灵活性比较大,保证灵活性的同时也能利用框架能力提效;另一个则因为该库是作者 scena 项目的基础组件,moveable 的首要用户是他们自己,react 则是他们团队或项目的技术栈。

preact-moveable 的实现非常简单,实际并没有增加任何的代码,只是修改了它的 ts 类型,以便在 preact 中使用。

import Moveable from "react-moveable";import Preact from "preact";import { PreactMoveableInterface } from "./types";export default Moveable as any as new (...args: any[]) => PreactMoveableInterface;

在原生 js 和其他框架的适配上,moveable 并没有直接使用完整的 react 来渲染,不然的话就有点杀鸡用牛刀了。它选择自研的类 react 库—— 《croact》(https://github.com/daybrush/croact)。croact 和 preact 的做法有些相似,使用更少的抽象层和逻辑来打造更轻量级的 react 渲染方案,而它的主要目标就是为了方便将基于 react 构建的组件用于其他 UI 框架。所以 moveable 在此之上适配到其他框架,都是通过 croact 来渲染的。

因为 react-moveable 的方法都是挂载到组件之上的,要在 react 外调用组件的方法,可以通过组件的 ref 参数,在创建真实 DOM 时执行会 ref 指定的函数,将元素作为第一个参数传递传入,这时候我们也就能拿到该组件的实例。

containerProvider = renderSelf(    <InnerMoveable        ref={ref(this, "innerMoveable")}        {...nextOptions}        {...events}    /> as any,    selfElement,);export function ref(target: any, name: string) {    return (e: any) => {        e && (target[name] = e);    };}

moveable 的核心都在 《MoveableManager》(https://github.com/daybrush/moveable/blob/master/packages/react-moveable/src/MoveableManager.tsx) 这个类上,它本身是一个 react 的组件,利用 react 的生命周期方法来管理自身的状态。

moveable 通过两种类型的事件来实现各种功能,这两种事件也会通过回调函数的方式暴露给 able 和使用方。一种是浏览器原生的 mouseEntermouseLeave 事件,另一种则是通过 《Gesto》(https://github.com/daybrush/gesto) 封装的事件:dragStartdragdragEndpinchStartpinchpinchEnd

事件操作的对象包括目标元素和控制元素,控制元素指的是 able render 出来的组件。分别对各操作对象的不同处理,就能做不同的交互了。

例如:监听目标元素的 drag 事件,修改元素的位置,可以实现 draggable 的能力。监听 control 元素的 drag 事件,更新 control 的位置和修改目标元素的大小,可以实现 scalable 和 resiable 的能力。

3.2. 能力扩展

moveable 不仅是一个基础功能丰富的库,而且本身也非常灵活和可扩展,允许用户实现更加复杂有趣的功能。

moveable 是通过定义 able 的方式来实现功能的扩展,例如 Tmagic 选中组件后上面的操作按钮就是通过 able 的方式扩展的。

  1. 实现一个 moveable able =====================

看完 moveable 的实现原理之后,我们不妨上手做一个 able 来扩展一下它的能力。

4.1. 如何自定义一个 able

官方仓库中提供了 able 的 api 《文档》(https://github.com/daybrush/moveable/blob/master/packages/react-moveable/src/ables/README.md),在 《storybook》(https://daybrush.com/moveable/storybook/?path=/story/make-custom-able--custom-able-dimension-viewable) 中也有对应的例子,参照文档和官方示例我们就能很实现一个简单的 able。

定义一个最小 able,我们只需要实现提供一个 name 和一个 render 函数即可。

const CustomAble = {  name: 'customeAble',  render() {      return <div class></div>;  }}

name 即是对 able 的声明,在初始化 moveable 时,需要通过 name 来开启 able 的能力。render 方法返回一个 react node,因为 moveable 的底层是 react 实现的,moveable 的各种功能一般都通过一个元素操作,所以我们这里会把一个 control 组件渲染到页面上。

例如我们可以在这个元素上绑定一个删除元素的点击事件,那我们就能实现删除目标元素的效果。

render(moveable) {  function handleClick() {    moveable.getTargets()[0].remove();  }  return <div class onClick={handleClick}></div>;}

4.2. 实现一个智能放置组件的 able

笔者设计这个 able 的功能是:给定一个方框,当目标元素拖动到这个方框的范围时,方框高亮,这时候如果放开鼠标按键,就会把目标的元素的大小位置设定到这个方框里面。

4.2.1. 参考范围

首先给这个 able 取一个唯一的名字,就叫做 snappableSizeAble,还需要定义一个参考范围,用来指定目标元素变化后的大小和位置,就用 Rect 类型表示即可。所以我们可以这样定义。

interface SnapSizeRect {  top: number;  left: number;  width: number;  height: number;}export const SnappableSizeAble: Able = {  // 定义 able 的名字  name: 'snappableSizeAble',  // 定义 able 接收的参数  props: [    'snapSizeRect'  ],};// 使用function App() {  const targetRef = useRef(null);  return (    <div class>      <div className='target' ref={targetRef}></div>      <Moveable        // 声明需要使用的 ables        ables={[SnappableSizeAble]}        props={{          // 根据组件的名字将配置设置成 ture 才能启用 able          snappableSizeAble: true,          // 传入 able 的参数          snapSizeRect: {            top: 200,            left: 400,            width: 200,            height: 100,          },        }}        target={targetRef}        // 开启拖拽和缩放的能力        scalable={true}        draggable={true}        // 当拖拽和缩放时通过 transform 修改元素的位置和大小        onScale={e => {          e.target.style.transform = e.drag.transform;        }}        onDrag={e => {          e.target.style.transform = e.transform;        }}      ></Moveable>    </div>  );}

4.2.2. 渲染参考范围标识

我们需要有一个标识来表示可以放置的位置,以及放置生效时需要有高亮提示。所以我们使用到了 able 的 render 方法,用来渲染一个范围标识。

export const SnappableSizeAble: Able = {  // ……省略其他  render(moveable: MoveableManagerInterface<{snapSizeRect: SnapSizeRect}>) {    const sizeRect = moveable.props.snapSizeRect;    const style = {      transform: `translate(${-moveable.state.left}px, ${-moveable.state.top}px)`,      position: 'fixed',      // 根据参数设置的范围来设置范围标识的大小和位置      top: `${sizeRect.top}px`,      left: `${sizeRect.left}px`,      width: `${sizeRect.width}px`,      height: `${sizeRect.height}px`,      // 通过背景图的样式改变来标识是否可放置      // moveable.hitTest 是用来检测是否碰撞的方法      backgroundColor: moveable.hitTest(sizeRect) ? 'rgba(0, 115, 255, 0.146)' : 'transparent',    };    return <div       // key 是必传的      key='snappable-size'       className='moveable-snappable-size'      style={style}    >    </div>  },};

这时候,我们就能实时检测目标元素的位置并更新范围标识的样式了。

4.2.3. 自适应功能的实现

当拖拽结束后,把 target 元素的大小和位置设置成和 sizeRect 表示的信息一致即可。需要注意的是,在修改 target 的样式后,需要通过 moveable.updateRect() 触发更新。

export const SnappableSizeAble: Able = {  // 定义了 dragStart 才会回调 dragEnd  dragStart() {},  // 拖拽结束后会调用  dragEnd(moveable: MoveableManagerInterface<{snapSizeRect: SnapSizeRect}>) {    const sizeRect = moveable.props.snapSizeRect;    if(!moveable.hitTest(sizeRect)) return;    // 如果在标识的范围内,就更新 target 的样式    const target = moveable.getTargets()[0];    target.style.width = `${sizeRect.width}px`;    target.style.height = `${sizeRect.height}px`;    target.style.transform = `translate(${sizeRect.left}px, ${sizeRect.top}px)`;    // 修改 target 样式后需要调用这个方法更新 moveable 的状态    moveable.updateRect()  },  // ……其他逻辑};

至此,我们就实现了拖拽到指定范围内,元素就自动修改成指定的大小和放置到指定位置。

4.3. 应用场景

以上我们实现了一个基本可用的 able ,不妨思考一下,这个能力我们可以用在哪些地方呢?

我目前想到的应用场景是在低代码编辑的时候,可能存在一些特殊的容器组件内部仅包含一个组件,且该宽高位置和容器组件一致,利用这个能力就可以做一个交互优化。

另外一个就是如果编辑器使用的场景是活动,这种场景往往是一个基础背景图 + 组件实现,背景是包含按钮的位置等图案的。如果能结合 AI 的能力识别出背景图中的按钮和其他组件的边框位置,也能提高页面的编排效率。

  1. 总结 =====

浏览器的拖拽能力在许多地方都会用到,例如我们团队正在开发的低代码编辑器、以及后续计划开发的逻辑编排系统、接口裁剪系统等相关的系统,或者 C 端的一些交互需求,都可能需要用到相关的能力。

本文对常用的几个拖拽库做了一些介绍和对比,也针对 moveable 的实现原理做了简单的介绍,并带读者们通过 moveable able 扩展了拖拽功能。

最后,如果客官觉得文章还不错,👏👏👏欢迎点赞、转发、收藏、关注,这是对小编的最大支持和鼓励,鼓励我们持续产出优质内容。