LoginSignup
1
2

More than 3 years have passed since last update.

【React】GWにReact勉強してプロダクト1個作った話@1日目-前半-

Posted at

連休中にReactやりました

この連休にCTOとエンジニア1人の3人で「React勉強しようぜ」ということになりました。
チュートリアルから始めて最終的にプロダクト作ったのでその履歴です。

関係ないですが、こうやって少人数でノリで集まって気軽に新しい技術を仕入れたり、プロダクト作れたりできるのってエンジニアの醍醐味ですよね。

というわけで公式チュートリアルから

まずはということで公式のチュートリアルから始めました。

Reactのチュートリアルはこれ

ご存知の方も多いんでしょうか、◯×ゲームを作るというやつですね。

中身を見ていく

オプション 1: ブラウザでコードを書く
始めるのに一番手っ取り早い方法です!

まず、このスターターコードを新しいタブで開いてください。空の三目並べの盤面と React のコードが表示されるはずです。このチュートリアルではこのコードを編集していくことに>なります。

次のオプションはスキップして、直接概要へと進んで React の全体像を学びましょう。

オプション 2: ローカル開発環境
これは完全にオプションであり、このチュートリアルを進めるのに必須ではありません!

とあるのでお好きな方で。僕はローカル環境で進めました。

まずはここまでいきます

スクリーンショット 2020-05-06 23.47.26.png

Reactにおいてはhtmlをコンポーネントという単位に分けて書いていきます。コンポーネントにはクラスコンポーネントと関数コンポーネントという2種類があるようです。

まず小さいクラスコンポーネントを2つ作って、propsで値をやりとりします。

class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }
}

class Square extends React.Component {
  render() {
    return (
      <button className="square">
        {this.props.value}
      </button>
    );
  }
}

BoardコンポーネントとSquareコンポーネントですね。
BoardコンポーネントにはrenderSquare関数が定義されておりその中で

return <Square value={i} />;

Squareコンポーネントを呼び出しています。つまりBoardが親コンポーネント、Squareが子コンポーネントというわけですね。
そしてSquareを呼び出すと同時にvalue属性にiという変数を代入しています。これが親から子に任意の値を渡すprops
コンポーネントの呼び出し元(親)から呼び出される側(子)に値をpropsするやり方は上記のように非常に単純です。

渡されたpropsの値にアクセスするには

this.props

と書きます。そしてvalue属性として渡しているので上記の例では

this.props.value

となるわけです。

この< />というHTMLタグっぽいやつとか変数を{}で囲っていたりとかっていうのはJSXと呼ばれる構文ですね。

たいていの React 開発者は、これらの構造を簡単に記述できる “JSX” と呼ばれる構文を使用しています。

という構文は、ビルド時に >React.createElement('div') に変換されます。

ということのようです。

さて上記のままではrenderSquare関数が呼びされてもいないので、何も表示されません。このjsファイルのこの時点での全体を見て見ましょう

class Square extends React.Component {
  render() {
    return (
      <button className="square">
        {this.props.value}
      </button>
    );
  }
}

class Board extends React.Component {
  renderSquare(i) {
    return <Square value={i} />;
  }

  //この下から見ていきます

  render() {
    const status = 'Next player: X';

    return (
      <div>
        <div className="status">{status}</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 {
  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }
}

// ========================================

ReactDOM.render(
  <Game />,
  document.getElementById('root')
);

ブラウザで見てみるとこんな感じです。
スクリーンショット 2020-05-06 23.47.26.png

ソースを見ると、BoardクラスではrenderSquare関数を9回、それぞれ1~9までの数字を渡して呼び出しています。
これがマス目とマス目の中の数字を描画しています。そして新たに追加されたGameコンポーネントですが、今の所Boardコンポーネントをそのまま描画しているだけですね。

そして最後に

ReactDOM.render(
<Game />,
document.getElementById('root')
);
これによってindex.html内にある#rootに対してGameコンポーネントを描画させているというわけです。

クリックしたところが×になるようにする

スクリーンショット 2020-05-06 23.55.17.png

さてもうここまでの完成のソースを見ちゃいましょう

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

class Board extends React.Component {
  renderSquare(i) {
    return <Square />;
  }

  render() {
    const status = 'Next player: X';

    return (
      <div>
        <div className="status">{status}</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 {
  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }
}

// ========================================

ReactDOM.render(
  <Game />,
  document.getElementById('root')
);

onClick={ () => }
→onClickに渡しているのは関数です。アロー関数と呼ばれる書き方です。

さてクリックしたらマス目が×になるためにはどうしたらよいでしょう?
もっというと◯×ゲームということを考えると、今どこが×なのか、がわかる状態にしておく必要があります。

ここでstateというものが出てきます。
stateとはその名の通りコンポーネントの値の状態のことでコンストラクタでまずstateを初期化することで、
stateのなかにオブジェクト形式で値を保持することができるようになります。

constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

super(props)に関してはとりあえずコンストラクタに必ず書く、おまじないだと思っておけばいいです。
コンストラクタの中に書くthis.stateの中身はstateの初期値です。

this.state.valueでstateのvalueがキーの値にアクセスし色々書き換えたりしていくイメージです。

では実際この新たに追加したstateに対してどんな処理をしているのか、というとそれが

render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }

この処理ですね。これはSquareクラスののなかに書かれていて最終的に
stateの現在のvalueの値をbuttonタグの中に表示して返します。

onClick={() => this.setState({value: 'X'})}

このthis.setStateによってクリックした際にstateのvalueを'X'に書き換えています。

  • クリックしたことでstateのvalueがXに書き換わる

  • {this.state.value}によってXがbuttonタグに表示される
    という流れですね。

Xと交互に◯にして、勝者を識別するロジックを追加

さてこの時点での完成版のコードは下記です。

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

class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
  }

  handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
  }

  renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

  render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

    return (
      <div>
        <div className="status">{status}</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 {
  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</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;
}

はい一個ずつ見ていきましょう。
まず勝利判定をするためには、9つのマス目全体の◯Xの状態がわかる必要がありますね。
では9つのマス目をレンダリングしているコンポーネントはどれでしょう?
Boardコンポーネントですね。Boardコンポーネントでは9回にわたってSquareコンポーネントを表示させているわけですが、さっきまでの時点ではどこがXかというのはSquareコンポーネントで直接管理されていました。なぜかというと、Squareコンポーネントにstateを定義し、Squareコンポーネント内でstateを書き換えていたからです。

このままではSquare全部に対して、Boardコンポーネントから値を1つずつ取得する必要が出てきます。それが理想的ではないということは一瞬でわかります。

チュートリアルにも太字で書かれています。

複数の子要素からデータを集めたい、または 2 つの子コンポーネントに互いにやりとりさせたいと思った場合は、代わりに親コンポーネント内で共有の state を宣言する必要があります。親コンポーネントは props を使うことで子に情報を返すことができます。こうすることで、子コンポーネントが兄弟同士、あるいは親との間で常に同期されるようになります。

「親コンポーネントで共有のstateを宣言する」

要するに、複数描画されているSquareコンポーネントの値をBoardコンポーネントで一括管理できるようにします。つまりBoardコンポーネントでstateを定義します。

constructor(props) {
    super(props);
    this.state = {
      squares: Array(9).fill(null),
      xIsNext: true,
    };
 }

xIsNextは次のプレイヤーが0かXかを判定するための値で初期値はXになってます。(Xが先行ですね)
squaresの中身はindexが9の配列で全て値はnullが初期値です。

そして下記のようにSquareコンポーネントにはBoardコンポーネントのstateの値をpropsで渡しています。

renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

さらにここで
onClick={() => this.handleClick(i)}
こんな記述も追加されています。

クリックイベントでhandleClickという関数が呼び出されているのがわかりますね。this.をつけるのを忘れないようにしましょう。

ここはチュートリアルの説明がわかりやすいです。

次に、マス目がクリックされた時の挙動を変更しましょう。現在、どのマス目に何が入っているのかを管理しているのは Board です。Square が Board の state を更新できるようにする必要があります。state はそれを定義しているコンポーネント内でプライベートなものですので、Square から Board の state を直接書き換えることはできません。
代わりに、Board からSquare に関数を渡すことにして、マス目がクリックされた時に Square にその関数を呼んでもらうようにしましょう。renderSquare メソッドを以下のように書き換えましょう

どうやってSquare側でこの関数を受け取っているかというと

<button
    className="square"
    onClick={() => this.props.onClick()}
>

onClick={() => this.props.onClick()}として受け取っています。

Boardコンポーネントで定義されている
handleClickの処理はこうなっています。

handleClick(i) {
    const squares = this.state.squares.slice();
    if (calculateWinner(squares) || squares[i]) {
      return;
    }
    squares[i] = this.state.xIsNext ? 'X' : 'O';
    this.setState({
      squares: squares,
      xIsNext: !this.state.xIsNext,
    });
}

ますsliceを使ってstateのsquares配列を丸ごと全て切り出しています。
つまり完全にコピーしているということですね。なんでthis.state.squaresを直接書き換えないのかというと、ざっくりいうと変更の履歴を確認できるようにするためです。
チュートリアルの方でだいぶ詳しく説明されていたので割愛します。

次に勝者判定をするcalculateWinner関数がtrueもしくはsquare[i]がtrue(nullの場合はfalseを返します)の時は
そこでイベントハンドラを終了します。そうでない場合にはthis.state.xIs.NextがtrueならXをfalseなら0をsquaresにstateとしてセットします。

(calcukateWinner関数に関してはReactというよりロジックメインですので勝利条件を定義して判定してるんだなー程度でよいかと。。
チュートリアルもここだけコピペを推奨してますのでお言葉に甘えましょう。)

また地味にSquareコンポーネントがクラスコンポーネントから関数コンポーネントに書き換えられています。

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

基本的にstateを自身で保持しないコンポーネントは関数コンポーネントでシンプルに書きます。

最後にゲームの履歴を表示する機能を実装

疲れてきましたので、チュートリアルの説明を引用しますね。。
stateの値を直接書き換えずに、sliceで丸ごとコピーしてstateにセットしていたことが生きるというお話ですね。

squares の配列をミューテートしていたとすれば、タイムトラベルの実装はとても難しかったでしょう。
しかし我々は着手があるたびに squares のコピーを作り、この配列をイミュータブルなものとして扱っていました。このため、squares の過去のバージョンをすべて保存しておいて、過去の手番をさかのぼることができるようになります。
過去の squares の配列を、history という名前の別の配列に保存しましょう。

もうなんとなくわかってきませんか?
history配列に盤面の値を格納した配列を着手があるたびに追加していきそれを表示するようにするということですね。
またhistory配列はGameコンポーネントで管理したいそうなのでBoardコンポーネントで管理している各マス目のstateをGameコンポーネントで管理できるようにしてあげる必要があります。

トップレベルの Game コンポーネント内で過去の着手の履歴を表示したいと思います。そのためには Game コンポーネントが history にアクセスできる必要がありますので、history という state はトップレベルの Game コンポーネントに置くようにしましょう。
history state を Game コンポーネント内に置くことで、squares の state を、子である Board コンポーネントから取り除くことができます。Square コンポーネントにあった「state をリフトアップ」して Board コンポーネントに移動したときと全く同様にして、今度は Board にある state をトップレベルの Game コンポーネントにリフトアップしましょう。これにより Game コンポーネントは Board のデータを完全に制御することになり、history 内の過去の手番のデータを Board にレンダーさせることができるようになります。
まず、Game コンポーネントの初期 state をコンストラクタ内でセットします。

Boardコンポーネントを描画させているのはGameコンポーネントです。なのでGameコンポーネントでhistoryの状態を保存し、必要に応じてデータをBoardコンポーネントにpropsすることでタイムトラベル機能を実装しようということですね。

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)
        }
      ],
      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"));

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つずつ見ていきます。
ここに至るまでの過程を知りたい方は公式チュートリアルをどうぞ。

ざっくりと流れを整理すると

Boardコンポーネントに現在のsquaresの状態を渡す

<Board
    squares={current.squares}
    onClick={i => this.handleClick(i)}
/>

current.squaresは何かというと

const history = this.state.history;
const current = history[this.state.stepNumber];

この2行目ですね。history配列のstateのstepNumberの数字がキーの配列をレンダリングしていることになります。
つまりここから履歴のjumpボタンをクリックした時に、history配列のstateのstepNumberの数字を戻せば良いわけですね。

クリックした時の処理はこちら

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

jumpTo関数にhisotry配列のindexを渡しつつ呼び出しています。
jumpToは

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

stepNumberに引数のhistory配列のindex数値をそのままstateのstepNumberにセットしています。
これによって描画を巻き戻している形ですね!

history配列にはどのタイミングでマス目の状態を追加しているかというと、もちろんクリックした時です。BoardコンポーネンtのにhandleClick関数を渡して、
handleClick関数によってhistory配列を更新しています。

handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    //currentの中身はクリックする直前のマス目の状態が格納されたhistory配列のコピー
    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のなかにcrrent.squaresのコピーを格納している
      history: history.concat([
        {
          squares: squares
        }
      ]),
      stepNumber: history.length,
      xIsNext: !this.state.xIsNext
    });
 }

はい、こんな感じでしょうか。結構長くなりましたね。
割とざっくりと流れを追う感じになりましたが、もっと詳細をという方はコメントください!

次回は1日目の後半ということでまだ座学が続きます(笑)

1
2
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
2