본문 바로가기

React

[Tutorial]5.Time Trave 기능 추가



※ React.org의 공식문서( https://reactjs.org/tutorial/tutorial.html ) 번역본입니다. ※

1. 플레이 내역 저장하기

2. 다시 State 끌어올리기

3. 이동내역 보여주기

4. Key 고르기

5. Time Travel 구현하기

  

마지막 단계로 게임 플레이 내역을 볼 수 있는 Time Travel 기능을 구현해보자.


1. 플레이 내역 저장하기


 우리가 만약 squares 배열을 직접 수정했다면, Time Travel 기능을 구현하기 어려웠을 것이다. 그러나 우리는 slice() 함수를 통해 플레이 할때마다 새로운 복사본을 만들어서 원본 데이터를 보존했다. 이는 게임 플레이 내역을 저장할 수 있게 해주고, 현재 턴과 과거 턴을 비교할 수 있게 해준다. 이제 과거 Squares 배열의 내용을 history라고 불리는 또 다른 배열에 저장하자. histsory 배열은 Board의 처음부터 끝까지 상태를 보여주고 배열의 내용은 아래와 같다.

 

  let history = [
    //플레이 전
    {
      squares : [
        nullnullnull,
        nullnullnull,
        nullnullnull,
      ]
    },
//첫번째 턴
    {
      squares : [
        nullnullnull,
        null'X'null,
        nullnullnull,
      ]
    },
//두번째 턴
    {
      squares : [
        nullnullnull,
        null'X'null,
        nullnull'O',
      ]
    },
    //...
  ]


 이제 history 를 어떤 컴포넌트에 넣을지 결정해야 한다.


2. 다시 State 끌어올리기


 가장 상위인 Game 컴포넌트가 과거 플레이 내역을 보여주도록 할 것이다. 그러면 Game 컴포넌트가 history에 접근할 수 있어야 하므로 history를 가장 상위엔 Game 컴포넌트에 넣어야 한다. history state를 Game 컴포넌트에 넣고 Board 컴포넌트의 squares state를 지우자. Square 컴포넌트의 State를 Board 컴포넌트로 끌어올렸던 것처럼 이제 Board 컴포넌트의 State를 Game 컴포넌트로 끌어올리는 것이다. 이제 Game 컴포넌트가 모든 Board 데이터를 관리할 수 있게 되었고 Board가 history로부터 이전 턴을 화면에 그릴 수 있게 되었다.


첫번째로 Game 컴포넌트의 생성자 안에서 초기 state를 설정하자.

 

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


다음으로 Board 컴포넌트가 Game 컴포넌트로부터 squares와 onClick을 내려받도록 하자. 지금은 Board가 여러개 Squares를 위한 클릭 핸들러를 가지고 있기 때문에 클릭 핸들러에 각 Square의 위치 정보를 넘겨줄 필요가 있다. Board 컴포넌트를 아래와 같이 수정하자.

  • Board의 constructor를 삭제한다.

  • Board의 renderSquare 함수에서 this.state.squares[i]를 this.props.squares[i]로 변경한다.

  • Board의 renderSquare 함수에서 this.handleClick(i)를 this.props.onClick(i)로 변경한다.

 

  class Board extends React.Component {
    handleClick(i){
      const squares = this.state.squares.slice();
      if(calculateWinner(squares) || squares[i]){
        return;
      }
      squares[i] = this.state.xIsNext?'O':'X';
      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 컴포넌트의 render함수를 가장 최근의 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 컴포넌트가 이제 게임 상태를 표시하기 때문에 Board의 render 함수에서 Game컴포넌트와 겹치는 코드를 삭제하자. 수정 후 Board의 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 컴포넌트에서 Game 컴포넌트로 옮기자. Game 컴포넌트의 state 구조가 변경되었기 때문에 handleClick 함수도 수정해야 한다. Game의 handleClick 함수 안에서 새로운 history를 기존의 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,
      });
    }



 

  Note

 더 익숙하실 배열의 push() 함수와는 달리 concat() 함수는 원본 배열을 변경하지 않고 추가된 배열을 리턴한다.



 이 시점에서, Board 컴포넌트는 오직 renderSquare와 render 함수만 필요하다. game 상태와 handleCick 함수는 Game 컴포넌트 안에 있어야 한다.


3. 이동내역 보여주기


 이제 tic-tac-toe 게임의 내역을 저장하고 있기 때문에 플레이어에게 그 내역을 보여줄 수 있다. React 엘리먼트는 우선 JavaScript의 객체라고 언급했습니다. 어플리케이션 안에서 주고받을 수 있는 객체입니다. React의 여러 항목들을 화면에 표시하기 위해 React 엘리먼트의 배열을 사용할 수 있다.

 JavaScript에서 배열은 map() 함수를 갖는데 이는 data와 다른 data를 맵핑시키는 주요 함수이다. 예를 들면 : 

 

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


map 함수를 사용하여 플레이 내역과 화면에 보이는 React 엘리먼트인 버튼을 맵핑하여 과거 시점으로 이동하는 jump 버튼들을 보여줄 수 있다. Game의 render 함수에서 history를 맵핑하자.

 

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



tic-tac-toe 게임의 history간 이동을 위해 list항목인 <li>를 만들고 그안에 <button>을 넣자. 이 버튼의 onClick 핸들러는 this.jumpTo() 함수를 호출한다. 아직 jumpTo() 함수는 구현하지 않았다. 지금은 게임 플레이 내역을 리스트로 보고 개발자 콘솔에서 다음과 같은 경고 메세지를 확인하자.

 

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

 배열이나 반복자의 자식요소는 반드시 유일한 "key"속성이 있어야 한다. "Game" 컴포는트의 render 함수를 확인하라.


위의 경고 메세지의 의미에 대해 얘기해보자.


4. Key 고르기


 리스트를 화면에 그릴 때, React는 각 리스트 아이템에 대한 정보를 저장한다. 리스트를 갱신할 때, React는 무엇을 다시 그릴지 결정할 필요가 있다. 리스트의 아이템 추가, 삭제, 재배열, 수정등의 작업을 할 수 있다. 

 아래 리스트에서

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

리스트의 항목 개수를 업데이트하는것과 더불어 사람이 이 리스트를 본다면 Alexa와 Ben의 순서가 바뀌고 Caludia가 Alexa와 Ben 사이에 들어왔다고 할 것이다. 그러나 React는 컴퓨터 프로그램이고 우리의 의도를 모른다. 왜냐하면 React는 컴퓨터 프로그램일 뿐이고 우리는 각 아이템을 다른 아이템과 구분짓기 위해 key 속성을 정의해야 한다. key 속성을 정의하는 방법으로 alexa, ben, claudia같은 문자열을 사용할 수 있다. 만약 데이터베이스로부터 데이터를 보여준다면 Alexa, Ben, Caludia의 데이터베이스 ID를 key로 사용할 수 있다.

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


 리스트가 다시 그려질 때 React는 리스트의 각 아이템의 키를 갖고 변경되기 전의 리스트의 항목을 찾는다. 만약 현재 리스트에 있는 키가 변경 전의 리스트에 존재하지 않는다면 React는 이전 컴포넌트를 파괴한다. 만약 과거 리스트에 키가 존재한다면 대응되는 컴포넌트는 이동된다. Key가 React에게 각 컴포넌트를 다시 그릴 때 어떤 컴포넌트를 다시 그릴지 기준을 알려준다. 만약 Key가 변경된다면 컴포넌트는 파괴되고 새로운 state로 다시 만들어질 것이다.

Key는 React에서 특별하고 보존되어야 하는 속성이다. 엘리먼트가 만들어질때 React는 Key를 추출하고 그 Key를 리턴되는 엘리먼트에 바로 저장한다. Key가 props에 속해있는 것처럼 보일지라도 Key는 this.props.key를 사용해서 참조할 수 없다. React는 컴포넌트를 업데이트할 때 key를 자동으로 사용한다. 컴포넌트는 키에 대한 정보를 조회할 수 없다.

 동적 리스트를 작성할 때마다 적절한 Key를 할당하는 것이 중요하다. 적절한 Key가 없다면, 데이터 구조를 다시 작성하는 것을 고려해야 할 것이다. 


 정의된 Key가 없다면 React는 경고를 보여주고 배열의 index를 Key로 사용한다. 배열의 index를 key로 사용하는 것은 리스트 아이템의 순서를 변경하거나 아이템을 삭제/추가할 때 문제가 있다. 명시적으로 key={i}를 전달하는 것은 Warning을 발생시키지 않는다. 그러나 배열 인덱스와 같은 문제가 있는데 대부분의 경우에 추천하지 않는다.


 Key는 전역 범위로 유일할 필요는 없다. 컴포넌트와 그 형제 컴포넌트 사이에서만 유일하면 된다.


5. Time Travel 구현하기


 tic-tac-toe 게임 내역에서 각 턴들은 유일한 ID가 있어야 한다. 연속적으로 증가하는 턴의 번호를 ID로 할 것이다. 턴 내역은 절대 재조정, 삭제, 중간에 삽입되지 않으므로 턴 번호를 Key로 사용하는 것은 안전하다.

 Game 컴포넌트의 render 함수에서 Key prop : <li key={move}> 을 추가하자. 그러면 React의 Warning이 사라질 것이다.

 

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


리스트의 item 버튼을 클릭하면 jumpTo 함수가 정의되지 않아 오류가 발생한다. jumpTo 함수를 구현하기 전에 먼저 stepNumber를 Game 컴포넌트에 추가하여 현재 게임 상태를 표시할 것이다. 


Game 컴포넌트의 생성자에 초기 state로 stepNumber : 0을 추가한다.

 

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


다음으로 stepNumber를 업데이트 하는 jumpTo 함수를 Game 컴포넌트 안에 구현할 것이다. 또한 xIsNext도 stepNumber가 짝수면 true로 수정한다. 

 

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


이제 Square를 클릭할 때 동작하는 Game 컴포넌트의 handleClick 함수만 조금 수정해주자. 추가한 stepNumber는 이제 턴의 번호를 반영한다. 새로운 턴 다음 this.setState의 stepNumber를 history.length로 업데이트 해야한다. 이것은 stepNumber가 이전 턴임에 멈춰 있지 않기 위해 필요하다.  또한 this.state.history를 this.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 함수를 수정하자. 마지막 턴을 그렸던 render 함수는 이제 stepNumber에 따른 턴을 화면에 그려야 한다. 

 

    render() {
      const history = this.state.history;
      const current = history[this.state.stepNumber];
      const winner = calculateWinner(current.squares);
      const moves = history.map((step,move) => {


이제 게임 내역의 어떤 턴을 클릭하더라도 tic-tac-toe는 바로 그 턴의 모습을 보여준다.



'React' 카테고리의 다른 글

[Tutorial]6.마무리  (0) 2019.02.15
[Tutorial]4.게임 완성하기  (0) 2019.02.15
[Tutorial]3.개요  (0) 2019.02.15
[Tutorial]2.Tutorial 환경설정  (0) 2019.02.15
[Tutorial]1.Tutorial을 시작하면서...  (0) 2019.02.15