読者です 読者をやめる 読者になる 読者になる

Arantium Maestum

プログラミング、囲碁、読書の話題

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のみ):

gist.github.com