今天与你分享的是 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 之类的应用程序,其中拖动在应用程序的不同部分之间传输数据,并且组件会根据拖放事件更改其外观和应用程序状态。
imageReact 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 核心实现
image.png<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
网友评论