はじめに
React公式チュートリアルを完了したので、アウトプット&初学者の方に向けた解説を目的として記事にしました。
実際の手順に関しては公式に細かく記載されているため、本記事では実装手順を省き、内部の動きについてかなり細かく噛み砕いて解説しました。
対象としては、
- 何となくは理解できたけど細かい所が不安・・・
- とりあえずチュートリアルを終わらせたけど何が起こっているのかよく分からん
- そもそもJavaScriptが分からねえ
上記のような方々を対象としています。
少し長めの記事ですが、本記事を読めば公式チュートリアルの内容をほぼ理解できるような内容になっているかと思いますので、最後までどうかお付き合い下さい。
Squareコンポーネント
ここでは、三目並べのマス目部分を担当しているSquareコンポーネントについて解説します。
function Square(props) {
return (
<button className="square" onClick={props.onClick}>
{props.value}
</button>
);
}
とは言っても、非常に単純な構成のため1点のみです。
1. クラスコンポーネントではなく関数コンポーネントを使用
チュートリアルのコンポーネント構成は、上からGame > Borad > Square
となっており、stateは全てGameコンポーネントで管理されています。
そのため、単に要素をreturnするだけのSquareコンポーネントは、よりシンプルに記載できる関数コンポーネントで実装されています。
Boardコンポーネント
Boardコンポーネントは、Squareのまとまりを管理しています。
render内に記載されている9つのrenderSquare
関数によって、Boradコンポーネント内にSquareコンポーネントを配置しています。
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>
);
}
}
先程と比べると少しだけコード量が増えてきましたね。
しかしここではrenderSquare
関数を押さえれば意味が理解できると思います。
1. Square value
{this.props.squares[i]}
のsquares
は、Gameコンポーネントで管理されている配列のstateです。
後述しますが、squares
は9つのマス目全てに関して、どこにどちらのマーク(☓か○か)が入っているのか情報を持っています。
引数として渡す数値をもとに、マス目の場所に応じたマークを呼び出し、Square毎のvalueに代入しています。
2. Square onClick
ここでは、Gameコンポーネント内のhandleClick
関数を呼び出すための処理が記載されています。
Gameコンポーネントのrender部分は以下です。
<Board
squares={current.squares}
onClick={(i) => this.handleClick(i)}
/>
Boardコンポーネント内のonClick={() => this.props.onClick(i)}
によって、配置を表す引数を渡しつつhandleClick
関数を呼び出しています。
この記述をもとに、マス目毎にhandleClick
イベントを発動させることができるようになっています。
3. onClick
内の() =>
onClick={() => this.props.onClick(i)}
の中に記載されているアロー関数は何のためにあるのか?
答えはクラスメソッドをバインドするためです。
JSX のコールバックにおける this の意味に注意しなければなりません。JavaScript では、クラスのメソッドはデフォルトではバインドされません。this.handleClick へのバインドを忘れて onClick に渡した場合、実際に関数が呼ばれた時に this は undefined となってしまいます。
公式ドキュメント:イベント処理
つまり、今回のように末尾に()
を付けずに関数を呼び出す際は、何かしらの形でバインドしなければなりません。
別の方法としては、Gameコンポーネントのコンストラクタ内に以下のように記載することで解決できます。
this.handleClick = this.handleClick.bind(this);
calculateWinnerコンポーネント
Gameコンポーネントに進む前に、まずは「勝利条件が成立しているか」を判定するcalculateWinnerコンポーネントについて、ほぼReactに関係ない部分ですが説明しておきます。
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;
}
1. lines
定数
蛇足ですが、三目並べの勝利条件は「縦・横・斜めのいずれかに同じマークが3つ並ぶこと」です。
後述するfor文で、lines
内の組み合わせ全てに、同じマークがあるかどうかを判定していきます。
ハードコーディングされていますが、今回は3マス×3マスかつ三目並べ専用なので問題はありません。
2. for文
まずconst [a, b, c] = lines[i]
で、lines
内の組み合わせを分割代入しています。
例えばlines[2]
であれば、a, b, c
はそれぞれ6, 7, 8
が代入されます。
それらを9つのマス目全ての情報を持っているsquares
配列のインデックスとして使用し、if文内で総当たりの条件判定を行っている形です。
Gameコンポーネント
いよいよ本題のGameコンポーネントです。大まかに機能を分けると、
1. state
2. handleClick
3. jumpTo
4. render
このあたりでしょうか。順を追って解説していきます。
コード全体
class Game extends React.Component {
constructor(props) {
super(props);
this.state = {
history: [{
squares: Array(9).fill(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,
}]),
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 :
'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')
);
1. state
constructor(props) {
super(props);
this.state = {
history: [{
squares: Array(9).fill(null),
}],
stepNumber: 0,
xIsNext: true,
};
}
-
history
ここではhistory
内部に配列のsquares
がセットされています。
まずArray(9).fill(null)
は「全てがnullである、サイズが9の配列」です。
この記述によりstateの初期値として、一切マークされていない、ゲームスタート時のボードがセットされます。
次に直接suqares
を保持するのではなく、わざわざhistory
内部にsquares
を保持している理由ですが、これは後で過去の着手を表示するためです。
後述しますが、ゲームが進行する度に1つずつvalue
が追加されたsquares
が増えていくイメージ。
-
stepNumber
render内にconst current = history[this.state.stepNumber];
と記載されている通り、ゲームの手番を管理するためのstateです。
history[]
にインデックスを渡すことで、現在のsquares
の状態をcurrent
に代入しています。 -
xIsNext
こちらは次の手番が○か☓かを管理するためのstateです。
同じくrender内にstatus = 'Next Player: ' + (this.state.xIsNext ? 'X' : 'O');
と記載されています。
見慣れない方も居るかもしれませんが、こちらは条件(三項)演算子と呼ばれる記法で、?
までが条件文、:
の左がtrue、右がfalse時の処理を表しています。
今回の例だとthis.state.xIsNext
がtrueならX、falseなら○と、より簡潔に記述できますね。
2. handleClick
ここではイベント発火元Squareのインデックスを引数として、squareがクリックされた時の処理について解説します。
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,
});
}
- 定数
-
history
現在のhistory
をsliceメソッドで「現在の手番 + 1」で作り直し、history
に代入しています。
ぱっと見は何のための処理か分からなくなりそうですが、これは後述のjumpTo
メソッドが実行された時に必要な処理です。
jumpTo
メソッド内ではhistory
をsetStateしないので、メソッド実行後もhistory
の中身は変わりません。
しかし、このままではhistory
がおかしくなるため、handleClick
メソッド実行時に正しい中身に再設定している形です。
以下は前回画像の状況から過去の手番に戻った場面ですが、盤面とhistory
の数が一致していません。
空のマス目をクリックすることで`historyが作り直され、正しい履歴に戻ります。
-
current
こちらは単に現在のhistory
のみを取得・代入しています。 -
squares
cconst squares = current.squares.slice()
と繋げることで、盤面を更新する前に、現在の状態をsquares
に代入しています。
- 処理
- if文
ここでは「ゲームの勝者が居る場合」 or 「マーク済のマスがクリックされた場合」に早期returnするためにif文が使用されています。
if (calculateWinner(squares) || squares[i])
となっているので、どちらかがtrueであればこの先の処理は行われません。 - 条件演算子
先程のような構文が再び出てきましたね。ここではxIsNext
がtrueなら☓、falseなら○がクリックされたsquareのvalueとして代入されます。
- setState
-
history
少し前でhistory
を作り直した意味がやっとここで出てきます。
history.concat([{squares: squares,}])
とすることで、今までの履歴に今回作成されたsquares
を追加できます。
この一連の処理によって、今までの履歴を管理しつつ正しい順番で今回の処理結果を追加することができました。 -
stepNumber
どうせ使用時に+ 1するので単にhistory.length
を代入。 -
xIsNext
!this.state.xIsNext
とする事で値を反転させています。
xIsNext
は真偽値なので、この処理の度にtrue / false切り替わる形です。
3. jumpTo
jumpTo(step) {
this.setState({
stepNumber: step,
xIsNext: (step % 2) === 0,
});
}
-
stepNumber
引数のstep
にはターゲットとなるhistory
のインデックスが入るため、stepNumber
に正しい値がセットされます。 -
xIsNext
xIsNext: (step % 2) === 0
とすることでxIsNext
に正しい値がセットされます。
4. render
いよいよ大詰めです。少し長いですが頑張りましょう!
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 (
<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>
);
}
- 定数
-
const history = this.state.history
現時点での全てのhistory
をセット -
const current = history[this.state.stepNumber]
最新の盤面をセット -
const winner = calculateWinner(current.squares)
現在の盤面での勝者をセット -
const moves = history.map((step, move) => {}
まず、この処理内で使われているdesc
は「descending order」ではなく「description」です。(蛇足かもしれませんが、初見で勘違いしたので・・・)
ここではhistory
それぞれのインデックスを取得するためにmap処理を行っています。
なので「'step' が宣言されていますが、その値が読み取られることはありません。」と表示されていますが問題ありません。
const desc = move ? 'Go to move #' + move : 'Go to game start';
ここでまたもや条件演算子の登場です。
move
がtrue、つまり「0やnull以外の値」であればdesc
に'Go to move #' + move'
を代入、move
がfalseであれば'Go to game start'
を行います。
move
がfalse(今回だと0のみ)の場合はhistory[0]
=ゲームスタート時の盤面なので'Go to game start'
と表示させる寸法です。
-
let status
空のstatus
を宣言しておき、if文内でwinner
の有無に応じて表示を出し分けています。
ここは非常にシンプルですね。 -
return
ここではあまり説明することもありませんが1点だけ。
onClick={(i) => this.handleClick(i)}
でアロー関数の引数になっているi
ですが、これはBoardコンポーネント内のrenderSquare(i)
から来ています。
Square value
に渡しているインデックスをついでに利用した形です。
あとは最後にReactDOM.render
して終了です!
おわりに
ここまでお付き合い下さりありがとうございました。
本記事が少しでも皆様のお役に立てれば幸いです。
ほぼ初めての記事投稿だったのですが、作成にあたり自分の中でも理解が深まったと感じます。
散々言われている事ですが、やはり自身で言語化 → アウトプットする事は大切だなーとしみじみ・・・
これを機にアウトプットをしっかり癖付けていきます。
、、、とはいえ今年は終わりが近いので来年から頑張ることにします。
それでは皆さま良いお年を。