HTML5テクニカルノート
Create React App 入門 07: ゲームの履歴をさかのぼる
- ID: FN2102001
- Technique: ECMAScript 2015
- Library: React 17.0.1
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.jsconst 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.jsexport 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■マルバツゲームの動きは変わらない
02 今が何手目かをボタンのリストで示す
履歴の配列(history
)には、手を指すごとに9マスのデータが加わります。そのデータの数だけページに履歴ボタンを 増やして、今が何手目かを示しましょう(図001)。ボタンを差し込むのは、ゲーム情報表示のコンポーネントです。コンテクストのvalue
には、履歴の配列を含めなければなりません。
図002■何手目かをボタンのリストで示す
src/components/GameContext.jsexport 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.jsconst 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.jsconst 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.jsconst 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.jsexport 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.jsconst 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.jsexport 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.jsconst GameInfo = () => { const moves = history.map((_, move) => { const desc = move ? // 'Move #' + move : 'Go to move #' + move : // 'Game start'; 'Go to game start'; }); };
図003■ボタンで履歴がさかのぼれる
書き替えた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;
}
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;
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 入門
- Create React App 入門 01: 3×3マスのゲーム盤をつくる
- Create React App 入門 02: クリックしたマス目にXをつける
- Create React App 入門 03: マルバツで勝ち負けを決める
- Create React App 入門 04: クラスのコンポーネントをuseState()で関数に書き替える
- Create React App 入門 05: useContextで状態をコンポーネントツリー内に共有する
- Create React App 入門 06: アプリケーションのロジックをコンテクストに切り出す
- Create React App 入門 07: ゲームの履歴をさかのぼる
- Create React App 入門 08: useMemoフックで無駄な再計算を省く
- Create React App 入門 09: useCallbackフックで無駄な処理を省く
- Create React App 入門 10: 条件によってハンドラは無効にする ー useMemoを使って
作成者: 野中文雄
作成日: 2021年02月02日 FN2001003「Create React App 入門 04: ゲームの履歴をさかのぼる」を大幅に改訂。
Copyright © 2001-2021 Fumio Nonaka. All rights reserved.