プログラミング

【応用】React公式チュートリアル改良のアイデアを実装してみた

nayamu

React公式サイトには入門に最適なチュートリアルが用意されています。

マルバツゲームの作成を通して、Reactとその構文について学ぶことができるようになっています。

実際に手を動かしながら学ぶことができるので、一段階ずつ学べるガイドと合わせてスムーズにReactに入門できるような内容です。

公式チュートリアルはこちらになります。

https://ja.reactjs.org/tutorial/tutorial.html

今回は公式チュートリアルの最後に載っている「改良のアイデア」を実装してみました。

ただ、前の記事で指摘したように、公式チュートリアルは実装方法が古いです。
なので、今回もTypeScriptと関数コンポーネントで実装しました。

こちらの記事を読んでいない方はまずこちらの記事からリファクタリングをどうぞ。

reactチュートリアルをリファクタリング
ReactチュートリアルをTypeScriptでリファクタリング【&Hooks】React公式サイトには入門に最適なチュートリアルが用意されています。 マルバツゲームの作成を通して、Reactとその構文について...

まだ時間がある場合や、今回身につけた新しいスキルを練習してみたい場合に、あなたが挑戦できる改良のアイデアを以下にリストアップしています。後ろの方ほど難易度が上がります。

と公式には書かれています。
確かにさらにスキルを伸ばす上ではかなり良い題材でした。

しかし、個人的には全く難易度順では無いと思いましたので、記事内に個人的な難易度も記載しておきます。

では、それぞれ順に解説をしていきます。

履歴内のそれぞれの着手の位置を (col, row) というフォーマットで表示する

難易度4

タイムトラベル用のボタンにcol(縦のマス目)、row(横のマス目)の番号を表示するような機能になります。

実装方法としては、

  1. colAndRowsというマス目番号の配列を状態としてコンポーネントに持たせる
  2. それを番号順にボタンの中に表示する

という感じです。

まずは、useStateを使って新しいstateを宣言します。

次に、クリックされたマス目ごとに対応するcolとrowの番号を変数に持たせます。

後は、タイムトラベル機能にも対応して、stateにそれらを設定すれば、第一段階は完了です。
useGameControl.tsは以下のようになります。

import {useState} from "react";
import {historyType} from "../types/historyType";
import {calculateWinner} from "../utils/calculateWinner";

export const useGameControl = () => {
 const [history, setHistory] = useState<Array>([{squares: Array(9).fill(null)}])
 const [stepNumber, setStepNumber] = useState(0);
 const [xIsNext, setXIsNext] = useState(true);
 const [colAndRows, setColAndRows] = useState<Array<Array>>([]);

 const handleClickSquare = (i: number) => {

  const copyedHistory = history.slice(0, stepNumber + 1);
  const current = history[copyedHistory.length - 1];
  const squares = current.squares.slice();
  if (calculateWinner(squares) || squares[i]) {
   return;
  }

  let nextColAndRow:Array = [];
  if(i === 0){
   nextColAndRow = [0,0];
  }else if(i === 1){
   nextColAndRow = [0,1];
  }else if(i === 2){
   nextColAndRow = [0,2];
  }else if(i === 3){
   nextColAndRow = ;
  }else if(i === 4){
   nextColAndRow = ;
  }else if(i === 5){
   nextColAndRow = ;
  }else if(i === 6){
   nextColAndRow = [2,0];
  }else if(i === 7){
   nextColAndRow = [2,1];
  }else if(i === 8){
   nextColAndRow = [2,2];
  }
  let slicedColAndRows = colAndRows.slice(0, stepNumber);
  slicedColAndRows.push(nextColAndRow);
  setColAndRows(slicedColAndRows);

  squares[i] = xIsNext ? "X" : "O";
  setHistory(copyedHistory.concat([
   {
    squares: squares
   }
  ]));
  setStepNumber(copyedHistory.length);
  setXIsNext(!xIsNext);
 }

 const jumpToPast = (step: number) => {
  setStepNumber(step);
  setXIsNext((step % 2) === 0);
 }

 return {history, stepNumber, xIsNext, colAndRows, handleClickSquare, jumpToPast};
}

最後に、Gameコンポーネント側で新しく作成した、colsAndRowsを受け取り、ボタン内にそれらを表示すれば完成です。

Gameコンポーネントは以下のようになります。

import React, {VFC} from 'react';
import {Board} from "./Board";
import {useGameControl} from "../hooks/useGameControl";
import {calculateWinner} from "../utils/calculateWinner";

export const Game: VFC = () =>  {

  const {history, stepNumber, xIsNext, colAndRows, handleClickSquare, jumpToPast} = useGameControl();

  const handleClick = (i: number) => handleClickSquare(i);

  const jumpTo = (step: number) => jumpToPast(step);

  const current = history[stepNumber];
  const winner = calculateWinner(current.squares);

  const moves = history.map((step, move) => {
    const desc = move ?
      'Go to move #' + move + "(" + colAndRows[move - 1][0] + ", " + colAndRows[move - 1] + ")":
      'Go to game start';
    return (
      <li key={move}>
        <button onClick={() => jumpTo(move)}>{desc}</button>
      </li>
    );
  });

  let status;
  if (winner) {
    status = "Winner: " + winner;
  } else {
    status = "Next player: " + (xIsNext ? "X" : "O");
  }

  return (
    <div className="game">
      <div className="game-board">
        <Board
          squares={current.squares}
          onClick={i => handleClick(i)}
        />
      </div>
      <div className="game-info">
        <div>{status}</div>
        <ol>{moves}</ol>
      </div>
    </div>
  );
}

着手履歴のリスト中で現在選択されているアイテムをボールドにする

難易度2

こちらは、現在表示している着手のタイムトラベルボタンの文字をボールド(太字)にする機能になります。

実装方法としてはめちゃくちゃ簡単でstepNmuberで条件分岐を作るだけで実現できます。

React内でCSSを実装する方法はいくつかあるのですが、今回は一番オーソドックスなInline Stylesという方法で実装します。

実装方法を説明すると、CSSのプロパティ名と値を対応させたJSのオブジェクト作成して、CSSを当てたいコンポーネントにstyleとして指定すればOKです。

この機能を実装したGameコンポーネントの一部は以下のようになります。

const boldStyles = {
 fontWeight: 700
};

const moves = history.map((step, move) => {
 const desc = move ?
 'Go to move #' + move + "(" + colAndRows[move - 1][0] + ", " + colAndRows[move - 1] + ")":
 'Go to game start';
 return (
  <ul>
 	<li>{move === stepNumber ? <button> jumpTo(move)} style={boldStyles}>{desc}</button> :
    <button> jumpTo(move)}>{desc}</button>
   </li>
  </ul>
 );
 });

CSSを別ファイルで管理したりする現場が多いかと思いますが、今回はここしかInline Stylesは使用していないので、同じファイルに置いたままにします。

Board でマス目を並べる部分を、ハードコーディングではなく 2 つのループを使用するように書き換える

難易度3

こちらは、renderSquareを9回繰り返していて冗長になっている所を、ループを使うことで短く表現するような実装になります。

実装方法としては簡単なのですが、どう実現するか考えるのが結構大変なので、難易度は3にしてあります。

実装手順としては、

  1. 対応するColとRowの配列を準備する
  2. それらを使用して2重ループを作る

という手順になります。

では、実装していきましょう。

まず、最初にPropsを分割代入しておきましょう。
const {squares, onClick} = props;

これをすることで、いちいちprops.hogeと打たずにhogeだけでPropsの値を扱えるようになります。
結構使っている現場も多いと思いますので、覚えておきましょう。

そして、rowsとcolsの二つの配列を準備します。
それらを使って2重ループを実装すれば完了です。

rowsをmapで回してboard-rowのdivタグを3つ作成し、colsを使ってrenderSquareにそれぞれ対応する値を与えてやればOKです。

ただ、公式のチュートリアルにあったようにリストにはしっかりとKeyを指定しないとコンソールエラーになるので、指定しておきましょう。

実装後のBoardコンポーネントは以下のようになります。

export const Board: VFC<boardProps> = (props) => {
  const {squares, onClick} = props;
  const renderSquare = (i: number) => {
    return (
      <Square
        value={squares[i]}
        onClick={() => onClick(i)}
        key={i}
      />
    );
  }
  const cols = [0,3,6];
  const rows = [0,1,2];

  return (
    <div>
      {rows.map(row =>
        <div className="board-row" key={row}>
          {cols.map( (square, index) => renderSquare(index + cols[row]))}
        </div>
      )}
    </div>
  );

}

着手履歴のリストを昇順・降順いずれでも並べかえられるよう、トグルボタンを追加する

難易度5

さて、1番の難題になります。
ここがかなり難しいですが、残り2つは簡単なので、気張っていきましょう。

まず、これはタイムトラベル用のボタンを降順・昇順入れ替えられるようにするトグルボタンの実装なります。

一見簡単そうに見えるのですが、考慮することが多くあるので、かなり大変です。

実装手順としては、

  1. 降順と昇順を扱えるようにするreverseFlgという新たなstateとそれを入れ替えるための関数を用意する
  2. Gameコンポーネント内に降順用の配列を用意する
  3. 降順にも対応した表示やタイムトラベルに変更する

になります。

まずは、useGameControl.ts内にフラグとトグルボタン用の関数を用意します。

const [reverseFlg, setReverseFlg] = useState(false);
const reverseHistoryInf = () => {
 setReverseFlg(!reverseFlg);
}

そして、game-infoのdivタグの中にトグルボタンを実装します。

<button onClick={reverseHistoryInf}>Reverse History Order</button>

次に、Gameコンポーネント内に、降順にする用の配列を実装をします。

const reverseArr: Array = [];
for(let i=history.length -1; i >= 0; i--){
 reverseArr.push(i);
}

そして、moves内をフラグのtrue、falseそれぞれに対応した実装にします。

const moves = history.map((step, move) => {

    let number = reverseFlg ? reverseArr[move] : move;

    const desc =  (reverseFlg === false && move) || (reverseFlg && move !== history.length -1) ?
      `Go to move #${number}(${colAndRows[number - 1][0]}, ${colAndRows[number - 1]})`:
      'Go to game start';
    return (
      <li key={move}>
        {number === stepNumber ? <button style={boldStyles}>{desc}</button> :
        <button onClick={() => jumpTo(number)}>{desc}</button>
        }
      </li>
    );
  });

これで、タイムトラベルや先ほど実装したBoldなどにも対応した状態になりました。

どちらかが勝利した際に、勝利につながった 3 つのマス目をハイライトする

難易度3

これは比較的簡単でして、calculateWinner関数を書き換えて、勝利を決定したマス目の配列を返すようにし、そのマス目にcssを当てれば完成です。

まず、calculateWinnerを書き換えて対象の配列を返すようにしましょう。
returnをsquares[a]から[a,b,c]に変更するだけです。

次に、Gameコンポーネント内にある、勝者を格納する所を少し書き換えます。

if (winner) {
 status = "Winner: " + current.squares[winner[0]];
} else {
 status = "Next player: " + (xIsNext ? "X" : "O");
 winner = []
}

そして、calculateWinner関数から返ってきた配列をpropsとしてBoardコンポーネントに渡します。

boardPropsも書き換える必要があるので、そこもお忘れなく。

そして、Boardコンポーネント内で対応するSquareには背景色をつけるようのstyleをpropsとして渡します。

const winnersStyle = {backgroundColor: "yellow"}
  const renderSquare = (i: number) => {
    if(winRow.some(value => value === i)){
      return (
          <Square
          value={squares[i]}
          onClick={() => onClick(i)}
          key={i}
          style={winnersStyle}
        />
      );
    }else{
      return (
        <Square
          value={squares[i]}
          onClick={() => onClick(i)}
          key={i}
        />
      );
    }
  }

こちらも、squareTypeを書き換えるようにしてください。

そして、square関数にstyleを設定してやれば完成です。

export const Square: VFC<squareProps> = (props) => {
  return (
    <button className="square" onClick={props.onClick} style={props.style}>
      {props.value}
    </button>
  );
}

どちらも勝利しなかった場合、結果が引き分けになったというメッセージを表示する

難易度1

最後になります。
こちらは結果が引き分けの時に、勝者や指し手を表示している箇所にDrawと表示するようにします。

実装方法としては、

  1. calculateWinner関数内で、全部のマス目が埋まっておりかつ勝敗が決まっていない時に10という数字が入った配列を返すように変更する
  2. 10が入った配列が返ってきた時は、Drawと表示する

以上になります。

ではまず、calculateWinner関数を書き換えましょう。

export const calculateWinner = (squares: Array<oneSquareType>) => {
  const lines = [
    [0, 1, 2],
    [3, 4, 5],
    [6, 7, 8],
    [0, 3, 6],
    ,
    [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 [a,b,c];
    }
  }
  for (let i=0; i < squares.length; i++){
    if(!squares[i]){
      return null
    }
  }
  return [10]
}

次に、Gameコンポーネント内の勝者指し手を表示している箇所を書きます。

let status;
  if(winner === null){
    status = "Next player: " + (xIsNext ? "X" : "O");
    winner = [];
  } else if(winner[0] === 10){
    status = "Draw";
    winner = [];
  }else {
    status = "Winner: " + current.squares[winner[0]];
  }

これで、完成です。

ちなみに、Drawの時に返している数字が10なのは特に理由はありません。

単に、TypeScript的に数字の配列を返さないといけないので、Drawとわかるように通常では絶対に返さない値を返しているだけです。

公式チュートリアル的には、これが一番難しい追加機能らしいのですが、絶対に違いますね。謎です、、

まとめ

チュートリアルにこのようなアウトプットの題材が書かれているのはかなり良いですね。

正直、まだ良い書き方が全然あると思いますので、そこも考えつつさらにリファクタリングすれば、スキルをもう1段階上げることができるかと思います。

また、わからなかった所などがありましたら、こちらのリポジトリを参考にしてみてください。
https://github.com/hinoharashinya/react-tutorial-add-function

最後まで、読んでいただきありがとうございました。
僕のブログでは他にもプログラミングについての解説記事もありますので、気になる方は見てみてください。

Reactエンジニアになりたい人はこちらからどうぞ

Reactロードマップ
【完全無料ロードマップ】Reactエンジニアへなるための方法まとめ【現役Reactエンジニアが解説】Reactを学びたいと思ってますか?Reactエンジニアから勉強方法を学ぶのが一番効率的です。なので今回は、現役のReactエンジニアがReactの学習ロードマップを無料で公開します。Reactは今とても需要が高いスキルなので、今すぐ学びましょう。...

おわり

ABOUT ME
ひのしん
【執筆者情報】ひのしんです。「正しい努力をすれば誰でも成果は出せる」 をモットーに発信しています。元ニートの現フリーランスエンジニアです。 プログラミング歴は4年ほどで、TOEIC885点を取得。科学と実体験を元にした効率的で正しいスキルアップ(プログラミング・英語・ブログ)の方法を発信してます。
\\こちらの記事が大好評です//