리액트(React) Tic Tac Toe 게임 만들기

2020, Oct 16    

React 공식 문서에 있는 Tic Tac Toe 게임을 따라하면서 정리 해보았다.

https://ko.reactjs.org/tutorial/tutorial.html#prerequisites

Props로 데이터 전달

class Board extends React.Component {
  renderSquare(i) {
    //Square 컴포넌트에 value라는 키값으로 i값을 pros로 전달
    return <Square value={i} />;
  }
}

class Square extends React.Component {
  render() {
    return (
      <button className="square">
				//Board 컴포넌트에서 받아온 value 값을 출력
        {this.props.value}
      </button>
    );
  }
}

버튼을 클릭할 시 X 표시가 되도록 하기

onClick={alert('click')} 로 작성하는 실수를 많이 한다고 함.

onClick={() => alert('click')} 로 작성하도록 함.

화살표 함수를 쓰는 이유 : 타이핑 횟수를 줄이고 this의 혼란스러운 동작을 피하기 위함.

class Square extends React.Component {
  render() {
    return (
			//버튼을 클릭했을 때 alert이 뜨도록 처리
      <button className="square" onClick={function() { alert('click'); }}>
        {this.props.value}
      </button>
    );
  }
}

class Square extends React.Component {
 render() {
   return (
			//화살표 함수를 사용해서 간단하게 표현할 수도 있음
     <button className="square" onClick={() => alert('click')}>
       {this.props.value}
     </button>
   );
 }
}

생성자 추가

  • JavaScript 클래스에서 하위 클래스의 생성자를 정의할 때 항상 super를 호출해야 함
  • 모든 React 컴포넌트 클래스는 생성자를 가질 때 super(props) 호출 구문부터 작성해야 함
class Square extends React.Component {
  //생성자를 추가하여 value라는 state 값을 초기화
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

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

  render() {
    return (
      <button
        className="square"
				//버튼 클릭시 value라는 state 변수에 'X'값을 세팅
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

State 끌어 올리기

  • 각 Square 컴포넌트가 아닌 부모 Board 컴포넌트에 게임의 상태를 저장하는 것이 가장 좋은 방법
  • 각 Square에 숫자를 넘겨주었을 때와 같이 Board 컴포넌트는 각 Square에게 prop을 전달하는 것으로 무엇을 표시할 지 알려줌
  • 자식으로부터 데이터를 모으거나 두 개의 자식 컴포넌트들이 서로 통신하게 하려면 부모 컴포넌트에 공유 state를 정의해야 함
  • 부모 컴포넌트는 props를 사용하여 자식 컴포넌트에 state를 다시 전달할 수 있음
class Board extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
			//9개의 사각형에 해당하는 9개의 null 배열을 초기화 하고 state로 설정
      squares: Array(9).fill(null),
    };
  }

// ==> state로 변경
	renderSquare(i) {
    return <Square value={this.state.squares[i]} />;
  }
  • Board에서 Square로 함수를 전달하고 사각형을 클릭할 때 함수 호출
//Board 컴포넌트 중간
renderSquare(i) {
    return (
      <Square
        value={this.state.squares[i]}
        onClick={() => this.handleClick(i)}
      />
    );
  }

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

class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
				//Board에서 받아온 props에 들어있는 함수 세팅
        onClick={() => this.props.onClick()}
      >
				//Board에서 받아온 props에 들어있는 value 값 세팅
        {this.props.value}
      </button>
    );
  }
}

불변성

  • 복잡한 특징들을 단순하게 만듦, 변화를 감지함, 다시 렌더링하는 시기를 결정함
  • 데이터 변경 방법

    1.데이터를 직접 변경

      var player = {score: 1, name: 'Jeff'};
      player.score = 2;
    

    2.새로운 사본 데이터로 교체

      var player = {score: 1, name: 'Jeff'};
    
      var newPlayer = Object.assign({}, player, {score: 2});
    

함수 컴포넌트

  • 더 간단하게 컴포넌트를 작성하는 방법
  • state 없이 render 함수만을 가짐
  • this가 사라짐
//클래스형 컴포넌트
class Square extends React.Component {
  render() {
    return (
      <button
        className="square"
				//Board에서 받아온 props에 들어있는 함수 세팅
        onClick={() => this.props.onClick()}
      >
				//Board에서 받아온 props에 들어있는 value 값 세팅
        {this.props.value}
      </button>
    );
  }
}

//함수형 컴포넌트
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();
    //삼항 연산자 활용 세팅
	  squares[i] = this.state.xIsNext ? 'X' : 'O';
	  this.setState({
	    squares: squares,
      //xIsNext state 값에 따라 true, false 값 변경
	    xIsNext: !this.state.xIsNext,
	  });
	}

	render() {
			//status을 통해 다음 차례 플레이어를 알려줌
	    const status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
	
	    return (
	      // 나머지는 그대로
  • 순서 만들기 완성 소스
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();
    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 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>
    );
  }
}

승자 결정하기

  • 최하단에 해당 소스 추가
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;
}
  • Board render 함수에 추가 (누가 우승했는지 문구 표시용)
render() {
    const winner = calculateWinner(this.state.squares);
    let status;
    if (winner) {
      status = 'Winner: ' + winner;
    } else {
      status = 'Next player: ' + (this.state.xIsNext ? 'X' : 'O');
    }

...
handleClick(i) {
  const squares = this.state.squares.slice();
  //누군가 승리하거나 Square가 이미 채워져있다면 handleClick 함수가 무시하도록 처리
  if (calculateWinner(squares) || squares[i]) {
    return;
  }
  squares[i] = this.state.xIsNext ? 'X' : 'O';
  this.setState({
    squares: squares,
    xIsNext: !this.state.xIsNext,
  });
}

시간 여행 추가하기

동작에 대한 기록 저장하기

  • 과거의 squares 배열들을 history라는 다른 배열에 저장

다시 state 끌어올리기

class Game extends React.Component {
  //초기 state 설정
  constructor(props) {
    super(props);
    this.state = {
      //history라는 저장된 과거의 차례를 Board가 렌더링할 수 있게 만들 예정
      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>
    );
  }
}
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
        //Game 컴포넌트에서 전달받은 value값과 onClick 함수를 전달함
        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 컴포넌트, Board 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>
  );
}
//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 컴포넌트로 이동
//state가 다르기 때문에 handleClick 수정 필요
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,
  });
}

과거의 이동 표시하기

  • 다른 데이터와 함께 매핑할 때 사용하는 map() 함수 활용
//Game 컴포넌트
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>
  );
}

시간 여행 구현하기

//Game 컴포넌트 render 함수안
const moves = history.map((step, move) => {
    const desc = move ?
      'Go to move #' + move :
      'Go to game start';
    return (
      //key 값 구현, React의 key에 대한 경고가 사라짐
      <li key={move}>
        <button onClick={() => this.jumpTo(move)}>{desc}</button>
      </li>
    );
  });
//Game 컴포넌트 state 추가
class Game extends React.Component {
	constructor(props) {
	  super(props);
	  this.state = {
	    history: [{
	      squares: Array(9).fill(null),
	    }],
      //초기값 세팅
	    stepNumber: 0,
	    xIsNext: true,
	  };
	}
//Game 컴포넌트에 jumpTo 함수 추가
handleClick(i) {
  // 이 함수는 변하지 않음
}

//짝수일 때마다 xIsNext true
jumpTo(step) {
  this.setState({
    stepNumber: step,
    xIsNext: (step % 2) === 0,
  });
}

render() {
  // 이 함수는 변하지 않음
}
//Game 컴포넌트
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
    }]),
    //history의 갯수만큼 세팅
    stepNumber: history.length,
    xIsNext: !this.state.xIsNext,
  });
}
//Game 컴포넌트
render() {
    const history = this.state.history;
    //state 값으로 세팅한 stepNumber로 현재 선택된 이동으로 렌더링
    const current = history[this.state.stepNumber];
    const winner = calculateWinner(current.squares);

최종 소스

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,
    };
  }
  
  jumpTo(step){
    this.setState({
      stepNumber: step,
      xIsNext: (step % 2) === 0,
    })
  }
  
  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,  
    });
  }
  
  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>
    );
  }
}

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

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

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