React DnD如何处理拖拽详解?

正文

React DnD 是一个专注于数据变更的 React 拖拽库,通俗的将,你拖拽改变的不是页面视图,而是数据。React DnD 不提供炫酷的拖动体验,而是通过帮助我们管理拖拽中的数据变化,再由我们根据这些数据进行渲染。我们可能需要写额外的视图层来完成想要的效果,但是这种拖拽管理方式非常的通用,可以在任何场景下使用。初次使用可能感觉并不是那么方便,但是如果场景比较复杂,或者是需要高度定制,React DnD 一定是首选。

React DnD 的使用说明可以参见官方文档。本文分析 React DnD 的源码,更深层次的了解这个库。以下的代码来源于 react-dnd 14.0.4。

代码结构

React-DnD 是单个代码仓库,但是打了多个包。这种方式也表示了 React DnD 的三层结构。

___________     ___________     _______________
|           |   |           |   |               | 
|           |   |           |   | backend-html  |
| react-dnd |   |  dnd-core |   |               |  
|           |   |           |   | backend-touch |
|___________|   |___________|   |_______________|

react-dnd 是 React 版本的 Drag and Drop 的实现。它定义了 DragSource, DropTarget, DragDropContext 等高阶组件,以及 useDrag,useDrop 等 hook。我们可以简单的理解为这是一个接入层。

dnd-core 是整个拖拽库的核心,它实现了一个和框架无关的拖放管理器,定义了拖放的交互,根据 dnd-core 中定义的规则,我们完全可以根据它自己实现一个 vue-dnd。dnd-core 中使用 redux 做状态管理。

backend 是 React DnD 抽象了后端的概念,这里是 DOM 事件转换为 redux action 的地方。如果是 H5 应用,backend-html,如果是移动端,使用 backend-touch。也支持用户自定义。

DndProvider

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

import { HTML5Backend } from 'react-dnd-html5-backend';
import { DndProvider } from 'react-dnd';
<DndProvider backend={HTML5Backend}>
    <TutorialApp />
</DndProvider>

DndProvider 的本质是一个由 React.createContext 创建一个上下文的容器(组件),用于控制拖拽的行为,数据的共享。DndProvider 的入参是一个 Backend。Backend 是什么呢?React DnD 将 DOM 事件相关的代码独立出来,将拖拽事件转换为 React DnD 内部的 redux action。由于拖拽发生在 H5 的时候是 ondrag,发生在移动设备的时候是由 touch 模拟,React DnD 将这部分单独抽出来,方便后续的扩展,这部分就叫做 Backend。它是 DnD 在 Dom 层的实现。

以下是 DndProvider 的核心代码,通过入参生成一个 manager,这个 manager 用于控制拖拽行为。这个 manager 放到 Provider 中,子节点都可以访问这个 manager。

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

DragDropManager

DndProvider 将 DndProvider 放到了 context 中,这个 manager 非常关键,后续的拖动都依赖于 manager,如下是它的创建过程。

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 的创建过程,manager 中 store 的创建使用了 redux 的 createStore 方法,store 是用来以存放应用中所有的 state 的。它的第一个参数 reducer 接收两个参数,分别是当前的 state 树和要处理的 action,返回新的 state 树。

function makeStoreInstance(): Store<State> {
    return createStore(reduce)
}

manager 中的 store 管理着如下 state,每个 state 都有对应的方法进行更新。

export interface State {
    dirtyHandlerIds: DirtyHandlerIdsState
    dragOffset: DragOffsetState
    refCount: RefCountState
    dragOperation: DragOperationState
    stateId: StateIdState
}

标准的 redux 更新数据的方法是 dispatch action 的方式。如下是 dragOffset 更新方法,判断当前 action 的类型,从 payload 中获得需要的参数,然后返回新的 state。

export function reduce(
    state: State = initialState,
    action: Action&lt;{
        sourceClientOffset: XYCoord
        clientOffset: XYCoord
    }&gt;,
): 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:
      ...
        case END_DRAG:
        case DROP:
            return initialState
        default:
            return state
    }
}

接下来看 monitor,已知 store 表示的是拖拽过程中的数据,那么我们可以根据这些数据计算出当前的一些状态,比如某个物体是否可以被拖动,某个物体是否正在悬空等等。monitor 提供了一些方法来访问这些数据,不仅如此,monitor 最大的作用是用来监听这些数据的,我们可以为 monitor 添加一些监听器,这样在数据变动之后就能及时响应。

如下列出了一些 monitor 中的方法。

export interface DragDropMonitor {
    subscribeToStateChange(
        listener: Listener,
        options?: {
            handlerIds: Identifier[] | undefined
        },
    ): Unsubscribe
    subscribeToOffsetChange(listener: Listener): Unsubscribe
    canDragSource(sourceId: Identifier | undefined): boolean
    canDropOnTarget(targetId: Identifier | undefined): boolean
    isDragging(): boolean
    isDraggingSource(sourceId: Identifier | undefined): boolean
    getItemType(): Identifier | null
    getItem(): any
    getSourceId(): Identifier | null
    getTargetIds(): Identifier[]
    getDropResult(): any
    didDrop(): boolean
  ...
}

subscribeToStateChange 就是添加监听函数的方法,其原理是使用了 redux 的 subscribe 方法。

public subscribeToStateChange(
        listener: Listener,
        options: { handlerIds: string[] | undefined } = { handlerIds: undefined },
    ): Unsubscribe {
  ...
  return this.store.subscribe(handleChange)
}

要注意的是,DragDropMonitor 是一个全局的 monitor,它监听的范围是 DndProvider 下所有可拖拽的元素,也就是 monitor 中会存在多个对象,这些拖拽对象有全局唯一性的 ID 标识(从 0 自增的 ID)。这也是 monitor 中的发部分方法都需要传一个 Identifier 的原因。还有一点就是,最好不要存在多个 DndProvider,除非你确定不同 DndProvider 下拖拽元素一定不会交互。

我们在 DndProvider 传入了一个参数 backend,其实它是个工厂方法,执行之后会生成真正的 backend。

manager 比较简单,它包含了之前生成的 monitor, store, 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
            }
        }
    }
}

manager 创建完成,表示此时我们有了一个 store 来管理拖拽中的数据,有了 monitor 来监听数据和控制行为,能通过 manager 进行注册,可以通过 backend 将 Dom 事件转换为 action。接下来就能使用 useDrag 来创建一个真正的可拖拽对象了。

useDrag

一个元素想要被拖拽,Hooks 的写法如下,使用 useDrag 实现。useDrag 的入参和返回值可以参考官方文档,这里不加赘述。

import { DragPreviewImage, useDrag } from 'react-dnd';
export const Knight: FC = () => {
    const [{ isDragging }, drag, preview] = useDrag(
        () => ({
            type: ItemTypes.KNIGHT,
            collect: (monitor) => ({
                isDragging: !!monitor.isDragging()
            })
        }),
        []
    );
    return (
        <>
            <DragPreviewImage connect={preview} src={knightImage} />
            <div
                ref={drag}
            >
                ♘
            </div>
        </>
    );
};

在 使用 useDrag 的时候,我们配置了入参,是一个函数,这个函数的返回值就是配置参数,useOptionalFactory 就是使用 useMemo 将这个方法包了一层,避免重复调用。

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),
    ]
}

原先在 manager 中的 monitor 类型是 DragDropMonitor,看名字就知道,该 monitor 中的方法是结合了 Drag 和 Drop 两种行为的,目前只是使用 Drag,因此将 monitor 包装一下,屏蔽 Drop 的行为。使其类型变为 DragSourceMonitor。 这就是 useDragSourceMonitor 做的事情,

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

以上,我们有 Backend 控制 Dom 层级的行为,Store 和 Monitor 控制数据层的变化,那如何让 Monitor 知道现在要监听到底是哪个节点,还需要将这两者连接起来,才能真正的让 Dom 层和数据层保持一致,React DnD 中使用 connector 来连接着两者。

useDragSourceConnector 方法中会 new 一个 SourceConnector 的实例,该实例会接受 backend 作为入参,SourceConnector 实现了 Connector 接口。Connector 中成员变量不多,最重要就是 hooks 对象,该对象用于处理 ref 的逻辑。

export interface Connector {
    // 获得 ref 指向的 Dom
    hooks: any
    // 获得 dragSource
    connectTarget: any
    // dragSource 唯一 Id
    receiveHandlerId(handlerId: Identifier | null): void
    // 重新连接 dragSource 和 dom
    reconnect(): void
}

我们在例子中将 ref 属性给到了一个 useDrag 的返回值。该返回值其实就是 hooks 中的 dragSource 方法。

export function useConnectDragSource(connector: SourceConnector) {
    return useMemo(() =&gt; connector.hooks.dragSource(), [connector])
}

从 dragSource 方法可以看出,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 方法为该节点添加事件监听,后续会分析 backend。

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

现在还需要对 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],
    )
}

DragSource 实现以下几个方法,这个几个方法我们使用 useDarg 的时候可以配置同名函数,这些配置的方法会被以下方法调用。

export interface DragSource {
    beginDrag(monitor: DragDropMonitor, targetId: Identifier): void
    endDrag(monitor: DragDropMonitor, targetId: Identifier): void
    canDrag(monitor: DragDropMonitor, targetId: Identifier): boolean
    isDragging(monitor: DragDropMonitor, targetId: Identifier): boolean
}

总结下 useDarg 做的事情,首先就是支持一些配置参数,这是最基础的,然后获得 Provider 中的 managre,对其中的一些对象进行包装,屏蔽一些方法,增加一些参数。最重要的就是创建 connector,在界面加载完毕后,connector 通过 ref 的方式获得 Dom 节点的实例,为该节点添加拖拽属性和拖拽事件。同时根据配置参数和 connector 封装 DragSource 对象,将其注册到 monitor 中。

useDrop 和 useDrag 的流程大同小异,大家可以自己看。

HTML5Backend

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

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

如下是 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 销毁方法。上文提到过,setup 和 teardown 是在 handleRefCountChange 中执行的。React DnD 会在我们第一个使用 useDrag 或是 useDrop 的时候,执行 setup 方法,而在它检测到没有任何地方在使用拖拽功能的时候,执行 teardown 方法。

HTML5BackendImpl 的 setup 方法中执行如下方法,target 默认状态下指的是 window。这里监听了所有的拖拽事件。这是典型的事件委托的方式,统一将拖拽事件的回调函数都绑定在 window 上,不仅能提高性能,而且极大的降低了事件销毁的难度。

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, true)
    target.addEventListener('drop', this.handleTopDrop as EventListener)
    target.addEventListener(
        'drop',
        this.handleTopDropCapture as EventListener,
        true,
    )
}

HTML5Backend 拖拽的监听函数就是获得拖拽事件的对象,拿到相应的参数。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 方法。该方法用于将某个 Node 节点转换为可拖拽节点,并且添加监听事件。

HTML5Backend 使用 HTML5 拖放 API 实现。 首先:为了把一个元素设置为可拖放,把 draggable 属性设置为 true。然后监听 ondragstart 事件,该事件在用户开始拖动元素时触发。至于 selectstart,不用关心,是用来处理一些 IE 特殊情况的。

public connectDragSource(
    sourceId: string,
    node: Element,
    options: any,
): Unsubscribe {
    ...
    // 设置 draggable 属性
    node.setAttribute('draggable', 'true')
    // 添加 dragstart 监听
    node.addEventListener('dragstart', handleDragStart)
        // 添加 selectstart 监听
    node.addEventListener('selectstart', handleSelectStart)
    ...
}

Node 上绑定的 dragstart 事件很简单,就是更新了下 sourceId。负责的逻辑绑定在了 window 上。

public handleDragStart(e: DragEvent, sourceId: string): void {
    if (e.defaultPrevented) {
        return
    }
    if (!this.dragStartSourceIds) {
        this.dragStartSourceIds = []
    }
    this.dragStartSourceIds.unshift(sourceId)
}

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

TouchBackend

最后简单的看下 TouchBackend,与 HTML5Backend 相比,TouchBackend 的使用场景更加广泛,因为它不依赖于 H5 的 API,兼容性很好,既能用于浏览器端,又能用在移动端。

TouchBackend 使用简单的事件来模拟拖放行为。比如在浏览器端,使用的是 mousedown,mousemove,mouseup。移动端使用 touchstart,touchmove,touchend。

const eventNames: Record&lt;ListenerType, EventName&gt; = {
    [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 使用了分层设计的方式,react-dnd 是接入层,它为准备了高阶组件和 Hooks 两种方式。dnd-core 是核心,它定义了拖拽接口,管理方式,数据流向。backend 中将 DOM 事件转换为 redux action 的地方,该层用于屏蔽设备之间的差异性。

dnd-core 使用了 redux 管理数据,这些数据通过 dispatch action 进行修改,使用 monitor 进行数据的监控,使用 connector 连接 dom 和 store。最终拖拽实现依赖于 backend,为节点添加了监听事件,然后将事件转化为 action。

整体上看,React-DnD 的核心思路就是将事件转换为数据,设计上参考了 redux 的单一数据流的方式(毕竟一个作者写的),这样我们在处理拖拽的时候就可以关注于数据方面的变化,而不用费心去维护拖拽中的一些中间状态,更不用自己去添加,移除事件,是非常好的一种设计。

以上就是React DnD如何处理拖拽详解的详细内容,更多关于React DnD 拖拽处理的资料请关注其它相关文章!

原文地址:https://zhuanlan.zhihu.com/p/429986799