이 글은 아래 React 자습서의 하단에 있는 틱택토 개선 아이디어를 순서대로 구현해 본 예제이다.
>> 참조: reactjs.org/tutorial/tutorial.html#implementing-time-travel
직접 구현해 보려는데, 뭔가 잘 안 풀릴 때 참고가 될 수 있기를 바라며 정리해 봤다.
원래 코드가 아래와 같았다면,
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
>> 참고: developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/slice
마지막 남은 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
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 컴포넌트로 감싸서 추가해 두면 크롬이나 엣지 브라우저 개발도구에서 위 코드에 설정한 방식대로 표시된다.
각자 구현하는 방법은 다를 수 있으므로 참고만 할 것.
'Tech: > 일반·기타' 카테고리의 다른 글
Visual Studio 2019로 React 개발/디버깅 #3 (0) | 2020.11.30 |
---|---|
React: CRA(create-react-app)+Mobx 사용 (0) | 2020.11.30 |
Visual Studio 2019로 React 개발/디버깅 #2 (0) | 2020.11.23 |
Visual Studio 2019로 React 개발/디버깅 #1 (0) | 2020.11.19 |
[초급] GitHub 만들고 올리고 받고 업데이트하기 (0) | 2020.11.10 |