前書き
SPAやってみたいなあと思っていたので、以下の記事を読んでReactを初めてみました。
ReactとVueのどちらを選ぶか - Qiita
https://qiita.com/teradonburi/items/fb91e5feacab5071cfef - Qiita
◆ Spring Bootで作成したAPIをReactからGETする - Qiita
最初にやってみるのはやはり公式ということで、チュートリアル:React の導入にチャレンジ。
基本的なprops
やstate
の考え方が分かりやすく、リファクタリング手順も載っているのでかなり見返すことになりそうです。
チュートリアルの最後に以下のような記載がありましたので、こちらにもチャレンジ。
時間がある場合や、今回身につけた新しいスキルを練習してみたい場合に、あなたが挑戦できる改良のアイデアを以下にリストアップしています。
見返した自分の役に立つようチャレンジ時に考えていたことも書いているので、
参考になれば幸いです。
実装
コードは最終手順からのステップアップ形式でやっていきます。
1. 履歴内のそれぞれの着手の位置を (col, row) というフォーマットで表示する。
まず、変更したいのは以下の部分です。
// 省略
class Game extends React.Component {
constructor(props) {
// 省略
}
handleClick(i) {
// 省略
}
jumpTo(step) {
// 省略
}
render() {
const history = this.state.history;
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
const desc = move ?
'Go to move #' + move : // ←ここ
'Go to game start';
return (
// 省略
);
});
// 省略
}
}
// 省略
この部分で以下のように表示できれば。。。
const desc = move ?
'Go to move #' + move + '(' + col + ', ' + row + ')' :
'Go to game start';
では、col
とrow
はどのように求めるか。
1マスを表すSquare
コンポーネントで、自身の座標を持つのが良さそうかな?と最初は考えました。
しかし、State のリフトアップで行ったように、
Board が各 Square に、現時点の state がどうなっているか問い合わせればよいだけでは、と思うかもしれません。
React でそれをすることも可能ですが、コードが分かりにくく、より壊れやすく、リファクタリングしづらいものになるのでお勧めしません。
とあります。
実際にSquare
コンポーネント(とその親のBoard
コンポーネント)はステートレスの状態になっているので、
Game
コンポーネントで管理することになりそうです。
ではまず、Square
コンポーネントを生成している部分を見ていきます。
このプログラム内でクリックした場所を表すのは、以下の部分です。
// 省略
class Board extends React.Component {
renderSquare(i) { // ← 2. ここの引数として渡され
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)} // ← 3. ここでGameコンポーネントから受け取った関数に渡される
/>
);
}
render() {
return (
<div>
<div className="board-row">
{this.renderSquare(0)} // ← 1. ここでべた書きされているセル番号が
{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>
);
}
}
// 省略
つまり、最終的にGame
コンポーネントから渡す関数オブジェクトに渡されるわけですね。
この関数オブジェクトは以下のように定義されています。
// 省略
class Game extends React.Component {
// 省略
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() {
// 省略
return (
<div className="game">
<div className="game-board">
<Board
squares={current.squares}
onClick={i => this.handleClick(i)} // ← ここでpropsとして渡されている
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
</div>
</div>
);
}
}
ですので、handleClick
メソッド内部でstate
として保持してあげれば良さそうです。
現状state
として保持されている情報は以下の3つです。
- history:
squares
をプロパティとして持つObjectの配列 - stepNumber: いま何手目の状態を見ているのかを表す数字
- xIsNext: 次が
X
の手番であるかどうかを表す真偽値
保持したい情報は各手番で押されたセルの座標
ですので、history
と1対1の関係ですね。
ここで、history
が「Objectの配列」であるということが大切になります。
実際、私がチュートリアルをやっていたときは「history
ってオブジェクトでラップせずに配列の配列でよくね」と考えていましたが、
ここまで手を進めて拡張性のためにわざわざしていたのだと気付きました。(違ったらすみません。。。)
ということで、history
の配列内オブジェクトにプロパティを追加します。
// 省略
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
- squares: Array(9).fill(null)
+ squares: Array(9).fill(null),
+ location: {
+ col: null,
+ row: null,
+ },
}
],
stepNumber: 0,
xIsNext: true
};
}
// 省略
}
初期値はnull
ですが、handleClick
メソッド内で値を設定します。
col
は0~8
のセル番号を3で割った余り
にすればよいでしょう。
コードで表すと、i % 3
となります。
row
は0~8
のセル番号を3で割った商
にすればよいでしょう。
コードで表すと、Math.trunc(i / 3)
となります。
※Math.floor
でも良いという記事もあります。
今回はi
が0を含む自然数ですので問題にはなりませんが、Math.trunc
の方が定義としては正しいでしょう。
Math.trunc(-6.5 / 3) -> -2
とMath.floor(-6.5 / 3) -> -3
という違いがあります。
// 省略
class Game extends React.Component {
// 省略
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
+ squares: squares,
+ location: {
+ col: i % 3,
+ row: Math.trunc(i / 3),
+ },
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
// 省略
}
座標を保持できたので、ボタンに表示します。
// 省略
class Game extends React.Component {
// 省略
render() {
const history = this.state.history;
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
const desc = move ?
- 'Go to move #' + move :
+ 'Go to move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={move}>
<button onClick={() => this.jumpTo(move)}>{desc}</button>
</li>
);
});
// 省略
}
}
// 省略
これで完成です。
【クリックで展開】ここまでのコードは以下になります。([CodePenはこちら](https://codepen.io/ExoticToybox/pen/jOPKwbr))
<div id="errors" style="
background: #c00;
color: #fff;
display: none;
margin: -20px -20px 20px;
padding: 20px;
white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
document.body.classList.add('mouse-navigation');
document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
if (e.keyCode === 9) {
document.body.classList.add('kbd-navigation');
document.body.classList.remove('mouse-navigation');
}
});
window.addEventListener('click', function(e) {
if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
e.preventDefault();
}
});
window.onerror = function(message, source, line, col, error) {
var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
errors.textContent += text + '\n';
errors.style.display = '';
};
console.error = (function(old) {
return function error() {
errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
errors.style.display = '';
old.apply(this, arguments);
}
})(console.error);
</script>
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
/>
);
}
render() {
return (
<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>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null),
location: {
col: null,
row: null,
},
}
],
stepNumber: 0,
xIsNext: true
};
}
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,
location: {
col: i % 3,
row: Math.trunc(i / 3),
},
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
render() {
const history = this.state.history;
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
const desc = move ?
'Go to move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={move}>
<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>
);
}
}
// ========================================
ReactDOM.render(<Game />, document.getElementById("root"));
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;
}
2. 着手履歴のリスト中で現在選択されているアイテムをボールドにする。
ボタンのテキストを**font-weight: bold
**にします。
イメージはこんな感じ。
なんか1.より2.の方が簡単な気もしますが。。。
まずCSSに**bold
**のスタイルを作成します。
/* 省略 */
.text-bold {
font-weight: bold;
}
このスタイルをどのように当てるかですが、チュートリアル内で紹介されている一段階ずつ学べるガイドの項目の中に2. JSX の導入というものがあります。
これによると、
あらゆる有効な JavaScript の式を JSX 内で中括弧に囲んで使用できます。
ということなので、className
の右辺で中括弧を使用した分岐ができれば良さそう。
// 省略
class Game extends React.Component {
// 省略
render() {
const history = this.state.history;
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
const desc = move ?
'Go to move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={move}>
- <button onClick={() => this.jumpTo(move)}>{desc}</button>
+ <button
+ onClick={() => this.jumpTo(move)}
+ className={move === this.state.stepNumber ? 'text-bold' : ''}
+ >
+ {desc}
+ </button>
</li>
);
});
// 省略
}
}
// 省略
※読みやすくするため、onClick
とclassName
の両プロパティをそれぞれ独立した行に配置
【クリックで展開】ここまでのコードは以下になります。([CodePenはこちら](https://codepen.io/ExoticToybox/pen/GRJGEjb))
<div id="errors" style="
background: #c00;
color: #fff;
display: none;
margin: -20px -20px 20px;
padding: 20px;
white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
document.body.classList.add('mouse-navigation');
document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
if (e.keyCode === 9) {
document.body.classList.add('kbd-navigation');
document.body.classList.remove('mouse-navigation');
}
});
window.addEventListener('click', function(e) {
if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
e.preventDefault();
}
});
window.onerror = function(message, source, line, col, error) {
var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
errors.textContent += text + '\n';
errors.style.display = '';
};
console.error = (function(old) {
return function error() {
errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
errors.style.display = '';
old.apply(this, arguments);
}
})(console.error);
</script>
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
.text-bold {
font-weight: bold;
}
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
/>
);
}
render() {
return (
<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>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null),
location: {
col: null,
row: null,
},
}
],
stepNumber: 0,
xIsNext: true
};
}
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,
location: {
col: i % 3,
row: Math.trunc(i / 3),
},
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
render() {
const history = this.state.history;
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
const desc = move ?
'Go to move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={move}>
<button
onClick={() => this.jumpTo(move)}
className={move === this.state.stepNumber ? 'text-bold' : ''}
>
{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>
);
}
}
// ========================================
ReactDOM.render(<Game />, document.getElementById("root"));
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;
}
3. Board でマス目を並べる部分を、ハードコーディングではなく 2 つのループを使用するように書き換える。
見た目に変更はありません。
こちらもとっかかりやすいですね。
対象は以下の部分です。
// 省略
class Board extends React.Component {
// 省略
render() {
const cols = [0, 1, 2];
return (
<div>
<div className="board-row"> // ┐
{this.renderSquare(0)} // ┐ │
{this.renderSquare(1)} // │ここがループ(1) │
{this.renderSquare(2)} // ┘ │
</div> // │
<div className="board-row"> // │
{this.renderSquare(3)} // │
{this.renderSquare(4)} // │ ここがループ(2)
{this.renderSquare(5)} // │
</div> // │
<div className="board-row"> // │
{this.renderSquare(6)} // │
{this.renderSquare(7)} // │
{this.renderSquare(8)} // │
</div> // ┘
</div>
);
}
}
// 省略
まずループ(1)からやっていきます。
JSXでは中括弧で囲まれた部分でJavascriptを使用できるので、以下のようにループします。
// 省略
class Board extends React.Component {
// 省略
render() {
+ const cols = [0, 1, 2];
return (
<div>
<div className="board-row">
- {this.renderSquare(0)}
- {this.renderSquare(1)}
- {this.renderSquare(2)}
+ {cols.map(col => this.renderSquare(col))}
</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>
);
}
}
// 省略
このままではチュートリアルの「過去の着手の表示」項目でも起きたように、
以下の警告も出力されているはずです。
Warning: Each child in an array or iterator should have a unique "key" prop. Check the render method of "Board".
そのため、renderSquare
メソッド内で呼び出しているSquare
コンポーネントにkey
プロパティを追加します。
// 省略
class Board extends React.Component {
renderSquare(i) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
+ key={i}
/>
);
}
// 省略
}
// 省略
これでエラーが表示されなくなったので、他の<div className="board-row">
にもループを適用します。
// 省略
class Board extends React.Component {
// 省略
render() {
const cols = [0, 1, 2];
return (
<div>
<div className="board-row">
{cols.map(col => this.renderSquare(col))}
</div>
<div className="board-row">
- {this.renderSquare(3)}
- {this.renderSquare(4)}
- {this.renderSquare(5)}
+ {cols.map(col => this.renderSquare(col + 3))}
</div>
<div className="board-row">
- {this.renderSquare(6)}
- {this.renderSquare(7)}
- {this.renderSquare(8)}
+ {cols.map(col => this.renderSquare(col + 6))}
</div>
</div>
);
}
}
// 省略
そして、ループ(2)を実装します。
// 省略
class Board extends React.Component {
// 省略
render() {
+ const rows = [0, 1, 2];
const cols = [0, 1, 2];
return (
<div>
- <div className="board-row">
- {cols.map(col => this.renderSquare(col))}
- </div>
- <div className="board-row">
- {cols.map(col => this.renderSquare(col + 3))}
- </div>
- <div className="board-row">
- {cols.map(col => this.renderSquare(col + 6))}
- </div>
+ {rows.map(row => {
+ return (
+ <div className="board-row">
+ {cols.map(col => this.renderSquare(row * 3 + col))}
+ </div>
+ );
+ })}
</div>
);
}
}
// 省略
2重ループは実装できましたが、また同じエラーが出ているので、row
でループしている<div className="board-row">
にもkey
プロパティを追加します。
// 省略
class Board extends React.Component {
// 省略
render() {
const rows = [0, 1, 2];
const cols = [0, 1, 2];
return (
<div>
{rows.map(row => {
return (
- <div className="board-row">
+ <div
+ className="board-row"
+ key={row}
+ >
{cols.map(col => this.renderSquare(row * 3 + col))}
</div>
);
})}
</div>
);
}
}
// 省略
※読みやすくするため、className
とkey
の両プロパティをそれぞれ独立した行に配置
【クリックで展開】ここまでのコードは以下になります。([CodePenはこちら](https://codepen.io/ExoticToybox/pen/XWbYgRZ))
<div id="errors" style="
background: #c00;
color: #fff;
display: none;
margin: -20px -20px 20px;
padding: 20px;
white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
document.body.classList.add('mouse-navigation');
document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
if (e.keyCode === 9) {
document.body.classList.add('kbd-navigation');
document.body.classList.remove('mouse-navigation');
}
});
window.addEventListener('click', function(e) {
if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
e.preventDefault();
}
});
window.onerror = function(message, source, line, col, error) {
var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
errors.textContent += text + '\n';
errors.style.display = '';
};
console.error = (function(old) {
return function error() {
errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
errors.style.display = '';
old.apply(this, arguments);
}
})(console.error);
</script>
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
.text-bold {
font-weight: bold;
}
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
key={i}
/>
);
}
render() {
const rows = [0, 1, 2];
const cols = [0, 1, 2];
return (
<div>
{rows.map(row => {
return (
<div
className="board-row"
key={row}
>
{cols.map(col => this.renderSquare(row * 3 + col))}
</div>
);
})}
</div>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null),
location: {
col: null,
row: null,
},
}
],
stepNumber: 0,
xIsNext: true
};
}
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,
location: {
col: i % 3,
row: Math.trunc(i / 3),
},
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
render() {
const history = this.state.history;
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
const desc = move ?
'Go to move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={move}>
<button
onClick={() => this.jumpTo(move)}
className={move === this.state.stepNumber ? 'text-bold' : ''}
>
{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>
);
}
}
// ========================================
ReactDOM.render(<Game />, document.getElementById("root"));
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;
}
4. 着手履歴のリストを昇順・降順いずれでも並べかえられるよう、トグルボタンを追加する。
こちらは一見簡単そうに見えます。
具体的には、「Reverse history order」のボタンを用意し、クリックした際にhistory
プロパティを逆順にするreverseHistoryOrder
メソッドを追加します。
// 省略
class Game extends React.Component {
// 省略
+ reverseHistoryOrder() {
+ this.setState({
+ history: this.state.history.slice().reverse(),
+ });
+ }
render() {
// 省略
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>
+ <button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
</div>
</div>
);
}
}
ですが、この方法では以下の点で不具合が生じています。(他にもあるが割愛)
- 「Go to game start」ボタンが常に最初に表示されている
- 「Go to move」の番号が逆転しない
- 逆順にした際にゲームデータがリセットされる
- ゲームを続けた際、
history
の最後にmove
が追加される
これらの不具合は、Go to ~
ボタンの表示がhistory.map
のインデックス(変数名はmove
)に基づいて行われているためです。
解消するには、Game
コンポーネントで現状「昇順」と「降順」のどちらで表示されているかを管理する必要があります。
まず、Game
コンポーネントのstate
にisAscendingOrder
の真偽値を追加します。
// 省略
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null),
location: {
col: null,
row: null,
},
}
],
stepNumber: 0,
+ isAscendingOrder: true,
xIsNext: true
};
}
// 省略
}
次に、reverseHistoryOrder
メソッド内ではisAscendingOrder
を反転させます。
// 省略
class Game extends React.Component {
// 省略
reverseHistoryOrder() {
this.setState({
- history: this.state.history.slice().reverse(),
+ isAscendingOrder: !this.state.isAscendingOrder,
});
}
// 省略
}
これでhistory
の並び順を管理できるようになりましたが、このままでは表示が変わりません。
Game
コンポーネントのrender
メソッドを修正します。
// 省略
class Game extends React.Component {
// 省略
render() {
- const history = this.state.history;
// 逆順の場合はthis.state.history配列のコピーを反転させる
// これにより、this.state.historyはつねに昇順のデータを保持し続ける
+ const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
const current = history[this.state.stepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
// 逆順の場合はインデックスを反転させる
+ const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
- const desc = move ?
+ const desc = moveIndex ?
- 'Go to move #' + move + '(' + step.location.col + ', ' + step.location.row + ')' :
+ 'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
- <li key={move}>
+ <li key={moveIndex}>
<button
- onClick={() => this.jumpTo(move)}
+ onClick={() => this.jumpTo(moveIndex)}
className={move === currentStepNumber ? 'text-bold' : ''}
>
{desc}
</button>
</li>
);
});
// 省略
}
}
// 省略
ポイントは(コメントにも書いていますが)this.state.history
配列のコピーを反転させることです。
チュートリアルの「イミュータビリティは何故重要なのか」項目にも記載されていますが、
this.state.history
はつねに昇順のデータを保持し、あくまで表示のタイミングのみ反転した履歴を扱うことで、
Game
コンポーネントのhandleClick
メソッドなどが修正不要となります。
ただし、このままでは画像のように、順序を入れ替えた場合に「現在選択されているアイテム」がずれてしまうバグがあります。
#3
を選択していたのに、反転後に#1
が選択されてしまっている
ですので、「現在選択されているアイテム」は降順の場合に反転させる必要があります。
// 省略
class Game extends React.Component {
// 省略
render() {
// 逆順の場合はthis.state.history配列のコピーを反転させる
// これにより、this.state.historyはつねに昇順のデータを保持し続ける
const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
// 現在選択されているアイテムのインデックスを逆順の場合に反転させる
+ const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
- const current = history[this.state.stepNumber];
+ const current = history[currentStepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
// 逆順の場合はインデックスを反転させる
const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
const desc = moveIndex ?
'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={moveIndex}>
<button
onClick={() => this.jumpTo(moveIndex)}
- className={move === this.state.stepNumber ? 'text-bold' : ''}
+ className={move === currentStepNumber ? 'text-bold' : ''}
>
{desc}
</button>
</li>
);
});
// 省略
}
}
// 省略
これで完成です。
【クリックで展開】ここまでのコードは以下になります。([CodePenはこちら](https://codepen.io/ExoticToybox/pen/mdJKwqb))
<div id="errors" style="
background: #c00;
color: #fff;
display: none;
margin: -20px -20px 20px;
padding: 20px;
white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
document.body.classList.add('mouse-navigation');
document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
if (e.keyCode === 9) {
document.body.classList.add('kbd-navigation');
document.body.classList.remove('mouse-navigation');
}
});
window.addEventListener('click', function(e) {
if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
e.preventDefault();
}
});
window.onerror = function(message, source, line, col, error) {
var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
errors.textContent += text + '\n';
errors.style.display = '';
};
console.error = (function(old) {
return function error() {
errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
errors.style.display = '';
old.apply(this, arguments);
}
})(console.error);
</script>
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
.text-bold {
font-weight: bold;
}
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
key={i}
/>
);
}
render() {
const rows = [0, 1, 2];
const cols = [0, 1, 2];
return (
<div>
{rows.map(row => {
return (
<div
className="board-row"
key={row}
>
{cols.map(col => this.renderSquare(row * 3 + col))}
</div>
);
})}
</div>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null),
location: {
col: null,
row: null,
},
}
],
stepNumber: 0,
isAscendingOrder: true,
xIsNext: true
};
}
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,
location: {
col: i % 3,
row: Math.trunc(i / 3),
},
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
reverseHistoryOrder() {
this.setState({
isAscendingOrder: !this.state.isAscendingOrder,
});
}
render() {
// 逆順の場合はthis.state.history配列のコピーを反転させる
// これにより、this.state.historyはつねに昇順のデータを保持し続ける
const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
const current = history[currentStepNumber];
const winner = calculateWinner(current.squares);
const moves = history.map((step, move) => {
// 逆順の場合はインデックスを反転させる
const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
const desc = moveIndex ?
'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={moveIndex}>
<button
onClick={() => this.jumpTo(moveIndex)}
className={move === currentStepNumber ? 'text-bold' : ''}
>
{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>
<button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
</div>
</div>
);
}
}
// ========================================
ReactDOM.render(<Game />, document.getElementById("root"));
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;
}
5. どちらかが勝利した際に、勝利につながった3つのマス目をハイライトする。
こちらはまずcalculateWinner
関数を修正する必要がありそうです。
現状のcalculateWinner
関数は勝者(X
or O
or null
)を返却していますが、
勝者がいる場合はそのパターンも一緒にしたオブジェクトを返却するようにしましょう。
// 省略
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 {
+ winner: squares[a],
+ causedWinCells: lines[i],
+ };
}
}
- return null;
+ return {
+ winner: null,
+ causedWinCells: [],
+ };
}
さらに、calculateWinner
関数の呼び出し元も修正します。
// 省略
class Game extends React.Component {
// 省略
handleClick(i) {
const history = this.state.history.slice(0, this.state.stepNumber + 1);
const current = history[history.length - 1];
const squares = current.squares.slice();
+ const winInfo = calculateWinner(squares);
- if (calculateWinner(squares) || squares[i]) {
+ if (winInfo.winner || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? "X" : "O";
// 省略
}
// 省略
render() {
// 逆順の場合はthis.state.history配列のコピーを反転させる
// これにより、this.state.historyはつねに昇順のデータを保持し続ける
const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
// 現在選択されているアイテムのインデックスを逆順の場合に反転させる
const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
const current = history[currentStepNumber];
- const winner = calculateWinner(current.squares);
+ const winInfo = calculateWinner(current.squares);
// 省略
let status;
- if (winner) {
+ if (winInfo.winner) {
- status = "Winner: " + winner;
+ status = "Winner: " + winInfo.winner;
} else {
status = "Next player: " + (this.state.xIsNext ? "X" : "O");
}
// 省略
}
}
// 省略
これで今まで通りの表示ができるようになりました。
次はハイライト部分を作成します。
まず、ハイライトされた際に適用されるCSSを記述します。色は適当
/* 省略 */
.square.caused-win {
background: #fff799;
}
最終的に<button class="square caused-win"></button>
を生成するのはSquare
コンポーネントですので、
真偽値が渡される前提で修正します。
function Square(props) {
return (
- <button className="square" onClick={props.onClick}>
+ <button
+ className={'square' + (props.causedWin ? ' caused-win' : '')}
+ onClick={props.onClick}
+ >
{props.value}
</button>
);
}
// 省略
※読みやすくするため、className
とonClick
の両プロパティをそれぞれ独立した行に配置
' caused-win'
の部分で、最初にスペースが入ることに気を付けてください。
Square
コンポーネントの呼び出し元であるBoard
コンポーネントのrenderSquare
メソッドも修正します。
// 省略
class Board extends React.Component {
- renderSquare(i) {
+ renderSquare(i, causedWin) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
key={i}
+ causedWin={causedWin}
/>
);
}
// 省略
}
// 省略
Board
コンポーネントのrenderSquare
メソッドはBoard
コンポーネントのrender
メソッドから呼び出されており、
ここで初めてセル番号(Square
コンポーネントのkey
)を生成しています。
ですので、同時にそのセルが勝利につながったかどうかを判定します。
// 省略
class Board extends React.Component {
// 省略
render() {
const rows = [0, 1, 2];
const cols = [0, 1, 2];
return (
<div>
{rows.map(row => {
return (
<div
className="board-row"
key={row}
>
- {cols.map(col => this.renderSquare(row * 3 + col))}
+ {cols.map(col => {
+ const cell = row * 3 + col;
+ const causedWin = this.props.causedWinCells.includes(cell);
+ return this.renderSquare(cell, causedWin);
})}
</div>
);
})}
</div>
);
}
}
// 省略
あとはGame
コンポーネントからBoard
コンポーネントにcausedWinCells
をprops
で渡せば完成です。
// 省略
class Game extends React.Component {
// 省略
render() {
// 省略
return (
<div className="game">
<div className="game-board">
<Board
squares={current.squares}
onClick={i => this.handleClick(i)}
+ causedWinCells={winInfo.causedWinCells}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
<button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
</div>
</div>
);
}
}
// 省略
【クリックで展開】ここまでのコードは以下になります。([CodePenはこちら](https://codepen.io/ExoticToybox/pen/abOKyoM))
<div id="errors" style="
background: #c00;
color: #fff;
display: none;
margin: -20px -20px 20px;
padding: 20px;
white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
document.body.classList.add('mouse-navigation');
document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
if (e.keyCode === 9) {
document.body.classList.add('kbd-navigation');
document.body.classList.remove('mouse-navigation');
}
});
window.addEventListener('click', function(e) {
if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
e.preventDefault();
}
});
window.onerror = function(message, source, line, col, error) {
var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
errors.textContent += text + '\n';
errors.style.display = '';
};
console.error = (function(old) {
return function error() {
errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
errors.style.display = '';
old.apply(this, arguments);
}
})(console.error);
</script>
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
.text-bold {
font-weight: bold;
}
.square.caused-win {
background: #fff799;
}
function Square(props) {
return (
<button
className={'square' + (props.causedWin ? ' caused-win' : '')}
onClick={props.onClick}
>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i, causedWin) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
key={i}
causedWin={causedWin}
/>
);
}
render() {
const rows = [0, 1, 2];
const cols = [0, 1, 2];
return (
<div>
{rows.map(row => {
return (
<div
className="board-row"
key={row}
>
{cols.map(col => {
const cell = row * 3 + col;
const causedWin = this.props.causedWinCells.includes(cell);
return this.renderSquare(cell, causedWin);
})}
</div>
);
})}
</div>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null),
location: {
col: null,
row: null,
},
}
],
stepNumber: 0,
isAscendingOrder: true,
xIsNext: true
};
}
handleClick(i) {
const history = this.state.history.slice(0, this.state.stepNumber + 1);
const current = history[history.length - 1];
const squares = current.squares.slice();
const winInfo = calculateWinner(squares);
if (winInfo.winner || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? "X" : "O";
this.setState({
history: history.concat([
{
squares: squares,
location: {
col: i % 3,
row: Math.trunc(i / 3),
},
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
reverseHistoryOrder() {
this.setState({
isAscendingOrder: !this.state.isAscendingOrder,
});
}
render() {
// 逆順の場合はthis.state.history配列のコピーを反転させる
// これにより、this.state.historyはつねに昇順のデータを保持し続ける
const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
// 現在選択されているアイテムのインデックスを逆順の場合に反転させる
const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
const current = history[currentStepNumber];
const winInfo = calculateWinner(current.squares);
const moves = history.map((step, move) => {
// 逆順の場合はインデックスを反転させる
const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
const desc = moveIndex ?
'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={moveIndex}>
<button
onClick={() => this.jumpTo(moveIndex)}
className={move === currentStepNumber ? 'text-bold' : ''}
>
{desc}
</button>
</li>
);
});
let status;
if (winInfo.winner) {
status = "Winner: " + winInfo.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)}
causedWinCells={winInfo.causedWinCells}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
<button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
</div>
</div>
);
}
}
// ========================================
ReactDOM.render(<Game />, document.getElementById("root"));
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 {
winner: squares[a],
causedWinCells: lines[i],
};
}
}
return {
winner: null,
causedWinCells: [],
};
}
6. どちらも勝利しなかった場合、結果が引き分けになったというメッセージを表示する。
この部分に表示しているテキストはGame
コンポーネントのrender
メソッド内にてstatus
という変数で管理されています。
// 省略
class Game extends React.Component {
// 省略
render() {
// 省略
let status;
if (winInfo.winner) {
status = "Winner: " + winInfo.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)}
causedWinCells={winInfo.causedWinCells}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
<button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
</div>
</div>
);
}
}
// 省略
ここに新しい条件を追加します。
引き分け
というステータスは、「勝者がいない
かつすべてのセルがnullではない("X"か"O"である)
」です。
// 省略
class Game extends React.Component {
// 省略
render() {
// 省略
let status;
if (winInfo.winner) {
status = "Winner: " + winInfo.winner;
+ } else if (!current.squares.includes(null)) {
+ status = "Draw";
} else {
status = "Next player: " + (this.state.xIsNext ? "X" : "O");
}
// 省略
}
}
// 省略
これで完成です。
【クリックで展開】ここまでのコードは以下になります。([CodePenはこちら](https://codepen.io/ExoticToybox/pen/gOpKxwg))
<div id="errors" style="
background: #c00;
color: #fff;
display: none;
margin: -20px -20px 20px;
padding: 20px;
white-space: pre-wrap;
"></div>
<div id="root"></div>
<script>
window.addEventListener('mousedown', function(e) {
document.body.classList.add('mouse-navigation');
document.body.classList.remove('kbd-navigation');
});
window.addEventListener('keydown', function(e) {
if (e.keyCode === 9) {
document.body.classList.add('kbd-navigation');
document.body.classList.remove('mouse-navigation');
}
});
window.addEventListener('click', function(e) {
if (e.target.tagName === 'A' && e.target.getAttribute('href') === '#') {
e.preventDefault();
}
});
window.onerror = function(message, source, line, col, error) {
var text = error ? error.stack || error : message + ' (at ' + source + ':' + line + ':' + col + ')';
errors.textContent += text + '\n';
errors.style.display = '';
};
console.error = (function(old) {
return function error() {
errors.textContent += Array.prototype.slice.call(arguments).join(' ') + '\n';
errors.style.display = '';
old.apply(this, arguments);
}
})(console.error);
</script>
body {
font: 14px "Century Gothic", Futura, sans-serif;
margin: 20px;
}
ol, ul {
padding-left: 30px;
}
.board-row:after {
clear: both;
content: "";
display: table;
}
.status {
margin-bottom: 10px;
}
.square {
background: #fff;
border: 1px solid #999;
float: left;
font-size: 24px;
font-weight: bold;
line-height: 34px;
height: 34px;
margin-right: -1px;
margin-top: -1px;
padding: 0;
text-align: center;
width: 34px;
}
.square:focus {
outline: none;
}
.kbd-navigation .square:focus {
background: #ddd;
}
.game {
display: flex;
flex-direction: row;
}
.game-info {
margin-left: 20px;
}
.text-bold {
font-weight: bold;
}
.square.caused-win {
background: #fff799;
}
function Square(props) {
return (
<button
className={'square' + (props.causedWin ? ' caused-win' : '')}
onClick={props.onClick}
>
{props.value}
</button>
);
}
class Board extends React.Component {
renderSquare(i, causedWin) {
return (
<Square
value={this.props.squares[i]}
onClick={() => this.props.onClick(i)}
key={i}
causedWin={causedWin}
/>
);
}
render() {
const rows = [0, 1, 2];
const cols = [0, 1, 2];
return (
<div>
{rows.map(row => {
return (
<div
className="board-row"
key={row}
>
{cols.map(col => {
const cell = row * 3 + col;
const causedWin = this.props.causedWinCells.includes(cell);
return this.renderSquare(cell, causedWin);
})}
</div>
);
})}
</div>
);
}
}
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [
{
squares: Array(9).fill(null),
location: {
col: null,
row: null,
},
}
],
stepNumber: 0,
isAscendingOrder: true,
xIsNext: true
};
}
handleClick(i) {
const history = this.state.history.slice(0, this.state.stepNumber + 1);
const current = history[history.length - 1];
const squares = current.squares.slice();
const winInfo = calculateWinner(squares);
if (winInfo.winner || squares[i]) {
return;
}
squares[i] = this.state.xIsNext ? "X" : "O";
this.setState({
history: history.concat([
{
squares: squares,
location: {
col: i % 3,
row: Math.trunc(i / 3),
},
}
]),
stepNumber: history.length,
xIsNext: !this.state.xIsNext
});
}
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0
});
}
reverseHistoryOrder() {
this.setState({
isAscendingOrder: !this.state.isAscendingOrder,
});
}
render() {
// 逆順の場合はthis.state.history配列のコピーを反転させる
// これにより、this.state.historyはつねに昇順のデータを保持し続ける
const history = this.state.isAscendingOrder ? this.state.history : this.state.history.slice().reverse();
// 現在選択されているアイテムのインデックスを逆順の場合に反転させる
const currentStepNumber = this.state.isAscendingOrder ? this.state.stepNumber : history.length - 1 - this.state.stepNumber;
const current = history[currentStepNumber];
const winInfo = calculateWinner(current.squares);
const moves = history.map((step, move) => {
// 逆順の場合はインデックスを反転させる
const moveIndex = this.state.isAscendingOrder ? move : history.length - 1 - move;
const desc = moveIndex ?
'Go to move #' + moveIndex + '(' + step.location.col + ', ' + step.location.row + ')' :
'Go to game start';
return (
<li key={moveIndex}>
<button
onClick={() => this.jumpTo(moveIndex)}
className={move === currentStepNumber ? 'text-bold' : ''}
>
{desc}
</button>
</li>
);
});
let status;
if (winInfo.winner) {
status = "Winner: " + winInfo.winner;
} else if (!current.squares.includes(null)) {
status = "Draw";
} 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)}
causedWinCells={winInfo.causedWinCells}
/>
</div>
<div className="game-info">
<div>{status}</div>
<ol>{moves}</ol>
<button onClick={() => this.reverseHistoryOrder()}>Reverse history order</button>
</div>
</div>
);
}
}
// ========================================
ReactDOM.render(<Game />, document.getElementById("root"));
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 {
winner: squares[a],
causedWinCells: lines[i],
};
}
}
return {
winner: null,
causedWinCells: [],
};
}
後書き
できるだけチュートリアルの構文に則った書き方をしてみました。
最後の問題だけどうしても簡単すぎて、何か見落としているのではないかと思ってしまいます。
バグや、「ここはこうしたほうがいいんじゃない?」というようなポイントがあればコメントいただければ嬉しいです。
あと、この記事ではコードブロックのシンタックスハイライトをjs
にしているのですが、QiitaではJSX構文で赤くエラーの表示がされてしまいます。
こちらについても解決策をご存じの方がいらっしゃいましたらコメントお待ちしています。
→ @d0ne1s さんからコメントで指摘をいただき、シンタックスハイライトをjsx
に修正しました。
次はこれをTypeScriptで書いてみたい。
参考
React公式チュートリアル
React公式ガイド
React.js 実戦投入への道 - Qiita
React における State と Props の違い - Qiita
Reactのstate,props,componentの使い方を自分なりにまとめてみた - Qiita
React.jsでループするには。 - Qiita