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 之类的应用程序,其中拖动在应用程序的不同部分之间传输数据,并且组件会根据拖放事件更改其外观和应用程序状态。

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 核心实现

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 分离

  • 潜逃放置目标的支持

  • 镜像源

参考资料

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容