React DnD 拖放庫淺析

今天與你分享的是 redux 作者 Dan 的另外一個(gè)很贊的項(xiàng)目 react-dnd (github 9.6k star),dnd 是 Drag and Drop 的意思,為什么他會(huì)開發(fā) react-dnd 這個(gè)項(xiàng)目,這個(gè)拖放庫解決了什么問題,和 html5 原生 Drag Drop API 有什么樣的聯(lián)系與不同,設(shè)計(jì)有什么獨(dú)特之處?讓我們帶著這些問題一起來了解一下 React DnD 吧。

React DnD 是什么?

React DnD是React和Redux核心作者 Dan Abramov創(chuàng)造的一組React 高階組件,可以在保持組件分離的前提下幫助構(gòu)建復(fù)雜的拖放接口。它非常適合Trello 之類的應(yīng)用程序,其中拖動(dòng)在應(yīng)用程序的不同部分之間傳輸數(shù)據(jù),并且組件會(huì)根據(jù)拖放事件更改其外觀和應(yīng)用程序狀態(tài)。

image

React DnD 的出發(fā)點(diǎn)

現(xiàn)有拖放插件的問題

  • jquery 插件思維模式,直接改變DOM

  • 拖放狀態(tài)改變的影響不僅限于 CSS 類這種改變,不支持更加自定義

HTML5 拖放API的問題

  • 不支持移動(dòng)端

  • 拖動(dòng)預(yù)覽問題

  • 無法開箱即用

React DnD 的需求

  • 默認(rèn)使用 HTML5 拖放API,但支持

  • 不直接操作 DOM

  • DOM 和拖放的源和目標(biāo)解耦

  • 融入HTML5拖放中竊取類型匹配和數(shù)據(jù)傳遞的想法

React DnD 的特點(diǎn)

專注拖拽,不提供現(xiàn)成組件

React DnD提供了一組強(qiáng)大的原語,但它不包含任何現(xiàn)成組件,而是采用包裹使用者的組件并注入 props 的方式。 它比jQuery UI等更底層,專注于使拖放交互正確,而把視覺方面的效果例如坐標(biāo)限制交給使用者處理。這其實(shí)是一種關(guān)注點(diǎn)分離的原則,例如React DnD不打算提供可排序組件,但是使用者可以基于它快速開發(fā)任何需要的自定義的可排序組件。

單向數(shù)據(jù)流

類似于 React 一樣采取聲明式渲染,并且像 redux 一樣采用單向數(shù)據(jù)流架構(gòu),實(shí)際上內(nèi)部使用了 Redux

隱藏了平臺(tái)底層API的問題

HTML5拖放API充滿了陷阱和瀏覽器的不一致。 React DnD為您內(nèi)部處理它們,因此使用者可以專注于開發(fā)應(yīng)用程序而不是解決瀏覽器問題。

可擴(kuò)展可測(cè)試

React DnD默認(rèn)提供了HTML5拖放API封裝,但它也允許您提供自定義的“后端(backend)”。您可以根據(jù)觸摸事件,鼠標(biāo)事件或其他內(nèi)容創(chuàng)建自定義DnD后端。例如,內(nèi)置的模擬后端允許您測(cè)試Node環(huán)境中組件的拖放交互。

為未來做好了準(zhǔn)備

React DnD不會(huì)導(dǎo)出mixins,并且對(duì)任何組件同樣有效,無論它們是使用ES6類,createReactClass還是其他React框架創(chuàng)建的。而且API支持了ES7 裝飾器。

React DnD 的基本用法

下面是讓一個(gè)現(xiàn)有的Card組件改造成可以拖動(dòng)的代碼示例:

// 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 函數(shù)可以生成一個(gè)高階組件,包裹 Card 組件之后就可以實(shí)現(xiàn)可以拖動(dòng)。Card組件可以通過 props 獲取到 text, isDragging, connectDragSource 這些被 React DnD 注入的 prop,可以根據(jù)拖拽狀態(tài)來自行處理如何顯示。

那么 DragSource, connectDragSource, collect, cardSource 這些都是什么呢?下面將會(huì)介紹React DnD 的基本概念。

React DnD 的基本概念

Backend

React DnD 抽象了后端的概念,你可以使用 HTML5 拖拽后端,也可以自定義 touch、mouse 事件模擬的后端實(shí)現(xiàn),后端主要用來抹平瀏覽器差異,處理 DOM 事件,同時(shí)把 DOM 事件轉(zhuǎn)換為 React DnD 內(nèi)部的 redux action。

Item

React DnD 基于數(shù)據(jù)驅(qū)動(dòng),當(dāng)拖放發(fā)生時(shí),它用一個(gè)數(shù)據(jù)對(duì)象來描述當(dāng)前的元素,比如{ cardId: 25 }

Type

類型類似于 redux 里面的actions types 枚舉常量,定義了應(yīng)用程序里支持的拖拽類型。

Monitor

拖放操作都是有狀態(tài)的,React DnD 通過 Monitor 來存儲(chǔ)這些狀態(tài)并且提供查詢

Connector

Backend 關(guān)注 DOM 事件,組件關(guān)注拖放狀態(tài),connector 可以連接組件和 Backend ,可以讓 Backend 獲取到 DOM。

DragSource

將組件使用 DragSource 包裹讓它變得可以拖動(dòng),DragSource 是一個(gè)高階組件:

DragSource(type, spec, collect)(Component)
  • **type**: 只有 DragSource 注冊(cè)的類型和DropTarget 注冊(cè)的類型完全匹配時(shí)才可以drop

  • **spec**: 描述DragSource 如何對(duì)拖放事件作出反應(yīng)

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

    • **endDrag(props, monitor, component)** 結(jié)束拖拽事件

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

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

  • **collect**: 類似一個(gè)map函數(shù)用最終inject給組件的對(duì)象,這樣可以讓組件根據(jù)當(dāng)前的狀態(tài)來處理如何展示,類似于 redux connector 里面的 mapStateToProps ,每個(gè)函數(shù)都會(huì)接收到 connectmonitor 兩個(gè)參數(shù),connect 是用來和 DnD 后端聯(lián)系的, monitor是用來查詢拖拽狀態(tài)信息。

DropTarget

將組件使用 DropTarget 包裹讓它變得可以響應(yīng) drop,DropTarget 是一個(gè)高階組件:

DropTarget(type, spec, collect)(Component)
  • **type**: 只有 DropTarget 注冊(cè)的類型和DragSource 注冊(cè)的類型完全匹配時(shí)才可以drop

  • **spec**: 描述DropTarget 如何對(duì)拖放事件作出反應(yīng)

    • **drop(props, monitor, component)** drop 事件,返回值可以讓DragSource endDrag 事件內(nèi)通過monitor獲取。

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

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

DragDropContext

包裹根組件,可以定義backend,DropTargetDropTarget 包裝過的組件必須在 DragDropContext 包裹的組件內(nèi)

DragDropContext(backend)(RootComponent)

React DnD 核心實(shí)現(xiàn)

image.png

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

dnd-core

核心層主要用來實(shí)現(xiàn)拖放原語

  • 實(shí)現(xiàn)了拖放管理器,定義了拖放的交互

  • 和框架無關(guān),你可以基于它結(jié)合 react、jquery、RN等技術(shù)開發(fā)

  • 內(nèi)部依賴了 redux 來管理狀態(tài)

  • 實(shí)現(xiàn)了 DragDropManager,連接 BackendMonitor

  • 實(shí)現(xiàn)了 DragDropMonitor,從 store 獲取狀態(tài),同時(shí)根據(jù)store的狀態(tài)和自定義的狀態(tài)獲取函數(shù)來計(jì)算最終的狀態(tài)

  • 實(shí)現(xiàn)了 HandlerRegistry 維護(hù)所有的 types

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

  • 工廠函數(shù) createDragDropManager 用來接收傳入的 backend 來創(chuàng)建一個(gè)管理器

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

react-dnd

上層 React 版本的Drag and Drop的實(shí)現(xiàn)

  • 定義 DragSource, DropTarget, DragDropContext 等高階組件

  • 通過業(yè)務(wù)層獲取 backend 實(shí)現(xiàn)和組件來給核心層工廠函數(shù)

  • 通過核心層獲取狀態(tài)傳遞給業(yè)務(wù)層

DragDropContext 從業(yè)務(wù)層接受 backendFactory 和 backendContext 傳入核心層 createDragDropManager 創(chuàng)建 DragDropManager 實(shí)例,并通過 Provide 機(jī)制注入到被包裝的根組件。


/** * 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 內(nèi)部的 DragSource 等高階組件的呢?

請(qǐng)看內(nèi)部 decorateHandler 的實(shí)現(xiàn)

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 創(chuàng)建 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 等函數(shù),通過 Consumer 拿到 manager 實(shí)例,并保存在 this.manager,并將 manager 傳給前面的函數(shù)生成 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函數(shù)的實(shí)現(xiàn)是 createSourceFactory,可以看到


export interface Source extends DragSource {    receiveProps(props: any): void}
export default function createSourceFactory<Props, DragObject = {}>(    spec: DragSourceSpec<Props, DragObject>,) {  // 這里實(shí)現(xiàn)了 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 中會(huì)調(diào)用通過 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 實(shí)現(xiàn)

主要暴露了一個(gè)工廠函數(shù),傳入 manager 來獲取 HTML5Backend 實(shí)例

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

HTML5Backend 實(shí)現(xiàn)了 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 節(jié)點(diǎn) 或者 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節(jié)點(diǎn)上綁定事件,事件處理器里會(huì)調(diào)用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 設(shè)計(jì)中犯過的錯(cuò)誤

  • 使用了 mixin

    • 破壞組合

    • 應(yīng)使用高階組件

  • 核心沒有 react 分離

  • 潛逃放置目標(biāo)的支持

  • 鏡像源

參考資料

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容