MSBOOKS

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

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

はじめに

react公式のチュートリアルの続き、ゲームを完成させるからやります。三目並べゲームを盤面に交互に置けるようにすることと勝利判定をやっていきます。
ja.reactjs.org

Stateのリフトアップ

前回まではstateとしてそれぞれのSquareコンポーネントがprivate的な変数に状態を保持していたものを、親のBoardコンポーネントで保持できるよう変更します。バラバラのままでは今squareに何の値が入っているか何度も呼びにいかないといけなくなりますからね。これによって可読性やリファクタリングのしやすさが向上するそうです。
具体的にはBoard コンポーネントが、各Squareコンポーネントにprops を渡すことで、何を表示すべきかを伝えられます。以前やったpublicな変数に値を入れるってイメージのやつですね。

数の子要素からデータを集めたい、または 2 つの子コンポーネントに互いにやりとりさせたいと思った場合は、代わりに親コンポーネント内で共有の state を宣言する必要があります。親コンポーネントは props を使うことで子に情報を返すことができます。つまり、親コンポーネントで共有変数を用意しておいて、子へ渡すときは子のpropsに直接書き込めば良いってことですね。これを親コンポーネントにリフトアップするというそうです。子の変数を親へリフトするってイメージですかね。
・Boardコンポーネント

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

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

renderSquareが呼ばれたら、Squareの中の情報が、Boardのstateに入っている情報に更新されるようになりました。

次に、マス目がクリックされた時の挙動を変更しましょう。現在、どのマス目に何が入っているのかを管理しているのは Board です。Square が Board の state を更新できるようにする必要があります。state はそれを定義しているコンポーネント内でプライベートなものですので、Square から Board の state を直接書き換えることはできません。代わりに、Board から Square に関数を渡すことにして、マス目がクリックされた時に Square にその関数を呼んでもらうようにしましょう。
・Boardコンポーネント

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

Squareに渡すpropsとしてvalueと、今回onClickを追加しています。onClickはマス目がクリックされた時に Square が呼び出すためのものだそうで、この後定義します。

・Squareコンポーネント

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
        onClick={() => this.props.onClick()}
      >
        {this.props.value}
      </button>
    );
  }
}

親のBoardで基本的には情報を管理するようにしたので、表示するものをthis.props.valueとすることで、受け取った値をそのまま表示しています。でもこのままだとSquareでクリックされたとわかっても親コンポーネントの値を変えられないので、さっきpropsに追加したonClickを使います。SquareのonClickをthis.props.onClick()とすることで、propsで渡されてきたonClick、つまり親のthis.handleClick(i)が呼ばれるんですね。

・Boardコンポーネント

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = 'X';
    this.setState({squares: squares});
  }

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

  render() {
    const status = 'Next player: X';

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

handleClickではsliceを使って(この重要性は後述)squaresをコピーしてきて、iで指定した箇所にXを代入し、新たにできたsquaresをsetStateで更新しています。Board の state が変更になると、squaresとして全体を更新しているので、個々の Square コンポーネントも自動的に再レンダーされていますね。
Square コンポーネントは、Board コンポーネントから値を受け取って、クリックされた時はそのことを Board コンポーネントに伝えるだけになりました。React 用語でいうと、Square コンポーネント制御されたコンポーネント (controlled component) になったということです。Board が Square コンポーネントを全面的に制御しています。

イミュータビリティは何故重要なのか

さっきのhandleClickでsquaresをsliceでコピーしている理由についてですね。この操作をイミュータビリティ(immutability; 不変性)というそうです。反対にデータの値を直接いじってデータを書き換えることを、ミューテート(mutate; 書き換え)するというそうです。メリットとしては以下の3つがあげられていました。

  1. 複雑な機能が簡単に実現できる:ゲームの以前のヒストリをそのまま保って後で再利用することができるようになる。
  2. 変更の検出:イミュータブルなオブジェクトでの変更の検出は簡単で、参照しているイミュータブルなオブジェクトが前と別のものであれば、変更があったと判断できる。
  3. React の再レンダータイミングの決定:イミュータブルなデータは変更があったかどうか簡単に分かるため、コンポーネントをいつ再レンダーすべきなのか決定しやすくなる。

つまり、変更の検出が容易であるため、stateの値を遡って利用したり、再レンダーするタイミングが容易になるっていうことでしょうか。

関数コンポーネント

React における関数コンポーネントとは、render メソッドだけを有して自分の state を持たないコンポーネントを、よりシンプルに書くための方法です。React.Component を継承するクラスを定義する代わりに、props を入力として受け取り表示すべき内容を返す関数を定義します。関数コンポーネントはクラスよりも書くのが楽であり、多くのコンポーネントはこれで書くことができます。確かに先ほどのSquareコンポーネントはやることが少ない割に書くことが多かったですからね。thisが省略できるのも良いね。
・Squareコンポーネント→Square関数コンポーネント

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

手番の処理

XとOのOがまだ出てきていないのを修正します。デフォルトでは、先手を仮に“X”にします。
・Boardコンポーネント

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

プレーヤが着手するたびに、どちらのプレーヤの手番なのかを決める xIsNext(真偽値)が反転され、ゲームの状態が保存されます。
・Boardコンポーネント

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

this.state.xIsNext ? 'X' : 'O'という三項演算子が急に出てきてびっくりしましたが、xIsNextがtrueだったらX、falseだったらOを入れるという感じですね。先ほど手番で決めたようにtrueはXの番だったので、それを入れているようです。最後に!で真偽値を反転していますね。
Board の render 内にある “status” テキストも変更して、どちらのプレーヤの手番なのかを表示するようにしましょう。
・Boardコンポーネント

  render() {
    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      // the rest has not changed

・Boardコンポーネント全体

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

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

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

  render() {
    const 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>
    );
  }
}

おおよその枠組みができました。ちなみにこのままだとOとXが同じSquareを奪い合える状態ですね、これは後で直すんでしょう。

ゲーム勝者の判定

次にやることはゲームが決着して次の手番がなくなった時にそれを表示することです。新たにcalculateWinner関数を追加するようです。
・calculateWinner関数

function calculateWinner(squares) {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    [1, 4, 7],
    [2, 5, 8],
    [0, 4, 8],
    [2, 4, 6],
  ];
  for (let i = 0; i < lines.length; i++) {
    const [a, b, c] = lines[i];
    if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
      return squares[a];
    }
  }
  return null;
}

9 つの square の配列が与えられると、この関数は勝者がいるか確認し、'X' か 'O'、あるいは null を返します。揃っているかの確認はconst linesで地道に全部定義してありますね。これさらに大きい盤面になったら大変そう…、forとかで定義できるならそっちの方がよさそうですね。

Board の render 関数内で calculateWinner(squares) を呼び出して、いずれかのプレーヤが勝利したかどうか判定し、決着がついた場合は “Winner: X” あるいは “Winner: O” のようなテキストを表示するように変更します。
・Boardコンポーネント

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

    return (
      // the rest has not changed

レンダーするところで定義してますね。if(winner)でwinnerがいる場合はこちらに入り、nullだった場合はelseの方に入っています。

・Boardコンポーネント

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

先ほど言ったクリックしたマス目が埋まっている場合の処理ですね。決着がついている場合も即returnしてクリックしても次のターンに進まないようになっています。実行結果↓
f:id:msteacher:20210811145650p:plain

おわりに

今回はゲームを完成させる部分までやりました。Squareは表示とクリックされたかの判定をするだけに役割を分けることで、基本の操作はBoardに集約し、管理しやすくなりました。stateとpropsの使い方もだんだんわかってきた気がします。次回はsquaresのイミュータビリティの重要性がわかるタイムトラベル機能の追加の部分をやっていきたいと思います。それではまた。