首页 > 其他分享 >深入解析React DnD拖拽原理,轻松掌握拖放技巧!

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

时间:2023-06-09 12:00:21浏览次数:59  
标签:DnD const monitor dnd React manager 拖拽 拖放 backend

我们是袋鼠云数栈 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<unknown, unknown>> = 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<unknown, unknown>) {
  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<State>, 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

标签:DnD,const,monitor,dnd,React,manager,拖拽,拖放,backend
From: https://www.cnblogs.com/dtux/p/17468866.html

相关文章

  • 【React工作记录九十五】前端小知识点扫盲笔记记录4
    前言我是歌谣最好的种树是十年前其次是现在微信公众号关注前端小歌谣带你进入氛围极好的前端巅峰人才交流群url参数解析成一个对象<!DOCTYPEhtml><htmllang="en"><head><metacharset="UTF-8"><metahttp-equiv="X-UA-Compatible"content="IE=edge">......
  • React - 02 React的工程化/组件化开发
    我们可以基于webpack自己去搭建一套工程化打包的架子,但是这样非常的麻烦/复杂;React官方,为我们提供了一个脚手架:create-react-app!!脚手架:基于它创建项目,默认就把webpack的打包规则已经处理好了,把一些项目需要的基本文件也都创建好了!!1.create-react-app基础运用安装脚手架$npmicrea......
  • react+antd面包屑导航
    根据以前写vue面包屑写的。import{useLocation,matchRoutes,}from"react-router-dom";constlocation=useLocation();importrouterfrom'./router'useEffect(()=>{//router全部路由constmatched=matchRoutes(rout......
  • Vue3 之 响应式 API reactive、 effect源码,详细注释
    Vue3之响应式APIreactive、effect源码,详细注释目录一.实现响应式API:reactive、shallowReactive、readonly、shallowReadonly1.针对不同的API创建不同的响应式对象2.实现createReactiveObject3.实现不同的拦截函数baseHandlers.ts二.实现响应式effect1.创建响应式的......
  • 如何在2023年开启React项目
    在这里,我想给你一个新的React项目入门的简要概述。我想反思一下优点和缺点,反思一下作为一个开发者所需要的技术水平,反思一下作为一个React开发者,每个启动项目都能为你提供哪些功能。最后,你将了解到针对不同需求的3种解决方案。免责声明:从个人开发者的角度来看,我完全支持React团队......
  • 如何在运行并调试React Native App
     1.进入工程目录,启动:Metro  npxreact-nativestart 2.然后在新的terminal窗口中运行app   npxreact-nativerun-ios第1,2步参考:https://reactnative.dev/docs/environment-setup#running-your-react-native-application这时你的程序应该运行起来了。每次操作可......
  • 如何设计React应用程序的样式——比较不同的选项
    样式在创建具有视觉吸引力和用户友好的Web应用程序方面起着至关重要的作用。对于React应用程序,可以通过多种方式来设置组件和UI元素的样式。在本文中,我们将探讨几个流行的选项,包括纯CSS、CSS模块、CSS预处理器、TailwindCSS、CSS-in-JS库(如StyledComponents)以及预构......
  • vite + react + arco-design-mobile 使用 babel-plugin-import 实现按需加载
    0.什么是vite?vite是一种新型前端构建工具。一个开发服务器,它基于原生ES模块提供丰富的内建功能一套构建指令,它使用Rollup打包你的代码,可输出用于生产环境的高度优化过的静态资源1.什么是babel?babel是一个javasctipt编译器,他是一个工具链,主要用于在当前浏览器和旧浏览器......
  • 二、Spring Reactive Security自定义登录页
    添加配置类:@ConfigurationpublicclassMyReactiveSecurityConfig{@BeanpublicReactiveUserDetailsServicereactiveUserDetailsService(){UserDetailsuser=User.withUsername("user").password("12345")......
  • 一、Spring Reactive Security简单使用
    SpringReactiveSecurity是结合SpringWebFlux使用的。结合SpringBoot使用,简化了大量配置。 新建SpringBoot项目,添加依赖:<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId>......