#タイムトラベル機能の追加
前回は通常の三目並べ完成までやりました。
今回はその三目並べに「タイムトラベル機能」なるものを実装していきたいと思います。履歴ですね。
##着手の履歴の保存
suquaresの配列をsetState
で毎回新規オブジェクトで更新していたことがここで活きるらしいです。
このオブジェクトを更新のたびに保持していきます。
その履歴を保持する場所は一番TOPのGame
にするそうです。
これによりBoardはstate
を保持する必要がなくなります。
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [{ suares: Array(9).fill(null) }],
xIsNext: true,
};
}
また、status
の更新やonClick
の処理もすべてGameに持っていくことができます。
以下がすべてを移動させたバージョン
class Board extends React.Component {
// constructor削除
renderSquare(i) {
return (
<Square
value={this.props.squares[i]} //state → props
onClick={() => {
this.props.onClick(i); // 処理はすべてGameに移動して、GameのonClickを呼び出す
}}
/>
);
}
render() {
return (
<div>
{/* satateは削除。Game側で表示する */}
<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>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
// Gameで履歴を保持する
this.state = {
history: [{ squares: Array(9).fill(null) }],
xIsNext: true,
};
}
render() {
// statusの表示を移動してきた
const history = this.state.history;
const current = history[history.length - 1]; // 最新の履歴を取得
const winner = this.calculateWinner(current.squares);
let status;
if (winner) {
status = '勝者:' + winner;
} else {
status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
}
return (
<div className="game">
<div className="gmae-board">
<Board
squares={current.squares} // クロージャを使ってcurrentからsquaresを取得して、Boardに渡す
// BoardからonClick処理を移動
onClick={i => {
const squares = Object.create(current.squares); // 最新のsquare
if (this.calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? 'X' : 'O';
// 更新用のstateを作る
const newState = {
// historyに新しい履歴を追加する
history: history.concat({ squares: squares }),
xIsNext: !this.state.xIsNext,
};
this.setState(newState);
}}
/>
</div>
<div className="game-info">
<div>{status}</div>
<div>{/* TODO */}</div>
</div>
</div>
);
}
// 勝敗判定関数(Boardから移動してきた)
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;
}
}
history.concat
のところはArray.prototype.push
ではなくArray.prototype.concat
を使用します。
pushだと既存のstateを更新してしまうことになります。
concatであれば、新たに配列を作り出すため、安全です。
Array.prototype.concat()
##過去の着手の表示
※以降はチュートリアルはあまり見ずに自分でやり遂げてみたかったので、ちょっとチュートリアルとは異なります。
履歴はすべて保持しておいて、「○回目の履歴表示」ボタンが押されたら、その履歴の状態を表示させるようです。
これを実現させるためにstate
に「表示したい履歴のインデックス」を表すstepNumber
を設けます。
class Game extends React.Component {
constructor(props) {
super(props);
// Gameで履歴を保持する
this.state = {
history: [{ squares: Array(9).fill(null) }],
stepNumber: 0, // 表示したい履歴のインデックスを表す
xIsNext: true,
};
}
render
ではstepNumber
を用いて表示したい履歴を取得します。(current
のところ)
render() {
// statusの表示を移動してきた
const history = this.state.history;
const current = history[this.state.stepNumber]; // カレントはstepNumberのインデックスで求める
const winner = this.calculateWinner(current.squares);
そして、履歴表示ボタンのHTMLを作ります。
履歴の配列をmap
でループして、新しい配列を作ります。
この配列は「履歴表示ボタンのHTML」の配列になります。
Array.prototype.map()
// 履歴表示ボタン配列を作成
const moves = history.map((step, move) => {
const desc = move ? 'Go to move #' + move : 'Go to game start';
return (
// keyが無いと警告がでる
<li key={move}>
<button
// 履歴ボタン押下イベント
onClick={() => {
// 対象の履歴インデックスの状態に変更する
// xIsNextは2で割ったあまりで求められる
this.setState({
stepNumber: move,
xIsNext: move % 2 === 0,
});
}}
>
{desc} {/* ボタン表示名 */}
</button>
</li>
);
});
【※2020/03/24追記】
li
にkey
属性がないため、エラーになっていました。
一意な値を振ることが推奨されるようです。
key を選ぶ
step
はhistory内の1つ1つの要素を表します。
move
は、今のstep
要素が配列の中でどの位置にいる要素であるか(インデックス)を表す。
onClick
イベントで、表示させたい履歴のインデックスと、xIsNext
をsetState
します。
ここではhistroty
は更新しません。履歴を保持している配列の中から、指定したインデックスの履歴を表示させるだけなので、更新する必要はありません。
returnのところ
最後に、BoardのHTMLを返すreturn();
の所です。
Boardに渡すonClick
は、Squareのクリックイベント(つまり、マス目を押された時)の処理です。
ここでは、マス目を押された時点からまた新しく履歴を保持するようにします。
なので、**「最初の履歴 ~ 今表示している履歴」**までを、履歴の配列全体から抜き出し、その抜き出した物の最後尾に今の状態(履歴)を追加します。
return (
<div className="game">
<div className="gmae-board">
<Board
squares={current.squares} // クロージャを使ってcurrentからsquaresを取得して、Boardに渡す
// BoardからonClick処理を移動(※これはあくまでマス目押下イベント)
onClick={i => {
const squares = Object.create(current.squares); // カレントのsquare
if (this.calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? 'X' : 'O';
// 履歴の最初~直前に押された履歴までを抜き出す
const newHistory = history.slice(0, this.state.stepNumber + 1);
// 更新用のstateを作る
const newState = {
// 抜き出した履歴の続きからまた新たに履歴を保持していく
history: newHistory.concat({ squares: squares }),
stepNumber: newHistory.length, // 最新のインデックス
xIsNext: !this.state.xIsNext,
};
this.setState(newState);
}}
/>
</div>
<div className="game-info">
<div>{status}</div>
<div>{moves}</div>
</div>
</div>
);
moves
はTODO
となっていたところに埋め込みます。
これで完成です。
全文
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as serviceWorker from './serviceWorker';
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
class Board extends React.Component {
// constructor削除
renderSquare(i) {
return (
<Square
value={this.props.squares[i]} //state → props
onClick={() => {
this.props.onClick(i); // 処理はすべてGameに移動して、GameのonClickを呼び出す
}}
/>
);
}
render() {
return (
<div>
{/* satateは削除。Game側で表示する */}
<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>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
// Gameで履歴を保持する
this.state = {
history: [{ squares: Array(9).fill(null) }],
stepNumber: 0, // 表示したい履歴のインデックスを表す
xIsNext: true,
};
}
render() {
// statusの表示を移動してきた
const history = this.state.history;
const current = history[this.state.stepNumber]; // カレントはstepNumberのインデックスで求める
const winner = this.calculateWinner(current.squares);
// 履歴表示ボタン配列を作成
const moves = history.map((step, move) => {
const desc = move ? 'Go to move #' + move : 'Go to game start';
return (
<li>
<button
// 履歴ボタン押下イベント
onClick={() => {
// 対象の履歴インデックスの状態に変更する
// xIsNextは2で割ったあまりで求められる
this.setState({
stepNumber: move,
xIsNext: move % 2 === 0,
});
}}
>
{desc} {/* ボタン表示名 */}
</button>
</li>
);
});
let status;
if (winner) {
status = '勝者:' + winner;
} else {
status = '次の手番: ' + (this.state.xIsNext ? 'X' : 'O');
}
return (
<div className="game">
<div className="gmae-board">
<Board
squares={current.squares} // クロージャを使ってcurrentからsquaresを取得して、Boardに渡す
// BoardからonClick処理を移動(※これはあくまでマス目押下イベント)
onClick={i => {
const squares = Object.create(current.squares); // カレントのsquare
if (this.calculateWinner(squares) || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? 'X' : 'O';
// 最初の履歴~直前に押された履歴までを抜き出す
const newHistory = history.slice(0, this.state.stepNumber + 1);
// 更新用のstateを作る
const newState = {
// 抜き出した履歴の続きからまた新たに履歴を保持していく
history: newHistory.concat({ squares: squares }),
stepNumber: newHistory.length, // 最新のインデックス
xIsNext: !this.state.xIsNext,
};
this.setState(newState);
}}
/>
</div>
<div className="game-info">
<div>{status}</div>
<div>{moves}</div>
</div>
</div>
);
}
// 勝敗判定関数(Boardから移動してきた)
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;
}
}
ReactDOM.render(
<React.StrictMode>
<Game />
</React.StrictMode>,
document.getElementById('root')
);
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
#感想
ちょっと最後らへんは混乱しました。
数日たってソースみるとおそらく解読できないと思います。
jQueryのようにセレクタをたくさん書かずとも実現できたのはメリットだと思いました。
おそらくjQueryならもっとソースコードが煩雑になるはず。
このチュートリアルはQiitaで別の方々がたくさん試されているので、今更でしたね・・・笑