React DnD 拖放库浅析

今天与你分享的是 redux 作者 Dan 的另外一个很赞的项目 react-dnd (github 9.6k star),dnd 是 Drag and Drop 的意思,为什么他会开发 react-dnd 这个项目,这个拖放库解决了什么问题,和 html5 原生 Drag Drop API 有什么样的联系与不同,设计有什么独特之处?让我们带着这些问题一起来了解一下 React DnD 吧。

React DnD 是什么?

React DnD是React和Redux核心作者 Dan Abramov创造的一组React 高阶组件,可以在保持组件分离的前提下帮助构建复杂的拖放接口。它非常适合Trello 之类的应用程序,其中拖动在应用程序的不同部分之间传输数据,并且组件会根据拖放事件更改其外观和应用程序状态。

  React DnD 拖放库浅析 image

React DnD 的出发点

现有拖放插件的问题

  • jquery 插件思维模式,直接改变DOM

  • 拖放状态改变的影响不仅限于 CSS 类这种改变,不支持更加自定义

HTML5 拖放API的问题

  • 不支持移动端

  • 拖动预览问题

  • 无法开箱即用

React DnD 的需求

  • 默认使用 HTML5 拖放API,但支持

  • 不直接操作 DOM

  • DOM 和拖放的源和目标解耦

  • 融入HTML5拖放中窃取类型匹配和数据传递的想法

React DnD 的特点

专注拖拽,不提供现成组件

React DnD提供了一组强大的原语,但它不包含任何现成组件,而是采用包裹使用者的组件并注入 props 的方式。 它比jQuery UI等更底层,专注于使拖放交互正确,而把视觉方面的效果例如坐标限制交给使用者处理。这其实是一种关注点分离的原则,例如React DnD不打算提供可排序组件,但是使用者可以基于它快速开发任何需要的自定义的可排序组件。

单向数据流

类似于 React 一样采取声明式渲染,并且像 redux 一样采用单向数据流架构,实际上内部使用了 Redux

隐藏了平台底层API的问题

HTML5拖放API充满了陷阱和浏览器的不一致。 React DnD为您内部处理它们,因此使用者可以专注于开发应用程序而不是解决浏览器问题。

可扩展可测试

React DnD默认提供了HTML5拖放API封装,但它也允许您提供自定义的“后端(backend)”。您可以根据触摸事件,鼠标事件或其他内容创建自定义DnD后端。例如,内置的模拟后端允许您测试Node环境中组件的拖放交互。

为未来做好了准备

React DnD不会导出mixins,并且对任何组件同样有效,无论它们是使用ES6类,createReactClass还是其他React框架创建的。而且API支持了ES7 装饰器。

React DnD 的基本用法

下面是让一个现有的Card组件改造成可以拖动的代码示例:

// Let's make <Card text='Write the docs' /> draggable!
import React, { Component } from 'react';import PropTypes from 'prop-types';import { DragSource } from 'react-dnd';import { ItemTypes } from './Constants';
/** * Implements the drag source contract. */const cardSource = {  beginDrag(props) {    return {      text: props.text    };  }};
/** * Specifies the props to inject into your component. */function collect(connect, monitor) {  return {    connectDragSource: connect.dragSource(),    isDragging: monitor.isDragging()  };}
const propTypes = {  text: PropTypes.string.isRequired,
  // Injected by React DnD:  isDragging: PropTypes.bool.isRequired,  connectDragSource: PropTypes.func.isRequired};
class Card extends Component {  render() {    const { isDragging, connectDragSource, text } = this.props;    return connectDragSource(      <div style={{ opacity: isDragging ? 0.5 : 1 }}>        {text}      </div>    );  }}
Card.propTypes = propTypes;
// Export the wrapped component:export default DragSource(ItemTypes.CARD, cardSource, collect)(Card);

可以看出通过 DragSource 函数可以生成一个高阶组件,包裹 Card 组件之后就可以实现可以拖动。Card组件可以通过 props 获取到 text, isDragging, connectDragSource 这些被 React DnD 注入的 prop,可以根据拖拽状态来自行处理如何显示。

那么 DragSource, connectDragSource, collect, cardSource 这些都是什么呢?下面将会介绍React DnD 的基本概念。

React DnD 的基本概念

Backend

React DnD 抽象了后端的概念,你可以使用 HTML5 拖拽后端,也可以自定义 touch、mouse 事件模拟的后端实现,后端主要用来抹平浏览器差异,处理 DOM 事件,同时把 DOM 事件转换为 React DnD 内部的 redux action。

Item

React DnD 基于数据驱动,当拖放发生时,它用一个数据对象来描述当前的元素,比如{ cardId: 25 }

Type

类型类似于 redux 里面的actions types 枚举常量,定义了应用程序里支持的拖拽类型。

Monitor

拖放操作都是有状态的,React DnD 通过 Monitor 来存储这些状态并且提供查询

Connector

Backend 关注 DOM 事件,组件关注拖放状态,connector 可以连接组件和 Backend ,可以让 Backend 获取到 DOM。

DragSource

将组件使用 DragSource 包裹让它变得可以拖动,DragSource 是一个高阶组件:

DragSource(type, spec, collect)(Component)
  • **type**: 只有 DragSource 注册的类型和DropTarget 注册的类型完全匹配时才可以drop

  • **spec**: 描述DragSource 如何对拖放事件作出反应

    • **beginDrag(props, monitor, component)** 开始拖拽事件

    • **endDrag(props, monitor, component)** 结束拖拽事件

    • **canDrag(props, monitor)** 重载是否可以拖拽的方法

    • **isDragging(props, monitor)** 可以重载是否正在拖拽的方法

  • **collect**: 类似一个map函数用最终inject给组件的对象,这样可以让组件根据当前的状态来处理如何展示,类似于 redux connector 里面的 mapStateToProps ,每个函数都会接收到 connectmonitor 两个参数,connect 是用来和 DnD 后端联系的, monitor是用来查询拖拽状态信息。

DropTarget

将组件使用 DropTarget 包裹让它变得可以响应 drop,DropTarget 是一个高阶组件:

DropTarget(type, spec, collect)(Component)
  • **type**: 只有 DropTarget 注册的类型和DragSource 注册的类型完全匹配时才可以drop

  • **spec**: 描述DropTarget 如何对拖放事件作出反应

    • **drop(props, monitor, component)** drop 事件,返回值可以让DragSource endDrag 事件内通过monitor获取。

    • **hover(props, monitor, component)** hover 事件

    • **canDrop(props, monitor)** 重载是否可以 drop 的方法

DragDropContext

包裹根组件,可以定义backend,DropTargetDropTarget 包装过的组件必须在 DragDropContext 包裹的组件内

DragDropContext(backend)(RootComponent)

React DnD 核心实现

  React DnD 拖放库浅析 image.png

<input type="file" accept=".jpg, .jpeg, .png, .gif" style="display: none;">

dnd-core

核心层主要用来实现拖放原语

  • 实现了拖放管理器,定义了拖放的交互

  • 和框架无关,你可以基于它结合 react、jquery、RN等技术开发

  • 内部依赖了 redux 来管理状态

  • 实现了 DragDropManager,连接 BackendMonitor

  • 实现了 DragDropMonitor,从 store 获取状态,同时根据store的状态和自定义的状态获取函数来计算最终的状态

  • 实现了 HandlerRegistry 维护所有的 types

  • 定义了 Backend , DropTarget , DragSource 等接口

  • 工厂函数 createDragDropManager 用来接收传入的 backend 来创建一个管理器

export function createDragDropManager<C>(   backend: BackendFactory,    context: C,): DragDropManager<C> {  return new DragDropManagerImpl(backend, context)}

react-dnd

上层 React 版本的Drag and Drop的实现

  • 定义 DragSource, DropTarget, DragDropContext 等高阶组件

  • 通过业务层获取 backend 实现和组件来给核心层工厂函数

  • 通过核心层获取状态传递给业务层

DragDropContext 从业务层接受 backendFactory 和 backendContext 传入核心层 createDragDropManager 创建 DragDropManager 实例,并通过 Provide 机制注入到被包装的根组件。


/** * Wrap the root component of your application with DragDropContext decorator to set up React DnD. * This lets you specify the backend, and sets up the shared DnD state behind the scenes. * @param backendFactory The DnD backend factory * @param backendContext The backend context */export function DragDropContext(   backendFactory: BackendFactory, backendContext?: any,) {    // ...  return function decorateContext<        TargetClass extends         | React.ComponentClass<any>         | React.StatelessComponent<any> >(DecoratedComponent: TargetClass): TargetClass & ContextComponent<any> {       const Decorated = DecoratedComponent as any     const displayName = Decorated.displayName || Decorated.name || 'Component'
        class DragDropContextContainer extends React.Component<any>         implements ContextComponent<any> {          public static DecoratedComponent = DecoratedComponent           public static displayName = `DragDropContext(${displayName})`
            private ref: React.RefObject<any> = React.createRef()
            public render() {               return (                   // 通过 Provider 注入 dragDropManager                    <Provider value={childContext}>                     <Decorated                          {...this.props}                         ref={isClassComponent(Decorated) ? this.ref : undefined}                        />                  </Provider>             )           }       }
        return hoistStatics(            DragDropContextContainer,           DecoratedComponent,     ) as TargetClass & DragDropContextContainer }}

那么 Provider 注入的 dragDropManager 是如何传递到DragDropContext 内部的 DragSource 等高阶组件的呢?

请看内部 decorateHandler 的实现

export default function decorateHandler<Props, TargetClass, ItemIdType>({   DecoratedComponent, createHandler,  createMonitor,  createConnector,    registerHandler,    containerDisplayName,   getType,    collect,    options,}: DecorateHandlerArgs<Props, ItemIdType>): TargetClass &   DndComponentClass<Props> {
    //  class DragDropContainer extends React.Component<Props>      implements DndComponent<Props> {
            public receiveType(type: any) {         if (!this.handlerMonitor || !this.manager || !this.handlerConnector) {              return          }
            if (type === this.currentType) {                return          }
            this.currentType = type
            const { handlerId, unregister } = registerHandler(              type,               this.handler,               this.manager,           )
            this.handlerId = handlerId          this.handlerMonitor.receiveHandlerId(handlerId)         this.handlerConnector.receiveHandlerId(handlerId)
            const globalMonitor = this.manager.getMonitor()         const unsubscribe = globalMonitor.subscribeToStateChange(               this.handleChange,              { handlerIds: [handlerId] },            )
            this.disposable.setDisposable(              new CompositeDisposable(                    new Disposable(unsubscribe),                    new Disposable(unregister),             ),          )       }

        public getCurrentState() {          if (!this.handlerConnector) {               return {}           }           const nextState = collect(              this.handlerConnector.hooks,                this.handlerMonitor,            )
            return nextState        }
        public render() {           return (        // 使用 consume 获取 dragDropManager 并传递给 receiveDragDropManager                <Consumer>                  {({ dragDropManager }) => {                     if (dragDropManager === undefined) {                            return null                     }                       this.receiveDragDropManager(dragDropManager)
                        // Let componentDidMount fire to initialize the collected state                     if (!this.isCurrentlyMounted) {                         return null                     }
                        return (              // 包裹的组件                          <Decorated                              {...this.props}                             {...this.state}                             ref={                                   this.handler && isClassComponent(Decorated)                                     ? this.handler.ref                                      : undefined                             }                           />                      )                   }}              </Consumer>         )       }
    // receiveDragDropManager 将 dragDropManager 保存在 this.manager 上,并通过 dragDropManager 创建 monitor,connector     private receiveDragDropManager(dragDropManager: DragDropManager<any>) {         if (this.manager !== undefined) {               return          }           this.manager = dragDropManager
            this.handlerMonitor = createMonitor(dragDropManager)            this.handlerConnector = createConnector(dragDropManager.getBackend())           this.handler = createHandler(this.handlerMonitor)       }   }
    return hoistStatics(DragDropContainer, DecoratedComponent) as TargetClass &     DndComponentClass<Props>}

DragSource 使用了 decorateHandler 高阶组件,传入了createHandler, registerHandler, createMonitor, createConnector 等函数,通过 Consumer 拿到 manager 实例,并保存在 this.manager,并将 manager 传给前面的函数生成 handlerMonitor, handlerConnector, handler

/** * Decorates a component as a dragsource * @param type The dragsource type * @param spec The drag source specification * @param collect The props collector function * @param options DnD optinos */export default function DragSource<Props, CollectedProps = {}, DragObject = {}>( type: SourceType | ((props: Props) => SourceType),  spec: DragSourceSpec<Props, DragObject>,    collect: DragSourceCollector<CollectedProps>,   options: DndOptions<Props> = {},) {   // ...    return function decorateSource<     TargetClass extends         | React.ComponentClass<Props>           | React.StatelessComponent<Props>   >(DecoratedComponent: TargetClass): TargetClass & DndComponentClass<Props> {        return decorateHandler<Props, TargetClass, SourceType>({            containerDisplayName: 'DragSource',         createHandler: createSource,            registerHandler: registerSource,            createMonitor: createSourceMonitor,         createConnector: createSourceConnector,         DecoratedComponent,         getType,            collect,            options,        })  }}

比如传入的 DragSource 传入的 createHandler函数的实现是 createSourceFactory,可以看到


export interface Source extends DragSource {    receiveProps(props: any): void}
export default function createSourceFactory<Props, DragObject = {}>(    spec: DragSourceSpec<Props, DragObject>,) {  // 这里实现了 Source 接口,而 Source 接口是继承的 dnd-core 的 DragSource   class SourceImpl implements Source {        private props: Props | null = null      private ref: React.RefObject<any> = createRef()
        constructor(private monitor: DragSourceMonitor) {           this.beginDrag = this.beginDrag.bind(this)      }
        public receiveProps(props: any) {           this.props = props      }
    // 在 canDrag 中会调用通过 spec 传入的 canDrag 方法     public canDrag() {          if (!this.props) {              return false            }           if (!spec.canDrag) {                return true         }
            return spec.canDrag(this.props, this.monitor)       }    // ... }
    return function createSource(monitor: DragSourceMonitor) {      return new SourceImpl(monitor) as Source    }}

react-dnd-html5-backend

react-dnd-html5-backend 是官方的html5 backend 实现

主要暴露了一个工厂函数,传入 manager 来获取 HTML5Backend 实例

export default function createHTML5Backend(manager: DragDropManager<any>) { return new HTML5Backend(manager)}

HTML5Backend 实现了 Backend 接口

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}
export default class HTML5Backend implements Backend {  // DragDropContxt node 节点 或者 window  public get window() {      if (this.context && this.context.window) {          return this.context.window      } else if (typeof window !== 'undefined') {         return window       }       return undefined    }
    public setup() {        if (this.window === undefined) {            return      }
        if (this.window.__isReactDndBackendSetUp) {         throw new Error('Cannot have two HTML5 backends at the same time.')     }       this.window.__isReactDndBackendSetUp = true     this.addEventListeners(this.window) }
    public teardown() {     if (this.window === undefined) {            return      }
        this.window.__isReactDndBackendSetUp = false        this.removeEventListeners(this.window)      this.clearCurrentDragSourceNode()       if (this.asyncEndDragFrameId) {         this.window.cancelAnimationFrame(this.asyncEndDragFrameId)      }   }
  // 在 DragSource 的node节点上绑定事件,事件处理器里会调用action  public connectDragSource(sourceId: string, node: any, options: any) {       this.sourceNodes.set(sourceId, node)        this.sourceNodeOptions.set(sourceId, options)
        const handleDragStart = (e: any) => this.handleDragStart(e, sourceId)       const handleSelectStart = (e: any) => this.handleSelectStart(e)
        node.setAttribute('draggable', true)        node.addEventListener('dragstart', handleDragStart)     node.addEventListener('selectstart', handleSelectStart)
        return () => {          this.sourceNodes.delete(sourceId)           this.sourceNodeOptions.delete(sourceId)
            node.removeEventListener('dragstart', handleDragStart)          node.removeEventListener('selectstart', handleSelectStart)          node.setAttribute('draggable', false)       }   }}

React DnD 设计中犯过的错误

  • 使用了 mixin

    • 破坏组合

    • 应使用高阶组件

  • 核心没有 react 分离

  • 潜逃放置目标的支持

  • 镜像源

参考资料



作者:binggg_booker
链接:https://www.jianshu.com/p/81c1735b1944
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
上一篇:Havok


下一篇:WPF ICommand 用法