了解元素拖拽,看这一篇就够了

avatar
@比心

一、背景

最近公司后台项目需要实现一个资金流配置功能,每条资金流配置需要新增一个列表,每个列表元素中有多个支持增删改的复杂表单,同时列表元素需要支持拖拽排序,项目使用React框架开发。功能需要对拖拽时数据和页面更新有一定的性能要求同时支持定制化场景。于是我们针对元素拖拽的技术方案和实现方式做了基本的了解和调研。接下来,我们将详细了解元素拖拽及其原理。

元素拖拽功能指用户可以通过鼠标拖动页面上的元素,改变元素的位置和顺序。这种交互使得我们在一些固有的事件场景中,给予用户更方便的交互操作,能够有效的提升用户体验和产品的流畅度。如今,拖拽功能广泛应用于可视化搭建、列表排序、图片上传预览等场景。

然而,实现元素拖拽需要解决元素拖动、释放后元素定位、其他元素响应等技术难点。对此,利用如今较为成熟的拖拽开源库可以更简单地实现拖拽效果。

二、拖拽解决方案

早在Jquery 时代就有拖拽这种交互方式,前端实现方式主要有两种:

  • 一是以jquery-ui为代表的 draggable 和 Droppable,其原理是通过鼠标事件 mousedown、mousemove、mouseup 或者 触摸事件 touchstart、touchmove、touchend,记录开始位置和结束位置、以达到拖拽传递数据的效果。
  • 二是通过HTML Drag and Drop API

这些方式实现拖拽的难点在于需要监听各阶段发生在元素上的拖动事件,最后还需要处理ondrop事件完成最终的放置。另外,我们还需要做好数据的传递,识别元素放置区域、元素最终位置的处理,页面元素的更新等等一系列细节繁多的工作。

有幸的是,如今许多成熟的开源库可以帮助我们处理这些细节,我们只需要关注视图层的渲染即可。

下面列举一些常见的拖拽开源库:

React DnD,是 React 和 Redux 核心作者 Dan Abramov 开发的一组React 高阶组件。它可以帮助我们构建复杂的拖放界面,同时保持组件解耦。可以在应用程序的不同部分之间通过拖动传输数据,并且组件会更改其外观和应用状态以响应拖放事件。React DnD提供了对底层的拖拽的一层封装。

640 (2).png

React Beautiful Dnd, 是由Alassian团队开发的React拖拽工具库。相比于React DnD,提供了更高层级功能的封装,如动画、虚拟列表、移动端等功能。

640.gif

Sortable,是一个用于可重新排序的拖放列表的 JavaScript 库。支持触摸设备和浏览器(包括 IE9),可与React、Vue等框架无缝集成,使用简单,学习成本低。

640 (1).gif

Dragula, 是一个 JavaScript 库,实现了网页上的拖放功能。提供 JavaScript、AngularJS 和 React 版本。

640 (2).gif

总结对比

开源库React DnDReact Beautiful DndSortableDragula
开发公司/社区由Dan Abramov编写,有社区支持由Atlassian编写,有社区支持由Rubaxa公司开发,有社区支持没有明确的开发公司,有社区支持
开发社区热度(github star数)19k29.6k26.5k21.5k
学习门槛学习门槛较高,需要熟悉React的基础知识和Drag and Drop的概念学习门槛相对较低,提供了易于理解的文档和示例学习门槛低,有完整的API文档和示例学习门槛低,有完整的API文档和示例
其他框架的整合能力兼容React 14及更高版本兼容React 16及更高版本可以与任何JavaScript框架一起使用可以与任何JavaScript框架一起使用
浏览器支持支持所有现代浏览器,包括IE11支持所有现代浏览器,包括IE11支持所有主流浏览器,包括IE8及更高版本支持所有主流浏览器,包括IE8及更高版本
可定制可以提供高度定制可以提供高度定制提供的功能较少提供的功能较少

React DnD和React Beautiful Dnd都是专为React开发的拖放库,学习曲线较高,但可以提供高度定制。Sortable和Dragula较易于学习,可以与任何JavaScript框架一起使用,但提供的功能较少。

总体来说,使用成熟的开源库会更高效和可靠,除非我们对拖拽功能有非常定制化的需求,否则不建议自己从零开发。由于我们需要在基于React后台系统实现拖拽功能,更偏向于从底层来实现整套拖拽逻辑,因此选用了React DnD来完成拖拽功能的开发。

三、React DnD解析

3.1 React DnD

React DnD (Drag and Drop for React)是一组 React 高阶组件,使用的时候只需要用对应的 API 将目标组件进行包裹,即可实现元素拖动。

因为React DnD自身使用了redux管理自身的状态,因此在拖动的过程中不需要开发者自己判断拖动状态,只需要对传入的配置对象中各个状态属性中做对应处理即可。

值得注意的是 React DnD并不会改变页面的视图,它只会改变页面元素的数据流向。所以在一些定制化场景上,我们可以自己实现视图层逻辑,这也使得在使用React DnD实现元素拖拽时更具有灵活性。

3.1.1使用方法

安装

npm install react-dnd react-dnd-html5-backend 

DndProvider注入

DndProvider组件提供了react-dnd功能,必须通过backend参数注入后端。backend后端可以理解为具体拖拽的实现方式。

import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndProvider } from 'react-dnd';

<DndProvider backend={HTML5Backend}>
    <TutorialApp />
</DndProvider>

DndProvider Props

  • backend:必填。dnd后端可以使用官方的提供的两个 HTML5Backend or TouchBackend,或者也可以自己写backend后端。
  • context:选填。用于配置后端的上下文,这取决于后端的实现。
  • options:选填。用于配置后端的选项对象,这取决于后端实现。

useDrag 声明拖动源

useDrag是将当前组件用作拖动源的钩子。

import { DragPreviewImage, useDrag } from 'react-dnd';

export const KnightFC = () => {
    const [{ isDragging }, drag, preview] = useDrag(
        () => ({
            item: { typeItemTypes.KNIGHT }, //指定相同类型的元素才能进行拖动
            collect(monitor) => ({
                  isDragging: !!monitor.isDragging()
              })
           }),
          []
    );

    return (
        <>
            <DragPreviewImage connect={preview} src={knightImage} />
            <div ref={drag}></div>
        </>
    );
};

useDrag返回三个参数

  • 第一个返回值是一个对象:表示关联在拖拽过程中的变量,需要在传入useDrag的规范方法的collect属性中进行映射绑定,比如:isDragging,canDrag等。
  • 第二个返回值:代表拖拽元素的ref,作为拖动源的连接器。
  • 第三个返回值:代表拖拽元素拖拽后实际操作到的DOM。

useDrag传入两个参数

  • 第一个参数是一个对象:用于描述了drag的配置信息,常用属性。
    • item:必填。一个普通的JavaScript对象,描述了要拖动的数据。这是可用于放置目标的有关拖动源的唯一信息。
    • item.type:必填。指定元素的类型,只有类型相同的元素才能进行drop操作。
    • end(item, monitor):选填。拖拽结束的回调函数。
    • canDrag(monitor):选填。指定当前是否允许拖动,默认允许。
    • isDragging(monitor):选填。判断元素是否在拖拽过程中。默认情况下,只有启动拖动操作的拖动源才被视为拖动。
    • collect:选填。返回一个描述状态的普通对象,然后返回以注入到组件中。
  • 第二个参数是一个数组:选填,用于记忆依赖的数组,只有当数组中的参数发生改变,才会重新生成方法,基于react的useMemo实现。

DragSourceMonitor对象

DragSourceMonitor对象是传递给拖动源的收集函数的对象。它的方法可以获得有关特定拖动源的拖动状态的信息。

常用的方法:

  • canDrag():描述元素是否可以拖拽,返回一个bool值。
  • isDragging():判断元素是否在拖拽过程中,返回一个bool值。
  • getItemType():获取元素的类型,返回一个bool值。
  • getItem():获取元素的描述数据,返回一个对象。
  • getDropResult():拖拽结束,返回拖拽结果的钩子,可以拿到从drop元素中返回的数据。
  • didDrop():拖拽结束,元素是否放置成功,返回一个bool值。
  • getDifferenceFromInitialOffset():获取相对于拖拽起始位置的相对偏移坐标。

useDrop 声明放置源

useDrop是使用当前组件作为放置目标的钩子。

function BoardSquare({ x, y, children }) {
  const black = (x + y) % 2 === 1
  const [{ isOver }, drop] = useDrop(() => ({
    acceptItemTypes.KNIGHT//指定相同类型的元素才能进行放置
    drop() => moveKnight(x, y),
    collectmonitor => ({
      isOver: !!monitor.isOver(),
    }),
  }), [x, y])

  return (
    <div
      ref={drop}
      style={{
        position: 'relative',
        width: '100%',
        height: '100%',
      }}
    >
     ....
    </div>,
  )
}

export default BoardSquare

useDrop返回两个参数

  • 第一个返回值是一个对象:表示关联在拖拽过程中的变量,需要在传入useDrop的规范方法的collect属性中进行映射绑定。
  • 第二个返回值:代表放置元素的ref,作为拖动源的连接器。

useDrop传入一个参数

  • accept:必填。指定接收元素的类型,只有类型相同的元素才能进行drop操作。
  • drop(item, monitor):选填。有拖拽物放置到元素上触发的回调方法。
  • hover(item, monitor):选填。当拖拽物在上方hover时触发的回调方法。
  • canDrop(item, monitor):选填。判断拖拽物是否可以放置。
  • collect:选填。返回一个描述状态的普通对象,然后返回以注入到组件中。

DropTargetMonitor对象

DropTargetMonitor对象是传递给拖动源的收集函数的对象。它的方法可以获取有关特定拖动源的拖动状态的信息。

  • canDrop():判断拖拽物是否可以放置,返回一个bool值。
  • isOver(options): 拖拽物悬停元素上方触发的回调方法,options表示拖拽物的options信息。
  • getItemType():获取元素的类型,返回一个bool值。
  • getItem():获取元素的描述数据,返回一个对象。
  • didDrop():拖拽结束,元素是否放置成功,返回一个bool值。
  • getDifferenceFromInitialOffset():获取相对于拖拽起始位置的相对偏移坐标。
  • getClientOffset():在进行拖动操作时,返回最后记录的指针的 { x, y } 偏移量。如果没有项目被拖动,则返回 null。

3.1.2使用React DnD实现的拖拽效果

640 (3).gif

3.1.3 React DnD 原理分析

设计架构

React-DnD主要包含三部分,react-dnd、backend和dnd-core。

  • react-dnd :负责封装react插件。定义了DragSource、DropTarget、DragDropContext 等高阶组件,以及 useDrag,useDrop 等 hook。
  • backend:后端部分。是 DOM 事件转换为 redux action 的地方,是具体场景的DOM操作。
  • dnd-core:整个拖放库的核心。实现了一个拖放管理器,定义了拖放的交互,并使用 redux 做状态管理。

源码分析

DndProvider

如果想要使用 React DnD,首先需要在外层元素上加一个 DndProvider。

DndProvider 的本质是一个由 React.createContext 创建一个上下文的容器组件,用于控制拖拽的行为,数据的共享。

import type { BackendFactoryDragDropManager } from 'dnd-core'
import { createDragDropManager } from 'dnd-core'

import { DndContext } from './DndContext.js'

const INSTANCE_SYM = Symbol.for('__REACT_DND_CONTEXT_INSTANCE__')

export const DndProviderFC<DndProviderProps<unknownunknown>> = memo(
 function DndProvider({ children, ...props }) {
  const [manager, isGlobalInstance] = getDndContextValue(props)
  // ...
  return <DndContext.Provider value={manager}>{children}</DndContext.Provider>
 },
)

function getDndContextValue(props: DndProviderProps<unknownunknown>) {
 // ...
 const manager = createSingletonDndContext(
  props.backend,
  props.context,
  props.options,
  props.debugMode,
 )
 const isGlobalInstance = !props.context

 return [manager, isGlobalInstance]
}

function createSingletonDndContext<BackendContextBackendOptions>(
 backendBackendFactory,
 contextBackendContext = getGlobalContext(),
 optionsBackendOptions,
 debugMode?: boolean,
) {
 const ctx = context as any
 // 创建manager实例
 if (!ctx[INSTANCE_SYM]) {
  ctx[INSTANCE_SYM] = {
   dragDropManagercreateDragDropManager(
    backend,
    context,
    options,
    debugMode,
   ),
  }
 }
 return ctx[INSTANCE_SYM]
}

getDndContextValue方法使用dnd-core的createDragDropManager创建了一个manager实例DragDropManager,我们可以称它为主实例,它主要用于控制拖拽行为。接下来我们来看一下DragDropManager包含了哪些内容。

DragDropManager

整个拖拽库的核心是dnd-core,而dnd-core的核心就是DragDropManager。

export function createDragDropManager(
 backendFactory: BackendFactory,
 globalContext: unknown = undefined,
 backendOptions: unknown = {},
 debugMode = false,
): DragDropManager {
 const store = makeStoreInstance(debugMode)
 const monitor = new DragDropMonitorImpl(store, new HandlerRegistryImpl(store))
 const manager = new DragDropManagerImpl(store, monitor)
 const backend = backendFactory(manager, globalContext, backendOptions)
 manager.receiveBackend(backend)
 return manager
}

store

store 用来存放应用中所有的 state ,store 的创建使用了 redux 的 createStore 方法。它的第一个参数 reduce 接收两个参数,分别是当前的 state 树和要处理的 action,返回新的 state 树。

function makeStoreInstance(debugMode: boolean): Store<State> {
 const reduxDevTools =
  typeof window !== 'undefined' &&
  (window as any).__REDUX_DEVTOOLS_EXTENSION__
 return createStore(
  reduce,
  debugMode &&
   reduxDevTools &&
   reduxDevTools({
    name'dnd-core',
    instanceId'dnd-core',
   }),
 )
}

reduce内部更新数据的方式是 dispatch action 的方式,dispatch负责发出action,action通知state发生变化。以 dragOffset获取拖动偏移量为例,判断当前 action 的类型,从 payload 中获得需要的参数,然后返回新的 state。

export function reduce(
 state: State = initialState,
 action: Action<{
  sourceClientOffset: XYCoord
  clientOffset: XYCoord
 }>,
): State {
 const { payload } = action
 switch (action.type) {
  case INIT_COORDS:
  case BEGIN_DRAG:
   return {
    initialSourceClientOffset: payload.sourceClientOffset,
    initialClientOffset: payload.clientOffset,
    clientOffset: payload.clientOffset,
   }
  case HOVER:
   if (areCoordsEqual(state.clientOffset, payload.clientOffset)) {
    return state
   }
   return {
    ...state,
    clientOffset: payload.clientOffset,
   }
  case END_DRAG:
  case DROP:
   return initialState
  default:
   return state
 }
}

monitor

我们已知store 表示的是拖拽过程中的数据,根据这些数据可以计算出当前拖拽元素的一些状态,monitor 则提供了一些方法来访问这些数据。 此外,monitor最大的作用是用来监听这些数据,我们可以为 monitor添加一些监听器,以做到及时响应数据的变动。

以subscribeToOffsetChange为例 ,内部使用了 redux 的 subscribe 方法设置监听函数。

public subscribeToOffsetChange(listener: Listener): Unsubscribe {
  invariant(typeof listener === 'function''listener must be a function.')

  let previousState = this.store.getState().dragOffset
  const handleChange = () => {
   const nextState = this.store.getState().dragOffset
   if (nextState === previousState) {
    return
   }

   previousState = nextState
   listener()
  }

  return this.store.subscribe(handleChange)
 }

backend

在 DndProvider 还传入了一个参数 backend,其实它是个工厂方法,执行之后会生成真正的 backend。通过 backend 可以将 DOM 事件转换为 action

manager

包含了之前生成的 store、monitor、 backend,在初始化的时候为 store 添加了一个监听器。它监听 state 中的 refCount 方法, refCount 表示当前标记为可拖拽的对象,如果 refCount 大于 0,初始化 backend,否则,销毁 backend。

export class DragDropManagerImpl implements DragDropManager {
 private store: Store<State>
 private monitor: DragDropMonitor
 private backend: Backend | undefined
 private isSetUp = false

 public constructor(store: Store<State>, monitor: DragDropMonitor) {
  this.store = store
  this.monitor = monitor
  store.subscribe(this.handleRefCountChange)
 }
  ...
  
  private handleRefCountChange = (): void => {
  const shouldSetUp = this.store.getState().refCount > 0
  if (this.backend) {
   if (shouldSetUp && !this.isSetUp) {
    this.backend.setup()
    this.isSetUp = true
   } else if (!shouldSetUp && this.isSetUp) {
    this.backend.teardown()
    this.isSetUp = false
   }
  }
 }
}

DndProvider内部构成

图片

useDrag

在使用useDrag 的时候,入参是一个函数,这个函数的返回值就是配置参数。

export function useDrag<DragObject, DropResult, CollectedProps>(
    specArg: FactoryOrInstance<
        DragSourceHookSpec<DragObject, DropResult, CollectedProps>
    >,
    deps?: unknown[],
): [CollectedProps, ConnectDragSource, ConnectDragPreview] {
   // 获得配置参数
    const spec = useOptionalFactory(specArg, deps)

   // 获得 manager 中的 monitor 的包装对象(DragSourceMonitor)
    const monitor = useDragSourceMonitor<DragObject, DropResult>()

    // 连接 DOM 以及 redux
    const connector = useDragSourceConnector(spec.options, spec.previewOptions)

    // 生成唯一 id,封装 DragSource 对象
    useRegisteredDragSource(spec, monitor, connector)

    return [        useCollectedProps(spec.collect, monitor, connector),        useConnectDragSource(connector),        useConnectDragPreview(connector),    ]
}

useDragSourceMonitor,将本身包含Drag 和 Drop 两种行为的方法进行包装,屏蔽了 Drop 的行为,使其类型变为 DragSourceMonitor。

export function useDragSourceMonitor<OR>(): DragSourceMonitor<O, R> {
 const manager = useDragDropManager()
 return useMemo<DragSourceMonitor<O, R>>(
  () => new DragSourceMonitorImpl(manager),
  [manager],
 )
}

以上,我们有 backend 控制 DOM层的行为,store 和 monitor 控制数据层的变化。那如何让 monitor 知道现在到底是要监听哪个节点,还需要将这两者连接起来react-dnd 中使用了 connector 来连接这两者

useDragSourceConnector,该monitor中的方法会创建一个 SourceConnector 的实例作为connector。

export interface Connector {
  // 获得 ref 指向的 DOM
 hooksany
  // 获得 dataSource
 connectTargetany
  // dragSource 唯一 Id
 receiveHandlerId(handlerIdIdentifier | null): void
  // 重新连接 dragSource 和 DOM
 reconnect(): void
}

在SourceConnector中可以看出,connector中将DOM节点维护在了dragSourceNode属性上

export class SourceConnector implements Connector {
    // wrapConnectorHooks 判断 ref 节点是否是合法的 ReactElement,是的话,执行回调方法
    public hooks = wrapConnectorHooks({
        dragSource: (
            node: Element | ReactElement | Ref<any>,
            options?: DragSourceOptions,
        ) => {
            // dragSourceRef 和 dragSourceNode 赋值 null
            this.clearDragSource()
            this.dragSourceOptions = options || null
            if (isRef(node)) {
                this.dragSourceRef = node as RefObject<any>
            } else {
                this.dragSourceNode = node
            }
            this.reconnectDragSource() // 为节点添加事件监听
        },
        ...
    })
    ...
}

获得节点后,调用this.reconnectDragSource(),该方法中,backend 调用 connectDragSource 方法为该节点添加事件监听

private reconnectDragSource() {
    const dragSource = this.dragSource
    ...
    if (didChange) {
        ...
        this.dragSourceUnsubscribe = this.backend.connectDragSource(
            this.handlerId,
            dragSource,
            this.dragSourceOptions,
        )
    }
}

useRegisteredDragSource,对 DOM 进行抽象,更新唯一 ID, 封装为 DragSource 注册到 monitor 上。

export function useRegisteredDragSource<O, R, P>(
    spec: DragSourceHookSpec<O, R, P>,
    monitor: DragSourceMonitor<O, R>,
    connector: SourceConnector,
): void {
    const manager = useDragDropManager()
    // 生成 DragSource
    const handler = useDragSource(spec, monitor, connector)
    const itemType = useDragType(spec)

    // useLayoutEffect
    useIsomorphicLayoutEffect(
        function registerDragSource() {
            if (itemType != null) {
                // DragSource 注册到 monitor
                const [handlerId, unregister] = registerSource(
                    itemType,
                    handler,
                    manager,
                )

                // 更新唯一 ID,触发 reconnect 逻辑
                monitor.receiveHandlerId(handlerId)
                connector.receiveHandlerId(handlerId)
                return unregister
            }
        },
        [manager, monitor, connector, handler, itemType],
    )
}

useDrag的作用:

  • 获取一些拖拽配置参数。
  • 获得 Provider 中的 manager,对其中的一些对象进行包装,屏蔽一些drop方法,增加一些参数。
  • 创建connector,connector 通过 ref 的方式获得 DOM 节点的实例,为该节点添加拖拽属性和拖拽事件。
  • 根据配置参数和 connector 封装 DragSource 对象,将其注册到 monitor 中。

useDrop

useDrop 和 useDrag 的流程大同小异,区别是针对Drop行为进行处理。

backend

backend主要是HTML5Backend和TouchBackend

HTML5Backend

之前为 DndProvider 注入的参数 HTML5Backend,其实是个工厂方法,我们在 DndProvider 除了可以配置 backend 外,还可以配置 backend 的一些参数。DragDropManager 会根据这些参数初始化真正的 backend。

export const HTML5BackendBackendFactory = function createBackend(
    manager: DragDropManager,
    context?: HTML5BackendContext,
    options?: HTML5BackendOptions,
): HTML5BackendImpl {
    return new HTML5BackendImpl(manager, context, options)
}

backend需要实现的方法

export interface Backend {
   // 初始化方法
    setup(): void
   // 销毁方法
    teardown(): void
   // 将Node节点转化拖拽节点添加监听事件
    connectDragSource(sourceIdany, node?: any, options?: any): Unsubscribe
    connectDragPreview(sourceIdany, node?: any, options?: any): Unsubscribe
    connectDropTarget(targetIdany, node?: any, options?: any): Unsubscribe
    profile(): Record<stringnumber>
}

setup 是 backend 的初始化方法,teardown 是 backend 销毁方法

上文提到过,setup 和 teardown 是在创建DragDropManager中执行的。react-dnd 会在我们第一次使用 useDrag 或是 useDrop 的时候,执行 setup 方法,而在它检测到没有任何地方在使用拖拽功能的时候,执行 teardown 方法

setup方法中执行了如下方法,addEventListeners监听了所有的拖拽事件,统一将拖拽事件的回调函数都绑定在 window 上。

private addEventListeners(target: Node) {
  // SSR Fix (https://github.com/react-dnd/react-dnd/pull/813
  if (!target.addEventListener) {
   return
  }
  target.addEventListener(
   'dragstart',
   this.handleTopDragStart as EventListener,
  )
  target.addEventListener('dragstart'this.handleTopDragStartCapture, true)
  target.addEventListener('dragend'this.handleTopDragEndCapture, true)
  target.addEventListener(
   'dragenter',
   this.handleTopDragEnter as EventListener,
  )
  target.addEventListener(
   'dragenter',
   this.handleTopDragEnterCapture as EventListener,
   true,
  )
  target.addEventListener(
   'dragleave',
   this.handleTopDragLeaveCapture as EventListener,
   true,
  )
  target.addEventListener('dragover'this.handleTopDragOver as EventListener)
  target.addEventListener(
   'dragover',
   this.handleTopDragOverCapture as EventListener,
   true,
  )
  target.addEventListener('drop'this.handleTopDrop as EventListener)
  target.addEventListener(
   'drop',
   this.handleTopDropCapture as EventListener,
   true,
  )
 }

connectDragSource,该方法用于将某个 Node 节点转换为可拖拽节点,并且添加监听事件

  • 为了把一个元素设置为可拖放,把 draggable 属性设置为 true
  • 监听dragstart事件,在用户开始拖动元素时触发
  • 监听selectstart事件,用来处理一些 IE 特殊情况
public connectDragSource(
    sourceIdstring,
    nodeElement,
    optionsany,
): Unsubscribe {
    ...
    const handleDragStart = (e: any) => this.handleDragStart(e, sourceId)
  const handleSelectStart = (e: any) => this.handleSelectStart(e)

    // 设置 draggable 属性
    node.setAttribute('draggable''true')
    // 添加 dragstart 监听
    node.addEventListener('dragstart', handleDragStart)
    // 添加 selectstart 监听
    node.addEventListener('selectstart', handleSelectStart)
    ...
}

综上,HTML5Backend 在初始化的时候在 window 上绑定拖拽事件的监听函数,处理拖拽中的坐标数据,状态数据,并将其转换为 action 交由上层的 store 处理。完成由 DOM 事件到数据的转变

TouchBackend

TouchBackend更适用于移动端有触屏操作的场景,TouchBackend 使用简单的事件来模拟拖放行为。比如在浏览器端,使用的是 mousedown,mousemove,mouseup。移动端使用 touchstart,touchmove,touchend。

const eventNames: Record<ListenerType, EventName> = {
    [ListenerType.mouse]: {
        start: 'mousedown',
        move: 'mousemove',
        end: 'mouseup',
        contextmenu: 'contextmenu',
    },
    [ListenerType.touch]: {
        start: 'touchstart',
        move: 'touchmove',
        end: 'touchend',
    },
    [ListenerType.keyboard]: {
        keydown: 'keydown',
    },
}

原理总结

React DnD 使用了分层设计的方式。

react-dnd 是接入层,它实现了 Drag and Drop 的高级 API,提供了对 Drag Source 和 Drop Target 的包装组件,并提供了用于描述拖拽行为的一些属性和方法。dnd-core 是核心,提供了对拖拽事件的封装,用于实现更多的拖拽逻辑。内部使用了redux管理数据,使用 monitor 进行数据的监控,使用 connector 连接 DOM 和 store。backend 处理DOM 事件,为拖拽元素添加监听事件,然后将事件转化为 action。它还提供了对不同运行环境的适配。

总体来说,React DnD 的核心思路是将事件转换为数据,设计上参考了 redux 的单一数据流。这样我们在处理拖拽的时候就可以只关注于数据的变化,而不用去关心拖拽时的中间状态,拖拽事件的添加和移除等。这样的设计更好的实现了强大且灵活的拖拽功能,其源码设计也非常值得学习和参考。

四、Sortable.js

上述介绍的React DnD只能在React项目中使用,那么有没有不依赖任何框架并且使用简单的拖拽库呢?Sortable.js就是个不错的选择。Sortable.js是一个轻量级的独立拖拽库,支持拖拽网格、列表和树形结构,可与React、Vue等框架无缝集成,使用简单,学习成本低。

4.1 使用方法

安装

npm install sortablejs

代码示例

<div id="itxst" class="itxst">
 <div class="item" data-id="i1">item 1</div>
 <div class="item" data-id="i2">item 2</div>
 <div class="item" data-id="i3">item 3</div>
</div>
<script>
    //获取对象
  const el = document.getElementById('itxst')
  //设置配置
  const ops = {
   animation1000//过渡动画
   draggable".item"//指定class样式名称,指定类名为item的元素才允许拖动
   direction'vertical'//拖拽方向(默认会自动判断方向)
   forceFallbacktrue//忽略HTML5原生拖拽行为
   //拖动结束
   onEnd(evt: any) => {
    console.log(evt)
    //@ts-ignore
    document.getElementById("msg").innerHTML = "排序结果:" + JSON.stringify(sortable.toArray())
   },
  }
  //初始化
  const sortable = Sortable.create(el, ops)
</script>

4.2 使用Sortable.js实现的拖拽效果

图片

总体来说,React DnD功能更加强大和丰富,但是学习成本也较高。如果对拖拽行为有复杂的定制需求且是React项目,推荐使用React DnD。如果仅仅需要简单地实现拖拽排序,Sortable.js也是一个不错的选择,使用简单,性能良好。

五、拖拽性能优化

在我们的项目中需要实现复杂表单的拖拽,当然就免不了对表单数据的处理。在拖拽和操作表单的场景时,我们一般会用到setState对表单数据进行更新

一般复杂的表单数据会是一个对象数组,对象数组是引用方式,对于React来说它的值都是地址。如果没有被重新赋值,数据地址没有改变React 会认为仍然是之前的元素,则不更新视图。所以一般的做法需要将state数据进行深拷贝,为数据重新生成新的地址,对数据进行操作后进行setState,这样才能更新视图。

针对复杂表单拖拽排序这种场景下数据更新非常频繁,按上述方法操作数据就存在很大的弊端。Immutable-helper在处理复杂对象的时候要比用原生 API 修改,然后深拷贝一个新的对象优雅很多,同时也会优化频繁使用深拷贝占用内存的问题。

5.1 immutability-helper

JavaScript 中的对象一般是可变的(Mutable),如果使用了引用赋值,新的对象简单的引用了原始对象,改变新的对象将影响到原始对象。

const foo = {a:1}
const bar = foo
bar.a = 2
consle.log(foo) // foo: {a: 2}

如上代码发现foo.a也被改成了2,当应用复杂后,就造成了非常大的隐患。为了解决这个问题,一般的做法是使用浅拷贝或深拷贝来避免被修改,但这样做造成了 CPU 和内存的浪费。

Immutable Data可以很好地解决这些问题。

5.1.1 什么是Immutable Data

Immutable Data 就是一旦创建,就不能再被更改的数据。对 Immutable 对象的任何修改或添加删除操作都会返回一个新的 Immutable 对象。

Immutable 实现的原理是 Persistent Data Structure(持久化数据结构) ,也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免深拷贝把所有节点都复制一遍带来的性能损耗,Immutable 使用了 Structural Sharing(结构共享) ,即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。

图片

由于 Immutable 的 API 和用法学习起来比较困难,所以可以使用 immutability-helper 这个工具来对原生JS对象进行操作。

5.2 immutability-helper

immutability-helper用于处理 JavaScript 中的不可变(immutable)数据。

它的优势在于:

高效性:使用immutability-helper可以避免创建大量的新对象,提高应用程序的性能。
  • 易用性:由于immutability-helper提供了许多方便的API,开发者可以轻松地创建、修改和操作不可变状态。

  • 可维护性:通过使用immutability-helper,开发者可以避免手动更改状态,并编写更轻巧、易于维护的代码。

5.2.1使用

update()

update()提供简单的语法糖,使编写代码更容易

const myData = { 'x': { 'y': { 'z': 0 } }, 'a': { 'b': [2] } }
const newData = update(myData, {
  x: { y: { z: { $set: 1 } } },
  a: { b: { $push: [3] } },
})
// newData: { 'x': { 'y': { 'z': 1 } }, 'a': { 'b': [23] } }

set、push这种带$前缀的键称为命令,使用不同的命令可以操作数据结构

常用 API

  • {$push: array} 同数组的 push 方法,将参数 array 中的所有项 push 到目标数组中
  • {$unshift: array} 同数组的 unshift 方法,将参数 array 中的所有项 unshift 到目标数组中
  • {$splice: array of arrays} 同数组的 splice 方法,对于参数 arrays 中的每一项,使用该项提供的参数对目标数组调用 splice()
  • {$set: any} 使用 any 值替换目标
  • {$toggle: array of strings} 将参数 array 中提供的下标或者属性的值切换成相反的布尔值
  • {$unset: array of strings} 从目标对象中移除参数 array 中的键列表
  • {$merge: object} 将参数 object 的键与目标合并
  • {$apply: function} 将当前值传递给函数并用新的返回值更新它
  • {$add: array of objects} 向 Set 或 Map 中添加值。添加到 Set 时,参数 array 为要添加的对象数组,添加到 Map 时,参数 array 为 [key, value] 数组
  • {$remove: array of strings} 从 Set 或 Map 中移除参数 array 中的键列表

5.2.2 代码示例

响应ondrop事件时会在renderCard中重新渲染拖放源,调用moveCard方法对数据进行处理。在moveCard中,使用immutability-helper的$splice将拖拽元素与当前位置的元素位置互换,达到拖放的效果。

import update from 'immutability-helper'
... 
// 拖放后数据处理
  const moveCard = (dragIndex, hoverIndex) => {
    setCards(
      update(cards, {
        $splice: [
          [dragIndex, 1],
          [hoverIndex, 0, cards[dragIndex]],
        ],
      })
    )
  }

  // 渲染拖放源
  const renderCard = (card, index) => {
    return (
      <DragCard
        key={card.key}
        index={index}
        id={card.key}
        moveCard={moveCard}
        content={
          <div styleName='dragContent'>
            {/* @ts-ignore */}
            <Table dataSource={[card]} columns={columns} />
          </div>
        }
      />
    )
  }

总体来说,immutability-helper 是一个非常实用的工具库。在使用React DnD进行拖拽场景中,immutability-helper可以让开发者更方便地更新状态数据,避免手动深拷贝对象,在不改变原始状态对象的情况下,能够更轻松地根据条件执行一系列操作。

但是它的使用也有一定的成本,需要理解和运用不可变数据开发模式,对于传统的面向对象的开发方式可能会有一定的障碍。所以需要根据项目的实际情况来权衡它的使用。

六、总结

总的来说,拖拽技术已经成为现代Web开发中必不可少的一部分。无论是个人网站,还是商业项目,拖拽技术都有着广泛的应用场景。实现元素拖拽的方式有很多种,使用原生拖拽的方式会存在语法繁琐、需要设置很多属性和事件监听器、移动端支持不佳以及样式控制不灵活等缺点,因此我们通常会选择灵活度高、性能好、支持各种浏览器和设备并且高度支持定制化的开源库来实现拖拽功能。在实际项目开发中,可以针对我们的前端框架以及需求场景进行考量,来选择适合的开源库。

我们从一个拖拽需求出发,介绍了几种常用的拖拽解决方案和开源库,并对比了它们的优缺点和使用场景。此外,深入分析React DnD这个流行的React拖拽库的原理和实现,并给出一些拖拽性能优化方面的建议。相信大家已经对拖拽技术有了更深入的了解。

在开发类似拖拽这种较为繁琐的功能时,我们可以考虑引入一些优秀的开源库来帮助我们处理较为复杂的工作,同时理解其实现原理和运行机制来更好的发挥其作用。相信通过不断学习和实践,我们可以为用户提供更好的用户体验和更高效的交互方式。


wxg.JPG