Clojure Web Development勉強 - ReactチュートリアルをReagentでやってみる(その4)
最後までやるつもりだったのだが、長くなったので今回はStoring a Historyだけ。
https://facebook.github.io/react/tutorial/tutorial.html#storing-a-history
これまでReactチュートリアルでは、まずSquareを状態を持ったコンポーネントとして実装し、その後状態をBoardコンポーネントに移し、Squareは独自の状態を持たずpropsを通してのみ値を受け取るように変更した。
今回はそれをさらに一歩推し進め、Boardから状態をGameコンポーネントに引き上げる。その上でGameには現在の状態とともに、過去の状態もすべて保持させる。
コードの構造とデータの有無は変わるのだけど、実際のゲーム自体の挙動は何も変わらないので地味である。
状態の定義
Gameコンポーネントのコンストラクタに状態を定義:
class Game extends React.Component { constructor() { super(); this.state = { history: [{ squares: Array(9).fill(null) }], xIsNext: true }; } ... }
今まではBoardコンポーネントのstateの中にsquaresというフィールドがあり、そのフィールドにnullが9つ入ったArrayが入っていた。
今度はGameコンポーネントのstateの中にhistoryというArrayが入っていて、初期設定ではそのArrayに一つオブジェクトが入っている。このオブジェクトが以前Boardに定義されていたstateと同じで、squaresフィールドにnullが9つ入ったArrayが入っている。
ついでにxIsNextもBoardからGameに移行。
ClojureScriptだといつもどおりGameにr/atomを定義する:
(defn Game [] (let [history (r/atom [{:squares (apply vector (repeat 9 nil))}]) xIsNext (r/atom true) ...] ...))
historyはnilが9つ入ったvectorが値で:squareがキーなmapの入ったvectorを格納したr/atom。うーむ。
Render関数
次に、render関数で勝ち負け・次のプレーヤーの表示と、Boardに渡すデータを追加:
class Game extends React.Component { ... render() { const history = this.state.history; const current = history[history.length - 1]; const winner = calculateWinner(current.squares); let status; if (winner) { status = 'Winner: ' + winner; } else { status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O'); } return ( <div className="game"> <div className="game-board"> <Board squares={current.squares} onClick={(i) => this.handleClick(i)} /> </div> <div className="game-info"> <div>{status}</div> <ol>{/* TODO */}</ol> </div> </div> ); } }
<Board />
にsquaresとonClickというpropsが追加されている。そして、以前はBoardで表示していたstatusがGameの<div>{status}</div>
に移っている。
ClojureScript:
(defn Game [] (let [...] (fn [] (let [squares (:squares (last @history)) status (if-some [winner (calculateWinner squares)] (str "Winner: " winner) (str "Next player: " (if @xIsNext "X" "O")))] [:div.game [:div.game-board [Board squares handle-click]] [:div.game-info [:div status] [:ol]]]))))
render関数の外のletではr/atomを使った状態の定義、中のletではローカル変数定義。
handle-click関数
今回の肝であるhandle-clickのアップデート:
class Game extends React.Component { ... handleClick(i) { const history = this.state.history; const current = history[history.length - 1]; const squares = current.squares.slice(); if (calculateWinner(squares) || squares[i]) { return; } squares[i] = this.state.xIsNext ? 'X' : 'O'; this.setState({ history: history.concat([{ squares: squares }]), xIsNext: !this.state.xIsNext, }); } ... }
やはりBoardにあったものをあらかた移してきた流れだが、一つ大きな違いがある。
まずthis.state.squares
ではなく、this.state.history
から最後(つまり最新)の要素をとり、そのオブジェクトのsquaresフィールドをsquaresとしてコピー。
クリックされた四角の場所に応じてsquaresを更新(以前のsquaresをmutateするのではなく、コピーして変更した新しいsquaresに入れ替える)。そのsquaresを入れたオブジェクトをhistoryに追加している。
ClojureScript:
(defn Game [] (let [... handle-click (fn [i] (let [squares (:squares (last @history))] (if-not (or (calculateWinner squares) (squares i)) (let [next-move (if @xIsNext "X" "O") next-squares (assoc squares i next-move) next-step {:squares next-squares}] (do (swap! history #(conj % next-step)) (swap! xIsNext not))))))] ...))
せっかくなので一歩ずつ解説すると:
- 現在の状態を
(:squares (last @history))
でとってきて - もし以下二つの条件がどちらもマッチしなければ
- 勝者がいる
- クリックされた四角にすでに値が入っている(すでに過去クリックされていた)
- xIsNextの値によって次の手・プレーヤーを決め
- squaresのiの位置にその手を入れた新しいsquaresを作成し
- そのsquaresを入れたmapを作成し
- そのmapを付け加えるようhistory r/atomの値を更新し
- xIsNextの値を反転させる
まあそのまんまだ。
Boardコンポーネント
あとはBoardコンポーネントから状態や表示を消すだけ。
class Board extends React.Component { renderSquare(i) { return <Square value={this.props.squares[i]} onClick={() => this.props.onClick(i)} />; } render() { return ( <div> <div className="board-row"> {this.renderSquare(0)} {this.renderSquare(1)} {this.renderSquare(2)} </div> ... </div> ); } }
renderSquareは残っているがそれ以外の関数や状態定義は消えた。
Boardにstateがなく、GameからPropsとしてsquaresとonClickが渡されるのでthis.state.squares
だったのがthis.props.squares
などになっている。
<div {status} />
もGameコンポーネントに移行したのでBoardからは消えた。
renderSquareがなければBoardもFunctional Componentにできたのだが。
ClojureScript版:
(defn Board [squares click-fn] (let [renderSquare (fn [i] [Square (get squares i) #(click-fn i)])] (fn [] [:div [:div.board-row [renderSquare 0] [renderSquare 1] [renderSquare 2]] ...])))
Reagentだとpropsが引数として入ってきて、その引数の名前でlet内のrenderSquareに渡すだけでClosure化するので楽。
追記
おおっと、「楽」と思って油断していたら間違えた。let
の中にfn []
があると、Boardを作成した時のsquaresとclick-fnで固定されてしまう。解決策としてはsquaresの代わりに何らかのreagent atomを渡すか、renderSquareも毎回生成するようにするか。これだけ簡単かつライトなSPAならrenderのたびにrenderSquareも作成していいと思うが、より複雑・大規模なアプリケーションの場合コスト的にどうなんだろうか。
(defn Board [squares click-fn] (let [renderSquare (fn [i] [Square (get squares i) #(click-fn i)])] [:div [:div.board-row [renderSquare 0] [renderSquare 1] [renderSquare 2]] ...]))
JavaScriptの方はrenderSquareにmutable stateを渡して一回だけ作成する方向なので、atomを使ったほうが合致しているかも。
まとめ
というわけで、冒頭でも言ったがStateの在処を移したのとhistoryという内部データ構造を作っただけで、現在ゲームの挙動は変わらない。次はこのhistoryを表示させるコードを書いていく。
そして今度こそ次回でチュートリアル終了、なはず。
今回のコード(BoardとGameのみ):