시간여행 추가 부분을 추가하려고 하니, 문서 길이가 너무 길어서 그런지 오류가 발생한다.
그래서 어쩔 수 없이 2로 새로 글로 작성하게 되었다.

시간여행 추가

이제 마지막으로, 게임 플레이 중 이전 턴으로 돌아갈 수 있도록 해보자.

턴 저장하기

우리가 squares 배열을 .slice()를 통해서 새로운 복사본을 만들면서 사용하기 때문에 우리는 각 턴마다의 상태를 저장할 수 있다. 우리는 history라는 새로운 배열에 이전 턴의 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',
    ]
  },
  // ...
]

우리는 history 의 상태를 어떤 컴포넌트에서 관리해야 할 지 정해야 한다.

한 번 더 상태 전달하기

앞서 말한 history의 상태를 제일 최상단 컴포넌트인 Game 컴포넌트에서 관리하려고 한다.
그래서 state를 부모(parent) 컴포넌트에게 전달하기 에서
Square 컴포넌트의 상태를 Boardprops를 통해 전달한 것처럼, Board의 상태를 Game으로 props를 통해 전달할 것이다.
( 쉽게 설명하자면, 하위 컴포넌트의 state 로 쓰던 것-> 상위 컴포넌트에서 관리 및 props 를 통해 하위 컴포넌트로 전달)

먼저, 우리는 Game 컴포넌트에 생성자를 추가하자:

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

다음으로, Board 컴포넌트가 Game 컴포넌트로부터 squaresonClickprops로 받게 하자.
아래의 단계를 통해 Board 컴포넌트를 수정하자:

  • Board 생성자 삭제
  • BoardrenderSquare 함수에서 this.state.squares[i]this.props.squares[i] 로 교체
  • BoardrenderSquare 함수에서 this.handleClick(i)this.props.onClick(i) 로 교체

그러면 Board 컴포넌트는 이렇게 될 것이다:

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

그리고 게임 진행 상태와 승자 파악하는 부분을 history의 최근 상태를 이용하도록 수정하자.
Game 컴포넌트의 render 함수를 아래처럼 바꾸자:

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 컴포넌트 안에서 status를 관리하기 때문에 Board에 있는 status 부분을 지우자.
Boardrender 함수를 아래처럼 수정하면 된다:

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

마지막으로, Board 컴포넌트에 있던 handleClick 함수를 Game 컴포넌트로 옮길 것이다. 이제 history를 가지고 보여줄 것이므로 아래처럼 좀 수정해야한다:

  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,
    });
  }
여기서 잠깐! push() vs concat()

우리는 턴이 지날 때마다 history 배열에 추가하기 위해서 concat()을 사용했다. 왜 push()를 안 쓰고 concat()을 사용했을까?

push() : The push() method adds one or more elements to the end of an array and returns the new length of the array. (MDN)

var arr1 = [‘a’, ‘b’, ‘c’];
var arr2 = [‘d’, ‘e’, ‘f’];
var arr3 = arr1.push(arr2);
console.log(arr3); // 4
console.log(arr1); // [“a”, “b”, “c”, [“d”, “e”, “f”]]

concat() : The concat() method is used to merge two or more arrays. This method does not change the existing arrays, but instead returns a new array. (MDN)

var arr1 = [‘a’, ‘b’, ‘c’];
var arr2 = [‘d’, ‘e’, ‘f’];
var arr3 = arr1.concat(arr2);
console.log(arr3); //[“a”, “b”, “c”, “d”, “e”, “f”]

쉽게 설명하자면, push()를 쓰면 기존 배열에 영향이 있고, concat()은 영향이 없다. 우리는 이전 history를 유지해야하므로 concat()을 쓰는 것이다.

Board 컴포넌트에서 handleClickGame 컴포넌트로 옮겼으므로, Board에는 이제 renderSquarerender 함수만 있으면 된다.

현재까지 완성본

이전 턴 보여주기

이제 우리는 tic tac toe 게임의 히스토리를 저장하고 있기 때문에, 플레이어에게 과거 턴들을 리스트로 보여줄 수 있다.
자바스크립트에서 배열은 아래와 같이 배열 내 데이터를 매핑하기 위해서 map() 메소드를 쓸 수 있다:

const numbers = [1, 2, 3];
const doubled = numbers.map(x => x * 2); // [2, 4, 6]

우리는 map() 함수를 가지고 history 배열을 화면에 보여줄 버튼으로 매핑할 수 있다.
Gamerender 함수에서 아래와 같이 수정하자:

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

현재까지 완성본

턴이 지날 때마다, 우리는 버튼을 포함한 태그를 만들게 된다. 버튼은 this.jumpTo() 라는 함수를 호출하는데, 이 함수는 아직 만들지 않았기 때문에, 아래와 같은 경고창이 뜰 것이다:

Warning: Each child in an array or iterator should have a unique “key” prop. Check the render method of “Game”.

키(Key) 고르기

우리가 리스트를 화면에 보여줄 때, 리액트는 화면에 보여줄 리스트들의 정보를 저장하고 있다. 우리가 리스트의 값을 업데이트하면, 리액트는 무엇이 바뀌었는 지 알아내어서 다시 화면에 보여주게 된다.

<li>Alexa: 7 tasks left</li>
<li>Ben: 5 tasks left</li>

위의 리스트를 아래와 같이 바꾼다고 생각해보자.

<li>Ben: 9 tasks left</li>
<li>Claudia: 8 tasks left</li>
<li>Alexa: 5 tasks left</li>

우리는 AlexaBen 의 순서가 바뀌고 이 사이에 Claudia가 추가되었다는 것을 알 수 있다. 하지만 리액트는 우리의 의도를 쉽게 알 수가 없기 때문에, 우리는 비슷한 리스트들 중에 key를 통해서 무엇이 다른지 리액트에게 알려줄 필요가 있다.
예를 들면 Alexa',Ben,Claudia` 의 데이터베이스 ID를 알고 있다면 다음과 같이 표현할 수 있다:

<li key={user.id}>{user.name}: {user.taskCount} tasks left</li>

이렇게 key를 가지고 만들게되면, 리액트는 리스트 아이템의 키가 이전에 있던 것인지 확인하고 다시 화면에 그리게 된다.
이전에 없던 key면 리액트는 새로운 컴포넌트를 만든다. key가 없어졌다면 리액트는 만들었던 컴포넌트를 제거한다.
그리고 기존의 key가 변경되면 리액트는 기존의 컴포넌트를 없애고 새로운 컴포넌트를 만들게 된다.

keyprops에 종속되어 this.props.key처럼 사용된다고 생각할 수도 있지만 그렇지 않다. 컴포넌트는 keythis.props.key처럼 접근할 수 없고, 리액트가 알아서 key를 통해 업데이트 할 컴포넌트를 결정하게 된다.

동적 리스트(dynamic lists)를 만들 때, 적절한 key를 쓰는 것을 추천한다.

key를 정하지 않으면, 앞서 본 것처럼 리액트는 경고창을 띄워주고 배열의 index를 기본 key로 사용한다.
배열의 index를 key로 쓰는 것은 리스트를 재정렬하거나 기존 배열 가운데 새롭게 추가/삭제하게 될 경우 문제가 될 수 있다.

시간 여행 구현하기

우리가 만든 tic tac toe 게임에서는 플레이어의 턴이 재정렬되거나 중간에 추가, 삭제 될 일은 없기 때문에 우리는 history의 index를 key로 사용할 것이다.

Game 컴포넌트의 render 함수에서 <li key={move}>와 같이 key를 추가하자:

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


그리고 아직 `this.jumpTo(move)` 에서 `jumpTo` 함수를 만들지 않았다. 이 함수를 만들기 전에 `Game` 컴포넌트에 `stepNumber`를 추가할 것이다.

먼저, `Game`의 생성자에 `stepNumber: 0`을 추가하자:

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

그리고 stepNumber를 업데이트 해줄 jumpTo 함수를 만들자. 그리고 stepNumber가 짝수 일 때, xIsNexttrue가 되도록 수정하자:

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

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

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

이제 우리는 GamehandleClick 함수를 조금 바꿔야 한다.

먼저, 플레이어가 사각형을 클릭했을 때 stepNumber가 업데이트 되도록 this.setState 부분에 stepNumber: history.length,를 추가하자. 이걸 추가해야 플레이어가 사각형을 누를 때마다 턴이 증가하게 된다.

그리고 this.state.historythis.state.history.slice(0, this.state.stepNumber + 1)로 바꾸자.
이로써 게임을 플레이하다가 이전 턴으로 돌아갔을 경우에 그 이후의 턴들의 값을 다 날려버릴 수 있다.

  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 컴포넌트의 render 함수를 수정해서 stepNumber에 맞는 현재 상태를 보여주도록 하자:

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

    // the rest has not changed

이제, 턴을 표시하는 리스트 버튼을 누르게 되면 게임 화면이 바로 그 때의 턴으로 바뀌게 된다.

현재까지 완성본

마무리

이제 드디어 tic tac toe 게임을 완성했다.
여기서 좀 아쉽다면 아래의 것들을 추가해보는 것도 공부에 도움이 될 것이다:

  1. 히스토리에 선택한 (행,열) 표시하기
  2. 히스토리에 현재 상태는 진하게 표시하기
  3. Board 컴포넌트 calculateWinner 함수에서 하드코딩한 부분 2중 루프로 다시 작성하기
  4. 오름차순/내림차순 으로 히스토리를 볼 수 있는 토글 버튼 만들기
  5. 플레이어가 이겼을 경우, 승리하게 된 3개의 사각형을 하이라이트 해주기
  6. 승패가 안가려지고 사각형이 다 채워졌을 경우, 비겼다는 메세지 보여주기

기초 문법, 리액트 컴포넌트와 같은 문서들이 많으니 참고하면서 진행하면 될 것 같다.

이상 뿅!

'개발 > React.js' 카테고리의 다른 글

리액트 공식 튜토리얼을 쉽게 해보자  (0) 2019.03.29
React.js 개발 환경  (0) 2019.03.19

+ Recent posts