Arantium Maestum

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

Clojure Web Development勉強 - ReactチュートリアルをReagentでやってみる(その5)

ReactチュートリアルのShowing the Movesから最後のImplementing Time Travelまで。

Showing the Moves

https://facebook.github.io/react/tutorial/tutorial.html#showing-the-moves

前回作成したhistoryデータを使って過去の動きを表示するようにする。

JavaScript版:

class Game extends React.Component {
  ...
  render() {
    ...
    const moves = history.map((step, move) => {
      const desc = move ?
        'Move #' + move :
        'Game start';
      return (
        <li>
          <a href="#" onClick={() => this.jumpTo(move)}>{desc}</a>
        </li>
      );
    });

    return (
      <div className="game">
        ...
        <div className="game-info">
          <div>{status}</div>
          <ol>{moves}</ol>
        </div>
      </div>
    );
  }
}

historyのデータをもとにlistアイテムを動的に作って<ol>タグの中に入れている。各moveのlist itemをクリックするとJumpToという現在定義されていない関数が呼び出される。

実際にはこの段階だと「過去の動きの番号を並べる」だけで、実際にどういう手だったのかはわからない。その情報を表示するにはImplementing Time Travelまで待たないといけない。

ClojureScript版:

(defn Game []
  (let [...]
    (fn []
      (let [...
            jumpTo (fn [move] nil)
            move-desc #(if (zero? %) 
                        "Game start"
                        (str "Move #" %))
            move-elem (fn [move]
                        [:li 
                         [:a {:href "#" 
                             :onClick #(jumpTo move)}
                          (move-desc move)]])
            moves (->> @history
                       count
                       range
                       (map move-elem)
                       doall)]
        [:div.game
         ...
         [:div.game-info
          [:div status]
          [:ol moves]]]))))

JavaScriptと違ってClojureScriptでは定義されていない関数があるとコンパイルしないので、何もしないJumpTo関数を定義している。Implementing Time Travelでちゃんと内容も書く。

このコード、このままでも動くは動くのだが、Console Logを調べるとこんなワーニングが出ている:

react.inc.js:20209 Warning: Each child in an array or iterator should have a unique "key" prop.

Check the render method of react_tutorial.core.Game. See https://fb.me/react-warning-keys for more information.

in li (created by react_tutorial.core.Game)

in react_tutorial.core.Game

このワーニングを解決するために、コンポーネントにキーをつける必要がある。

Keys

https://facebook.github.io/react/tutorial/tutorial.html#keys

チュートリアルの説明によると:

key is a special property that's reserved by React (along with ref, a more advanced feature).

Keys tell React about the identity of each component, so that it can maintain the state across rerenders.

とのこと。キーをpropsっぽい形でコンポーネントに渡してやると、Reactが動的に作成されているコンポーネントの同一性を認識できるようになる。

JavaScript:

class Game extends React.Component {
  ...
  render() {
    ...
    const moves = history.map((step, move) => {
      ...
      return (
        <li key={move}>
          <a href="#" onClick={() => this.jumpTo(move)}>{desc}</a>
        </li>
      );
    });
    ...
  }
}

<li key={move}>というようにまるでpropsのように渡している。ちなみにこのkeyはあくまでこのコンポーネント内というコンテキストでの同一性判定に使われるので、まったく違うコンポーネントの中で同じキーが使われていても問題ない。

ClojureScript版:

(defn Game []
  (let [...]
    (fn []
      (let [...
            move-elem (fn [move]
                        [:li {:key move}
                         [:a {:href "#" 
                             :onClick #(jumpTo move)}
                          (move-desc move)]])
            ...]
        ...))))

ClojureScript/ReagentだとこのようにProps的に渡す方法と、^{:key move} [:li ...]というようにメタデータという形式でコンポーネントに紐付ける方法の2種類がある。今回はJavaScript/Reactと同じような見た目になるようPropsぽく渡している。

これでワーニングは消えるはずである。

Implementing Time Travel

https://facebook.github.io/react/tutorial/tutorial.html#implementing-time-travel

最後に、Showing the Movesで表示させた「過去の動き」をクリックするとゲームがその動きまで巻き戻る機能を実装する。

JavaScript:

class Game extends React.Component {
  constructor() {
    super();
    this.state = {
      ...,
      stepNumber: 0
    };
  }
  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({
      ...,
      stepNumber: history.length
    });
  }
  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) ? false : true,
    });
  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    ...
  }
}

Gameコンポーネントをかなり広範囲にわたっていじっている。

Gameに現在何手目かという情報を保持するstepNumberというstateフィールドを定義。それに合わせてhandleClickとrenderを変更し、jumpToでクリックされた「過去の動き」の番号にstepNumberを変更し、xIsNextもそれに合うように変更する。

上記のコードではmovesを消しているからわからないが、チュートリアルJavaScriptコードがすごくいけてない。jumpToの引数名はstepなのに、それを使っているmovesの中ではstepとmoveの二つの変数があってjumpToに渡しているのはmove。わかりにくい。

ClojureScript:

(defn Game []
  (let [...

        stepNumber (r/atom 0)
        
        handle-click
        (fn [i]
          (let [squares (:squares (get @history @stepNumber))]
             (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 (reset! stepNumber (count @history))
                     (swap! history #(conj % next-step))
                     (swap! xIsNext not))))))

        jumpTo
        (fn [move]
          (do (reset! stepNumber move)
               (reset! xIsNext (zero? (mod move 2)))))]

    (fn []
      (let [squares (:squares (get @history @stepNumber))
            ...]
        ...))))

やってることはだいたいJavaScript版と同じ。

チュートリアルのこの章の最後のほうで

You may also want to update handleClick to be aware of stepNumber when reading the current board state so that you can go back in time then click in the board to create a new entry.

と書いてある。実際上記のコードを試すと、クリックして途中から新しい手を打つと、明らかにバグっぽい作動(同じプレーヤーが二回連続で打てたり)が出る。

「過去の動き」を表示している時にボードがクリックされると、その時点以降の「未来」データを上書きするようにhandleClickを修正する。

JavaScript:

class Game extends React.Component {
  ...
  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber+1);
    ...
  }
  ...
}

けっこう簡単で、handleClickの冒頭でhistoryを「現在表示されているボードの状態」のところ以降を切り捨てるだけ。

ClojureScript版:

(defn Game []
  (let [...
        handle-click
        (fn [i]
          (swap! history #(apply vector (take (inc @stepNumber) %)))
          ...)))

JavaScriptと概念的にはまったく同じで、handle-clickの冒頭でhistory r/atomをstepNumberのところまでだけとって残りは捨てている。

これで途中まで戻って打ち出してもバグっぽい挙動は起きない。

まとめ

というわけでReactチュートリアルは一段落。

JavaScriptとClojureScriptで対応させるのは多くの場合は比較的簡単な印象。ちょこっと怪しいところもあるが・・・ (renderSquareが危険な香りがする)

なのでReactで書かれたコードをReagentにポートした上で修正したい、というような要望があってもそこまで難しくはないのではないか。

ただ、このチュートリアルのコードもそうだが、JavaScriptだとStateの扱いが雑になりがちな印象がある。結局Mutableなので、子コンポーネントに渡したStateを簡単に書き換えられる。

ClojureScriptだとデータそのものはImmutableなので、r/atomを渡すのか、r/atomクロージャに持つ関数を渡すのか、などStateをどこに配分してどこからどうやって変更するか、もうちょっと気をつけて考えることが多いと思う。JavaScript通りに書きたいなら全部r/atomにしてしまえばいいのだけど、そうじゃないデザインを自然と志向するようになる。

もしこの○×ゲームを一からReagentで実装するとすれば、多分けっこう違う書き方をすると思う。いつか「Reagent的にはこう書く」というコードも紹介するかも。

Reactチュートリアルの最終形のコード:

gist.github.com