• 深入解析React DnD拖拽原理,轻松掌握拖放技巧!


    我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。。

    本文作者:霁明

    一、背景

    1、业务背景

    业务中会有一些需要实现拖拽的场景,尤其是偏视觉方向以及移动端较多。拖拽在一定程度上能让交互更加便捷,能大大提升用户体验。以业务中心子产品配置功能为例,产品模块通过拖拽来调整顺序,的确会更加方便一些。

    file

    2、React DnD 介绍

    引用官网介绍:
    React DnD 是一组 React 实用程序,可帮助您构建复杂的拖放界面,同时保持组件分离。 它非常适合 Trello 和 Storify 等应用程序,在应用程序的不同部分之间拖动可以传输数据,组件会根据拖放事件更改其外观和应用程序状态。
    React-DnD 特点:

    • 使用包裹及注入的方式使组件实现拖拽
    • 可用于构建复杂的拖放界面,同时保持组件分离
    • 采用单向数据流
    • 抹平了不同浏览器平台的差异
    • 可扩展可测试
    • 支持触屏操作

    二、使用方式

    1、安装

    安装 react-dnd, react-dnd-html5-backend

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

    2、DndProvider

    将需要拖拽的组件使用DndProvider进行包裹

    import { DndProvider } from 'react-dnd';
    import { HTML5Backend } from 'react-dnd-html5-backend';
    import Container from '../components/container';
    
    export default function App() {
      return (
        <DndProvider backend={HTML5Backend}>
          <Container />
        DndProvider>
      );
    }
    
    

    看下Container组件,主要是管理数据,并渲染Card列表

    function Container() {
      // ...
      return (
        <div style={{ width: 400 }}>
          {cards.map((card, index) => (
            <Card
              key={card.id}
              index={index}
              id={card.id}
              text={card.text}
              moveCard={moveCard}
            />
          ))}
        div>
      );
    }
    

    3、useDrag和useDrop

    接下来看下Card组件,

    import { useRef } from 'react';
    import { useDrag, useDrop } from 'react-dnd';
    import styles from '../styles/home.module.css';
    
    function Card({ id, text, index, moveCard }: ICardProps) {
      const ref = useRef<HTMLDivElement>(null);
    
      const [{ handlerId }, drop] = useDrop({
        accept: CARD,
        collect(monitor) {
          return {
            handlerId: monitor.getHandlerId(),
          };
        },
        hover(item: IDragItem, monitor) {
          if (!ref.current) {
            return;
          }
          const dragIndex = item.index;
          const hoverIndex = index;
          // ...
          // 更新元素的位置
          moveCard(dragIndex, hoverIndex);
          // ...
        },
      });
    
      const [{ isDragging }, drag] = useDrag({
        type: CARD,
        item: { id, index },
        collect: (monitor: any) => ({
          isDragging: monitor.isDragging(),
        }),
      });
    
      drag(drop(ref));
      const opacity = isDragging ? 0 : 1;
    
      return (
        <div
          ref={ref}
          className={styles.card}
          style={{ opacity }}
          data-handler-id={handlerId}
        >
          {text}
        div>
      );
    }
    

    至此一个简单的拖拽排序列表就实现了,实现的效果类似于React DnD官网的这个示例:https://react-dnd.github.io/react-dnd/examples/sortable/simple,接下来我们来看看实现原理。

    三、原理解析

    1、总体架构

    主要代码代码目录结构

    file

    核心代码主要分三个部分:

    • dnd-core:核心逻辑,定义了拖拽接口、管理方式、数据流向
    • backend:抽象出来的后端概念,主要处理DOM事件
    • react-dnd:封装React组件,提供api,相当于接入层

    核心实现原理:
    dnd-core向backend提供数据的更新方法,backend在拖拽时更新dnd-core中的数据,dnd-core通过react-dnd更新业务组件。

    file

    2、DndProvider

    先看一下源码

    /**
     * A React component that provides the React-DnD context
     */
    export const DndProvider: FC<DndProviderProps> = memo(
      function DndProvider({ children, ...props }) {
        const [manager, isGlobalInstance] = getDndContextValue(props) // memoized from props
        // ...
        return <DndContext.Provider value={manager}>{children}DndContext.Provider>
      },
    )
    

    从以上代码可以看出,生成了一个manager,并将其放到DndContext.Provider中。先看下DndContext的代码:

    import { createContext } from 'react'
    // ...
    export const DndContext = createContext<DndContextType>({
      dragDropManager: undefined,
    })
    

    就是使用 React 的createContext创建的上下文容器组件。

    接下来看下这个manager,主要是用来控制拖拽行为,通过Provider让子节点也可以访问。我们看下创建manager的getDndContextValue方法:

    import type { BackendFactory, DragDropManager } from 'dnd-core'
    import { createDragDropManager } from 'dnd-core'
    // ...
    function getDndContextValue(props: DndProviderProps) {
      if ('manager' in props) {
         const manager = { dragDropManager: props.manager }
         return [manager, false]
      }
    
       const manager = createSingletonDndContext(
         props.backend,
         props.context,
         props.options,
         props.debugMode,
        )
       const isGlobalInstance = !props.context
    
       return [manager, isGlobalInstance]
    }
    
    function createSingletonDndContext<BackendContext, BackendOptions>(
       backend: BackendFactory,
       context: BackendContext = getGlobalContext(),
       options: BackendOptions,
       debugMode?: boolean,
    ) {
       const ctx = context as any
       if (!ctx[INSTANCE_SYM]) {
         ctx[INSTANCE_SYM] = {
           dragDropManager: createDragDropManager(
            backend,
    	context,
    	options,
    	debugMode,
           ),
         }
       }
       return ctx[INSTANCE_SYM]
    }
    

    从以上代码可以看出,getDndContextValue方法又调用了createSingletonDndContext方法,并传入了backend、context、options、debugMode这几个属性,然后通过dnd-core中的createDragDropManager来创建manager。

    3、DragDropManager

    看下createDragDropManager.js中的主要代码

    import type { Store } from 'redux'
    import { createStore } from 'redux'
    // ...
    import { reduce } from './reducers/index.js'
    
    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
    }
    
    function makeStoreInstance(debugMode: boolean): Store<State> {
      // ...
      return createStore(
        reduce,
        debugMode &&
        reduxDevTools &&
        reduxDevTools({
          name: 'dnd-core',
          instanceId: 'dnd-core',
        }),
      )
    }
    
    

    可以看到使用了redux的createStore创建了store,并创建了monitor和manager实例,通过backendFactory创建backend后端实例并安装到manager总实例。

    看一下DragDropManagerImpl的主要代码

    export class DragDropManagerImpl implements DragDropManager {
      private store: Store<State>
      private monitor: DragDropMonitor
      private backend: Backend | undefined
      private isSetUp = false
    
      public constructor(store: Store, monitor: DragDropMonitor) {
        this.store = store
        this.monitor = monitor
        store.subscribe(this.handleRefCountChange)
       }
    
       // ...
    
      public getActions(): DragDropActions {
      /* eslint-disable-next-line @typescript-eslint/no-this-alias */
        const manager = this
        const { dispatch } = this.store
    
        function bindActionCreator(actionCreator: ActionCreator<any>) {
          return (...args: any[]) => {
            const action = actionCreator.apply(manager, args as any)
            if (typeof action !== 'undefined') {
              dispatch(action)
            }
          }
      }
    
      const actions = createDragDropActions(this)
    
      return Object.keys(actions).reduce(
        (boundActions: DragDropActions, key: string) => {
          const action: ActionCreator<any> = (actions as any)[
            key
          ] as ActionCreator<any>
          ;(boundActions as any)[key] = bindActionCreator(action)
            return boundActions
          },
          {} as DragDropActions,
        )
      }
    
      public dispatch(action: Action<any>): void {
        this.store.dispatch(action)
      }
    
      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
          }
        }
      }
    }
    

    先说一下这个handleRefCountChange方法,在构造函数里通过store进行订阅,在第一次使用useDrop或useDrag时会执行setup方法初始化backend,在拖拽源和放置源都被卸载时则会执行teardown销毁backend。

    接下来看一下createDragDropActions方法

    export function createDragDropActions(
      manager: DragDropManager,
    ): DragDropActions {
      return {
        beginDrag: createBeginDrag(manager),
        publishDragSource: createPublishDragSource(manager),
        hover: createHover(manager),
        drop: createDrop(manager),
        endDrag: createEndDrag(manager),
      }
    }
    
    

    可以看到绑定一些action:

    • beginDrag(开始拖动)
    • publishDragSource(发布当前拖动源)
    • hover(是否经过)
    • drop(落下动作)
    • endDrag(拖拽结束)

    manager包含了之前生成的 monitor、store、backend,manager 创建完成,表示此时我们有了一个 store 来管理拖拽中的数据,有了 monitor 来监听数据和控制行为,能通过 manager 进行注册,可以通过 backend 将 DOM 事件转换为 action。接下来便可以注册拖拽源和放置源了。

    4、useDrag

    /**
     * useDragSource hook
     * @param sourceSpec The drag source specification (object or function, function preferred)
     * @param deps The memoization deps array to use when evaluating spec changes
     */
    export function useDrag<
      DragObject = unknown,
      DropResult = unknown,
      CollectedProps = unknown,
    >(
      specArg: FactoryOrInstance<
        DragSourceHookSpec<DragObject, DropResult, CollectedProps>
      >,
      deps?: unknown[],
    ): [CollectedProps, ConnectDragSource, ConnectDragPreview] {
      const spec = useOptionalFactory(specArg, deps)
      invariant(
        !(spec as any).begin,
        'useDrag::spec.begin was deprecated in v14. Replace spec.begin() with spec.item(). (see more here - https://react-dnd.github.io/react-dnd/docs/api/use-drag)',
      )
    
      const monitor = useDragSourceMonitor<DragObject, DropResult>()
      const connector = useDragSourceConnector(spec.options, spec.previewOptions)
      useRegisteredDragSource(spec, monitor, connector)
    
      return [
        useCollectedProps(spec.collect, monitor, connector),
        useConnectDragSource(connector),
        useConnectDragPreview(connector),
      ]
    }
    

    可以看到useDrag方法返回了一个包含3个元素的数组,CollectedProps(collect方法返回的对象)、ConnectDragSource(拖拽源连接器)、ConnectDragPreview(拖拽源预览)。

    monitor是从前面Provider中的manager中获取的,主要看下connector

    export function useDragSourceConnector(
      dragSourceOptions: DragSourceOptions | undefined,
      dragPreviewOptions: DragPreviewOptions | undefined,
    ): SourceConnector {
      const manager = useDragDropManager()
      const connector = useMemo(
        () => new SourceConnector(manager.getBackend()),
        [manager],
      )
      // ...
      return connector
    }
    

    可以看到connector获取了manager.getBackend后端的数据。

    useRegisteredDragSource方法会对拖动源进行注册,会保存拖动源实例,并记录注册的数量。

    5、useDrop

    看下useDrop源码

    /**
     * useDropTarget Hook
     * @param spec The drop target specification (object or function, function preferred)
     * @param deps The memoization deps array to use when evaluating spec changes
     */
    export function useDrop<
      DragObject = unknown,
      DropResult = unknown,
      CollectedProps = unknown,
    >(
      specArg: FactoryOrInstance<
        DropTargetHookSpec<DragObject, DropResult, CollectedProps>
      >,
      deps?: unknown[],
    ): [CollectedProps, ConnectDropTarget] {
      const spec = useOptionalFactory(specArg, deps)
      const monitor = useDropTargetMonitor<DragObject, DropResult>()
      const connector = useDropTargetConnector(spec.options)
      useRegisteredDropTarget(spec, monitor, connector)
    
      return [
        useCollectedProps(spec.collect, monitor, connector),
        useConnectDropTarget(connector),
      ]
    }
    
    

    useDrop返回了一个包含2个元素的数组,CollectedProps(collect方法返回的对象), ConnectDropTarget(放置源连接器),monitor和connector的获取都和useDrag类似。

    6、HTML5Backend

    HTML5Backend使用了HTML5 拖放 API,先了解下HTML拖拽事件:

    file

    一个简单拖拽操作过程,会依次触发拖拽事件:dragstart -> drag -> dragenter -> dragover (-> dragleave) -> drop -> dragend。

    drag事件会在dragstar触发后持续触发,直至drop。

    dragleave事件会在拖拽元素离开一个可释放目标时触发。

    接下来介绍一下HTML5Backend,是React DnD 主要支持的后端,使用HTML5 拖放 API,它会截取拖动的 DOM 节点并将其用作开箱即用的“拖动预览”。React DnD 中以可插入的方式实现 HTML5 拖放支持,可以根据触摸事件、鼠标事件或其他完全不同的事件编写不同的实现,这种可插入的实现在 React DnD 中称为后端。官网提供了HTML5Backend和TouchBackend,分别用来支持web端和移动端。

    后端担任与 React 的合成事件系统类似的角色:它们抽象出浏览器差异并处理原生DOM 事件。尽管有相似之处,但 React DnD 后端并不依赖于 React 或其合成事件系统。在后台,后端所做的就是将 DOM 事件转换为 React DnD 可以处理的内部 Redux 操作。

    前面给DndProvider传递的HTML5backend,看一下其代码实现:

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

    可以看到其实是个返回HTML5BackendImpl实例的函数,在创建manager实例时会执行createBackend方法创建真正的backend。

    如下是 Backend 需要被实现的方法:

    export interface Backend {
      setup(): void
      teardown(): void
      connectDragSource(sourceId: any, node?: any, options?: any): Unsubscribe
      connectDragPreview(sourceId: any, node?: any, options?: any): Unsubscribe
      connectDropTarget(targetId: any, node?: any, options?: any): Unsubscribe
      profile(): Record<string, number>
    }
    

    setup 是 backend 的初始化方法,teardown 是 backend 销毁方法。connectDragSource方法将元素转换为可拖拽元素,并添加监听事件。connectDropTarget方法会给元素添加监听事件,connectDragPreview方法会将preview元素保存以供监听函数使用,profile方法用于返回一些简要的统计信息。

    以上这几个方法都在HTML5BackendImpl中,我们先看一下setup方法:

    public setup(): void {
      const root = this.rootElement as RootNode | undefined
      if (root === undefined) {
        return
      }
    
      if (root.__isReactDndBackendSetUp) {
        throw new Error('Cannot have two HTML5 backends at the same time.')
      }
      root.__isReactDndBackendSetUp = true
      this.addEventListeners(root)
    }
    

    root默认是windows,通过addEventListeners方法把监听事件都绑定到windows上,这提高了性能也降低了事件销毁的难度。

    看下addEventListeners方法:

    private addEventListeners(target: Node) {
      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,
      )
    }
    
    

    以上代码中监听了一些拖拽事件,这些监听函数会获得拖拽事件的对象、拿到相应的参数,并执行相应的action方法。HTML5Backend 通过 manager 拿到一个 DragDropActions 的实例,执行其中的方法。DragDropActions 本质就是根据参数将其封装为一个 action,最终通过 redux 的 dispatch 将 action 分发,改变 store 中的数据。

    export interface DragDropActions {
      beginDrag(
        sourceIds?: Identifier[],
        options?: any,
      ): Action<BeginDragPayload> | undefined
        publishDragSource(): SentinelAction | undefined
        hover(targetIds: Identifier[], options?: any): Action<HoverPayload>
        drop(options?: any): void
        endDrag(): SentinelAction
    }
    

    最后我们再看下connectDragSource方法:

    public connectDragSource(
      sourceId: string,
      node: Element,
      options: any,
    ): Unsubscribe {
      // ...
      node.setAttribute('draggable', 'true')
      node.addEventListener('dragstart', handleDragStart)
      node.addEventListener('selectstart', handleSelectStart)
    
      return (): void => {
        // ...
        node.removeEventListener('dragstart', handleDragStart)
        node.removeEventListener('selectstart', handleSelectStart)
        node.setAttribute('draggable', 'false')
      }
    }
    

    可以看到主要是把节点的draggable属性设置为true,并添加监听事件,返回一个Unsubscribe函数用于执行销毁。

    综上,HTML5Backend 在初始化的时候在 window 对象上绑定拖拽事件的监听函数,拖拽事件触发时执行对应action,更新 store 中的数据,完成由 Dom 事件到数据的转变。

    7、TouchBackend

    HTML5 后端不支持触摸事件,因此它不适用于平板电脑和移动设备。可以使用react-dnd-touch-backend来支持触摸设备,简单看下ToucheBackend。

    ToucheBackend主要是为了支持移动端,也支持web端,在web端可以使用 mousedown、mousemove、mouseup,在移动端则使用 touchstart、touchmove、touchend,下面是ToucheBackend中对事件的定义:

    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',
      },
    }
    

    8、主要拖拽过程

    file

    四、总结

    React-DnD 采用了分层设计,react-dnd充当接入层,dnd-core实现拖拽接口、定义拖拽行为、管理数据流向,backend将DOM事件通过redux action转换为数据。

    使用可插入的方式引入backend,使拖拽的实现可扩展且更加灵活。

    使用了单向数据流,在拖拽时不用处理中间状态,不用额外对DOM事件进行处理,只需专注于数据的变化。
    React-DnD对backend的实现方式、数据的管理方式,以及整体的设计都值得借鉴。

    五、参考链接:


    最后

    欢迎关注【袋鼠云数栈UED团队】~
    袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star

  • 相关阅读:
    在DevExpress的GridView的列中,动态创建列的时候,绑定不同的编辑处理控件
    归并排序——
    太赞了,300+图解Pandas,超级用心的教程!
    RDMA概览
    C++ 五大链表排序(冒泡、插入、选择、归并、快排)
    QT软件开发-基于FFMPEG设计录屏与rtsp、rtmp推流软件(支持桌面与摄像头)(一)
    从今天起,换一种轻松有趣的方式学习计算机底层技术!
    大厂产品为何集体下架
    WordPress实时搜索插件Ajax Search Lite,轻松替代默认搜索功能
    centos jenkins 无法启动 active (exited)
  • 原文地址:https://www.cnblogs.com/dtux/p/17468866.html