이 글은 아래 React 자습서의 하단에 있는 틱택토 개선 아이디어를 순서대로 구현해 본 예제이다.

 

>> 참조: reactjs.org/tutorial/tutorial.html#implementing-time-travel

 

Tutorial: Intro to React – React

A JavaScript library for building user interfaces

reactjs.org

직접 구현해 보려는데, 뭔가 잘 안 풀릴 때 참고가 될 수 있기를 바라며 정리해 봤다.

 

 

 

 

원래 코드가 아래와 같았다면,

 

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

[index.js]

 

 

 

 

1~6번까지 아이디어를 적용하고 각 기능 컴포넌트를 파일 단위로 분리한 버전은 아래와 같다.

 

import React from 'react';
import ReactDOM from 'react-dom';
import Game from './Game';

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

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

[index.js]

 

index.js에서는 Game.js를 불러와서 렌더링하는 코드만 남기고 다 삭제했다.

이렇게 하면 기존 프로젝트에서 하위 폴더 단위 컴포넌트 묶음으로 만들어 다른 컴포넌트에서 불러다 쓰기 편리하다.

렌더링 관련 코드를 다 삭제하고 export 코드만 넣어주면 되니까.

 

즉, 해당 앱을 tictactoe 라는 하위 폴더에다 만들어 두고 다른 컴포넌트에서 참조해서 사용하도록 한다면... 이렇게

 

export { default as TicTacToe } from './Game';

[./tictactoe/index.js]

 

export 코드만 남겨두고 TicTacToe라는 이름을 사용하도록 해 두면 다른 컴포넌트에서 TicTacToe 이름으로 import해서 사용 가능하다.

 

import { TicTacToe } from './tictactoe';

[./App.js]

 

이렇게.

 

 

 

 

다음은, 실제 틱택토 구현 컴포넌트.

 

import React from 'react';
import nanoid from 'nanoid';
import Board from './Board';
import './index.css';

export default class Game extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      history: [{
        // object를 fill하면 by ref로 채워져서 이후 변경 시 모든 값이 같이 변경됨
        squares: /*Array(9).fill({ value: null, winner: false }),*/
          [{ value: null, winner: false },
           { value: null, winner: false },
           { value: null, winner: false },
           { value: null, winner: false },
           { value: null, winner: false },
           { value: null, winner: false },
           { value: null, winner: false },
           { value: null, winner: false },
           { value: null, winner: false },],
        position: null,
      }],
      stepNumber: 0,
      xIsNext: true,
      historySortAscending: true,
    };
  }

  handleClick(i) {
    const history = this.state.history.slice(0, this.state.stepNumber + 1);
    const current = history[history.length - 1];
    if (calculateWinner(current.squares) || current.squares[i].value) {
      return;
    }

    // object array는 slice해봤자 객체 참조이므로 동일한 값(객체)을 참조하게 된다
    /*const squares = current.squares.slice();*/
    // 객체 배열을 새로 생성하고
    const squares = [
      { value: null, winner: false },
      { value: null, winner: false },
      { value: null, winner: false },
      { value: null, winner: false },
      { value: null, winner: false },
      { value: null, winner: false },
      { value: null, winner: false },
      { value: null, winner: false },
      { value: null, winner: false },
    ];
    // 값을 일일이 복사해주어야 한다.
    for (let k = 0; k < current.squares.length; k++) {
      squares[k].value = current.squares[k].value; 
      squares[k].winner = current.squares[k].winner;
    }

    squares[i].value = this.state.xIsNext ? 'X' : 'O';

    // 5. 승자가 정해지면 승부의 원인이 된 세 개의 사각형을 강조
    let check = history.concat([{
      squares: squares,
      position: i,
    }]);

    if (calculateWinner(squares, ([a, b, c]) => {
        // 승부가 난 경우... 좌표값 3개를 받아 강조 표시한다
        squares[a].winner = true;
        squares[b].winner = true;
        squares[c].winner = true;
        this.setState({
          history: check,
          stepNumber: check.length - 1,
          xIsNext: !this.state.xIsNext,
        });
      })) {
      return;
    }

    this.setState({
      history: check,
      stepNumber: check.length - 1,
      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((obj, index) => {
      const position = index ? history[index].position : -1;
      // 1. 이동 기록 목록에서 특정 형식(행, 열)으로 각 이동의 위치를 표시
      const desc = index ? 'Go to move #' + index + ' (' + parseInt(position / 3) + ',' + (position % 3) + ')' : 'Go to game start';

      // 2. 이동 목록에서 현재 선택된 아이템을 굵게 표시
      const id = nanoid();
      return (
        <li key={'id' + id}>
          <button onClick={() => this.jumpTo(index)}
            style={current.position === position ? { fontWeight: 'bold'} : {}}
          >{desc}</button>
        </li>
      );
    });

    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      // 6. 승자가 없는 경우 무승부라는 메시지를 표시
      if (this.state.stepNumber >= 9) {
        status = 'Draw';
      } else {
        status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
      }
    }

    // 4. 오름차순이나 내림차순으로 이동을 정렬하도록 토글 버튼을 추가
    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>
          <div style={{ marginTop: 10 }}>
            <button onClick={() => this.setState({ historySortAscending: !this.state.historySortAscending })}>
              {this.state.historySortAscending ? "내림차순" : "오름차순"}
            </button>
          </div>
          {this.state.historySortAscending && <ol>{moves}</ol>}
          {!this.state.historySortAscending && <ol reversed>{moves.reverse()}</ol>}
        </div>
      </div>
    );
  }
}

function calculateWinner(squares, callback) {
  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++) {
    let [a, b, c] = lines[i];
    if (squares[a].value &&
      squares[a].value === squares[b].value &&
      squares[a].value === squares[c].value) {
      if (callback) {
        callback.call(this, [a, b, c]);
      }
      return squares[a].value;
    }
  }
  return null;
}

[Game.js]

 

사실상 Game 컴포넌트에서 1~6까지 아이디어 기능을 모두 구현했다.

주의할 점은 배열을 처리할 때 .fill( ) 함수와 .slice( ) 함수를 object에 대해 사용하는 경우 참조가 채워지거나 복사되기 때문에 이후 변경 시 모두 동일한 값을 가지게 된다는 점. Javascript 공식 문서에서도 사용 시 주의하라고 나와 있다.

 

>> 참고: developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/fill

 

Array.prototype.fill()

fill() 메서드는 배열의 시작 인덱스부터 끝 인덱스의 이전까지 정적인 값 하나로 채웁니다.

developer.mozilla.org

>> 참고: developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/slice

 

Array.prototype.slice()

slice() 메서드는 어떤 배열의 begin부터 end까지(end 미포함)에 대한 얕은 복사본을 새로운 배열 객체로 반환합니다. 원본 배열은 바뀌지 않습니다.

developer.mozilla.org

 

 

 

마지막 남은 3번 아이디어는 Board 컴포넌트에서 구현했다.

 

import React from 'react';
import Square from './Square';
import nanoid from 'nanoid';

export default class Board extends React.Component {
  renderSquare(i) {
    const id = nanoid();
    return (
      <Square
        key={'sqid' + id}
        value={this.props.squares[i].value}
        winner={this.props.squares[i].winner}
        onClick={() => this.props.onClick(i)}
      />
    );
  }

  // 3. 사각형들을 만들 때 하드코딩 대신에 두 개의 반복문을 사용하도록 Board를 다시 작성
  render() {
    let board = [];
    for (let i = 0; i < 3; i++) {
      let row = [];
      for (let j = 0; j < 3; j++) {
        row.push(this.renderSquare(i * 3 + j));
      }
      // 반복문 사용 시 key 필수
      board.push(<div key={'bid' + id} className="board-row">{row}</div>);
    }
    return (
      <div>
        {board}
      </div>
    );
  }
}

[Board.js]

 

render() 내부에서 반복문을 두 번 사용해서 Square를 표현하는 일이... 개인적으로 제일 힘들었다.

배열을 사용해서 .push( )로 처리하는 방법을 떠올리지 못했으면 영원히 못 풀었을 지도 모른다. ㄷㄷ

 

 

 

 

마지막으로 Sqaure 컴포넌트. 이건 별 것 없다. 속성으로 받은 winner 값(승부에 결정적 역할을 한 셀 위치)이 있는 경우 빨간색으로 강조해서 보여주는 것 정도로 구현 완료.

 

import React from 'react';
import logProps from './logProps';

// class를 function으로 변경
/*
class Square extends React.Component {
  //constructor(props) {
  //  super(props);
  //  this.state = {
  //    value: this.props.value,
  //  };
  //}

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

function Square(props) {
  return (
    <button
      className="square"
      onClick={props.onClick}
      style={props.winner ? { color: 'red' } : {}}
    >
      {props.value}
    </button>
  );
}

export default logProps(Square);

[Square.js]

 

 

 

 

추가로, ref 전달 방식으로 컴포넌트 로그를 기록하게 하기 위한 logProps 래퍼 컴포넌트까지 추가해 봤다.

 

>> 참고: ko.reactjs.org/docs/forwarding-refs.html

 

Forwarding Refs – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

import React from 'react';

export default function logProps(Component) {
  class LogProps extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('old props:', prevProps);
      console.log('new props:', this.props);
    }

    render() {
      const { forwardedRef, ...rest } = this.props;

      // Assign the custom prop "forwardedRef" as a ref
      return <Component ref={forwardedRef} {...rest} />;
    }
  }

  // Note the second param "ref" provided by React.forwardRef.
  // We can pass it along to LogProps as a regular prop, e.g. "forwardedRef"
  // And it can then be attached to the Component.
  /*
  return React.forwardRef((props, ref) => {
    return <LogProps {...props} forwardedRef={ref} />;
  });*/
  function forwardRef(props, ref) {
    return <LogProps {...props} forwardedRef={ref} />;
  }

  // Give this component a more helpful display name in DevTools.
  // e.g. "ForwardRef(logProps(MyComponent))"
  const name = Component.displayName || Component.name;
  forwardRef.displayName = `logProps(${name})`;

  return React.forwardRef(forwardRef);
}

[logProps.js]

 

이렇게 logProps 컴포넌트로 감싸서 추가해 두면 크롬이나 엣지 브라우저 개발도구에서 위 코드에 설정한 방식대로 표시된다.

 

 

각자 구현하는 방법은 다를 수 있으므로 참고만 할 것.

 

 

 



Posted by 떼르미
,


자바스크립트를 허용해주세요!
Please Enable JavaScript![ Enable JavaScript ]