상세 컨텐츠

본문 제목

[React.js] React Web Games - Lotto (Class & Hooks)

Knowledge/React.js

by winCow 2021. 8. 1. 16:09

본문

1초에 하나씩 번호를 뽑아 보여 주는 프로그램이다.

 

 

1. Class

import React, { Component } from "react";
import Ball from "./Ball";

function getWinNumbers() {
  console.log("getWinNumbers");
  const candidate = Array(45)
    .fill()
    .map((v, i) => i + 1);
  const shuffle = [];
  while (candidate.length > 0) {
    shuffle.push(
      candidate.splice(Math.floor(Math.random() * candidate.length), 1)[0]
    );
  }
  const bonusNumber = shuffle[shuffle.length - 1];
  const winNumbers = shuffle.slice(0, 6).sort((p, c) => p - c);
  return [...winNumbers, bonusNumber];
}

class GetLotto extends Component {
  state = {
    winNumbers: getWinNumbers(),
    winBalls: [],
    bonus: null,
    redo: false,
  };

  timeouts = [];

  runTimeouts = () => {
    console.log("run timeout");
    const { winNumbers } = this.state;
    for (let i = 0; i < winNumbers.length - 1; i++) {
      this.timeouts[i] = setTimeout(() => {
        this.setState((prevState) => {
          return {
            winBalls: [...prevState.winBalls, winNumbers[i]],
          };
        });
      }, (i + 1) * 1000);
    }
    this.timeouts[6] = setTimeout(() => {
      this.setState({
        bonus: winNumbers[6],
        redo: true,
      });
    }, 7000);
  };

  componentDidMount() {
    console.log("did mount");
    this.runTimeouts();
  }

  componentDidUpdate(prevProps, prevState) {
    console.log("did update");
    if (this.state.winBalls.length === 0) {
      this.runTimeouts();
    }
  }

  componentWillUnmount() {
    this.timeouts.forEach((v) => {
      clearTimeout(v);
    });
  }

  onClickRedo = () => {
    this.setState({
      winNumbers: getWinNumbers(),
      winBalls: [],
      bonus: null,
      redo: false,
    });
    this.timeouts = [];
  };

  render() {
    const { winBalls, bonus, redo } = this.state;
    return (
      <>
        <div>당첨 숫자</div>
        <div id="결과창">
          {winBalls.map((v) => (
            <Ball key={v} number={v} />
          ))}
        </div>
        <div>보너스!</div>
        {bonus && <Ball number={bonus} />}
        {redo && <button onClick={this.onClickRedo}>한 번 더!</button>}
      </>
    );
  }
}

export default GetLotto;

클래스형 컴포넌트로는 위와 같이 작성할 수 있다.

 

function getWinNumbers() {
  console.log("getWinNumbers");
  const candidate = Array(45)
    .fill()
    .map((v, i) => i + 1);
  const shuffle = [];
  while (candidate.length > 0) {
    shuffle.push(
      candidate.splice(Math.floor(Math.random() * candidate.length), 1)[0]
    );
  }
  const bonusNumber = shuffle[shuffle.length - 1];
  const winNumbers = shuffle.slice(0, 6).sort((p, c) => p - c);
  return [...winNumbers, bonusNumber];
}

먼저, state를 사용하지 않는 함수는 클래스 밖에서 따로 정의한다. 이렇게 하면 함수형 컴포넌트로 바꿀 때에 건드리지 않아도 된다. cadidate는 1부터 45까지의 숫자로, 당첨될 숫자의 후보들이다. 먼저 45개의 데이터가 들어갈 수 있는 배열을 선언하고, 이 값을 undefined로 채우기 위해 매개변수를 전달하지 않는 fill api를 사용한다. 이후 1부터 45까지의 값을 배열에 채우기 위해 map api를 사용하는데, 화살표 함수의 두 번째 인자는 index로 정해져 있음을 이용하여 index + 1을 리턴하는 화살표 함수를 map의 콜백 함수로 전달함으로써 1부터 45까지의 값을 배열에 채워 넣는다.

또한, 당첨 숫자를 무작위로 뽑기 위해 shuffle이라는 배열을 만들고, candidate를 무작위로 1 칸 splice하여 push로 집어넣는다. 이를 통해 shuffle 배열은 1부터 45까지의 숫자가 무작위로 섞여 담기게 되는데, 이 배열의 마지막 값이 bonusNumber가 되고, 가장 앞의 6개 숫자가 winNumbers가 된다. winNumbers의 요소들은 순서대로 나열하기 위해 sort api를 이용한다. 함수의 반환값은 winNumbers와 bonusNumber를 spread 문법을 이용해 하나의 배열로 만든 값으로 한다.

 

  state = {
    winNumbers: getWinNumbers(),
    winBalls: [],
    bonus: null,
    redo: false,
  };

  timeouts = [];

  runTimeouts = () => {
    console.log("run timeout");
    const { winNumbers } = this.state;
    for (let i = 0; i < winNumbers.length - 1; i++) {
      this.timeouts[i] = setTimeout(() => {
        this.setState((prevState) => {
          return {
            winBalls: [...prevState.winBalls, winNumbers[i]],
          };
        });
      }, (i + 1) * 1000);
    }
    this.timeouts[6] = setTimeout(() => {
      this.setState({
        bonus: winNumbers[6],
        redo: true,
      });
    }, 7000);
  };

  componentDidMount() {
    console.log("did mount");
    this.runTimeouts();
  }

  componentDidUpdate(prevProps, prevState) {
    console.log("did update");
    if (this.state.winBalls.length === 0) {
      this.runTimeouts();
    }
  }

  componentWillUnmount() {
    this.timeouts.forEach((v) => {
      clearTimeout(v);
    });
  }
  
    onClickRedo = () => {
    this.setState({
      winNumbers: getWinNumbers(),
      winBalls: [],
      bonus: null,
      redo: false,
    });
    this.timeouts = [];
  };

클래스에서는 먼저 state를 선언하고, ref로 사용할 timeouts를 만든다. 이후, 생명주기 함수를 만드는데, 중복되는 부분이 있으므로 runTimeouts 함수를 만들어 이용한다. runTimeouts 함수는 winNumbers에 담긴 여섯 개의 숫자 각각에 대해, setTimeout을 걸어 index + 1초의 시간을 두고 winBalls 배열에 추가한 뒤, 이를 timeouts 배열에 저장한다. 이 작업이 끝나면 winNumbers의 마지막 숫자인 bonusNumber를 추가하고, redo를 true 상태로 변경한다. 이 함수는 componentDidmount가 실행된 후, 즉 처음 프로그램을 실행하면 자동으로 실행될 것이다. 또, 한 번 더 버튼을 클릭하면 이 함수가 다시 실행될 수 있도록, componentDidUpdate에서도 runTimeouts를 실행시키는데, 버튼을 클릭했을 때만 실행되어야 하므로, 조건문을 걸어 this.state.winBalls.length === 0일 때만 실행되도록 한다. 한 번 더 버튼을 누르면 onClickRedo가 state의 값을 초기화하게 되는데, 이 때 winBalls 배열의 값도 모두 초기화되는 점을 이용한 조건이다. componentWillUnmount로 timeout을 클리어해 주면 생명주기 함수의 이용이 끝난다.

 

render() {
    const { winBalls, bonus, redo } = this.state;
    return (
      <>
        <div>당첨 숫자</div>
        <div id="결과창">
          {winBalls.map((v) => (
            <Ball key={v} number={v} />
          ))}
        </div>
        <div>보너스!</div>
        {bonus && <Ball number={bonus} />}
        {redo && <button onClick={this.onClickRedo}>한 번 더!</button>}
      </>
    );
  }

render 부분은 위와 같이 정의된다. 결과창은 winBalls에 담긴 일곱 개의 숫자들 각각을 Ball 컴포넌트 형태로 만들고, 보너스는 bonus의 값이 존재할 때 Ball로 전달되고, 그 뒤에는 redo의 값이 true일 때 버튼을 만들어 주도록 한다. 이 버튼에는 onClickRedo를 onClick 조건으로 걸어 준다.

 

import React, { memo } from "react";

const Ball = memo(({ number }) => {
  let background;
  if (number <= 10) {
    background = "red";
  } else if (number <= 20) {
    background = "orange";
  } else if (number <= 30) {
    background = "yellow";
  } else if (number <= 40) {
    background = "blue";
  } else {
    background = "green";
  }
  return (
    <div className="ball" style={{ background }}>
      {number}
    </div>
  );
});

export default Ball;

자식 컴포넌트인 Ball.jsx는 위와 같다. 말단에 위치한 자식 컴포넌트는 PureComponent나 memo로 감싸 주는 것이 좋은데, 이는 주로 화면 역할만을 하기 때문에 렌더링이 잦을 수 있기 때문이다. 불필요한 렌더링을 방지하기 위해 memo로 함수를 감싸 주고, 숫자별로 색깔을 달리하기 위해 background를 설정해 준다. return 부분에는 공의 색깔과 모양을 결정해둔 css를 적용하여 div 태그에 부모로부터 전달받은 number를 넣어 리턴한다.

 

 

2. Hooks

import React, {
  useState,
  useRef,
  useEffect,
  useMemo,
  useCallback,
} from "react";
import Ball from "./Ball";

function getWinNumbers() {
  console.log("getWinNumbers");
  const candidate = Array(45)
    .fill()
    .map((v, i) => i + 1);
  const shuffle = [];
  while (candidate.length > 0) {
    shuffle.push(
      candidate.splice(Math.floor(Math.random() * candidate.length), 1)[0]
    );
  }
  const bonusNumber = shuffle[shuffle.length - 1];
  const winNumbers = shuffle.slice(0, 6).sort((p, c) => p - c);
  return [...winNumbers, bonusNumber];
}

const GetLottoHooks = () => {
  const lottoNumbers = useMemo(() => getWinNumbers(), []);
  const [winNumbers, setWinNumbers] = useState(lottoNumbers);
  const [winBalls, setWinBalls] = useState([]);
  const [bonus, setBonus] = useState(null);
  const [redo, setRedo] = useState(false);
  const timeouts = useRef([]);

  useEffect(() => {
    for (let i = 0; i < winNumbers.length - 1; i++) {
      timeouts.current[i] = setTimeout(() => {
        setWinBalls((prevBalls) => [...prevBalls, winNumbers[i]]);
      }, (i + 1) * 1000);
    }
    timeouts.current[6] = setTimeout(() => {
      setBonus(winNumbers[6]);
      setRedo(true);
    }, 7000);
    return () => {
      timeouts.current.forEach((v) => {
        clearTimeout(v);
      });
    };
  }, [timeouts.current]);

  const onClickRedo = useCallback(() => {
    console.log("onClickRedo");
    console.log(winNumbers);
    setWinNumbers(getWinNumbers());
    setWinBalls([]);
    setBonus(null);
    setRedo(false);
    timeouts.current = [];
  }, [winNumbers]);

  return (
    <>
      <div>당첨 숫자</div>
      <div id="결과창">
        {winBalls.map((v) => (
          <Ball key={v} number={v} />
        ))}
      </div>
      <div>보너스!</div>
      {bonus && <Ball number={bonus} />}
      {redo && <button onClick={onClickRedo}>한 번 더!</button>}
    </>
  );
};

export default GetLottoHooks;

Hooks로는 위와 같이 만들 수 있다.

 

  const lottoNumbers = useMemo(() => getWinNumbers(), []);
  const [winNumbers, setWinNumbers] = useState(lottoNumbers);
  const [winBalls, setWinBalls] = useState([]);
  const [bonus, setBonus] = useState(null);
  const [redo, setRedo] = useState(false);
  const timeouts = useRef([]);

먼저 constructor는 위와 같이 개별적으로 useState를 이용해 구조분해할당을 해 주고, useRef도 설정한다. 또, useMemo를 사용해 lottoNumbers를 정의하는데, useMemo는 단순한 값을 기억해 주는 useRef와는 달리, 콜백함수의 매개변수로 전달된 함수의 리턴값을 기억해 주는 라이브러이이다. 이는, 렌더링될 때마다 함수 전체를 재실행하는 Hooks의 단점을 커버해 준다. 두 번째 매개변수로 전달된 배열에 전달되는 값이 있다면, 해당 값이 바뀌지 않는 한, useMemo가 첫 번째 매개변수의 리턴 값을 기억하고 있다가, 해당 함수가 호출될 때, 함수를 실행하지 않고 기억한 리턴 값을 그대로 사용한다. 즉, lottoNumbers에는 getWinNumbers의 리턴값인 [...winNumbers, bonusNumber]가 담겨 있고, 렌더링될 때마다 getWinNumbers를 재실행하는 대신 lottoNumbers를 사용하는 것이다.

 

  useEffect(() => {
    for (let i = 0; i < winNumbers.length - 1; i++) {
      timeouts.current[i] = setTimeout(() => {
        setWinBalls((prevBalls) => [...prevBalls, winNumbers[i]]);
      }, (i + 1) * 1000);
    }
    timeouts.current[6] = setTimeout(() => {
      setBonus(winNumbers[6]);
      setRedo(true);
    }, 7000);
    return () => {
      timeouts.current.forEach((v) => {
        clearTimeout(v);
      });
    };
  }, [timeouts.current]);

useEffect는 생명주기 함수를 대체하는 Hooks의 라이브러리이다. 두 번째 매개변수의 배열로 timeouts를 전달했는데, 이 값이 변할 때마다 첫 번째 매개변수로 전달된 함수가 실행된다. 이 함수는 클래스에서 만든 runTimeout 함수와 같은데, 1초마다 당첨 공들을 보여 주는 함수이다. 또, return부는 componentWillUnmount와 같은 역할을 하므로 여기서 timeout을 clear해 준다. 

 

  const onClickRedo = useCallback(() => {
    console.log("onClickRedo");
    console.log(winNumbers);
    setWinNumbers(getWinNumbers());
    setWinBalls([]);
    setBonus(null);
    setRedo(false);
    timeouts.current = [];
  }, [winNumbers]);

useCallback은 리턴값을 기억하는 useMemo와 달리 함수 자체를 기억한다. 브라우저가 함수를 생성하는 것 자체에 시간이 소요될 가능성이 있다면 이것을 활용하는 것이 좋다. onClickRedo 함수는 useCallback에 의해 기억되어, 불필요한 렌더링을 방지할 수 있다. 마찬가지로 두 번째로 전달되는 배열의 요소 값이 바뀔 때마다 state를 초기화해 줄 것이다. useCallback은 두 번째로 전달되는 배열의 요소가 없다면 처음 기억한 것을 끝까지 기억하고 있기 때문에 이 점에 주의해서 사용해야 한다. 특히, 자식 컴포넌트로 함수 형태의 props를 넘길 때는 반드시 useCallback을 사용해야 하는데, 자식 컴포넌트는 부모 컴포넌트가 변할 때마다 매변 새로운 props를 전달하는 것으로 인식하기 때문에, 실제로 함수가 변하지 않더라도 반복적으로 렌더링이 발생하기 때문이다.

 

 

3. useEffect

  useEffect(() => {
    // 요청
  }, []);

useEffect에서, 생명주기 함수 중 componentDidMount만을 수행하려면 위와 같은 형태로 빈 배열을 사용하면 된다. 두 번째 매개변수로 전달되는 배열의 값이 바뀔 때마다 useEffect의 함수가 실행되므로, 두 번째 매개변수로 아무것도 전달하지 않으면 처음 한 번만 실행되는 것이다.

 

  const mounted = useRef(false);
  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
    } else {
      // 요청
    }
  }, [/* 변하는 값 */]);

또, componentDidUpdate만을 사용하려면 위와 같이 작성할 수 있다. 처음 mount될 때는 작동하지 않도록 조건문을 사용하는 것이다. 이후, state 값이 변할 때 mounted도 false로 바꿔 주면 componentDidUpdate만을 사용할 수 있다.

 

 

 

 

관련글 더보기

댓글 영역