React関連の使い方を復習する/(1) create-react-app
React関連の使い方を復習する/(2) prop
React関連の使い方を復習する/(3) stateとイベントハンドラ
前回、reactのstateを使ってタスクのチェックボックスを通じて画面を少し動的にしてみました。
今回は、react-dndを使ってドラッグ&ドロップでカードを移動できるようにしてみます。合わせて、コンポーネントのPropが徐々に増えてきたので、ちょっとした型安全性をもたらしてくれるprop-typesを導入していきます。
この記事で触れること
ReactDndを導入する
カンバンと言うならば、カードを移動できないと面白くないですよね。ReactDndと言う便利なパッケージが公開されているので、これを利用します。
1 2 |
npm install --save react-dnd npm install --save react-dnd-html5-backend |
導入する手順をざっくりと書くと、以下の流れになります。
- ルートコンポーネントをDragDropContextでラップする
- ドラッグするコンポーネントをDragSourceでラップする
- ドロップするエリアとなるコンポーネントをDropTargetでラップする
- ドラッグ、ドロップしたときにstateを操作する処理を作成する
今回だと、ルートコンポーネントはAppコンポーネント、ドラッグするコンポーネントはCardコンポーネント、ドラックするエリアはひとまずLaneコンポーネントとして改造していきます。カードの順序をドラッグしている場所に応じて入れ替える処理は少々複雑なので、今回はレーンの移動だけをできるようにします。
ソースコードの変化を順に見ていきます。
Appコンポーネント
ルートコンポーネントとなるAppコンポーネントは、DragDropContextでラップするだけです。
1 2 3 4 5 6 7 8 9 10 11 |
import React from 'react'; import './App.css'; import Lane from './components/Lane' import HTML5Backend from 'react-dnd-html5-backend'; import { DragDropContext } from 'react-dnd'; class App extends React.Component { (snip) } export default DragDropContext(HTML5Backend)(App); |
Appクラスをexportする前に、ラップしてあげているだけですね。
公式によると以下らしいので、バックエンドにはとりあえずHTML5Backendを指定しておけば良いのだと思います。
This is the only officially supported backend for React DnD. It uses the HTML5 drag and drop API under the hood and hides its quirks.
Cardコンポーネント
DragSourceでラップするわけですが、es7のdecoratorsを利用すると、Javaで言うところのアノテーションのような形でクラス定義の頭に定義することができます。
1 2 3 4 |
@DragSource(type, spec, collect) class Card extends React.Component { (snip) } |
ただ、このdecorators記法を利用するためには、ejectして設定ファイルにtransform-decorators-legacyを追加しなければなりません。例によって本題ではないので、今回はes6の記法を使います。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
import React from 'react'; import { findDOMNode } from 'react-dom'; import { Panel, Accordion } from 'react-bootstrap'; import { DragSource } from 'react-dnd'; import Tasks from './Tasks'; class Card extends React.Component { (snip) render() { return ( <Accordion ref={instance => this.props.connectDragSource(findDOMNode(instance))}> <Panel bsStyle={this.cardColor()} header={ // 全てのタスクが完了していれば、カードも取り消し線を引く this.state.isDone ? <del>{this.props.label}</del> : <span>{this.props.label}</span> }> {<strong>{this.props.description}</strong>} <Tasks cardId={this.props.id} tasks={this.props.tasks} completeCheck={this.completeCheck.bind(this)}/> </Panel> </Accordion> ); } } export default DragSource( 'CARD', { /** * ドラッグ開始時に呼び出されるハンドラ * @param props ドラッグ元のCardコンポーネントのProps * @returns {*} DropTargetに渡す */ beginDrag(props) { return props; } }, (connect) => ({ connectDragSource: connect.dragSource(), }))(Card); |
ざっくりですが説明します。詳細はドキュメントを参照のこと。
DragSourceは3つの引数を必須でとります。
typeは、stringかsymbol、またはコンポーネントのpropを返す関数のいずれかを指定します。DropTargetも同様にtypeを持ち、これが一致するときだけDropTargetにドロップできるようになります。
specは、react-dndで定義された関数を実装したオブジェクトを指定します。例えば、ドラッグが開始された時の処理はbeginDrag、ドラッグが終了した時の処理はendDrag、などです。
collectは、コンポーネントでreact-dndの機能を使えるようにするための定義みたいなもので、connectとmonitorを引数に持つ関数を指定します。connectはDragSourceConnectorのインスタンスで、主にDOMノードとReactDNDを紐付けるための関数を提供します。
monitorは、名前の通りドラッグ元をモニタリングしてくれる関数を提供します。例えば、ドラッグしているかを判別するisDragging、ドラッグできるかを判別するcanDrag、などです。
実際のソースコードを見ていきます。少々長くなってきたので、今回変更のない箇所は省略しています。
L33-L47、decoratorsを使用しないとこのような記述になります。コンポーネントの定義とは別にDragSourceでラップをする形になります。カードを動かすための設定なので、typeは文字列でCARDとしています。specには、ドラッグ開始時に呼び出されるハンドラとしてbeginDragを実装しています。コメントに書いてある通りですが、CardコンポーネントのPropをDropTargetに渡す、と言う処理をしています。最後にcollectですが、今回Cardコンポーネントで使用するのは、DOMとReactDndを紐付けるconnectDragSourceだけなので、それだけ定義しています。
L16、connectDragSourceがpropsから取り出せるのでそれを利用して紐付けを行なっています。renderで返却するノードのルートがdiv要素であれば、以下のようにreturnの戻り値をconnectDragSourceでラップするだけでも良いのですが、今回はルートがAccordionのためこのような記述をしています。
1 2 3 4 5 |
return this.props.connectDragSource( <div> (snip) </div> ); |
Laneコンポーネント
続いて、ドロップ先となるLaneコンポーネントを改造していきます。
Cardコンポーネントと同様、今度はDropTargetでラップします。取る引数などの概要はだいたい同じなので省略
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 |
import React from 'react'; import { findDOMNode } from 'react-dom'; import { Col } from 'react-bootstrap'; import { DropTarget } from 'react-dnd'; import Card from './Card'; class Lane extends React.Component { constructor(props) { super(props); this.state = { cards: props.cards } } /** * レーンのstateを更新する * @param cards カード */ updateLaneState(cards) { this.setState({ cards: cards }); } /** * カードを移動した時に呼び出される関数 * @param sourceCard ドラッグ元のProps * @param targetComponent ドロップ先のコンポーネント */ moveCard(sourceCard, targetComponent) { // カードがドラッグされる前に属していたレーンから削除する let sourceLaneCards = sourceCard.laneCards.filter(card => card.id !== sourceCard.id); sourceCard.updateLaneState(sourceLaneCards); // カードがドロップされたレーンに追加する let targetLaneCards = [].concat(targetComponent.state.cards, sourceCard); targetComponent.updateLaneState(targetLaneCards); } // orderByの昇順にソート compare(a, b) { return a.orderBy > b.orderBy ? 1 : -1; } render() { const cards = this.state.cards.sort(this.compare).map(card => { return ( <Card description={card.description} id={card.id} key={card.id} label={card.label} laneCards={this.state.cards} laneId={this.props.id} orderBy={card.orderBy} tasks={card.tasks} updateLaneState={this.updateLaneState.bind(this)} /> ); }); return ( <Col className={"lane"} md={4} xs={4} ref={instance => this.props.connectDropTarget(findDOMNode(instance))}> <h2>{this.props.label}</h2> {cards} </Col> ); } } export default DropTarget( 'CARD', { /** * Cardコンポーネントがドロップされた時に呼び出されるハンドラ * @param props CardコンポーネントのProps * @param monitor ドロップ先のモニタリング情報 * @param component ドロップ先となるLaneコンポーネントのインスタンス */ drop(props, monitor, component) { // monitor.getItem()では、beginDragで返却した値を取得できる component.moveCard(monitor.getItem(), component); }, }, (connect, monitor) => ({ connectDropTarget: connect.dropTarget() }))(Lane); |
L74-90、DragSourceと同様にDropTargetでLaneコンポーネントをラップします。
typeはDragSourceと合わせないと動作しないため、文字列でCARDとしています。specには、カードをドロップした時に呼び出されるdropを実装しています。dropでは、L33-L40で定義したmoveCardを呼び出しています。collectでは、先ほどと同様にDOMとReactDndを紐付けるconnectDropTargetだけ定義しています。connectDropTargetは、L66でpropsから取り出して紐付けを行なっていますね。
ReactDndの定義部分はここまでで、あとは実際にstateをどうやって更新するかの処理を作り込みます。
L33-L40で、ドラッグ元のレーンとドロップ先のレーンそれぞれがstateで持っていたcardsを更新します。ドロップ先のレーンは、LaneコンポーネントなのでそのままupdateLaneStateを呼び出すことができますが、ドラッグ元はCardをドラッグして移動しているため取得できるコンポーネントの情報がCardコンポーネントなので、少々面倒です。L55、L59でCardコンポーネントに必要な情報をPropで持たせてあげて、そこから呼び出すようにしています。だんだんとコンポーネント間の相関が増えてきてわかりづらくなってきました。
prop-types
コンポーネントがもつPropが多くなってきて視認性が悪くなってきます。また、不毛な誤りも増えてきそうですよね。prop-typesを利用してコンポーネントがもつPropの型安全性を担保しつつ、ドキュメント代わりにしていきます。
prop-typesを利用するメリットは、主に以下でしょうか。個人的には、特にドキュメント代わりというところが大きい気がしています。
- 動的型付けであるが故のバグを早期に発見できる
- コンポーネントがもつPropを一覧で確認できる(ドキュメント代わり)
- isRequiredで必須としておくことで、undefinedやnullチェックを省略できる
React.PropTypesはReactのv15.5から別パッケージに移動したので、prop-typesというパッケージを別途インストールします。prop-typesですよ。間違えてprop-typeをインストールしてハマっていたのはご愛嬌。
1 |
npm install --save prop-types |
こんな感じで設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import React from 'react'; import { findDOMNode } from 'react-dom'; import { Panel, Accordion } from 'react-bootstrap'; import { DragSource } from 'react-dnd'; import PropTypes from 'prop-types'; import Tasks from './Tasks'; class Card extends React.Component { static propTypes = { connectDragSource: PropTypes.func.isRequired, description: PropTypes.string, id: PropTypes.number.isRequired, label: PropTypes.string.isRequired, laneCards: PropTypes.array.isRequired, laneId: PropTypes.number.isRequired, orderBy: PropTypes.number.isRequired, tasks: PropTypes.array, updateLaneState: PropTypes.func.isRequired } (snip) } |
esdocに出力できるように、別途Propごとにコメントを書いてもよいでしょう。CardコンポーネントがどんなPropを持つのかを一目で確認することができますね。この定義と違った実装をしている場合は、デベロッパーツールで警告が出るのですぐにわかります。
画面の確認
それでは、今回作り込んだ画面を確認して見ましょう。
だいぶそれっぽくなってきました。
現時点でのソースコードは、Githubに置いておきます。
だんだんコンポーネント間のバケツリレーが増えてきて、流れを読み解くのが大変になってきましたね・・・。Fluxを使わない場合でも、もっとスマートな方法があるのだろうか。
この状態で並び順を制御する処理を書いていく気力はなかったです・・。
そろそろreduxを導入して違いを見ていくとよいでしょうかね。
今回のはなかなか時間がかかって疲れた・・明日はお休みかな。