咱们是袋鼠云数栈 UED 团队,致力于打造优良的一站式数据中台产品。咱们始终保持工匠精力,摸索前端路线,为社区积攒并流传教训价值。。

本文作者:霁明

一、背景

1、业务背景

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

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、总体架构

    次要代码代码目录构造

外围代码次要分三个局部:

  • dnd-core:外围逻辑,定义了拖拽接口、治理形式、数据流向
  • backend:形象进去的后端概念,次要解决DOM事件
  • react-dnd:封装React组件,提供api,相当于接入层

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

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拖拽事件:

一个简略拖拽操作过程,会顺次触发拖拽事件: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、次要拖拽过程

四、总结

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

应用可插入的形式引入backend,使拖拽的实现可扩大且更加灵便。

应用了单向数据流,在拖拽时不必解决中间状态,不必额定对DOM事件进行解决,只需专一于数据的变动。
React-DnD对backend的实现形式、数据的治理形式,以及整体的设计都值得借鉴。

五、参考链接:

  • https://github.com/react-dnd/react-dnd/
  • https://react-dnd.github.io/react-dnd
  • https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_Drag_and_Drop_API
  • https://zhuanlan.zhihu.com/p/429986799
  • https://juejin.cn/post/6885511137236877325

最初

欢送关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队继续为宽广开发者分享技术成绩,相继参加开源了欢送star

  • 大数据分布式任务调度零碎——Taier
  • 轻量级的 Web IDE UI 框架——Molecule
  • 针对大数据畛域的 SQL Parser 我的项目——dt-sql-parser
  • 袋鼠云数栈前端团队代码评审工程实际文档——code-review-practices
  • 一个速度更快、配置更灵便、应用更简略的模块打包器——ko