⑦ 틱택토

hhkim·2020년 2월 2일
0
post-thumbnail

🔗 웹 게임을 만들며 배우는 React

1. 틱택토와 useReducer 소개

  • 리덕스를 쓸 필요가 없을 정도로 규모가 작은 앱에서는 useReducer와 컨텍스트 API로 대체 가능

    비동기 처리를 위해 결국 리덕스를 써야 하긴 함

  • useReducer: state가 많아지면 관리를 위해 사용

2. reducer, action, dispatch의 관계

  • 컴포넌트에 들어가는 이벤트 함수는 useCallback 선언


    이전 함수를 기억해서 리렌더링 방지

  • state를 바꾸고 싶으면 이벤트 발생
    ==> type과 data를 가지는 action을 dispatch
    ==> state를 어떻게 바꿀 것인지 reducer에 설정
    • 기존 state를 변경하면 안 되고 복사해서 재설정한다. (불변셩)
  • action명은 대문자로 작성하며, 상수로 선언하는 것이 좋음

  • 테이블을 클릭하면 'O님의 승리'가 출력되는 action

TicTacToe.jsx

import React, { useState, useReducer, useCallback } from 'react';
import Table from './Table';

const initialState = {
    winner: '',
    turn: 'O',
    tableData: [['', '', ''], ['', '', ''], ['', '', '']],
};

const SET_WINNER = 'SET_WINNER';

const reducer = (state, action) => {    // dispatch가 실행될 때마다 실행됨
    switch (action.type) {  // 여러 액션을 switch로 처리
        case SET_WINNER:
            return {    // 객체를 복사해서 새로운 값 세팅 (불변성)
                ...state,
                winner: action.winner,
            }
    }
};

const TicTacToe = () => {
    const [state, dispatch] = useReducer(reducer, initialState);
    // const [winner, setWinner] = useState('');
    // const [turn, setTurn] = useState('O');
    // const [tableData, setTableData] = userState([['', '', ''], ['', '', ''], ['', '', '']]);

    const onClickTable = useCallback(() => {
        dispatch({type: 'SET_WINNER', winner: 'O'});    // type: 액션, dispatch: 액션 실행
    }, []);

    return (
        <>
            <Table onClick={onClickTable} tableData={state.tableData} />
            {state.winner && <div>{state.winner}님의 승리</div>}
        </>
    );
};

export default TicTacToe;

3. action 만들어 dispatch 하기

  • useReducer 방식에서는 dispatch를 가장 하위 자식까지 넘겨줘야 하는 번거로움이 있음
    👉 immer라는 라이브러리로 가독성 문제 해결 가능

TicTacToe.jsx

import React, { useState, useReducer, useCallback } from 'react';
import Table from './Table';

const initialState = {...};

export const SET_WINNER = 'SET_WINNER';
export const CLICK_CELL = 'CLICK_CELL';
export const CHANGE_TURN = 'CHANGE_TURN';

const reducer = (state, action) => {  
    switch (action.type) {  
        case SET_WINNER:
            return { ...};
        case CLICK_CELL: {
            const tableData = [...state.tableData];    // 얕은 복사 (불변성)
            tableData[action.row] = [...tableData[action.row]];
            tableData[action.row][action.cell] = state.turn;    // O / X 값 넣기
            return {
                ...state,
                tableData
            };
        }
        case CHANGE_TURN: {
            return {
                ...state,
                turn: state.turn === 'O' ? 'X' : 'O',
            };
        }        
    }
};

const TicTacToe = () => {
    const [state, dispatch] = useReducer(reducer, initialState);

    const onClickTable = useCallback(() => {
        dispatch({type: 'SET_WINNER', winner: 'O'});    
    }, []);

    return (
        <>
        {/* dispatch 넘겨주기 */}
            <Table onClick={onClickTable} tableData={state.tableData} dispatch={dispatch} />
            {state.winner && <div>{state.winner}님의 승리</div>}
        </>
    );
};

export default TicTacToe;

Table.jsx

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

const Table = ({onClick, tableData, dispatch}) => {
    return (
        <table>
            <tbody>
            {Array(tableData.length).fill().map((tr, i) => (<Tr rowIndex={i} rowData={tableData[i]} dispatch={dispatch}/>))}
            </tbody>
        </table>
    );
};

export default Table;

Tr.jsx

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

const Tr = ({rowData, rowIndex, dispatch}) => {
    return (
        <tr>
            {Array(rowData.length).fill().map((td, i) => (
                <Td rowIndex={rowIndex} cellIndex={i} dispatch={dispatch} cellData={rowData[i]}>{''}</Td>
            ))}
        </tr>
    );
};

export default Tr;

Td.jsx

import React, {useCallback} from 'react';
import { CLICK_CELL, CHANGE_TURN } from './TicTacToe';

const Td = ({rowIndex, cellIndex, cellData, dispatch}) => {
    const onClickTd = useCallback(() => {
        dispatch({type: CLICK_CELL, row: rowIndex, cell: cellIndex});
        dispatch({type: CHANGE_TURN});
    }, []);

    return (
        <td onClick={onClickTd}>{cellData}</td>
    );
};

export default Td;

4. 틱택토 구현하기

1) 칸 중복 클릭 방지

Td.jsx

const Td = ({rowIndex, cellIndex, cellData, dispatch}) => {
    const onClickTd = useCallback(() => {
        if(cellData){   // 칸에 데이터가 있으면 리턴(중복클릭 방지)
            return;
        }
        dispatch({type: CLICK_CELL, row: rowIndex, cell: cellIndex});
    }, [cellData]); // 변경되는 값을 두 번째 인자에 넣음

    return (
        <td onClick={onClickTd}>{cellData}</td>
    );
};

2) 승리 조건

  • 리덕스와 달리 useReducer는 state가 비동기적으로 바뀜
    👉 useEffect를 사용해서 recentCell이 바뀔 때마다 승리 조건을 검사한다.

TicTacToe.jsx

import React, { useState, useReducer, useCallback, useEffect } from 'react';
import Table from './Table';

const initialState = {
    winner: '',
    turn: 'O',
    tableData: [['', '', ''], 
                ['', '', ''], 
                ['', '', '']],
    recentCell: [-1, -1],
};

export const SET_WINNER = 'SET_WINNER';
export const CLICK_CELL = 'CLICK_CELL';
export const CHANGE_TURN = 'CHANGE_TURN';
export const RESET_GAME = 'RESET_GAME';

const reducer = (state, action) => {    // dispatch가 실행될 때마다 실행됨
    switch (action.type) {  // 여러 액션을 switch로 처리
        case SET_WINNER:
            return {    // 객체를 복사해서 새로운 값 세팅 (불변성)
                ...state,
                winner: action.winner,
            };
        case CLICK_CELL: {
            const tableData = [...state.tableData];    // 얕은 복사 (불변성)
            tableData[action.row] = [...tableData[action.row]]; // immer라는 라이브러리로 가독성 문제 해결
            tableData[action.row][action.cell] = state.turn;    // O / X 값 넣기
            return {
                ...state,
                tableData,
                recentCell: [action.row, action.cell]
            };
        }
        case CHANGE_TURN: {
            return {
                ...state,
                turn: state.turn === 'O' ? 'X' : 'O',
            };
        }
        case RESET_GAME: {
            return {
                ...state, 
                turn: 'O',
                tableData: [['', '', ''], 
                            ['', '', ''], 
                            ['', '', '']],
                recentCell: [-1, -1],
            };
        }
        default:
            return state;        
    }
};

const TicTacToe = () => {
    const [state, dispatch] = useReducer(reducer, initialState);
    const {tableData, winner, turn, recentCell} = state;    // 구조분해

    const onClickTable = useCallback(() => {
        dispatch({type: 'SET_WINNER', winner: 'O'});    // type: 액션, dispatch: 액션 실행
    }, []);

    useEffect(() => {
        const [row, cell] = recentCell;
        if(row < 0) {
            return;
        }
        let win = false;
        if (tableData[row][0] === turn && tableData[row][1] === turn && tableData[row][2] === turn) {
            win = true;
        }
        if (tableData[0][cell] === turn && tableData[1][cell] === turn && tableData[2][cell] === turn) {
            win = true;
        }
        if (tableData[0][0] === turn && tableData[1][1] === turn && tableData[2][2] === turn) {
            win = true;
        }
        if (tableData[0][2] === turn && tableData[1][1] === turn && tableData[2][0] === turn) {
            win = true;
        }
        if(win) {   // 승리
            dispatch({type: SET_WINNER, winner: turn});
            dispatch({type: RESET_GAME});
        }else{  // 무승부
            let all = true; // 모든 칸이 찼을 때 (게임종료)
            tableData.forEach((row) => {
                row.forEach((cell) => {
                    if(!cell) {
                        all = false;
                    }
                });
            });
            if(all) {
                dispatch({type: RESET_GAME});
            }else{
                dispatch({type: CHANGE_TURN});
            }
        }
    }, [recentCell])

    return (
        <>
            <Table onClick={onClickTable} tableData={tableData} dispatch={dispatch} />
            {winner && <div>{winner}님의 승리</div>}
        </>
    );
};

export default TicTacToe;

💡
useState가 너무 많아질 때는 관리를 위해 useReducer 사용을 고려해볼 것!

5. 테이블 최적화하기

  • useRef, useEffect 또는 memo 또는 useMemo로 렌더링을 유발하는 요소 검사

1) useRef, useEffect

Td.jsx

import React, {useCallback, useEffect, useRef} from 'react';
import { CLICK_CELL } from './TicTacToe';

const Td = ({rowIndex, cellIndex, cellData, dispatch}) => {

    const ref = useRef([]);
    useEffect(() => {   // 렌더링 유발하는 요소 검사
        console.log(rowIndex === ref.current[0], cellIndex === ref.current[1] , dispatch === ref.current[2], cellData === ref.current[3]);
        ref.current = [rowIndex, cellIndex, dispatch, cellData];
    }, [rowIndex, cellIndex, dispatch, cellData]);
    ...
    return (...);
};

export default Td;

👉 결과가 true true true false이므로 cellData가 변경되면서 렌더링 유발

2) memo

  • 반복문이 있는 컴포넌트에 memo를 사용하는 것이 좋음

Td.jsx

import React, {useCallback, useEffect, useRef, memo} from 'react';
import { CLICK_CELL } from './TicTacToe';

const Td = memo(({rowIndex, cellIndex, cellData, dispatch}) => {...});

export default Td;

Tr.jsx

import React, {useRef, useEffect, memo} from 'react';
import Td from './Td';

const Tr = memo(({rowData, rowIndex, dispatch}) => {...});

export default Tr;

3) useMemo

  • memo 적용 후에도 해결되지 않을 때 사용
  • 컴포넌트 자체를 기억

Tr.jsx

import React, {useRef, useEffect, memo, useMemo} from 'react';
import Td from './Td';

const Tr = memo(({rowData, rowIndex, dispatch}) => {
    ...
    return (
        <tr>
            {Array(rowData.length).fill().map((td, i) => (
                useMemo(
                    () => <Td key={i} rowIndex={rowIndex} cellIndex={i} dispatch={dispatch} cellData={rowData[i]}>{''}</Td>,
                    [rowData[i]],	{/* 바뀔 여지가 있는 요소를 두 번째 인자에 씀 */}
                )
            ))}
        </tr>
    );
});

export default Tr;

0개의 댓글