React新ドキュメント(英語版)のチュートリアル追加課題
React公式ドキュメント新版のチュートリアル追加課題
新旧ともに、チュートリアルのお題は「Tic-Tac-Toe(三目並べ)」です。
https://react.dev/learn/tutorial-tic-tac-toe
はじめに
React初学者です。調査・考察しながら書いています。間違いは指摘いただけると幸甚です。
ReactはUIを「コンポーネント」と呼ばれる部品に分解して整理するためのライブラリです。
新版ドキュメントでは、すべての説明が、クラスではなく、Hooksを使って書かれています。
チュートリアル挑戦の過程はスクラップに残しています。
追加課題5つ(難しい順)
- 現在の手に対してのみ、ボタンの代わりに「You are at move #...」と表示する。
- 碁盤をハードコードするのではなく、2つのループでマスを作るように書き換える。
- トグルボタンを追加して、手を昇順または降順に並べ替えられるようにする
- 誰かが勝ったとき、その原因となった3つのマスをハイライト表示する (誰も勝てなかったときは、結果が引き分けであることをメッセージで表示する)。
- 着手履歴のリストに、各手順の位置を(col, row)の形式で表示する。
5⇒4⇒3⇒2⇒1の順にやります。
すべて、公式チュートリアル最後のCodeSandboxをForkした状態から始めます。
5. 着手履歴のリストに、各手順の位置を(col, row)の形式で表示する
リストに書き入れたいので、リストJSX構文の箇所に、各手順の位置を表す変数positionを追記します。まず、仮の値1行2列として、初手とそれ以外の場合分けをしておきました。
export default function Game() {
//...
const moves = history.map((squares, move) => {
let description;
+ let position;
+ let col = 1;
+ let row = 2;
if (move > 0) {
description = 'Go to move #' + move;
+ position = '(' + col + ',' + row + ')';
} else {
description = 'Go to game start';
+ position = '';
}
return (
<li key={move}>
+ <button onClick={() => jumpTo(move)}>{description}{position}</button>
</li>
);
});
//...
よし。いま、1,2と表示されている箇所を変数にしたいです。いろいろ試行錯誤した結果、状態変数history
に、squaresとcol/rowを一緒に格納することになりました。
先に、各指し手の位置の求め方を考えます。左上が(1,1)、右上が(3,1)、左下が(1,3)、右下が(3,3)です。
これは、何手目かを表すmove
からは計算することはできないですね。<Square />コンポーネントの番号{i}を使って算出します。列は3で割った余りに1を足す。行は3で割った商の値で良さそうです。整数に丸めるのを忘れずに。
function Board({ xIsNext, squares, onPlay }) {
function handleClick(i) {
if (calculateWinner(squares) || squares[i]) {
return;
}
const nextSquares = squares.slice();
if (xIsNext) {
nextSquares[i] = 'X';
} else {
nextSquares[i] = 'O';
}
+ let col = i % 3 + 1;
+ let row = Math.floor(i / 3) + 1;
+ const nextColRow = [col, row];
+ onPlay(nextSquares, nextColRow);
}
次に、状態変数history
を書き換えます。初期値はnull。更新時にsquares配列とcolRow配列を持たせます。
export default function Game() {
//historyの初期値に、打ち手の位置をセットすると、{moves}の出力で、ゲームスタート時に一手目のボタンが表示されてしまう
+ const [history, setHistory] = useState([[Array(9).fill(null)]]);
const [currentMove, setCurrentMove] = useState(0);
const xIsNext = currentMove % 2 === 0;
+ const currentSquares = history[currentMove][0];
function handlePlay(nextSquares, nextColRow) {
+ const nextHistory = [...history.slice(0, currentMove + 1), [nextSquares, nextColRow]];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
//...
//map()は配列historyのすべての要素に対して、関数を実行した結果を新しい配列として返す
//配列historyは多次元配列なので注意
+ const moves = history.map((position, move) => {
let description;
if (move > 0) {
+ description = 'Go to move #' + move + '(' + position[1][0] + ',' + position[1][1] + ')';
} else {
description = 'Go to game start';
}
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
できました!一番時間かかったのが、多次元配列のmap()メソッドの制御なので、Reactではなくjavascriptのスキル不足で詰みました。なんとかクリアです。
4. 誰かが勝ったとき、その原因となった3つのマスをハイライト表示する (誰も勝てなかったときは、結果が引き分けであることをメッセージで表示する)
誰かが勝ったときなので、calculateWinner()で勝利判定が出たときに、強引に色も変えてしまえばよさそうです。
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]) {
+ document.getElementsByClassName("square")[a].style.backgroundColor = 'orange';
+ document.getElementsByClassName("square")[b].style.backgroundColor = 'orange';
+ document.getElementsByClassName("square")[c].style.backgroundColor = 'orange';
return squares[a];
}
}
return null;
あ、ゲームスタートを押すと、背景色がハイライトのままです。こっちが本題か!
では、<Square />コンポーネントを更新のたびに、透明にリセットするような指示をインラインで書き入れてみます。
function Square({ value, onSquareClick }) {
return (
+ <button className="square" onClick={onSquareClick} style={{background:'transparent'}}>
{value}
</button>
);
}
あれ。Squareコンポーネントの再レンダリングをしてくれるのかと思ったんだけど、当てが外れました。
では、勝利判定で勝者がいない場合に、すべてのマスを透明にする処置を施します。
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]) {
document.getElementsByClassName("square")[a].style.backgroundColor = 'orange';
document.getElementsByClassName("square")[b].style.backgroundColor = 'orange';
document.getElementsByClassName("square")[c].style.backgroundColor = 'orange';
return squares[a];
}
+ for (let j = 0; j < 9; j++) {
+ const e = document.getElementsByClassName("square")[j];
+ e.style.backgroundColor = "transparent";
* }
}
return null;
}
よし。…と一瞬思いましたが、ブラウザをリセットするとエラーが出ました。
TypeError
Cannot read properties of undefined (reading 'style')
多分最初のレンダリングのときに、背景を透明にしたい相手がいませんよって言われる。なので、透明化する部分のコードをまるごとjumpTo()の中に移動させました。
function jumpTo(nextMove) {
//タイムトラベルボタンを押したら、背景色ハイライトは全部リセットする
for (let j = 0; j < 9; j++) {
const e = document.getElementsByClassName("square")[j];
e.style.backgroundColor = "transparent";
}
setCurrentMove(nextMove);
}
バッチリです。
次は引き分けの表示です。Winnerのところに「引き分けです!」と表示すれば良さそうです。引き分けの条件は、Winnerがいないことと、moveが10手目であること。
Winnerは<Board />コンポーネントにあるので、moveは使いにくそうです。今回はmoveではなく、squaresのマス目が全部埋まっているかどうかで判断することにしました。
function Board({ xIsNext, squares, onPlay }) {
//...
const winner = calculateWinner(squares);
let status;
if (winner) {
status = "Winner: " + winner;
+ } else if (squares.includes(null)) {
status = "Next player: " + (xIsNext ? "X" : "O");
+ } else {
+ status = "引き分けです!";
}
return (
<>
<div className="status">{status}</div>
//...
できました!
3. トグルボタンを追加して、手を昇順または降順に並べ替えられるようにする
こんなイメージでしょうか。ボタンを押すと、{moves}リストが降順に並べ変えられて、ボタンの表記が「昇順にする」となれば良さそうです。
ゲーム盤の状態history
、何手目かという状態currentMove
とは別の、独立した状態変数sortToggleを新しく設置してみることにします。履歴リスト{moves}は、JSXで記述した<li>要素を、json形式のオブジェクトにして配列に格納したものです。従って、{moves}をreverse()で反転させて、setSortToggleで状態変数を更新すれば再レンダリングするのでは!
export default function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
+ const [sortToggle, setSortToggle] = useState(true);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
//...
+ function sortReverse(movesArray) {
+ movesArray.reverse();
+ setSortToggle(!sortToggle);
+ }
//...
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
+ <button onClick={() => sortReverse(moves)}>降順にする</button>
<ol>{moves}</ol>
</div>
</div>
);
}
しませんでした。コンソールに出していろいろ確認しましたが、Toggleも配列の反転もできているようです。では、再レンダリングの制御の問題ってことですね。公式ドキュメントを読んでみます。
公式ドキュメント Render and Commit
訳)React(ウェイター)がブラウザクリックみたいなもので注文を受けて、その注文をもとにコンポーネント(料理人)が調理して、React(ウェイター)が注文を提供する。
大事なところだと思うので、丁寧に読んでいきます。レンダリングは3つのステップで考えられるそうです。
- Reactがレンダリングを始めるのは、以下の2つのケース。
- 初期レンダリング:createRootを呼び出し、render()メソッドでコンポーネントを呼び出す
- コンポーネント(またはその上位)の状態が更新された:set関数で状態を更新することで、レンダリングを開始することができる
- コンポーネントのレンダリング
レンダリングが始まると、Reactはコンポーネントを呼び出し、画面に表示する内容を決めます。「レンダリング」とは、Reactがコンポーネントを呼び出すことです。
初期レンダリングの場合は、ルートコンポーネントを呼び出す。それ以降のレンダリングでは、レンダリングのトリガーとなった状態更新の関数コンポーネントを呼び出す。ネストしたコンポーネントがなくなるまで再帰的に処理が続きます。
- DOMに変更をあてはめる
初期レンダリングのときは、appendChild() DOM APIを使って、すべてのDOMを画面上に配置します。それ以降のレンダリングでは、必要最小限の操作を適用して、DOMを最新のレンダリング出力と一致させます。ReactがDOMを変更するのは、レンダリング間で差がある場合だけです。
公式ドキュメントを読むと、setSortToggle
で状態変数を変更したのですが、この状態変数を反映させるコンポーネントがない。新しくなった状態を受け取るところがないことが問題のようです。
⇒ないなら作ればいいですね。<ToggleBtn />と<MovesList />。こんな感じでしょうか。
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
+ <ToggleBtn />
+ <ol><MovesList /></ol>
</div>
</div>
一度、コンポーネントの関係を整理してみます。
setSortToggleで再レンダリングさせようとしたら、sortToggle変数を使っているコンポーネントしか変更されないんだと思います。それと、今気づきましたが、{moves}は配列historyだけで作ることができるので、あとは昇順降順を判断するためのsortToggleを持ってくればあっさりコンポーネント化できそうです。
最初からやり直してみます。
//<ToggleBtn />コンポーネント
function ToggleBtn({ sortToggle, onToggleBtnClick }) {
let sortBtnText;
if (sortToggle) {
sortBtnText = "降順にする";
} else {
sortBtnText = "昇順にする";
}
return (
<button onClick={() => onToggleBtnClick({ sortToggle })}>
{sortBtnText}
</button>
);
}
//<MovesList />コンポーネント
function MovesList({ history, toggle, jumpTo }) {
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
description = "Go to move #" + move;
} else {
description = "Go to game start";
}
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
var array;
+ if (!toggle) {
+ //reverse()を使ったら戻らなくなってしまったのでNG
+ const reversedArr = moves.map((_, i, a) => a[a.length - 1 - i]);
+ array = reversedArr;
+ } else {
+ array = moves;
+ }
return array;
}
export default function Game() {
const [history, setHistory] = useState([Array(9).fill(null)]);
const [currentMove, setCurrentMove] = useState(0);
+ const [sortToggle, setSortToggle] = useState(true);
const xIsNext = currentMove % 2 === 0;
const currentSquares = history[currentMove];
function handlePlay(nextSquares) {
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares];
setHistory(nextHistory);
setCurrentMove(nextHistory.length - 1);
}
function jumpTo(nextMove) {
setCurrentMove(nextMove);
}
//toggleボタンを押す
+ function handleToggle(nextToggle) {
+ //関数型のstate更新:https://qiita.com/jonakp/items/7d768bc680b8a7a5e84f
+ setSortToggle((nextToggle) => !nextToggle);
+ }
// const moves = history.map((squares, move) => {
// let description;
// if (move > 0) {
// description = 'Go to move #' + move;
// } else {
// description = 'Go to game start';
// }
// return (
// <li key={move}>
// <button onClick={() => jumpTo(move)}>{description}</button>
// </li>
// );
// });
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
+ <ToggleBtn sortToggle={sortToggle} onToggleBtnClick={handleToggle} />
<ol>
+ <MovesList history={history} toggle={sortToggle} jumpTo={jumpTo} />
</ol>
</div>
</div>
);
}
できました!!!
詰んだところがたくさんありましたが、なんとかクリアしました。
- 配列を逆順にするjavascriptのreverse()を使うと、元の配列が壊されてしまうので、map()関数で逆順にしました
- トグルのset関数。最初は以下のように書いていました
setSortToggle(!nextToggle);
一回のクリックでtrueからfalseに変わったあと、制御できなくなってしまいました。ググったら解説が出てきました。良い書き方はこちら。
setSortToggle((nextToggle) => !nextToggle);
読んでもわからなかったので積読にしてます。
今までの課題よりも、Reactにきちんと向き合った感じがあってよかったです!
2. 碁盤をハードコードするのではなく、2つのループでマスを作るように書き換える。
これをループに変えたいのですが、returnの中でループを使えないようです。returnの前に移動して、変数boardPlateみたいなものに一度格納して、一気に出力にもっていく作戦でいきます。{moves}で同じようなことをやったので、できそうな気がしてきました。
ループを作っていきます。
あー、なるほど。HTMLテキスト列がブラウザ画面に表示されてしまいました。こういう課題だったわけですね。わかってきました。
コンポーネントをどうやって連結させればよいか分からない。JSXで記述しているから一見文字列みたいに見えるけど、中身はオブジェクトなんですよね。{moves}の時は、map()関数を使って新しい配列を作っていました。
同じようにやるなら、まずはベースとなる連番配列をつくって、map()で加工していきます。
※参考
const boardPlate = [...Array(3)].map((_, i) => {
let threeSquare = [...Array(3)].map((_, j) => {
let n = 3 * i + j;
return (
<Square value={n} onSquareClick={() => handleClick(n)} />
);
});
return (
<div className="board-row">
{threeSquare}
</div>
);
});
3×3で、それぞれの値も良い感じに入りました。
配列にしたときは、keyが必要ですよと怒られました。チュートリアルの本課題でもやったやつですね。Boardのrender()を確認してくださいと書いてあります。二つ配列を作っているので、どちらも制御できるように、それぞれkeyをつけておきました。
const boardPlate = [...Array(3)].map((_, i) => {
let threeSquare = [...Array(3)].map((_, j) => {
let n = 3 * i + j;
return (
<Square
key={n}
value={squares[n]}
onSquareClick={() => handleClick(n)}
/>
);
});
return (
<div key={i} className="board-row">
{threeSquare}
</div>
);
});
return (
<>
<div className="status">{status}</div>
{boardPlate}
</>
);
}
完成です!
ちなみに、ループといったときにmap()を使うのが正しかったかどうか分かりません。forループでもできるのでしょうか?これは保留です。
1. 現在の手に対してのみ、ボタンの代わりに「You are at move #...」と表示する
たとえば上の例だと「Go to move #3」ではなく「You are at move #3」となる感じですね。確かに現在の手の時に、このボタンは押しても何も変化しないので意味がない状態です。
一度、「Go to move #2」ボタンを押すと手が戻ります。この時、「Go to move #3」はボタンであってほしいし、「Go to move #2」は「You are at move #2」となってほしいです。
という制御を作りましょうという課題です。OKOK。
{moves}は<li>オブジェクトが入った配列です。追加課題2でもmap()関数を使ってJSXに差し込む配列を用意しましたが、同じようなことが必要です。それと、ボタンをクリックすることで、ゲーム盤面だけでなく、リストそのものを再レンダリングする必要があるので、{moves}をコンポーネントにします(これは追加課題3でやりました)。
function MovesList({ history, currentMove, jumpTo }) {
const moves = history.map((squares, move) => {
let description;
if (move > 0) {
if (move === currentMove) {
description = "You are at move #" + move;
} else {
description = "Go to move #" + move;
}
} else {
description = "Go to game start";
}
return (
<li key={move}>
<button onClick={() => jumpTo(move)}>{description}</button>
</li>
);
});
return moves;
}
Gameコンポーネントの出力部分は次のような感じです。
return (
<div className="game">
<div className="game-board">
<Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
</div>
<div className="game-info">
<ol>
<MovesList
history={history}
currentMove={currentMove}
jumpTo={jumpTo}
/>
</ol>
</div>
</div>
);
復習みたいなものなので、あっさりできました。ここからが本番ですかね。<button>と<div>の出し分けをしたいです。リストをdivにしたい。divにすると、下のような文字列がリスト表示されます。
それぞれコンソールで配列の中身を見てみました。
・buttonのとき
・divのとき
要するに、このオブジェクトのchildren{type: button}とchildren{type: div}を、currentMoveの値によって、出し分けしたい。こういうことできますか? >>chatGPT先生?
すごい(語彙力)。よくあるテクニックだそうです。TagName変数をつくって、JSXに埋め込むことができるみたいです。これを真似して修正してみます。
できました!
履歴ボタン押すと変わります!すごい!
TagNameみたいな感じで変数を埋め込めるなら、もっといろいろなことができるのでは?
などとワクワクしています。すごい!React!
まとめ
振り返ると、難易度は確かに1>2>3>4>5の順番だったを思います。特に、最後に取り組んだ1の課題は、2と3の課題を応用して乗り越えることができました。いきなり1からやってたら一人でクリアできなかったと思います。
紆余曲折の過程を少し載せているので、冗長な記事になってしまいましたが、誰かの助けになれば幸いです。
Discussion