今天与你分享的是 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 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( {text} ); }}
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 核心实现
dnd-core
核心层主要用来实现拖放原语
实现了拖放管理器,定义了拖放的交互
和框架无关,你可以基于它结合 react、jquery、RN等技术开发
内部依赖了 redux 来管理状态
实现了
DragDropManager
,连接Backend
和Monitor
实现了
DragDropMonitor
,从 store 获取状态,同时根据store的状态和自定义的状态获取函数来计算最终的状态实现了
HandlerRegistry
维护所有的 types定义了
Backend
,DropTarget
,DragSource
等接口工厂函数
createDragDropManager
用来接收传入的 backend 来创建一个管理器
export function createDragDropManager( backend: BackendFactory, context: C,): DragDropManager { 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 | React.StatelessComponent >(DecoratedComponent: TargetClass): TargetClass & ContextComponent { const Decorated = DecoratedComponent as any const displayName = Decorated.displayName || Decorated.name || 'Component'
class DragDropContextContainer extends React.Component implements ContextComponent { public static DecoratedComponent = DecoratedComponent public static displayName = `DragDropContext(${displayName})`
private ref: React.RefObject = React.createRef()
public render() { return ( // 通过 Provider 注入 dragDropManager ) } }
return hoistStatics( DragDropContextContainer, DecoratedComponent, ) as TargetClass & DragDropContextContainer }}
那么 Provider 注入的 dragDropManager 是如何传递到DragDropContext 内部的 DragSource 等高阶组件的呢?
请看内部 decorateHandler 的实现
export default function decorateHandler({ DecoratedComponent, createHandler, createMonitor, createConnector, registerHandler, containerDisplayName, getType, collect, options,}: DecorateHandlerArgs): TargetClass & DndComponentClass {
// class DragDropContainer extends React.Component implements DndComponent {
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 {({ dragDropManager }) => { if (dragDropManager === undefined) { return null } this.receiveDragDropManager(dragDropManager)
// Let componentDidMount fire to initialize the collected state if (!this.isCurrentlyMounted) { return null }
return ( // 包裹的组件 ) }} ) }
// receiveDragDropManager 将 dragDropManager 保存在 this.manager 上,并通过 dragDropManager 创建 monitor,connector private receiveDragDropManager(dragDropManager: DragDropManager) { 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}
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( type: SourceType | ((props: Props) => SourceType), spec: DragSourceSpec, collect: DragSourceCollector, options: DndOptions = {},) { // ... return function decorateSource< TargetClass extends | React.ComponentClass | React.StatelessComponent >(DecoratedComponent: TargetClass): TargetClass & DndComponentClass { return decorateHandler({ 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( spec: DragSourceSpec,) { // 这里实现了 Source 接口,而 Source 接口是继承的 dnd-core 的 DragSource class SourceImpl implements Source { private props: Props | null = null private ref: React.RefObject = 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) { 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