1
0

More than 3 years have passed since last update.

手を動かしながら学ぶReact入門 3. 三目並べを関数コンポーネントで実装する

Posted at

WebフレームワークであるReactについて、実際に手を動かしながら学んでいきたいと思います。

1. Create React Appでプロジェクトの雛形を作成
2. Reactについて学ぶ
3. 三目並べを関数コンポーネントで実装する ← ここ

作成するアプリケーション

React公式のチュートリアルで実装する三目並べを実装します。
チュートリアルではクラスコンポーネントで実装されていますが、本稿では関数コンポーネントだけを用いて実装します。
通常の三目並べに加え、特定の手数まで戻るタイムトラベル機能がついています。
image.png

コンポーネントの構成

下記のようなコンポーネントの構成で実装していきます。
GameコンポーネントはBoardコンポーネントを子の要素として持ち、Boardコンポーネントは9個のSquareコンポーネントを子として持ちます。
image.png

単純な三目並べの実装(Boardまで)

まずはタイムトラベル機能を持たない、単純な三目並べを実装していきます。
image.png

Square コンポーネント

9個ある正方形の升目をボタンとして実装します。
propsをBoardから受け取り、props.valueを表示、クリック時にはprops.onClickをコールするようにします。

App.jx
import './App.css'; // スタイル適用のためにcssをインポート

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

スタイルを適用させるため、App.cssをApp.jsと同じフォルダに作成し、下記のように記載します。

App.css
.square{
  height: 50px;
  width: 50px;
  vertical-align: top;
}

Board コンポーネント

[1/3] Squareを並べてみる

まずはSquareコンポーネントを3×3に並べてプロパティを設定してみましょう。
<Square/>を一つ一つ<div>の中に書いてもいいのですが、見通しと保守性を考えて、valueを引数に持つrenderSquareでSquareコンポーネントを作成するようにします。
この時にvalueonClickをpropsとして渡しているのがわかりますね。

App.js
function Board(){

  function handleClick(value){
    alert(value);
  };

  function renderSquare(value){
    return(
      <Square value={value} onClick={()=>handleClick(value)}/>
    );
  };

  return(
    <div>
      <div>
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div>
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div>
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
    </div>
  )
}

export default Board

下記のように3×3のボタンが表示され、ボタンをクリックするとボタンと同じ番号がalertされるのが確認できます。
image.png

[2/3] stateを実装する

三目並べとしたいので、Squareの中に書かれている内容は数字ではなくOかXかを表示させる必要があります。
そのために、現在の盤面を下記のように配列で表現し、これをstateとして保存するようにします。

var squares = [ null, null, 'X'
                'X' , 'O' , null
                null, null, null]

また、手番を交代とするために、xIsNextというstateも持つようにします。

まずはstateを利用するために、useStateをインポートします。

App.js
+ import React, {useState} from 'react';
import './App.css';

handleClickrenderSquareを下記のように更新することで、ボタンをクリックすると交代でXとOが入力されるようになります。
handleClick内でsquaresをそのまま使わずにコピーを取っている理由については、Reactのチュートリアルを参照してください。

App.js
import React, {useState} from 'react' // useState利用のためにimportする

// function Square()...

function Board(){
  const [squares, setSquares] = useState(Array(9).fill(null)); // 盤面
  const [xIsNext, setXIsNext] = useState(true);                // 手番

  function handleClick(value){
    const newSquares = squares.slice();     // squaresのコピーを取得
    newSquares[value] = xIsNext? 'X' : 'O'; // 手番に応じてXかOを設定
    setSquares(newSquares);                 // squaresを更新
    setXIsNext(!xIsNext);                   // 手番を更新
  };

  function renderSquare(value){
    return(
      <Square value={squares[value]} onClick={()=>handleClick(value)}/> // valueとしてsquares[value]を渡すように変更
    );
  };
  //return(...
}

[3/3] 勝敗の判定を実装する

盤面の状態に応じて、手番の表示と勝敗の判定を行う部分を実装していきます。
image.png

まずは手番を表示できるようにします。
メッセージを表すstatusstateを定義し、それを表示する<div>タグを記述します。

App.js
function Board(props){

  const [squares, setSquares] = useState(Array(9).fill(null));
  const [xIsNext, setXIsNext] = useState(true);
  const [status, setStatus] = useState("");


//  function renderSquare(value){
//  ...
//  };

  return(
    <div>
      <div>{status}</div> {/*追加*/}
      <div>
      {/*renderSquare(...)*/}
      </div>
    </div>
  )
}

このstatusを手番ごとに更新するのですが、ここではuseEffectというHookを利用します。
useEffectはViewが更新される度にコールされ、ここでstatusメッセージを更新することにします。
useEffectを利用するために、reactからuseEffectをインポートしておきます。

App.js
- import React, {useState} from 'react';
+ import React, {useState, useEffect} from 'react';
import './App.css';

勝敗を判定する関数は公式のものをコピーしてきます。

App.js
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;
}

useEffectを実装していきます。calculateWinnernullでない場合はstatusに勝者を表示、そうでない場合は手番を表示します。

App.js
function Board(props){
  // ...

  // function renderSquare(value){
  // ...
  // };

  useEffect(()=>{
    const winner = calculateWinner(squares);
    if(winner){
      setStatus("Winner : " + winner);
    }
    else{
      setStatus('Next player: ' + (xIsNext ? 'X' : 'O'));
    }
  })

  // return(...
}

このままだと、ゲーム終了後にも盤面をクリックできてしまう&同じボタンが何度も押せてしまうので、下記のようにhandleClickを修正します。

App.js
  function handleClick(value){
    if (calculateWinner(squares) || squares[value]) {
      return;
    }
    // ...

  };

App.js全体

App.jsの全体は下記のようになります。

App.js
import React, {useState, useEffect} from 'react';
import './App.css';

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

function Board(props){

  const [squares, setSquares] = useState(Array(9).fill(null));
  const [xIsNext, setXIsNext] = useState(true);
  const [status, setStatus] = useState("");

  function handleClick(value){
    if (calculateWinner(squares) || squares[value]) {
      return;
    }
    const newSquares = squares.slice();
    newSquares[value] = xIsNext? 'X' : 'O';
    setSquares(newSquares);
    setXIsNext(!xIsNext);
  };

  function renderSquare(value){
    return(
      <Square value={squares[value]} onClick={()=>handleClick(value)}/>
    );
  };

  useEffect(()=>{
    const winner = calculateWinner(squares);
    if(winner){
      setStatus("Winner : " + winner);
    }
    else{
      setStatus('Next player: ' + (xIsNext ? 'X' : 'O'));
    }
  })

  return(
    <div>
      <div>{status}</div>
      <div>
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div>
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div>
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
    </div>
  )
}

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;
}

export default Board;

タイムトラベル機能の実装(Gameの実装)

ここで完成形となるGameコンポーネントを実装します。
今まで作成したBoardコンポーネントを子要素に持たせ、今までの三目並べの機能に加えて、任意の手番まで戻ることができるタイムトラベル機能を実装します。

タイムトラベル機能の実現のため、過去の手番すべてを記憶させた、history配列を用意します。

var history = [
    {
        squares: [ null, null, null
                   null, null, null
                   null, null, null]
    },
    {
        squares: [ null, null, null
                   null, 'X' , null
                   null, null, null]
    },
    {
        squares: [ null, null, 'O' 
                   null, 'X' , null
                   null, null, null]
    },
    // ...

[1/3] stateのリフトアップ

上記のhistoryというstateはゲーム全体に関わる要素なので、Gameコンポーネントのstateとして保持することにします。
こうすることで、Boardコンポーネントはsquaresというstateを保つ必要がなくなり、親要素であるGameコンポーネントから逐一squaresを受け取り、それを描画するだけでよくなります。
これに合わせて、stateを変更するhandleClickや、他のstate(xIsNextstatus)もGameコンポーネントにリフトアップさせます。

App.js
function Game(){
  const [history, setHistory] = useState([{squares: Array(9).fill(null)}]);
  const [xIsNext, setXIsNext] = useState(true);
  const [status, setStatus] = useState("");

  function handleClick(value){
    const current = history[history.length - 1];

    if (calculateWinner(current.squares) || current.squares[value]) {
      return;
    }
    const newSquares = current.squares.slice();
    newSquares[value] = xIsNext? 'X' : 'O';
    setXIsNext(!xIsNext);
    setHistory(history.concat([{squares: newSquares}]));
  };

  useEffect(()=>{
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);
    if(winner){
      setStatus("Winner : " + winner);
    }
    else{
      setStatus('Next player: ' + (xIsNext ? 'X' : 'O'));
    }

  });

  return(
    <div>
      <div>
        <Board squares={history[history.length - 1].squares} onClick={(i)=>handleClick(i)}/>
      </div>
      <div>
        <div>{ status }</div>
      </div>
  </div>
  );
}

Boardコンポーネントはstateを扱う必要がなくなり、親要素からpropsとして受け取った値だけを表示するだけでよくなります。

App.js
function Board(props){

  function renderSquare(value){
    return(
      <Square value={props.squares[value]} onClick={()=>props.onClick(value)}/>
    );
  };

  return(
    <div>
      <div>
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div>
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div>
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
    </div>
  )
}

[2/3] 過去の手の表示

過去の手に戻る、下記部分の表示を実装していきます。
image.png

historyに対してmapを行い、その結果をmovesとして表示させるようにします。
ループでリストの要素を追加しているため、key要素を割り当てる必要があります。詳細はこちら
ボタンをクリックした際にコールされるjumpToは後ほど実装します。

App.js
function Game(){
  // ...

  const moves = history.map((step, move) => {
    const desc = move ?
      'Go to move #' + move :
      'Go to game start';
    return (
      <li key={move}> {/*リストにはKeyを割り当てる必要がある*/}
        <button onClick={() => jumpTo(move)}>{desc}</button> {/*jumpToは後ほど実装*/}
      </li>
    );
  });

  // ...

  return(
    <div className="game">
    <div className="game-board">
      <Board squares={history[stepNumber].squares} onClick={(i)=>handleClick(i)}/>
    </div>
    <div className="game-info">
      <div>{ status }</div>
      <ol>{moves}</ol> {/*追加*/}
    </div>
  </div>
  );
}

[3/3] タイムトラベルの実装

タイムトラベル機能の実装にあたって、現在の手が何番目かを表すstepNumberというstateを定義します。

App.js
function Game(){
  const [history, setHistory] = useState([{squares: Array(9).fill(null)}]);
  const [xIsNext, setXIsNext] = useState(true);
  const [status, setStatus] = useState("");
+ const [stepNumber, setStepNumber] = useState(0);

  // ...
}

このstepNumberは手番が進むたびに更新される必要があるため、handleClickの中で更新されるようにします。
また、それに合わせて現在の盤面を表すcurrentstepNumberから取得するように変更します。

App.js
function Game(){
  // ...

  function handleClick(value){
+   const hist = history.slice(0, stepNumber + 1);
+   const current = hist[hist.length - 1];
-   const current = history[history.length - 1];

    if (calculateWinner(current.squares) || current.squares[value]) {
      return;
    }
    const newSquares = current.squares.slice();
    newSquares[value] = xIsNext? 'X' : 'O';
    setXIsNext(!xIsNext);
+   setStepNumber(hist.length);
    setHistory(hist.concat([{squares: newSquares}]));
  };

  useEffect(()=>{
+   const current = history[stepNumber];
-   const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);
    if(winner){
      setStatus("Winner : " + winner);
    }
    else{
      setStatus('Next player: ' + (xIsNext ? 'X' : 'O'));
    }

  });

  // ...
}

最後に、未定義だったjumpToメソッドを下記のように定義して完成となります。

App.js
function Game(){
  // ...

  jumpTo(step) {
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0,
    });
  }
  // ...
}

お疲れさまでした!これで三目並べの関数コンポーネントでの実装は完了です!
最後に、App.js全体のコードを貼り付けておきます。

App.js
import React, {useState, useEffect} from 'react';
import './App.css';

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

function Board(props){

  function renderSquare(value){
    return(
      <Square value={props.squares[value]} onClick={()=>props.onClick(value)}/>
    );
  };

  return(
    <div>
      <div>
        {renderSquare(0)}
        {renderSquare(1)}
        {renderSquare(2)}
      </div>
      <div>
        {renderSquare(3)}
        {renderSquare(4)}
        {renderSquare(5)}
      </div>
      <div>
        {renderSquare(6)}
        {renderSquare(7)}
        {renderSquare(8)}
      </div>
    </div>
  )
}

function Game(){
  const [history, setHistory] = useState([{squares: Array(9).fill(null)}]);
  const [xIsNext, setXIsNext] = useState(true);
  const [status, setStatus] = useState("");
  const [stepNumber, setStepNumber] = useState(0);

  function jumpTo(step){
    setStepNumber(step);
    setXIsNext((step%2) === 0);
  }

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

  function handleClick(value){
    const hist = history.slice(0, stepNumber + 1);
    const current = hist[hist.length - 1];

    if (calculateWinner(current.squares) || current.squares[value]) {
      return;
    }
    const newSquares = current.squares.slice();
    newSquares[value] = xIsNext? 'X' : 'O';
    setXIsNext(!xIsNext);
    setStepNumber(hist.length);
    setHistory(hist.concat([{squares: newSquares}]));
  };

  useEffect(()=>{
    const current = history[stepNumber];
    const winner = calculateWinner(current.squares);
    if(winner){
      setStatus("Winner : " + winner);
    }
    else{
      setStatus('Next player: ' + (xIsNext ? 'X' : 'O'));
    }

  });

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

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;
}

export default Game;
1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0