React DnD

一.设计理念

React DnD gives you a set of powerful primitives, but it does not contain any readymade components. It’s lower level than jQuery UI or interact.js and is focused on getting the drag and drop interaction right, leaving its visual aspects such as axis constraints or snapping to you. For example, React DnD doesn’t plan to provide a Sortable component. Instead it makes it easy for you to build your own, with any rendering customizations that you need.

(摘自Non-Goals

简言之,把DnD特性拆解成一些基础interface(能抓的东西,即萝卜;能放手的容器,即坑),并把DnD内部状态暴露给实现了这些interface的实例。不像其它库一样提供无穷尽的Draggable Component应对常见业务场景,React DnD从相对底层的角度提供支持,是对拖放能力的抽象与封装,通过抽象来简化使用,通过封装来屏蔽下层差异

二.术语概念

Backend

HTML5 DnD API兼容性不怎么样,并且不适用于移动端,所以干脆把DnD相关具体DOM事件抽离出去,单独作为一层,即Backend:

Under the hood, all the backends do is translate the DOM events into the internal Redux actions that React DnD can process.

Item和Type

Item是对元素/组件的抽象理解,拖放的对象不是DOM元素或React组件,而是特定数据模型(Item):

An item is a plain JavaScript object describing what’s being dragged.

进行这种抽象同样是为了解耦:

Describing the dragged data as a plain object helps you keep the components decoupled and unaware of each other.

Type与Item的关系类似于Class与Class Instance,Type作为类型标识符用来表示同类Item:

A type is a string (or a symbol) uniquely identifying a whole class of items in your application.

Type作为萝卜(drag source)和坑(drop target)的匹配依据,相当于经典DnD库的group name

Monitor

Monitor是拖放状态的集合,比如拖放操作是否正在进行,是的话萝卜是哪个坑是哪个:

React DnD exposes this state to your components via a few tiny wrappers over the internal state storage called the monitors.

例如:

monitor.isDragging()
monitor.isOver()
monitor.canDrop()
monitor.getItem()

props注入的方式暴露DnD内部状态,类似于Redux的mapStateToProps

export default DragSource(Types.CARD, cardSource, (connector, monitor) => ({
  // You can ask the monitor about the current drag state:
  isDragging: monitor.isDragging()
}))(Card);

P.S.事实上,React DnD就是基于Redux实现的,见下文核心实现部分

Connector

Connector用来建立DOM抽象(React)与DnD Backend需要的具体DOM元素之间的联系:

The connectors let you assign one of the predefined roles (a drag source, a drag preview, or a drop target) to the DOM nodes

用法很有意思:

render() {
  const { highlighted, hovered, connectDropTarget } = this.props;

  // 1.声明DnD Role对应的DOM元素
  return connectDropTarget(
    <div className={classSet({
      'Cell': true,
      'Cell--highlighted': highlighted,
      'Cell--hovered': hovered
    })}>
      {this.props.children}
    </div>
  );
}

// 2.从connector取出connect方法,并注入props
export default DragSource(Types.CARD, cardSource, (connector, monitor) => ({
  // Call this function inside render()
  // to let React DnD handle the drag events:
  connectDropTarget: connector.dropTarget()
}))(Card);

建立联系的部分connectDropTarget(<div/>)看起来相当优雅,猜测实际作用应该相当于:

render() {
  const { connectToRole } = this.props;
  return <div ref={(node) => connectToRole(node)}></div>
}

猜对了:

Internally it works by attaching a callback ref to the React element you gave it.

Drag Source与Drop Target

上面提到过这两个东西,可以称之为DnD Role,表示在DnD中所饰角色,除了drag source和drop target外,还有一个叫drag preview,一般可以看作另一种状态的drag source

DnD Role是React DnD中的基本抽象单元:

They really tie the types, the items, the side effects, and the collecting functions together with your components.

是该角色相关描述及动作的集合,包括Type,DnD Event Handler(例如drop target通常需要处理hoverdrop等事件)等

三.核心实现

./packages
├── dnd-core
├── react-dnd
├── react-dnd-html5-backend
└── react-dnd-test-backend

对应逻辑结构是这样:

API 接React
  react-dnd 定义Context,提供Provider、Container factory等上层API
-------
Core 抽象(定义interface)
  dnd-core 定义Action、Reducer,连接上下层
-------
Backends 接native,封装DnD特性(实现interface)
  react-dnd-xxx-backend 接具体环境,通过Dispatch Action把native DnD状态传递到上层

可以看作基于Redux的逻辑拆解,中间层Core持有DnD状态,下层Backends负责实现约定的interface,作为Core的数据源,上层API从Core取出状态并传递给业务层

四.基本用法

1.指定DragDropContext

给App根组件声明DragDropContext,例如:

import { DragDropContext } from 'react-dnd';
import HTML5Backend from 'react-dnd-html5-backend';

class App extends Component {}
export default DragDropContext(HTML5Backend)(App);

2.添加DragSource

DragSource高阶组件接受3个参数(typespeccollect()),例如:

export const ItemTypes = {
  KNIGHT: 'knight'
};

const knightSpec = {
  beginDrag(props) {
    // 定义Item结构,通过monitor.getItem()读取
    return {
      pieceId: props.id
    };
  }
};

function collect(connector, monitor) {
  return {
    connectDragSource: connectorconnector.dragSource(),
    isDragging: monitor.isDragging()
  }
}

最后与Component/Container连接起来(像Redux connect()一样):

export default DragSource(ItemTypes.KNIGHT, knightSpec, collect)(Knight);

组件拿到注入的DnD状态渲染对应UI,例如:

render() {
  const { connectDragSource, isDragging } = this.props;
  return connectDragSource(
    <div style={{
      opacity: isDragging ? 0.5 : 1,
      cursor: 'move'
    }} />
  );
}

很自然地实现了被拖走的效果(拖放对象变成半透明),看不到复杂的DnD处理逻辑(这些都被封装到了React DnD Backend,仅暴露出业务需要的DnD状态)

3.添加DropTarget

同样需要3个参数(typespeccollect()):

const dropSpec = {
  canDrop(props) {
    return canMoveKnight(props.x, props.y);
  },

  drop(props, monitor) {
    const { id } = monitor.getItem();
    moveKnight(id, props.x, props.y);
  }
};

function collect(connector, monitor) {
  return {
    connectDropTarget: connector.dropTarget(),
    isOver: monitor.isOver(),
    canDrop: monitor.canDrop()
  };
}

最后连接起来:

export default DropTarget(ItemTypes.KNIGHT, dropSpec, collect)(BoardSquare);

组件取这些注入的DnD状态来展示对应的UI,例如:

render() {
  const { connectDropTarget, isOver, canDrop } = this.props;

  return connectDropTarget(
    <div>
      {isOver && !canDrop && this.renderOverlay('red')}
      {!isOver && canDrop && this.renderOverlay('yellow')}
      {isOver && canDrop && this.renderOverlay('green')}
    </div>
  );
}

坑根据拖动操作合法性变色的效果也实现了,看起来同样很自然

4.定制DragPreview

浏览器DnD默认会根据被拖动的元素创建drag preview(一般像个半透明截图),需要定制的话,与DragSource的创建方式类似:

function collect(connector, monitor) {
  return {
    connectDragSource: connector.dragSource(),
    connectDragPreview: connector.dragPreview()
  }
}

通过注入的connectDragPreview()来定制DragPreview,接口签名与connectDragSource()一致,都是dragPreview() => (elementOrNode, options?),例如常见的拖动抓手(handle)效果可以这样实现:

render() {
  const { connectDragSource, connectDragPreview } = this.props;

    return connectDragPreview(
      <div
        style={{
          position: 'relative',
          width: 100,
          height: 100,
          backgroundColor: '#eee'
        }}
      >
        Card Content
        {connectDragSource(
          <div
            style={{
              position: 'absolute',
              top: 0,
              left: '100%'
            }}
          >
            &lt;HANDLE&gt;
          </div>
        )}
      </div>
  );
}

另外,还可以把Image对象作为DragPreview(IE不支持):

componentDidMount() {
  const img = new Image();
  img.src = 'http://mysite.com/image.jpg';
  img.onload = () => this.props.connectDragPreview(img);
}

五.在线Demo

Github仓库:ayqy/example-react-dnd-nested

在线Demo:https://ayqy.github.io/dnd/demo/react-dnd/index.html

参考资料

发表评论

电子邮件地址不会被公开。 必填项已用*标注

*

code