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 之類的應用程序,其中拖動在應用程序的不同部分之間傳輸數據,並且組件會根據拖放事件更改其外觀和應用程序狀態。

64173-68701495e446104d
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 核心實現

64173-31c078c2c0a276ae.png
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 分離

  • 潛逃放置目標的支持

  • 鏡像源

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章