サイトトップ

Director Flash 書籍 業務内容 プロフィール

HTML5テクニカルノート

Create React App 入門 07: ゲームの履歴をさかのぼる


Create React Appのひな形からReact公式チュートリアルと同じマルバツゲームをつくる「Create React App 入門」シリーズ第07回は、新たな機能を加えます。それは、待ったをかけることです。盤面の配置データを履歴でもたせ、何手でもさかのぼれるようにします。

01 ゲームの履歴を残す

盤面のXとOの配置データは、9マスの値を要素とする配列にもたせました。ゲームは多くても9手で終わりますから、9要素の配列9個ですべての指し手の履歴が残せるということです。そこで、コンテクストのモジュールsrc/components/GameContext.jsの状態変数(squares)に定めていた盤面の配置データはつぎのようにオブジェクトにして、それを親配列(history)の要素として収めることにします。こうすれば、一手ごとに9マスの配置をそれぞれ同じかたちのオブジェクトにしたうえで、配列要素として加えられるでしょう。

src/components/GameContext.js

const initialContext = {
	// squares: Array(9).fill(null),
	history: [
		{squares: Array(9).fill(null)},
	],

};

export const GameProvider = ({ children }) => {
	// const [squares, setSquares] = useState(initialContext.squares);
	const [history, setHistory] = useState(initialContext.history);

}

コンテクストのモジュールsrc/components/GameContext.jsは、9コマの配置データをクリックイベントのハンドラ(handleClick())で書き替え、コンテクストのvalueとしてコンポーネントツリーに渡します。そこで、とりあえずつぎのように、親配列(history)をの最後の要素から直近の配置データ(squares)を取り出すかたちにしました。親配列も要素の配置データも、スプレッド構文...で複製してそれぞれ変数(_history_squares)に収めています。参照もとのデータに直に手を入れないためです(「Create React App 入門 02」ノート01「データの変更にはイミュータビリティが大切」参照)。

src/components/GameContext.js

export const GameProvider = ({ children }) => {

	const handleClick = (i) => {
		const _history = [...history];
		// const _squares = [...squares];
		const _squares = [..._history[_history.length - 1].squares];

		// setSquares(_squares);
		setHistory([..._history, { squares: _squares }]);

	};
	return (
		<GameContext.Provider
			value={{

				// squares,
				squares: [...history[history.length - 1].squares],

			}}
		>
			{children}
		</GameContext.Provider>
	);
};

盤面の配置データの構造をオブジェクトの配列で履歴にしたというだけですから、マルバツゲームの動きはこれまでと変わりません(図001)。

図001■マルバツゲームの動きは変わらない

図001

02 今が何手目かをボタンのリストで示す

履歴の配列(history)には、手を指すごとに9マスのデータが加わります。そのデータの数だけページに履歴ボタンを 増やして、今が何手目かを示しましょう(図001)。ボタンを差し込むのは、ゲーム情報表示のコンポーネントです。コンテクストのvalueには、履歴の配列を含めなければなりません。

図002■何手目かをボタンのリストで示す

図002
src/components/GameContext.js

export const GameProvider = ({ children }) => {

	return (
		<GameContext.Provider
			value={{

				history,

			}}
		>

		</GameContext.Provider>
	);
};

ゲーム情報表示のモジュールsrc/components/GameInfo.jsは、useContextフックで履歴の配列(history)が取り出せます。配列のデータからJSXの要素をつくるのは、Array.prototype.map() メソッドです。ボタン(<button>)を入れ子にした<li>要素を、新たな配列に加えて返します。メソッドのコールバックに渡される第2引数(move)は、配列インデックスです。動的につくり出すJSX要素には、一意のkeyプロパティを与えなければならないことにご注意ください(後述05「keyプロパティを定める」参照)。

src/components/GameInfo.js

const GameInfo = () => {
	// const { winner, xIsNext } = useContext(GameContext);
	const { history, winner, xIsNext } = useContext(GameContext);

	const moves = history.map((_, move) => {
		const desc = move ?
			'Move #' + move :
			'Game start';
		return (
			<li key={move}>
				<button>{desc}</button>
			</li>
		);
	});
	return (
		<div className="game-info">

			<ol>{moves}</ol>
		</div>
	);
};

03 クリックしたボタンの履歴をゲーム情報として示す

それでは、リストのボタンをクリックしたときのゲーム情報の書き替えです。クリックイベントのハンドラ(jumpTo())をコンテクストのモジュールsrc/components/GameContext.jsに加えます。引数(step)は何手目のボタンなのかを示す整数です。その値は状態変数(stepNumber)にもたせます。関数は、改めて勝者(winner)やつぎの差し手を求め直して、勝負がついたか確かめるという処理の流れです。ハンドラはコンテクストのvalueに加えてください。

src/components/GameContext.js

const initialContext = {

	stepNumber: 0,
};

export const GameProvider = ({ children }) => {

	const [stepNumber, setStepNumber] = useState(initialContext.stepNumber);

	const jumpTo = (step) => {
		console.log('jumpto:', step);  // 確認用
		const winner = calculateWinner([...history[step].squares]);
		setWinner(winner);
		setStepNumber(step);
		setXIsNext((step % 2) === 0);
		if (winner) {
			setFinished(true);
		} else {
			setFinished(false);
		}
	};
	return (
		<GameContext.Provider
			value={{

				jumpTo,

			}}
		>

		</GameContext.Provider>
	);
};

ゲーム情報表示のコンポーネントモジュールsrc/components/GameInfo.jsは、コンテクストから受け取ったハンドラ関数(jumpTo())をクリックイベントから呼び出し、引数に渡すのはボタンの差し手のインデックス(move)です。それでコンテクスト(GameContext)の状態が変われば、コンポーネントのゲーム情報に反映されます。

src/components/GameInfo.js

const GameInfo = () => {
	// const { history, winner, xIsNext } = useContext(GameContext);
	const { jumpTo, history, winner, xIsNext } = useContext(GameContext);

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

		return (
			<li key={move}>
				{/* <button>{desc}</button> */}
				<button onClick={() => jumpTo(move)}>{desc}</button>
			</li>
		);
	});

};

04 盤面の配置をクリックした履歴に戻す

ゲーム情報表示のコンポーネントのリストボタンをクリックすると、ゲーム情報は切り替わっても、盤面の目の表示が変わりません。クリックしたのが何手目のボタンかは状態変数(stepNumber)にもたせましたので、コンテクストのvalueに加えましょう。

src/components/GameContext.js

export const GameProvider = ({ children }) => {

	return (
		<GameContext.Provider
			value={{

				// squares: [...history[history.length - 1].squares],
				stepNumber,

			}}
		>

		</GameContext.Provider>
	);
};

そうすれば、マス目のコンポーネントモジュールsrc/components/Square.jsは、履歴(history)からそのマス目データ(squares)を取り出して、表示する印を決めればよいのです。

src/components/Square.js

const Square = ({ id }) => {
	// const { onClick, squares } = useContext(GameContext);
	const { onClick, history, stepNumber } = useContext(GameContext);
	const squares = [...history[stepNumber].squares];

};

あとは、盤面のマス目をクリックしたらそのあとの履歴は消して、そこからゲームが続けられるようにしましょう。コンテクストのモジュールsrc/components/GameContext.jsに定めた盤面クリックのハンドラ(handleClick())は、つぎのように書き替えます。状態変数の履歴インデックス(stepNumber)を変え、Array.prototype.slice()メソッドにより、さかのぼった手からあとのマス目データは除いて、履歴を改めるだけです。

src/components/GameContext.js

export const GameProvider = ({ children }) => {

	const handleClick = (i) => {
		// const _history = [...history];
		const _history = history.slice(0, stepNumber + 1);

		setStepNumber(_history.length);

	};

};

ゲーム情報表示のコンポーネントモジュールsrc/components/GameInfo.jsの履歴ボタンに示すテキストも、Reactの公式チュートリアルに合わせて書き替えましょう(図003)。ボタンで履歴がさかのぼれるようになりました。

src/components/GameInfo.js

const GameInfo = () => {

	const moves = history.map((_, move) => {
		const desc = move ?
			// 'Move #' + move :
			'Go to move #' + move :
			// 'Game start';
			'Go to game start';

	});

};

図003■ボタンで履歴がさかのぼれる

図002

書き替えた3つのモジュールのスクリプト全体は、つぎのコード001にまとめたとおりです。また、CodeSandboxに以下のサンプル001を掲げました。

コード001■ゲームの履歴をさかのぼる機能が加わった

src/components/GameContext.js

import { createContext, useState } from 'react';

const initialContext = {
	history: [
		{squares: Array(9).fill(null)},
	],
	xIsNext: true,
	winner: null,
	stepNumber: 0,
};
export const GameContext = createContext(initialContext);
export const GameProvider = ({ children }) => {
	const [history, setHistory] = useState(initialContext.history);
	const [xIsNext, setXIsNext] = useState(initialContext.xIsNext);
	const [finished, setFinished] = useState(false);
	const [winner, setWinner] = useState(initialContext.winner);
	const [stepNumber, setStepNumber] = useState(initialContext.stepNumber);
	const handleClick = (i) => {
		const _history = history.slice(0, stepNumber + 1);
		const _squares = [..._history[_history.length - 1].squares];
		if (_squares[i]) { return; }
		if (finished) { return; }
		_squares[i] = xIsNext ? 'X' : 'O';
		setHistory([..._history, { squares: _squares }]);
		setXIsNext(!xIsNext);
		setStepNumber(_history.length);
		const _winner = calculateWinner(_squares);
		setWinner(_winner);
		if (_winner) {
			setFinished(true);
		}
	};
	const jumpTo = (step) => {
		const winner = calculateWinner([...history[step].squares]);
		setWinner(winner);
		setStepNumber(step);
		setXIsNext((step % 2) === 0);
		if (winner) {
			setFinished(true);
		} else {
			setFinished(false);
		}
	};
	return (
		<GameContext.Provider
			value={{
				onClick: handleClick,
				jumpTo,
				history,
				stepNumber,
				winner,
				xIsNext,
			}}
		>
			{children}
		</GameContext.Provider>
	);
};

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],
	];
	const length = lines.length;
	for (let i = 0; i < length; i++) {
		const [a, b, c] = lines[i];
		const player = squares[a];
		if (player && player === squares[b] && player === squares[c]) {
			return player;
		}
	}
	return null;
}

src/components/Square.js

import { useContext } from 'react';
import { GameContext } from './GameContext';

const Square = ({ id }) => {
	const { onClick, history, stepNumber } = useContext(GameContext);
	const squares = [...history[stepNumber].squares];
	return (
		<button
			type="button"
			className="square"
			onClick={() => onClick(id)}
		>
			{squares[id]}
		</button>
	);
};

export default Square;
src/components/GameInfo.js

import { useContext } from 'react';
import { GameContext } from './GameContext';

const GameInfo = () => {
	const { jumpTo, history, winner, xIsNext } = useContext(GameContext);
	const status = (winner) ?
		`Winner: ${winner}` :
		`Next player: ${xIsNext ? 'X' : 'O'}`;
	const moves = history.map((_, move) => {
		const desc = move ?
			'Go to move #' + move :
			'Go to game start';
		return (
			<li key={move}>
				<button onClick={() => jumpTo(move)}>{desc}</button>
			</li>
		);
	});
	return (
		<div className="game-info">
			<div>{status}</div>
			<ol>{moves}</ol>
		</div>
	);
};

export default GameInfo;

サンプル001■Create React App: Tic Tac Toe 07

05 keyプロパティを定める

前述項02で、履歴(history)の配置データから複数差し込む<li>要素にはkeyプロパティを与えました。このプロパティが加えられていないと、Reactの開発時にはつぎのような警告が示されます。

Warning: Each child in a list should have a unique "key" prop.

配列から動的に要素を加える場合、数が増えたり減ったり、あるいは順番が変わるかもしれません。keyはそのとき、Reactが要素を識別するために求める一意の値です。もっとも、JavaScriptコードでこの値は参照できませんし、使うこともありません。Reactが内部的に要素をダイナミックに扱えるように与えておくのです(「keyを選ぶ」参照)。

ノート01■keyの値に配列インデックスを使うのは注意が必要

React公式チュートリアルによれば、「配列のインデックスをkeyとして使うことは、項目を並び替えたり挿入/削除する際に問題の原因となります」(「keyを選ぶ」)。一意のkeyは、Reactが要素の動的な変更を追いかけるための仕組みです。問題は配列の途中の要素が除かれたときで、あとの要素のインデックスが繰り上がります。keyが振り直されると、Reactは要素の変更を識別する手段がなくなってしまうのです。

けれど、今回の作例では履歴をさかのぼってやり直しても、あとのボタンが消えるだけで、すでにある要素のkeyは書き替わりません。Reactのチュートリアルの作例でも、keyの値として配列インデックスが用いられています。なお、「keyが指定されなかった場合、Reactは警告を表示し、デフォルトでkeyとして配列のインデックスを使用します」(「keyを選ぶ」)。また、「keyはグローバルに一意である必要はありません。コンポーネントとその兄弟の間で一意であれば十分です」(同前)。

Create React App 入門


作成者: 野中文雄
作成日: 2021年02月02日 FN2001003「Create React App 入門 04: ゲームの履歴をさかのぼる」を大幅に改訂。


Copyright © 2001-2021 Fumio Nonaka.  All rights reserved.