MSBOOKS

プログラミングとか深層学習とか日常の出来事とか

【勉強メモ】Reactのチュートリアルを試してみた その3

はじめに

react公式のチュートリアルの続き、タイムトラベル機能の追加からやります。以前の着手まで時間を巻き戻すことができるようにします。
ja.reactjs.org

着手の履歴の保存

文章がすごくカタカナだらけで読みにくかったので要約してみます。
squaresの配列のデータを直接変更していたら、この実装は難しいです。しかし私たちは着手があるたびにsquaresのコピーを作る不変性(イミュータビリティ)を利用しました。なので、過去のバージョンをすべて保存しておいて過去の手番にさかのぼることができるようになります。なので過去のsquaresの配列をhistoryという名前の別の配列に保存します。
直接書き換えをすると、オブジェクトが変わらないので、変更の検出がしづらくなるって話ですね。

State のリフトアップ、再び

トップレベル(最も上の親の部分)のGameコンポーネント内で過去の着手の履歴を表示したいので、history stateをGameコンポーネントに置き、そのためにBoardにあるsquaresなどのstateをリフトアップ(親にもっていく)する必要があります。なのでGameコンポーネントのコンストラクタでそのstateを定義します。
・Gameコンポーネント

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{
        squares: Array(9).fill(null),
      }],
      xIsNext: true,
    };
  }

  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }
}

BoardコンオーネントにあったsquaresとonClickはGameコンポーネントからpropsで受け取るように変更します。こうなるとsquareコンポーネントからonClickを受け取り、それをBoardコンポーネントが受けとり、それをさらに位置の情報iを加えてGameコンポーネントが受け取るっていうとても深い構造になりましたね。

・Boardコンポーネント

class Board extends React.Component {
  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.props.squares[i]}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div>
        <div className="status">{status}</div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }
}

これに伴ってGameコンポーネントのrenderを更新します。
・Gameコンポーネント

  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>
    );
  }

currentのhistory.length - 1で長さ5の配列であれば4となり、0から始まるその配列の最後の要素を取り出せる仕組みですね。その取り出した盤面で決着がついているか計算した後、それに応じた表示をしています。historyの中身を取り出すときにcurrent.squaresのようにsquaresを明示的に書かないと取れないのにも注目です。以下のような配列になっているからですね。

history = [
  // Before first move
  {
    squares: [
      null, null, null,
      null, null, null,
      null, null, null,
    ]
  },
  // After first move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, null,
    ]
  },
  // After second move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, 'O',
    ]
  },
  // ...
]

HTMLの this.handleClick(i)でBoardのthis.props.onClick(i)のiが(i)にはいってきて、それがthis.handleClick(i)のiに入っていくんですね。
statusがGameコンポーネントで書くことになったので、Boardコンポーネントからは削除します。
・Boardコンポーネント

  render() {
    return (
      <div>
        <div className="board-row">
          {this.renderSquare(0)}
          {this.renderSquare(1)}
          {this.renderSquare(2)}
        </div>
        <div className="board-row">
          {this.renderSquare(3)}
          {this.renderSquare(4)}
          {this.renderSquare(5)}
        </div>
        <div className="board-row">
          {this.renderSquare(6)}
          {this.renderSquare(7)}
          {this.renderSquare(8)}
        </div>
      </div>
    );
  }

またhandleClickメソッドを Board コンポーネントから Game コンポーネントに移動します。Game コンポーネントの state はhistroyの中にsquaresがあるのでそれに合わせて修正します。
・Gameコンポーネント

  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,
    });
  }

concat()は配列の結合で使い、push()と違って書き換えが起きないメリットがあるそうです。

過去の着手の表示

"React で複数の要素を描画するには、React 要素の配列を使うことができます。"とあり、おそらくReactの特徴でしょう。
配列には map() メソッドを用いることでデータを別のデータにマップするのによく利用されます。

const numbers = [1, 2, 3];
const doubled = numbers.map(x => x * 2); // [2, 4, 6]

このmapメソッドを使うことで、着手履歴の配列をマップ(新たに生成)して画面上のボタンを表現する React 要素を作ることができます。そしてこれによって、過去の手番に「ジャンプ」するためのボタンの一覧を表示できます。つまり、mapによってhistoryから新たなReact要素(過去にジャンプするためのボタン)を作り出すことができるということですね。
・Gameコンポーネント

  render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

    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>{moves}</ol>
        </div>
      </div>
    );
  }

jumpToはまだ実装していませんね。<ol>{moves}</ol>の部分にconst movesで定義したbuttonが返ってくるんですね、HTMLがこんなにバラバラに書かれているのは慣れていなかったので、最初は追うのが大変そう…。

keyを選ぶ

リストの項目に変更が生じたかどうかを確かめるためにkeyプロパティを追加します。keyの有無によって、Reactはコンポーネットを作成したり破棄したりするそうです。
なにも指定しなければkeyは、デフォルトで配列のインデックスを使用されますが、keyは動的なリストを構築する場合は割り当てることが推奨されています。またkeyはグローバルで一意にする必要はなく、コンポーネントの関係がある間で一意であれば大丈夫だそうです。

タイムトラベルの実装

三目並べゲームで履歴を保存するには、着手順によって一意なIDを設定可能です。今回の場合<li>の1つに対してkeyを加えることで、設定可能です。
・Gameコンポーネント

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );
    });

ちょっと気持ち悪いですが、mapはhistoryから一個ずつ要素を取ってきて新たな配列を作るものですが、第一引数のstepはhistory[0]などのオブジェクトが入っていて、第二引数にはインデックス番号0などが入っています。なので、moveは0,1,2というように着手順になるため、これをkeyにしています。つまりmovesに入っているものは、liのリストのHTMLがたくさん入っているイメージで、moveとmovesはまったく違うものが入っているイメージですね。
次にjumpToが未定義なので、実装しますがどこへジャンプするのかを定義するために、gameコンポーネントのstateにstepNumberを加えます。初期値は0です。

・Gameコンポーネント

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{
        squares: Array(9).fill(null),
      }],
      stepNumber: 0,
      xIsNext: true,
    };
  }

jumpToを定義し、先ほど定義したstepNumberが更新されるように設定します。xIsNextはstepNumberの値が偶数だった場合はtrueにします。書き方も面白いですね、ifをそのままxIsNextのところに入れ、返ってきた結果をそのまま入れています。

・Gameコンポーネント

  handleClick(i) {
    // this method has not changed
  }

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0,
    });
  }

  render() {
    // this method has not changed
  }

マス目をクリックしたときhandleClickが呼ばれますが、ここにstepNumberを入れる必要がありますね。次の登録するstepNumberは、history.length(historyの最後)に登録すればよいので、そのままstepNumberを入れています。また、タイムトラベルをした後はhistoryを部分的に切り出しているので、historyの0~stepNumber + 1に書き換えれば良いです。sliceの終了位置は指定した位置を含まないので、+1をしてその位置も含めるようにしているんですね。
・Gameコンポーネント

  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    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
      }]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext,
    });
  }

最後にrenderを最後の着手後の状態をレンダーするのではなく、stepNumberによって現在選択されている状態をレンダーするようにします。

・Gameコンポーネント

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    // the rest has not changed

実行結果は↓
f:id:msteacher:20210812151602p:plain
確かに戻ることができるようになりました。
f:id:msteacher:20210812151654p:plain

おわりに

Reactのチュートリアルを通して、HTMLを分けて書くことや、props、stateの使い方がなんとなくわかった気がします。ただかなりいろいろなところにHTMLが分散されているので、慣れるまでは読むのにかなり時間を費やしそうな気がしますね。実務で色々経験していってまた何か気づきがあれば書いてみようかなって思ってます。それではまた。