React DnDの公式Overviewを訳して理解を深めたい!

React環境で、ドラッグ&ドロップ操作をどうやって扱えばよいのだろうかと思ってると、 React DnDというライブラリを見つけた。便利そうではあるが、いくつかのブログを見てもよくわからないし、DecoratorやHigher Order Componentの考え方も登場し正直難しいと感じた。

だから、公式ページのOverviewを翻訳しながら、少しでも理解を深めていこうと思い、 その記録をブログに残そうと思う。翻訳は、適当である。自分が理解できるようにしか直訳していない。翻最終的に自分の頭で理解できる表現になっていれば多少ニュアンスが違っても翻訳と呼べるだろうと思ってる。

はじめに

翻訳してみて感じたことをはじめに記しておこうと思う。Overviewを呼んでも正直React DnDが使えるようになるぐらいになるとは思えなかった。Overviewで登場するいくつかの概念をぼんやりわかりながら、tutorialの実装をやってみることでOverviewの効果がでてくるし、React DnDの使い方もわかるようになった気がする。

だから、僕のOverviewを呼んでさっぱりわからなかったとしても絶望せず、tutorialまで手を進めてもらえばそこではじめて何かつかめるものがあるのではないかと思う。

後々紹介されるが、僕のようにせっかちな人向けに、
React DnDにおいて重要な概念を先に載せておこうと思う。

  • drap source, drop target(ドラッグされたもの、ドラッグされたものがドロップされる先)
  • ItemとType(識別子・分類子)
  • monitor関数(ドラッグ&ドロップ状態を管理する)
  • connect関数(イベントをどのDOMと結びつけるか決める)
  • HOCとES7のデコレータアノテーション(既存コンポーネントとうまく結びつける)

注意: 公式では、ES6,7ごとの書き方が紹介されているが、ここではES7のみ記載。

翻訳

Overview

React DnDは、ちまたの多くのdrag&dropライブラリと異なり、今まで使用したことがなかったらきっと驚かせるものであろう。しかし、ひとたびReact DnDのいくつかのデザインコンセプトを味わえば、理解できるようになる。後述のドキュメントでこららのコンセプトを読むことをおすすめする。

これらのコンセプト中には、FluxやReduxの考え方と共通する部分がある。 これは偶然ではなく、React DnDは、Reduxを内部で使用してる。

Backends

React DnDは、HTML5 drag and drop APIをもとに作られている。このAPIを使えば、ドラッグ中のDOMノードのスクリーンショットが取れたり、それを"ドラッグプレビュー"として利用できるため、合理的な手抜きなのである。カーソルが動いてるときに何も描画する必要がないので便利である。またこのAPIは、ファイルドロップイベントを扱える唯一のものでもある。

残念なことに、HTML5 drag and drop APIには、いくつかの欠点もある。APIは、タッチスクリーンでは機能しないし、他のブラウザと比べると、IEではカスタマイズできることが少ない。

そういうわけで、React DnDでは、プラグイン的な方法で、HTML5 drag and drop機能を可能にしている。そのプラグインを使う必要はない。タッチイベントや、マウスイベント、その他のすべてを含んだ別の実装をすることができる。このようなプラグイン的な実装を、React DnDではbackendsと呼んでいる。HTML5 backendはライブラリにすぎず、今後もっと多く機能が追加されるかもだろう。

backendsの役割は、Reactの仮想的なイベントシステムとよく似ている: ブラウザの差異を抽象化しネイティブのDOMイベントを処理する。類似するといっても、React DnD backendsは、Reactやその仮想的なイベントシステムに依存していない。backendsがやっていることは、DOMイベントをReact DnDが扱えるように内部で使用してるReduxのactionに丁寧に変換してるだけなのである。

Items and Types

Flux (あるいはRedux)のように、React DnDは信頼できる情報としてデータを扱い、ビューを使用しない。何かをドラッグしたとき、コンポーネントやDOMがドラッグされていると表現しない。代わりに、特定のtypeitemがドラッグされていると表現する。

itemとは何か? itemJavascriptのプレーンオブジェクトで、何がドラッグされているかを記述したものである。たとえば、カンバンボードアプリの場合、カードをドラッグしてるとき、itemは{cardId: 42}のように表現される。チェスアプリならば、あるピースを持ち上げたとき、itemは{fromCell: 'C5', piece: 'queen'}のように表現される。プレーンオブジェクトとしてドラッグ情報を表現することでコンポーネントを分けて、ごっちゃにしてしまわないようにできるのである。

typeとは何か? typeは、アプリ内にある全てのitemクラスのどのクラスに分類されるか一意に特定してくれる文字列(あるいはシンボル)である。カンバンボードアプリの場合、ドラッグできるカードを示すものとしてcardtypeを持たせたり、ドラッグできるカードの一覧をlisttypeをもたせたりするであろう。チェスアプリならば、piecetypeだけしか持たないかもしれない。

Typeが役に立つのは、アプリが大きくなったとき、もっと多くのものをドラッグさせたいが、既存のドロップ先が新しいitemに反応してほしくないときである。Typeを使うことでどのドラッグ元とどのドロップ先が対応するのかを特定させられる。Reduxのaction typeの一覧をもつように、React DnDでもtypeの一覧をアプリ内でもつようになるであろう。

Monitors

ドラッグ&ドロップは、本質的にステートフルである。ドラッグ操作中は、進行中という状態であり、ドラッグ操作をやめると進行中でなくなる。TypeとItemも同じであり、どこかにstateをもたせなければならいない。

React DnDは、内部stateストレージを備えた少規模のラッパーを通じてコンポーネントにstateをもたせており、そのストレージをmonitorsと呼んでいる。monitorsによって、ドラッグ&ドロップのstateが変更されるたびに、コンポーネントのpropsを変更できるようになる。

コンポーネントドラッグ&ドロップ状態を追跡できるように、monitorsから適切なstateを取り出せるcollect関数を定義できる。そしてReact DnDは、適切なタイミングでcollecting関数を呼び出したり、その返り値をコンポーネントのpropsにマージすることに力を注いでくれる。

たとえば、ピースがドラッグされているときに、チェスのある盤面をハイライトさせたいとする。そのとき、Cellコンポーネントのcollect関数は、次のようになるだろう。

function collect(monitor) {
  return {
    highlighted: monitor.canDrop(),
    hovered: monitor.isOver()
  };
}

collect関数から指示をうけ、React DnDはすべてのCellコンポーネントに最新のハイライトホバー状態をpropsとして伝達する。

Connectors

backendはDOMイベント扱うが、ReactコンポーネントがDOM描画するならば、どのDOMノードから発したイベントに反応すればよいのかをbackendが知る術はあるのだろうか。 そこでconnectorsである。connectorsによって、DOMノードはrender関数内で、事前に決められた(ドラッグ元か、ドラッグプレビューか、ドロップ先か)役割に割り当てられる。

訳しても、ちょっと意味不明なので、もう少し自分の言葉で表現すると、 ドラッグされたり、ドロップされたときに、どのDOMからイベントが発生したのか、backendにはわからない。 だから、connectorを使ってどのDOMが対象なのかを決めようということ。

実装時、connectorは上述したcollect関数の第一引数となる。connectorによっていかにしてドロップ先を特定するのか見てみよう。

function collect(connect, monitor) {
  return {
    highlighted: monitor.canDrop(),
    hovered: monitor.isOver(),
    connectDropTarget: connect.dropTarget() // drop先であることを決める関数
  };
}

コンポーネントのrenderメソッド内で、monitorから得られるデータ(1)や、connectorから得られる関数(2)の両方にアクセスできることがわかる。

1とはmonitor.canDrop()やmonitor.isOver()のことで、Drag&Dropの状態がわかる。 2とは、connect.dropTarge()のことであり、これは関数を返してる

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

  return connectDropTarget(
    <div className={classSet({
      'Cell': true,
      'Cell--highlighted': highlighted,
      'Cell--hovered': hovered
    })}>
      {this.props.children}
    </div>
  );
}

connectDropTargetで包んであげることで、React DnDコンポーネントのルートDOMノードがドロップ先だと理解でき、ホバーイベントやドロップイベントがbackendで処理されるだろうとわかる。そのメカニズムとして、Reactが提供してるのcallback refが利用されている。connector関数の戻り値関数はメモリに保存されるため、shouldComponentUpdateによる最適化の邪魔しない。

Drag Sources and Drop Targets

これまで、DOMイベントを扱うbackendsやItemやTypeで表現されるデータや、monitor関数やconnector関数のおかげで、React DnDコンポーネントにどんなpropsを注入すべきかをcollect関数が表現できることを説明してきた。

しかし実際propsをコンポーネントに注入するにはどのように設定すればよいのだろうか? ドラッグ&ドロップイベントに反応する副作用をどのように扱えばよいのだろうか? そこで、React DnDの中の主要な抽象ユニットであるdrag sourcesdrop targetsの登場である。これによって、type、item、副作用、collect関数がすべてがコンポーネントに結び付けられる。

ひとつあるいはその中の一部のコンポーネントをドラッグさせたいときは常に、drag source宣言でコンポーネントをラッピングさせる必要がある。あらゆるドラッグ元はある特定のtypeとして登録され、コンポーネントのpropsからitemを生成するメソッドを実装しなければならない。オプションとして、ドラッグやドロップイベントを処理するいくつかのメソッドを設定することもできる。その他に、drag source宣言時に、コンポーネントが内部で使用するcollect関数も設定する。

drop targetもdrag sourceと非常に似ている。唯一の違いは、ひとつのドロップ先に一度に複数のitem typeを登録したり、itemを生成するのかわりに、ホバーやドロップイベントを処理をするであろうということである。

ここでいうdrag source宣言とは、ソースでいうと@DragSourveのことであり、 その実現方法として次のHigher Order ComponentとDecoratorが登場するわけだ。

Higher-Order Component and ES7 decorators

コンポーネントをどうやってラッピングするのか? そもそもラッピングとはどういう意味だろうか? もし今までHigher-orderコンポーネントを使ったことがなかったら、お先にこの記事を読もう。この記事ではHOCの概念について詳細に説明されている。

higer-orderコンポーネントは、Reactコンポーネントクラスを受け取り、別のReactコンポーネントクラスを返すただの関数である。ライブラリによって提供されたラッピングコンポーネントは、renderメソッドでコンポーネントレンダリングしたり、それにpropsを与えるが、さらにいくつの便利な振る舞いも追加する。

React DnDでは、DragSourceDropTargetは、他のいくつかのトップレベルの関数と同じように、実際HOCである。これらは、コンポーネントドラッグ&ドロップの魔法をかける。

DragSourceやDropTargetを使うときの注意として、2つの関数を求められることである。たとえば、DragSourceYourComponentがどのようにラッピングされるかみてみよう。

ES6

import { DragSource } from 'react-dnd';

class YourComponent {
  /* ... */
}

export default DragSource(/* ... */)(YourComponent);

1つ目の関数呼び出しでは、DragSourceパラメータを渡し、その後の二つ目の関数呼び出しで、ようやく自分のコンポーネントクラスを渡してることに気づくだろう。これはカリー化、や部分適用と呼ばれるものであり、ES7 decoratorシンタックスが機能するには必要である。

ES7

import { DragSource } from 'react-dnd';

@DragSource(/* ... */)
export default class YourComponent {
  /* ... */
}

ES7シンタックスは必要ないが、もし好みならば、.babelrcファイルに{ "stage": 1 }を設定し、Babelでトランスパイルすれば実現できる。

ES7を使うつもりがなくても、部分適用は簡単に実装できる。_.flowのような合成ヘルパー関数をつかって、ES5, ES6のシンタックスの範囲でDragSourceDropTarget宣言と結合することができるからだ。ES7ならば、decoratorを付与するだけで、同じ効果を実現できる。

import { DragSource, DropTarget } from 'react-dnd';

@DragSource(/* ... */)
@DropTarget(/* ... */)
export default class YourComponent {
  render() {
    const { connectDragSource, connectDropTarget } = this.props
    return connectDragSource(connectDropTarget(
      /* ... */
    ))
  }
}

下に、Cardコンポーネントをドラッグ元としてラッピングした例を示そう。

Putting It All Together

import React from 'react';
import { DragSource } from 'react-dnd';


// ドラッグ元とドロップ先が相互作用するのは、
// 両者が同じtypeをもつ場合である。
// ファイルを分れば、他のファイルからも呼べる。
const Types = {
  CARD: 'card'
};

/**
* ドラッグ元として特定させる。
* `begin`関数だけが必要になる。
 */
const cardSource = {
  beginDrag(props) {
    // ドラッグされてるitemを示すデータを返す
    const item = { id: props.id };
    return item;
  },

  endDrag(props, monitor, component) {
    if (!monitor.didDrop()) {
      return;
    }

    // 然るべき先でドロップされたときの処理
    const item = monitor.getItem();
    const dropResult = monitor.getDropResult();
    CardActions.moveCardToList(item.id, dropResult.listId);
  }
};

// decoratorシンタックスを利用
@DragSource(Types.CARD, cardSource, (connect, monitor) => ({
  // render関数内でこれを呼べば、
  // React DnDはドラッグイベントを処理できる
  connectDragSource: connect.dragSource(),
  // monitorから現在のドラッグstateを尋ねることができる。
  isDragging: monitor.isDragging()
}))
export default class Card extends React.Component {
  render() {
    // いつものように自身のpropsを受け取れる
    const { id } = this.props;

    // この2つのpropsは、上で書いたcollect関数で定義したように
    // React DnDによって注入されたものである
    const { isDragging, connectDragSource } = this.props;

    return connectDragSource(
      <div>
        I am a draggable card number {id}
        {isDragging && ' (and I am being dragged now)'}
      </div>
    );
  }
}

感想

やっぱり訳しても、理解は難しいだろうなと思う。なぜならば、dragSourceの説明がなかったり、それらがdecorator関数の引数に与えられているのがなぜか説明がないからである。 このあたりは、tutorialやAPIドキュメントを見てまた、説明できたらいいな。 疲れた。