💬

【React】チュートリアルやってみた⑤

2021/05/10に公開

環境

Windows10Pro

目次

①Reactのプロジェクトを作成し、Webからアクセスできることを確認する。
https://zenn.dev/taka_tech/articles/773da5de8c822a
②クリックしたマスに×を表示させる。
https://zenn.dev/taka_tech/articles/17600f9bc5d4b5
③手番を追加し、クリック時に〇と×が交互に表示されるようにする
https://zenn.dev/taka_tech/articles/e1c0b0b01e9b96
④勝利判定を実装する。
https://zenn.dev/taka_tech/articles/d52991cdb0cbfd
⑤履歴機能を実装する。←ここ

最終目標

Reactチュートリアルの三目並べゲームを作成する
https://ja.reactjs.org/tutorial/tutorial.html

目標⑤

履歴機能を実装する。

実践

履歴情報を保存する配列を用意する

Boardコンポーネントのsquares配列を、1手番ごとに保存していく必要があります。今回は、historyという名前の配列を用意し、その中にsquares配列を入れる。

index.js
// イメージ
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',
    ]
  },
  // ...
]

stateのリフトアップ

履歴情報を参照するのは、トップレベルのGameコンポーネントです。そのためには、Gameコンポーネントがhistory配列にアクセスできる必要がある。history配列のstateは、Gameコンポーネントが持つようにする。SquareコンポーネントのstateをBoardコンポーネントにリフトアップしたときと同様にBoardコンポーネントのstateをGameコンポーネントにリフトアップする。

index.js
// Boardコンポーネント
class Board extends React.Component {
    // コンストラクタ
-    constructor(props) {
-        super(props);
-        this.state = {
-            squares: Array(9).fill(null),
-            xIsNext: true,
-        };
-    }
    
// Gameコンポーネント
class Game extends React.Component {
    // コンストラクタ
+    constructor(props) {
+        super(props);
+        this.state = {
       // 上のイメージ配列
+            history: [{
+                squares: Array(9).fill(null),
+            }],
+            xIsNext: true,
+        };
+    }

squares配列と、onClickイベントは、Gameコンポーネントから受け取るようになるので、
renderSquareメソッドは以下のように修正する必要がある。

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

Gameコンポーネントのrenderメソッドでゲームのメッセージの決定を最新の履歴で行うようにする。

index.js
  // 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>
        );
    }
}

そして、Boardコンポーネントのrenderメソッドで対応するコードを削除する。

index.js
    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>
        );
    }

最後に、handleClickメソッドをBoardコンポーネントからGameコンポーネントに移動させます。historyを扱うので、コードが異なることに注意する。

index.js
    // 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,
        });
    }
    
    // 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,
        });
    }

履歴の表示

mapメソッドを使って、履歴の数だけボタンを表示するようにする。

index.js
 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>
        );

この時点でゲームをプレイすると、1プレイ毎にボタンが増えていくことを確認できる。

ボタンをクリックすると、this.jumpToメソッドが実装されていないエラーが出る。
ディベロッパーツールのコンソールをみると以下のような警告が出る。

keyを選ぶ

動的なリストを構築する場合、keyと呼ばれるReactの特別なプロパティを割り当てる必要がある。renderメソッド内のliタグでkeyを設定する。

index.js
        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>
            );
        });

巻き戻し機能の実装

Gameコンポーネントのstateに、今何番手なのかを保存する変数を加える。

index.js
constructor(props) {
        super(props);
        this.state = {
            history: [{
                squares: Array(9).fill(null),
            }],
+            stepNumber: 0,
            xIsNext: true,
        };
    }

Gameコンポーネントに、手番数を更新し、真偽値を更新するjunpToメソッドを実装する。

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

新たな手番が発生したときに、手番数のstateを更新する必要がある。handleClickメソッドのsetStateでstepNumberを更新する。

index.js
    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,
            }]),
+            stepNumber: history.length,
            xIsNext: !this.state.xIsNext,
        });
    }

無くなった未来を削除する

巻き戻し機能をつかって巻き戻った後に1つ手番を進めた場合、巻き戻る前の未来の盤面の情報は削除しなければならない。そのため、今表示している手番数までの履歴のみを保持するようにする。すなわち、stepNumberを軸に履歴情報を扱うようにする。

index.js
    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() {
        const history = this.state.history;
+        const current = history[this.state.stepNumber];
        const winner = calculateWinner(current.squares);

        const moves = history.map((step, move) => {

完成

感想

React独自のstateの考え方やrenderの考え方を体験することができた。
リフトアップなどは慣れていく必要があるのでこの後も何かしらか作っていきたい。

Discussion