cisen / blog

Time waits for no one.
135 stars 20 forks source link

react-dnd #24

Open cisen opened 6 years ago

cisen commented 6 years ago

术语解析

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

collect

collect 是一个函数,默认有两个参数:connect 和 monitor。collect函数将返回一个对象,这个对象会注入到组件的 props 中,也就是说,我们可以通过 this.props 获取collect返回的所有属性。

参数 connect

source组件 collect 中 connect是 DragSourceConnector的实例,它内置了两个方法:dragSource() 和 dragPreview()。dragSource()返回一个方法,将source组件传入这个方法,可以将 source DOM 和 React DnD backend 连接起来;dragPreview() 返回一个方法,你可以传入节点,作为拖拽预览时的角色。 target组件 collect 中 connect是 DropTargetConnector的实例,内置的方法 dropTarget() 对应 dragSource(),返回可以将 drop target 和 React DnD backend 连接起来的方法。

参数 monitor

monitor 用于查询当前的拖拽状态,其对应实例内置了很多方法。

source组件 collect 中 monitor是 DragSourceMonitor的实例。 target组件 collect 中 monitor是 DropTargetMonitor的实例。

// DragSourceMonitor
monitor.canDrag()        // 是否能被拖拽
monitor.isDragging()      // 是否正在拖拽
monitor.getItemType()     // 拖拽组件type
monitor.getItem()         // 当前拖拽的item
monitor.getDropResult()   // 查询drop结果
monitor.didDrop()         // source是否已经drop在target
monitor.getInitialClientOffset()   // 拖拽组件初始拖拽时offset
monitor.getInitialSourceClientOffset()
monitor.getClientOffset() // 拖拽组件当前offset
monitor.getDifferenceFromInitialOffset() // 当前拖拽offset和初始拖拽offset的差别
monitor.getSourceClientOffset()

// DropTargetMonitor
monitor.canDrop()         // 是否可被放置
monitor.isOver(options)   // source是否在target上方
monitor.getItemType()     // 拖拽组件type
monitor.getItem()         // 当前拖拽的item
monitor.getDropResult()   // 查询drop结果
monitor.didDrop()         // source是否已经drop在target
monitor.getInitialClientOffset()   // 拖拽组件初始拖拽时offset
monitor.getInitialSourceClientOffset()
monitor.getClientOffset() // 拖拽组件当前offset
monitor.getDifferenceFromInitialOffset() // 当前拖拽offset和初始拖拽offset的差别
monitor.getSourceClientOffset()

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(

)看起来相当优雅,猜测实际作用应该相当于:

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通常需要处理hover、drop等事件)等

API

顶级API

想要灵活使用,就先知道几个核心API

  • DragSource 用于包装你需要拖动的组件,使组件能够被拖拽(make it draggable)
  • DropTarget 用于包装接收拖拽元素的组件,使组件能够放置(dropped on it)
  • DragDropContex 用于包装拖拽根组件,DragSource 和 DropTarget 都需要包裹在DragDropContex内
  • DragDropContextProvider 与 DragDropContex 类似,用 DragDropContextProvider 元素包裹拖拽根组件。
  • DragLayer 浏览器DnD默认会根据被拖动的元素创建drag preview(一般像个半透明截图),就是鼠标拖动时右边的的图,DragLayer是用来包含preview的组件的

Connecting to DOM

DragSourceConnector

  • dragPreview() => (elementOrNode, options?)
cisen commented 6 years ago

核心实现

./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取出状态并传递给业务层

基本用法

指定DragDropContext

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

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

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

添加DragSource

DragSource高阶组件接受3个参数(type,spec和collect()),例如:

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状态)

添加DropTarget

同样需要3个参数(type,spec和collect()):

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>
  );
}

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

定制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);
}

target和source互相传参

  1. source的beginDrag函数返回的值,target可以通过drop的monitor.getItem()获取
  2. target的drop函数返回的值,source可以通过endDrip的monitor.getDropResult()获取
  3. 可以通过在drop函数return false阻止拖拽