omohayui blog

おも‐はゆ・い【面映ゆい】[形][文]おもはゆ・し[ク]《顔を合わせるとまばゆく感じられる意》きまりが悪い。てれくさい。

Tutorial: Intro to React

なぜ

今更ながら React を触る必要が発生し、何も知らない状態はまずいということで、 チュートリアルを一通りやることにした。
その昔、 backbone + marionette とか、riot とか触ってた時代はある...

Tutorial: Intro to React

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

公式サイトでチュートリアルが用意されている。

Before We Start the Tutorial

チュートリアルでは 三目並べゲーム を作りながら、React アプリを作るための基礎を習得できるらしい。
1つずつ concept を知った上で習得して行きたい人は、 step-by-step guide がおすすめとのこと。
とりあえず基礎だけ習得したいので、チュートリアルの方を進めていく。

Setup for the Tutorial

codepen.io を使ってブラウザ上で開発するか、localで開発するかを選ぶ。 今回は local で開発することにする。

1. 最新バージョンの node を入れる。

自分は nodenv を使っているので

% nodenv install 10.15.3
% nodenv rehash
% nodenv global 10.15.3
% node -v
v10.15.3

2. create react app

npx コマンドを使って React アプリのプロジェクトを新規作成する。

% npx create-react-app my-app

3. Delete all files in the src/ folder

新規プロジェクト下の src/ ディレクトリを削除する。 消すのはファイルのみ、ディレクトリ毎消してはいけない。

% cd my-app/src
% rm -f *
% cd ..

4. Add a file named index.css in the src/ folder with this CSS code.

src/ ディレクトリ下に index.css を作成

body {
  font: 14px "Century Gothic", Futura, sans-serif;
  margin: 20px;
}

ol, ul {
  padding-left: 30px;
}

.board-row:after {
  clear: both;
  content: "";
  display: table;
}

.status {
  margin-bottom: 10px;
}

.square {
  background: #fff;
  border: 1px solid #999;
  float: left;
  font-size: 24px;
  font-weight: bold;
  line-height: 34px;
  height: 34px;
  margin-right: -1px;
  margin-top: -1px;
  padding: 0;
  text-align: center;
  width: 34px;
}

.square:focus {
  outline: none;
}

.kbd-navigation .square:focus {
  background: #ddd;
}

.game {
  display: flex;
  flex-direction: row;
}

.game-info {
  margin-left: 20px;
}

5. Add a file named index.js in the src/ folder with this JS code.

src/ ディレクトリ下に index.js を作成

class Square extends React.Component {
  render() {
    return (
      <button className="square">
        {/* TODO */}
      </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')
);

6. Add these three lines to the top of index.js in the src/ folder:

index.js に import文を追記。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';

7. npm start

npm start すると、ブラウザが立ち上がって http://localhost:3000 が開かれ、 三目並べゲームのベースが表示される。

Overview

What Is React?

React はなんぞやという話から、 Component の概念、JSXの説明まで。

Inspecting the Starter Code

Setup で作成した index.js の内容を確認。 下記、3つの React Component を扱う。

  • Square
  • Board
  • Game

Game > Board > Square > button というような構成になっている。 現時点では、インタラクティブな Component はない。

Passing Data Through Props

props は Properties の略。 Board Component から Square Component に props を通してデータを渡す。

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

Board Component の renderSquare method に value を渡せるようにする。

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

Square Component の {/* TODO */} の部分を props から渡される value に置き換える。

この状態で npm start すると1つ1つの Square (マス目)に数字が入るようになる。

f:id:omohayui:20190327104441p:plain

Making an Interactive Component

クリックイベントの処理を追加していく。

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

Square Component に onClick 属性で function() { alert('click'); }} を追記するとポップアップが表示されるようになる。

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

そしてこれは ES6 の allow関数で書いてもOK。

次に、マスを X に塗り替えた状態を保持する為に state を使う。 Square Component に constructor method を追加し、state を初期社する処理を追加。

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

  render() {
    return (
      <button className="square" onClick={() => alert('click')}>
        {this.props.value}
      </button>
    );
  }
}

subclass の constructor を定義するときには常に super を呼び出す必要がある。

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

Square Component で button をクリックしたときに state の value を X に書き換える処理と state の value を button に表示させる処理を追加。

この時点で npm start するとマス目をクリックしたときに「X」がマス目上に表示し続けるようになる。

Developer Tools

ChromeFirefox に React Devtools extension を追加してあげると React Component が tree 上に Devtool で表示され、
props や state の値が確認できるから便利だよという話。

Completing the Game

次はゲームを完成させる為に、ボード上に「X」と「O」を交互に配置させ、勝者を決定する処理を作っていく。

Lifting State Up

リファクタのしやすさまで考えて、ゲームの状態を各 Square Component ではなく親の Board Component に持たせるようにする。

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

  renderSquare(i) {
    return <Square value={i} />;
  }

Board Component に子 Square Component の状態を表す state を持たせる為に constructor を追加。 9つの null を持った Array となる。

▼ this.state.squares に値が入ったときのイメージ

[
  'O', null, 'X',
  'X', 'X', 'O',
  'O', null, null,
]

Square Component で state の value を表示するように修正。

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

次に、 Square Component の button をクリックしたときに state を更新するように処理を追加したいが、
Square Component が Board Component の state を直接更新することはできない為、
Board Component から state を更新する method を Square Component に渡すようにする。

※ Square Component が Board Component の state を直接更新することはできないのは、state が Component に対して private だからである。

Board Component の renderSquare に下記のように追記して、Square Component に onClick の props を渡すようにする。

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

次に Square Component で、渡された props.onClick と props.value を使うようにする。

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

ここで npm start してマス目をクリックすると、まだ handleClick を定義していないので「handleClick is not a function」というエラーになる。

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

  handleClick(i) {
    const squares = this.state.squares.slice();
    squares[i] = 'X';
    this.setState({squares: squares});
  }

handleClick method を Board Component に生やす。 ここで直接、 this.state.squares を編集せずに一度 squares に値をコピーしてから編集していることが重要。

Why Immutability Is Important

先のコードで一度 squares を別の変数にコピーしてから編集している点について、 immutability (不変性)について説明している。

Complex Features Become Simple

immutability で複雑な機能の実装がシンプルに。

Detecting Changes

mutable (可変)な object の変更は検知するのが難しい。

Function Components

Function Component は自身の state を持たず render method のみを持つ Component
React.Component Class の継承も不要なのでただの Function になる

ということで、Square Component を Function Component に書き換える

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

同時に this.props を props に修正する

Taking Turns

三目並べゲームにするために、「X」だけでなく「O」もマークできるようにする。

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

Board Component の state に xIsNext を追加。

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

handleClick で squares にセットする値を xIsNext で判定するようにする。

  render() {
    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');

    return (
      // the rest has not changed

Board Component の render で Next player も出し分けするように修正。

Declaring a Winner

どちらのプレイヤーが勝ったのか判定する。
下記の helper function を追加。

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

calculateWinner は渡された squares 配列から勝者を判定し、 'X', 'O', or null を返す。

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

    return (
      // the rest has not changed

Board Component の render() で calculateWinner を呼び出し winner を表示するように修正。

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

handleClick で既に winner が確定している場合 or 既に変更済みのマス目の場合何もしないという処理を追加。

ここまで実装して npm start すると三目並べゲームができるようになる

f:id:omohayui:20190327105430p:plain

Adding Time Travel

最後にゲームの動作を「戻す」機能を追加する。

Storing a History of Moves

squares 配列を mutable な配列として扱うと戻る機能の実装は難しいが、
各動作の後に immutable な配列として都度 copy しているので、過去の状態に戻すことができる。

過去の squares 配列のデータは history という名前の配列に格納するようにしていく。

history = [
  // Before first move
  {
    squares: [
      null, null, null,
      null, null, null,
      null, null, null,
    ]
  },
  // After first move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, null,
    ]
  },
  // After second move
  {
    squares: [
      null, null, null,
      null, 'X', null,
      null, null, 'O',
    ]
  },
  // ...
]

Lifting State Up, Again

次に Top Level の Game Component に history を持たせるようにする。
また、 Game Component で squares の state も触る必要があるので、
再度 state を Board Component から Game Component に渡す処理を追加する。

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

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

Game Component にも constructor を追加。

class Board extends React.Component {
  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.props.squares[i]}
        onClick={() => this.props.onClick(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>
    );
  }
}

次に Game Component から Board Component へ props として渡された squares と onClick() を Square Component に渡すようにする。 Board Component から要らなくなった constructor を削除。

そして Game Component の render function では history を使ってゲームステータスを表示する。

  render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    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>{/* TODO */}</ol>
        </div>
      </div>
    );
  }

Game Component がゲームステータスをレンダリングしているので、
Board Component の render から対応するコードを削除することができる。

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

最後に、handleClick() を Board Component から Game Component に移動させる。

  handleClick(i) {
    const history = this.state.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: history.concat([{
        squares: squares,
      }]),
      xIsNext: !this.state.xIsNext,
    });
  }

Game Component に移動させたことによって、 handleClick() 内でも 新しい history 配列を set する必要がある。 補足情報:push は破壊的なので concat の方がおすすめ

Showing the Past Moves

map() method を使って Game Component の render で履歴情報を表示するようにする。

  render() {
    const history = this.state.history;
    const current = history[history.length - 1];
    const winner = calculateWinner(current.squares);

    const moves = history.map((step, move) => {
      const desc = move ?
        'Go to move #' + move :
        'Go to game start';
      return (
        <li>
          <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>
    );
  }

この時点では、まだ jumpTo() が実装されていないので、devtool では warning が出る。

Picking a Key

ゲームの履歴毎にユニークな key を連番で持たせることができるので、
Game Component の render の <li><li key={move}> を追加する。

class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{
        squares: Array(9).fill(null),
      }],
      stepNumber: 0,
      xIsNext: true,
    };
  }

jumpTo() method を実装する前に、stepNumber を state に追加。

  handleClick(i) {
    // this method has not changed
  }

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

  render() {
    // this method has not changed
  }

Game Component 内に jumpTo() method を生やす。

次に handleClick() 内で stepNumber をセットする処理を追加する。 history も過去の分だけを保持するように、 stepNumber + 1 までに slice する。

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

最後に Game Component の render で表示するものを常に最新のゲーム状態ではなく、
選択された履歴を表示するようにする。

  render() {
    const history = this.state.history;
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

    // the rest has not changed

これで、履歴をクリックをするとその状態がマス目に表示されるようになった。

f:id:omohayui:20190327105355p:plain

Wrapping Up

三目並べゲームが完成したところで、最終チェック。
問題なく動いている。

さらにゲームに機能を追加するためのアイデアも。
※ 今回はそこまではやらず。

所管

  • Component の思想 (private化) は他のフレームワークと同様理解しやすい
  • props は Riot でいう opts だと思った(Riot v4 で opts が props , state になるという噂)
  • 一つ一つ読み込んでいくと immutable の話や React 関係なく重要な tips もあって面白かった