今天與你分享的是 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 的出發點
現有拖放插件的問題
-
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
,每個函數都會接收到connect
和monitor
兩個參數,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,DropTarget
和 DropTarget
包裝過的組件必須在 DragDropContext
包裹的組件內
DragDropContext(backend)(RootComponent)
React DnD 核心實現

<input type="file" accept=".jpg, .jpeg, .png, .gif" style="display: none;">
dnd-core
核心層主要用來實現拖放原語
-
實現了拖放管理器,定義了拖放的交互
-
和框架無關,你可以基於它結合 react、jquery、RN等技術開發
-
內部依賴了 redux 來管理狀態
-
實現了
DragDropManager
,連接Backend
和Monitor
-
實現了
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 分離
-
潛逃放置目標的支持
-
鏡像源
參考資料
-
The Future of Drag and Drop APIs https://medium.com/@dan_abramov/the-future-of-drag-and-drop-apis-249dfea7a15f
-
React DnD 文檔 https://react-dnd.github.io/react-dnd/
-
Mixins Are Dead. Long Live Composition https://medium.com/@dan_abramov/mixins-are-dead-long-live-higher-order-components-94a0d2f9e750
-
React DnD https://meta.tn/a/dadc5a19c47e3ae5ea430330693fdf6b5f17a757f7d1df80cad8eeae83ff831b
作者:binggg_booker
鏈接:https://www.jianshu.com/p/81c1735b1944
來源:簡書
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。