[React 공식문서 공부하기] - 자습서

Lee Jeong Min·2021년 12월 23일
0
post-thumbnail

리액트를 수업으로도 듣고있긴 하지만 개인적으로 공식문서를 보면서 공부를 하여 자세한 이해를 위해 정리하고자 한다.

자습서를 위한 설정

자습서를 위한 환경으로 두 가지 환경을 선택할 수 있다.

  1. 코드펜

  2. 로컬 환경 구성

개인 로컬에서 작업하는 것을 선호하기 때문에 로컬 환경을 구성하여 작업하였다.

환경 구성

npx create-react-app my-app

리액트 환경이 구성된 my-app이라는 폴더가 만들어진다.

이후 src/ 폴더에서 rm -f * 명령어(for Mac)를 통해 파일들을 다 제거하자.

y를 누르면 모든 파일이 제거된다.

폴더안에 index.css, index.js라는 파일을 생성하고 이 코드들을 추가하자.

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

index.js

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

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

이제 모든 환경 세팅이 끝났고, npm start 혹은 yarn start로 아래의 화면이 나오는지 확인하자.

개요

React란 무엇인가?

React는 사용자 인터페이스를 구축하기 위한 선언적이고 효율적이며 유연한 JS 라이브러리이다. '컴포넌트'라는 작고 고립된 코드의 파편으로 복잡한 UI를 구성하도록 돕는다.

예시 코드

class ShoppingList extends React.Component {
  render() {
    return (
      <div className="shopping-list">
        <h1>Shopping List for {this.props.name}</h1>
        <ul>
          <li>Instagram</li>
          <li>WhatsApp</li>
          <li>Oculus</li>
        </ul>
      </div>
    );
  }
}

// 사용 예제: <ShoppingList name="Mark" />

여기서 ShoppingList는 React 컴포넌트 클래스 또는 React 컴포넌트 타입이라고 한다. 개별 컴포넌트는 props라는 매개변수를 받아오고, render 함수를 통해 표시할 뷰 계층 구조를 반환한다.
render 함수는 화면에서 보고자 하는 내용을 반환하는데, 경량화한 React 엘리먼트를 반환한다.

일반적으로 React를 개발 시, JSX문법을 사용하여 작성하지만 후에 빌드되는 시점에서 <div />React.createElement('div')로 변환된다.

return React.createElement('div', {className: 'shopping-list'},
  React.createElement('h1', /* ... h1 children ... */),
  React.createElement('ul', /* ... ul children ... */)
);

createElement()

React.createElement(
  type,
  [props],
  [...children]
)

인자로 주어지는 타입에 따라 새로운 React 엘리먼트를 생성하여 반환한다. type 인자로는 태그, React 컴포넌트 타입 또는 React Fragment 타입 중 하나가 올 수 있다.

초기코드 살펴보기

초기코드는 위에서 본 틱택토 게임의 기본 틀이다.

코드를 보면 아래의 3가지 React Component를 확인할 수 있다.

  • Square - <button> 렌더링
  • Board - 사각형 9개 렌더링
  • Game - 게임판 렌더링 및 수정할 자리 표시자 값 가짐

Props를 통해 데이터 전달하기

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

Square에 value prop을 전달하기 위해 Board의 renderSquare 함수 코드를 수정하여 위와 같이 적어주고, Square에 값을 표시하기 위해 아래와 같이 적어주자

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

변경 전 사각형 안에 아무것도 없었지만 변경 후 렌더링 된 결과에서 사각형 안에 숫자가 표시된다. --> prop 전달의 결과(부모 -> 자식)

사용자와 상호작용하는 컴포넌트 만들기

Square 컴포넌트를 클릭하면 'X'가 체크되도록 만들어 보자. 우선 Square 컴포넌트의 render()함수에서 반환하는 버튼 태그를 아래와 같이 변경하자.

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

이후 콘솔을 확인하면 사각형을 클릭시 콘솔창에 'click'이 출력된다.

위에서 onClick 이벤트핸들러의 함수 작성시, 일반함수보다 화살표함수로 작성하는 것이 this(일반함수, strict모드에서 undefined <=> 화살표함수, this바인딩이 없어서 상위 렉시컬환경 가리킴)의 혼란스러운 동작을 피할 수 있다.

다음 단계로 Square 컴포넌트를 클릭한 것을 '기억하게'만들어 'X' 표시를 채워 넣기위해 '상태'가 필요하다. --> state 사용

React의 클래스 컴포넌트는 생성자에 this.state를 설정하는 것으로 state를 가질 수 있다.

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

클래스에 생성자를 추가하여 state를 초기화하자.

이후 Square를 클릭할 때, 현재 state 값을 표시하기 위해 render 함수를 변경하자

...
  render() {
    return (
      <button
        className='square'
        onClick={() => {
          this.setState({ value: 'X' });
        }}
      >
        {this.state.value}
      </button>
    );
  }
...

클릭을 하였을 시, 값을 'X'로 바꾸기 위해 setState 메서드를 사용하여 상태를 바꾸어주고, 결과를 출력하기위해 this.state.value를 보간처리하여 화면에 출력한다.

게임 완성하기

완전한 틱택토 게임을 위해 게임판의 'X'와 'O'를 번갈아 표시해야하며, 승자를 결정하는 방법이 필요하다.

State 끌어올리기

현재는 게임의 state를 Square각각이 관리를 하고 있는데, 승자를 확인하기 위해 상태 값을 한 곳에 유지해야 한다.

Board가 각 Square의 state를 요청해야 한다고 생각할 수 있지만 코드의 가독성과 버그에 취약, 리팩토링이 어려워 추천하지 않는다.

여러개의 자식으로부터 데이터를 모으거나 두 개의 자식 컴포넌트들이 서로 통신하게 하려면 부모 컴포넌트에 공유 state를 정의해야 한다. 부모 컴포넌트는 props를 사용하여 자식 컴포넌트에 state를 다시 전달할 수 있다.

state를 부모 컴포넌트로 끌어올리는 것은 React 컴포넌트를 리팩토링할 때 흔히 사용되며, 이를 위해 다음과 같이 Board에 생성자를 추가하고 9개의 사각형에 해당하는 9개의 null 배열을 초기 state로 설정하자.

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

Board에 상태를 정의하였으므로 이제 아래 자식인 Square에서 prop을 전달해주어야 한다.

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

이후 Square를 클릭할 때 발생하는 변화가 필요한데, 컴포넌트는 자신이 정의한 state에만 접근할 수 있으므로 Square에서 Board의 state를 직접 변경할 수 없다.

따라서 Board에서 Square로 함수를 전달하고 Square는 사각형을 클릭할 때 함수를 호출하여 상태를 변화시켜야 한다.

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

onClick이라는 prop을 전달하여 상태를 변화시키도록 하자.

전달받은 props을 사용하기 위해 Square 클래스 컴포넌트를 아래와 같이 바꾸어 준다.

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

여기까지 작성을 하고 앱을 실행시켜서 클릭을 하면 아직 handleClick()함수가 정의되어 있지 않기 때문에 에러가 발생한다.

이를 위해 handleClick을 정의해야한다.

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

이제 Square 컴포넌트는 Board 컴포넌트에 의해 제어되는 컴포넌트가 되었고 handleClick 함수를 보면 .slice() 메서드를 사용하여 기존배열이 아닌 squares의 복사본을 생성하여 불변성을 지켰다.

불변성이 왜 중요할까요?

일반적으로 데이터 변경에는 두 가지 방법이 있다.

  1. 데이터의 값을 직접 변경하는 방법
var player = {score: 1, name: 'Jeff'};
player.score = 2;
// 이제 player는 {score: 2, name: 'Jeff'} 이다.
  1. 원하는 변경 값을 가진 새로운 사본으로 데이터를 교체하는 방법
var player = {score: 1, name: 'Jeff'};

var newPlayer = Object.assign({}, player, {score: 2});
// 이제 player는 변하지 않았지만 newPlayer는 {score: 2, name: 'Jeff'}입니다.

// 객체 spread 구문을 사용한다면 이렇게 쓸 수 있습니다.
// var newPlayer = {...player, score: 2};

이 두가지의 결과는 동일하지만 기존 데이터의 변경을 하지 않는다면 아래와 같은 이점을 얻을 수 있다.

  • 복잡한 특징을 단순하게 만듦

이후 만들 '시간 여행' 기능은 게임에만 게임이력을 확인하고 이전 동작으로 되돌아가는 기능인데 기존 데이터의 변경이 발생하면 이전으로 되돌아갈 데이터가 없어지게 된다. 이처럼 직접적인 데이터 변이를 피하는 것은 이전 버전의 게임 이력을 유지하고 나중에 재사용할 수 있게 만든다.

  • 변화를 감지함

객체의 경우 원시 값이 아닌 메모리 값을 저장하고 있고, 불변 객체라면 이전 객체와 이 값이 다르기 때문에 변화를 감지하는 것은 상당히 쉽다.
그러나 객체가 직접적으로 수정된다면 변화를 감지하기 위해서는 이전 사본과 비교하면서 전체 객체 트리를 돌아야 한다.

  • React에서 다시 렌더링하는 시기를 결정함

React에서 순수 컴포넌트를 만드는데 도움을 주어 변하지 않는 데이터의 변경이 이루어졌는지 확인하여 이를 바탕으로 컴포넌트가 다시 렌더링할지를 결정할 수 있다.
관련 라이프사이클: shouldComponentUpdate()

함수 컴포넌트

Square의 state가 없어졌으므로 간단한 함수 컴포넌트로 바꾸어 보자.

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

this.propsprops로 변경되었다.

순서 만들기

현재는 'X'만 화면에 표시가 되는데 게임판에서 'O'가 출력되도록 순서를 정해보자.

첫 번째 차례를 'X'로 시작하기 위해 Board 생성자의 초기 state를 수정하여 기본값을 설정하자.

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

이를 활요하여 플레이어가 클릭을 할 때마다, xIsNext의 값이 뒤집혀 'O'를 출력하도록 만들 수 있다.

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

이후 앱을 실행시켜보면 번갈아가면서 'O'와 'X'가 나타나는 것을 확인할 수 있다.

또한 Next Player가 누구차례인지 알려주기 위해 Board의 renderstatus를 다음과 같이 바꾸어주자.

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

승자 결정하기

승부가 나는 때와 더 이상 둘 곳이 없을 때를 알려주어야 한다.

이를 위해 만들어둔 Helper(도우미)함수를 복사하여 파일안에 넣어주자.

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함수에서 calculateWinner(squares)를 호출한다.

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

또한 Square가 이미 채워진 곳은 다시 클릭했을 때 무시하도록 변경해야하기 때문에 다음과 같이 바꾸어 준다.

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

시간 여행 추가하기

마지막 연습으로 경기에서 이전 차례로 갈 수 있는 '시간 되돌리기' 기능을 구현해보자.

동작에 대한 기록 저장하기

squares 배열을 직접 변경한 것이 아닌 slice() 메서드를 사용하여 불변 객체로 취급하였기 때문에 과거의 기록을 저장하고 이미 지나간 차례를 탐색할 수 있다.

과거의 squares 배열들을 history라는 다른 배열에 저장하는 방법으로 구현해보기로 하고, 이제 어떤 컴포넌트가 history state를 가지고 있을 지 결정해야한다.

다시 State 끌어올리기

이전 동작에 대한 리스트를 보여주기 위해 최상위 단계의 Game 컴포넌트가 필요하다.
history 를 이용할 것이기에 이 Game 컴포넌트에 history state를 관리하자.

이제 자식 Board 컴포넌트에선 squares state를 사용하지 않아도 되고 Game 컴포넌트에서 관리하기 때문에 상태를 끌어올리자.

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

이후 Game 컴포넌트에서 Board 컴포넌트로 squaresonClick props를 전달해야하기 때문에 각 Square의 위치를 onClick 핸들러에 넘겨주어 어떤 Square를 클릭했는지 표시하기 위해 다음과 같이 설정한다.

  • 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 ? 'X' : 'O';
    this.setState({ squares: squares, xIsNext: !this.state.xIsNext });
  }

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

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

render부분에서 Board의 render 부분과 중복되는 부분이 있어서 이를 제거해주고, handleClick 또한 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(i) 메서드

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

concat() 함수는 기존 배열을 변경하지 않기 때문에 이를 더 권장한다.

과거의 이동 표시하기

플레이어가 과거의 이동을 목록으로 표시하기 위해 historymap() 함수를 이용하여 li 요소들을 만들어보자.

  render() {
    ...

    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>
      );
    });
    ...
    
    return (
      <div className='game'>
        ...
          <ol>{moves}</ol>
        ...
      </div>
    );

위의 버튼은 this.jumpTo() 함수를 호출하는 onClick 핸들러를 가지고 있다. 그러나 아직은 jumpTo() 함수를 구현하지 않았다.

현재까지 구현된 앱을 실행시켜보면 다음과 같은 화면을 볼 수 있다.

여기서 나오는 경고창은 리스트를 렌더링 할 시에 key가 필요함을 알려주고 있다.

Key 선택하기

리액트는 키를 가지고 이전 가상 노드와 변화된 가상노드를 확인하여 새로운 컴포넌트를 생성 및 제거한다. 또한 키는 각 컴포넌트를 구별할 수 있도록 하여 React에게 다시 렌더링할 때 state를 유지할 수 있게 해준다. 컴포넌트의 키가 변경된다면 컴포넌트는 제거되고 새로운 state와 함께 다시 생성된다.

React에서 key 는 심화 기능인 ref 와 동일하게 특별하고 미리 지정된 prop이다. keyprops 에 속하는 것처럼 보이지만 this.props.key 로 참조할 수 없고, React는 자동으로 key를 어떤 컴포넌트를 업데이트 할 지 판단하는데 사용한다.

키가 지정되지 않은 경우 React는 배열의 인덱스를 기본 키로 사용하는데, 이 경우 렌더링이 여러번 반복되는 경우 문제가 생기므로 추천하지 않고, 그 데이터 고유의 id 값이 존재한다면 그것으로 설정하는 것이 좋다!

시간 여행 구현하기

틱택토 게임의 기록에서 과거의 이동 정보는 이동의 순차적인 숫자를 고유한 ID로 가졌다. 이동은 순서가 바뀌거나 삭제되거나 중간에 삽입될 수 없으므로 이동의 인덱스를 키로 사용해도 안전하므로 다음과 같이 키설정을 하자.

      return (
        <li key={move}>
          <button onClick={() => this.jumpTo(move)}>{desc}</button>
        </li>
      );

이제 jumpTo 함수가 정의되어있지 않기 때문에 이를 구현해야한다. 구현하기 전, Game 컴포넌트 state에 stepNumber를 추가하여 현재 진행중인 단계를 표시해보자.

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

다음으로 Game의 stepNumber를 업데이트하기 위해 jumpTo를 정의하고, stepNumber 가 짝수일 때마다 xIsNext를 true로 설정하자.

  handleClick(i) {
    // 이 함수는 변하지 않는다.
  }

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

  render() {
    // 이 함수는 변하지 않는다.
  }

이제 사각형을 클릭할 때 마다 실행되는 handleClick 함수에 몇 가지 변화를 줄 시간이다.

stepNumber 는 현재 사용자에게 표시되는 이동을 반영하고, 새로운 이동을 만든 후 this.setState의 인자로 stepNumber: history.length를 추가하여 stepNumber를 업데이트하자. 이를 통해 새로운 이동이 생성된 후에 이동이 그대로 남아있는 것을 방지한다.

또한 this.state.historythis.state.history.slice(0, this.state.stepNumber + 1)로 교체하여 과거의 시점으로 jumpTo를 하였을 시 '미래'의 기록을 없애버린다.

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

마무리

아래 기능을 가진 틱택토 게임을 만들어 보았다.

  • 틱택토를 할 수 있게 해주고,
  • 게임 승리시 알려주며
  • 게임 기록을 저장하고
  • 플레이어가 게임 기록을 확인하고 게임판의 이전 버전을 볼 수 있도록 허용

시간이 더 있다면 아래 아이디어를 구현해보는 것을 리액트 공식문서에선 추천한다.

참고사이트

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

profile
It is possible for ordinary people to choose to be extraordinary.

0개의 댓글