다음과 같이 스크롤을 내리면 나타나는 카운트업 기능을 만들고자 한다.
부분적으로 보기
여러 레퍼런스들을 참고하여 아래와 같이 코드를 작성하였다.
useCountUp.jsx
import { useEffect, useState } from "react";
/* eslint-disable no-unused-vars */
const useCountUp = (end, start = 0, duration = 2000) => {
const [count, setCount] = useState(start);
useEffect(() => {
let currentNum = start;
const delay = duration / end;
const countUp = setInterval(() => {
currentNum++;
setCount(currentNum);
if (currentNum === end) {
clearInterval(countUp);
}
}, delay);
}, [end, start, duration]);
return count.toFixed(0);
};
export default useCountUp;
Counting.jsx
import styled from "styled-components";
import useCountUp from "../Hooks/useCountUp";
const CountingWrapper = styled.div`
display: flex;
flex-direction: column;
gap: 2rem;
align-items: center;
width: 100%;
`;
const Count = styled.div`
font-size: ${(props) => props.theme.fontSize.title_2};
font-weight: bold;
color: ${(props) => props.theme.color.darkGreen};
`;
const Name = styled.div`
font-size: ${(props) => props.theme.fontSize.content_14};
font-weight: bold;
color: ${(props) => props.theme.color.orange};
`;
export default function Counting({ ...props }) {
console.log(props.number);
console.log(Number(props.number));
return (
<CountingWrapper>
<Count>{useCountUp(Number(props.number), 0, 2000)}</Count>
<Name>{props.name}</Name>
</CountingWrapper>
);
}
참고로 Counting에서 전달받는
props.number
은 문자이다. 그래서 Number함수로 처리했을때 처음엔 다음과 같은 오류가 발생했었다.
uncaught typeerror: number is not a function
검색해보니 내가 스타일을 위해 Number를 선언해 사용하고 있어서 그런 것이었다.(예약어) 그래서 중복되는 이름이 없도록 number라고 사용한 것을 모두 제거하고 Count로 스타일 이름을 바꾸니 오류가 사라지게 되었다.
그러자 다음과 같이 작동하였다.
참고: 맨처음 숫자인 800k+는 우선 800을 먼저 넣어보았다.
문제점
구분 | 원본 | 내가 만든 것 |
---|---|---|
렌더링 시기 | 화면에서는 보이지 않지만 원본은 스크롤해서 해당 구역이 비춰질때부터 카운팅 시작함. | 처음 전체 화면 렌더링 되자마자 시작됨. |
카운팅 방식 | 원본은 3초 정도의 시간안에 동시에 끝남. | 내가 만든건 숫자가 높을수록 늦게까지 혼자 카운팅 되고있음. |
화면 효과 | 자연스럽게 숫자가 나타나는 페이드인? 이 있음 |
카운팅 방식을 먼저 수정해보자.
Q. 처음 작성한 코드에서 const delay = duration / end;
에 의해 이론적으로 end
숫자가 커질수록 delay
속도가 빨라지게 하여 모두 동시에 끝나는게 맞지만 왜 그렇게 동작하지 않을까?
A. mdn공식문서를 확인해보니 최소 4ms
까지만 가능 ⇒ 그럼 최소 4ms로 맞추고 나머지는 숫자를 띄엄띄엄 세는 식으로 가야겠따.
지연 제한
간격(interval)은 중첩될 수 있습니다. 즉,
setInterval()
에 대한 콜백이setInterval()
을 호출하여 첫 번째 간격이 계속 진행 중일지라도 다른 간격의 실행을 시작할 수 있습니다. 이것이 성능에 미칠 수 있는 잠재적인 영향을 완화하기 위해 간격이 5개 수준 이상으로 중첩되면 브라우저는 자동으로 간격에 대해 4 ms 최소 값을 적용합니다. 중첩 호출이 심화된setInterval()
의 호출에서 4 ms 미만의 값을 지정하면 4 ms로 고정됩니다.
각 숫자에 대해서 다음 표와 같이 실험(?)을 진행했다.
목표숫자 | 800k+ | 89 | 12 | 250 |
---|---|---|---|---|
지속시간(가정) | 2000ms | 2000ms | 2000ms | 2000ms |
시작숫자 | 0 | 0 | 0 | 0 |
예상 지연시간 | 0.0025ms | 22.5ms | 166ms | 8ms |
4ms이상? | X | O | O | O |
예상 순위 | 4 | 1 | 1 | 1 |
실제 순위 | 4 | 1 | 1 | 3 |
⇒ 250을 200으로 고쳤을때도 진짜 아주 살짝 늦게 끝나고(10ms) 150으로 고쳐보니 동일하게 끝나는 것을 확인했다. (13ms) 그래서 안전하게 20ms 로 맞추기로 한다. (100번 나누기 이하일 경우)
작성코드
// useCountUp.jsx
import { useEffect, useState } from "react";
/* eslint-disable no-unused-vars */
const useCountUp = (end, start = 0, duration = 2000) => {
const [count, setCount] = useState(start);
useEffect(() => {
let currentNum = start;
if (Math.abs(end) > 99) {
const delay = 20;
const countUp = setInterval(() => {
currentNum = currentNum + end / (duration / delay);
setCount(currentNum);
if (currentNum === end) {
clearInterval(countUp);
}
}, delay);
} else {
const delay = Math.abs(Math.floor(duration / end));
const countUp = setInterval(() => {
currentNum++;
setCount(currentNum);
if (currentNum === end) {
clearInterval(countUp);
}
}, delay);
}
}, [end, start, duration]);
return count.toFixed(0);
};
export default useCountUp;
맨처음 숫자를 8000으로 바꾸었을 때 화면이다.
//useConvert.jsx
const useConvert = (num, digits) => {
const si = [
{ value: 1, symbol: "" },
{ value: 1e3, symbol: "k" },
{ value: 1e6, symbol: "M" },
{ value: 1e9, symbol: "G" },
{ value: 1e12, symbol: "T" },
{ value: 1e15, symbol: "P" },
{ value: 1e18, symbol: "E" },
];
const rx = /\.0+$|(\.[0-9]*[1-9])0+$/;
let i;
for (i = si.length - 1; i > 0; i--) {
if (num >= si[i].value) {
break;
}
}
return (num / si[i].value).toFixed(digits).replace(rx, "$1") + si[i].symbol;
};
export default useConvert;
//useCounting.jsx
//생략
if (count>1000){
const convertedNum = useConvert(count,0) // 이 부분 에러
}
return count.toFixed(0);
};
export default useCountUp;
React Hook "useConvert" is called conditionally. React Hooks must be called in the exact same order in every component render.
⇒ 리액트 훅은 조건식 안에 사용될 수 없다고 한다. 간과한 리액트 훅의 규칙은 다음과 같다.
최상위(at the Top Level)에서만 Hook을 호출해야 합니다
반복문, 조건문 혹은 중첩된 함수 내에서 Hook을 호출하지 마세요. 대신 early return이 실행되기 전에 항상 React 함수의 최상위(at the top level)에서 Hook을 호출해야 합니다. 이 규칙을 따르면 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것이 보장됩니다. 이러한 점은 React가
useState
와useEffect
가 여러 번 호출되는 중에도 Hook의 상태를 올바르게 유지할 수 있도록 해줍니다. 이 점에 대해서 궁금하다면 아래에서 자세히 설명해 드리겠습니다.오직 React 함수 내에서 Hook을 호출해야 합니다
Hook을 일반적인 JavaScript 함수에서 호출하지 마세요. 대신 아래와 같이 호출할 수 있습니다.
- ✅ React 함수 컴포넌트에서 Hook을 호출하세요.
- ✅ Custom Hook에서 Hook을 호출하세요. (다음 페이지에서 이 부분을 살펴볼 예정입니다)
이 규칙을 지키면 컴포넌트의 모든 상태 관련 로직을 소스코드에서 명확하게 보이도록 할 수 있습니다.
//useCounting.jsx
//생략
const convertedNum = useConvert(count, 0); // 조건문 밖으로 뺌
if (count > 1000) {
return convertedNum;
} else {
return count.toFixed(0);
}
};
export default useCountUp
import { useEffect, useState } from "react";
import useConvert from "./useConvert";
/* eslint-disable no-unused-vars */
const useCountUp = (end, start = 0, duration = 5000) => {
const [count, setCount] = useState(start);
useEffect(() => {
// 아래는 1000 이상인 경우에 대해 새로 작성한 코드
let currentNum = start;
if (Math.abs(end) > 1000) {
const delay = 20;
const countUp = setInterval(() => {
currentNum = currentNum + 950 / (duration / delay);
setCount(currentNum);
if (currentNum === 950) {
clearInterval(countUp);
}
}, delay);
} else if (Math.abs(end) > 99) {
// (중략)
const convertedNum = useConvert(end, 0);
if (count > 949) {
return convertedNum;
}
return count.toFixed(0);
};
export default useCountUp;
참고: 페이드인 스타일 적용함
setInterval
을 사용하여 카운트 업 기능을 구현할 때는 지연간격의 제한이 있기 때문에 숫자가 얼만큼 크냐에 따라 조건별로 나눠 적용시켜줘야 한다는 것을 알게되었다.