😊타이핑 효과?
마치 글씨를 쓰는 것 처럼 글자가 한글자씩 출력되는 효과라고 제가.. 정의했습니다.
웹 포트폴리오를 준비하고 있었는데 타이핑 효과가 있으면 재미있을 것 같다.
라는 생각이 들어 타이핑효과를 간단하게 준비해봤습니다.
그냥 코드만 보는 것도 좋지만 그런 게시물은 이미 충분히 많다고 생각하기때문에
이 글에서는 만들면서 하게 된 고민들을 하나씩 톺아보도록 하겠습니다.
😉 기능 정의 및 요구사항
1. 글자는 한글자씩 차례대로 출력되어야한다.
2. 만약 출력된 요소들이 사용자의 시야에서 사라진다면 글자는 0으로 초기화된다.
3. 다시 사용자의 시야에 글자 요소가 보이면 처음부터 글자를 출력한다.
4. 글자의 출력속도는 개발자가 컨트롤 할 수 있어야 한다.
** 성능 최적화를 고려할 것
제 나름대로 정의와 요구사항을 만들어보았습니다.
동작하게만 만드는 것은 어렵지 않지만 잘 동작하게 하기 위해서는
고려할게 꽤나 많아진다고 생각합니다.
😙 (1번)글자는 한글자씩 차례대로 출력되어야한다.
이 기능을 리액트에서 구현하려면 어떤 방법을 선택할 수 있을까요?
간단한 방법으로 setInterval() API를 고려할 수 있습니다.
😙setIntervalAPI를 이용한 구현
setInterval 함수는 두번째 옵션에 정의된 숫자값을 기준으로
거의 유사한 interval로 첫번째 매개변수인 콜백함수를 호출해주는 비동기 API입니다.
(타이머함수의 실행시점은 실행컨텍스트의 상태에 의존하기 때문에 항상 같은 주기로 호출되기는 힘듭니다.)
setInterval과 같은 타이머 함수들은 부수효과(side Effect)로 간주되곤 합니다.
리액트의 관점에서 생각해보면 타이머 함수는 백그라운드에서 실행되며
타이머 함수의 실행시점이 리액트의 렌더링 사이클과 일치하지 않을 수 있습니다.
예컨대 컴포넌트가 unmount 된 경우에도 타이머함수는 계속 백그라운드에서 실행될 수 있습니다.
따라서 이러한 경우의 메모리 누수(memory leak)를 막기 위하여
리액트는 부수효과의 관리를 useEffect 내부에서 처리하도록 권장하고 있습니다.
이에 집중해서 코드를 작성해보면 다음과 같이 작성할 수 있습니다.
const TypingComponent = () => {
const [word, setWord] = React.useState('');
let sentence = '안녕하세요! 예제입니다.';
const currentIndex = React.useRef(0);
React.useEffect(() => {
const timerId = setInterval(() => {
setWord((state) => {
const newState = (state += sentence[currentIndex.current]);
currentIndex.current += 1;
return newState;
});
}, 100);
return () => clearTimeout(timerId);
}, [sentence]);
return (
<>
<p>{word}</p>
</>
);
};
간단한 로직입니다.
setInterval을 통해 100ms 마다 setInterval을 실행시키고
useState로 생성한 word의 상태에 원하는 값을 추가시켜줍니다.
그런데 아직 약간의 버그가 있습니다.
바로 sentence의 문자들을 다 출력시켜도 setInterval은 멈추지 않는다는 것입니다.
이를 해결하기 위해 조건문을 추가합니다.
React.useEffect(() => {
const timerId = setInterval(() => {
if (sentence.length > currentIndex.current) {
setWord((state) => {
const newState = (state += sentence[currentIndex.current]);
currentIndex.current += 1;
return newState;
});
}
}, 100);
return () => clearTimeout(timerId);
}, [sentence]);
이제 원하는대로 동작하는 코드를 얻었습니다.
100ms 간격으로 글씨가 화면에 출력되는 일을 정상적으로 수행할 수 있어졌습니다.
하지만 살짝 아쉽습니다.
타이머함수는 실행컨텍스트 상태에 실행 시점을 의존하기도 하고
브라우저 탭이 비활성화 된 상태에서 계속 실행되기 때문에
사용자의 CPU 리소스를 낭비시키는 문제가 있습니다.
어떻게 해결할 수 있을까요?
브라우저는 기존 타이머 함수를 통해 애니메이션을 구현할 때의 문제를 해결하며
브라우저의 리플로우, 리페인트 사이클 / CPU 리소스 사용에 최적화 된
requestAnimationFrame이라는 API를 제공합니다.
이를 사용하면 성능 문제를 어느정도 해소할 수 있을듯 합니다.
😉requestAnimationFrame을 이용한 리팩토링
requestAnimationFrame은 브라우저의 reflow 작업 직전에 애니메이션을 업데이트하기 위해
지정된 함수를 호출하도록 요청하는 메서드입니다.
그리고 이 과정은 "재귀"적으로 수행되어야만 합니다.
또한 requestAnimationFrame의 호출 횟수에 대해서도 알아두어야 하는 점이 있습니다.
requestAnimationFrame은 일반적으로 1초에 60번 60FPS를 기준으로 호출되지만
W3C 권장을 따르는 대부분의 웹브라우저는 display refresh rate를 따릅니다.
display의 Hz를 따른다는 뜻인데요 따라서 대부분의 경우
requestAnimationFrame은 모니터에 따라 초당 실행횟수가 달라지게됩니다.
requestAnimationFrame(callback)
이러한 형태로 사용하는 메서드이며
내부의 callback함수는 웬만하면 requestAnimationFrame을 재귀적으로 호출해야합니다.
이를 간단한 코드로 옮기면 다음과 같습니다.
React.useEffect(() => {
let animationFrameId: number;
const animateTyping = (timestamp: number) => {
setWord((state) => {
const newState = (state += sentence[currentIndex]);
setCurrentIndex((prevIndex) => prevIndex + 1);
return newState;
});
animationFrameId = requestAnimationFrame(animateTyping);
//재귀 호출
};
animationFrameId = requestAnimationFrame(animateTyping);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [currentIndex, sentence, typingSpeed]);
물론 이 코드를 그대로 동작시키면 원하는대로 동작하지 않습니다.
requestAnimationFrame함수는 초당 60번~ 백몇십번이 호출되기 때문에
눈깜짝할 새에 이미 모든 단어들이 출력되고 맙니다.
이 문제는 기능 구현 4번에서 해결하도록 하겠습니다.
우선은 한글자씩 잘 출력이 되고 있으니까요
😚(2번) 사용자의 시야에서 사라진다면 글자는 0으로 초기화된다.
사용자의 시야에서 요소가 사라진 것을 식별할 방법은 두세가지 정도가 떠오르는 것 같습니다.
1. scroll event를 활용하여 스크롤 이벤트로 인해 요소가 안보이는 시점이 되면 초기화하기
2. intersection observer API 활용하기
스크롤 이벤트를 다루는 것보다 intersection API를 활용하는 게
지금 원하는 기능과 더 잘 맞을 것 같습니다.
스크롤 이벤트는 화면 스크롤시에 매번 발생하지만 우리가 원하는 것은
요소가 화면에서 사라질 때와 등장할 때만 감지하는 것이니까요
또한 intersection observer는 메인 스레드를 차단하지 않기 때문에
동기적으로 발생하는 스크롤 이벤트 대비 성능 최적화적인 면에서도 유리합니다.
const targetRef = React.useRef<
HTMLDivElement | HTMLParagraphElement | HTMLHeadingElement
>(null);
React.useEffect(() => {
let observer: IntersectionObserver;
const { current } = targetRef;
if (current) {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setCurrentIndex(0);
setWord('');
}
console.log(entries[0].intersectionRatio);
},
{ threshold: 0.5 },
);
observer.observe(current);
}
return () => {
if (observer) {
observer.disconnect();
}
};
}, []);
intersectionObserver 역시도 리액트의 관점에서 부수효과라고 볼 수 있기 때문에
useEffect로 래핑해줍니다.
다만 intersectionObserver는 watch를 하고있을수도 안하고있을수도 있기 때문에
observer 변수가 truthy 할때에만 disconnect를 실행시킵니다.
그렇지 않은 경우에는 undefined.disconnect()를 시도하게 되니까요
intersectionObserver의 두번째 매개변수에는 옵션객체를 넣어줄 수 있는데
이 옵션객체에는 threshold 외에도 다양한 값들을 넣어서 컨트롤할 수 있습니다.
저는 간단하게 threshold 정도만 지정을 해주었습니다만
필요하시다면 옵션에 어떤 값들이 들어갈 수 있는지 찾아보시는 것을 추천드리겠습니다.
이제 word가 표현될 곳에 targetRef를 달아주면 intersectionObserver가 이를 감지하고
화면에서 요소가 벗어나는 경우 word를 ''로 초기화시켜줄것입니다.
😍(3번) 다시 사용자의 시야에 글자 요소가 보이면 처음부터 글자를 출력한다.
React.useEffect(() => {
let animationFrameId: number;
const animateTyping = (timestamp: number) => {
if (sentence.length > currentIndex) {
setWord((state) => {
const newState = (state += sentence[currentIndex]);
setCurrentIndex((prevIndex) => prevIndex + 1);
return newState;
});
}
animationFrameId = requestAnimationFrame(animateTyping);
};
animationFrameId = requestAnimationFrame(animateTyping);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [currentIndex, ms_delay, sentence]);
1번의 코드에서 if문을 추가시켜주었습니다.
이제 currentIndex가 sentence의 length보다 작을때에만 동작하기 때문에
2번 구현에서 setCurrentIndex를 0으로 초기화시켜주면
다시 요소가 보여졌을때 동작하게됩니다.
😶(4번) 글자의 출력속도는 개발자가 컨트롤 할 수 있어야 한다.
이 부분은 setInterval을 사용할 때엔 쉽게 구현할 수 있지만
requestAnimationFrame을 이용하여 구현한 경우 꽤 까다롭게 느껴질 수 있습니다.
이는 requestAnimationFrame 메서드가 기본적으로 속도조절에 관한 api를 제공하지 않기 때문입니다.
따라서 requestAnimationFrame 메서드의 호출주기는 개발자가 조절할 수 없는 영역이며
그럼에도 불구하고 속도를 조절하고 싶다면 내부에서 timeStamp를 활용하여야 합니다.
이는 requestAnimationFrame의 콜백함수에는 timeStamp:number를
매개변수로 넣어주기 때문에 가능합니다.
const [word, setWord] = React.useState('');
const targetRef = React.useRef<
HTMLDivElement | HTMLParagraphElement | HTMLHeadingElement
>(null);
const [currentIndex, setCurrentIndex] = React.useState(0);
const lastTimestamp = React.useRef<number | null>(null);
React.useEffect(() => {
let animationFrameId: number;
const animateTyping = (timestamp: number) => {
if (lastTimestamp.current === null) lastTimestamp.current = timestamp;
const elapsed = timestamp - lastTimestamp.current;
if (elapsed > ms_delay) {
lastTimestamp.current = timestamp;
if (sentence.length > currentIndex) {
setWord((state) => {
const newState = (state += sentence[currentIndex]);
setCurrentIndex((prevIndex) => prevIndex + 1);
return newState;
});
}
}
animationFrameId = requestAnimationFrame(animateTyping);
};
animationFrameId = requestAnimationFrame(animateTyping);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [currentIndex, ms_delay, sentence]);
lastTimestamp를 useRef로 선언해주었습니다.
이는 마지막에 실행된 타임스탬프를 의미하게 될 것입니다.
마지막에 실행된 타임스탬프와 현재 이벤트의 타임스탬프의 값의 차이가
내가 원하는 딜레이 이상일때에만 실행시키면 출력 속도 조절을 구현할 수 있습니다.
if (elapsed > ms_delay) {
lastTimestamp.current = timestamp;
elapsed(경과)가 개발자가 원하는 딜레이보다 클때에만
setWord를 실행하고 lastTimestamp의 값을 현재 타임스탬프로 업데이트 시켜줍니다.
이를 통하여 출력 속도 조절을 구현할 수 있었습니다.
😙커스텀훅으로 로직 분리
const useTyping: (sentence: string, ms_delay?: number) => {
word: string;
targetRef: React.RefObject<HTMLDivElement | HTMLParagraphElement | HTMLHeadingElement>;
setWord: React.Dispatch<...>;
}
커스텀훅의 인터페이스는 다음과 같은 형태로 구현했습니다.
targetRef와 word 상태 그리고 혹시 직접 조작해야하는 경우를 대비하여 setWord를 제공하였습니다.
이렇게 작성한 커스텀훅은
const PortfolioMain = ({}: PortfolioMainProps) => {
const { word, targetRef } = useTyping('안녕하세요! 예제입니다.');
return (
<PageWrapper>
<Centering col className=" justify-center items-center">
<p ref={targetRef}>{word}</p>
</Centering>
</PageWrapper>
);
};
이러한 형태로 사용할 수 있었으며 사전에 정의한 기능을 모두 만족할 수 있었습니다.
😑마치며
intersection observer와 request animation frame을 적절히 활용하면
많은 애니메이션 기능들을 라이브러리에 구애받지 않고 쉽게 구현할 수 있겠다는 생각이 들었습니다.
위 코드에서 intersection observer와 request animation frame을 사용하는 코드 역시
커스텀훅으로 분리하여 적절하게 다형성을 활용하는 방향으로 코드를 작성할수도 있을 것 같네요
😐레퍼런스
https://shylog.com/react-custom-hooks-scroll-animation-fadein/
https://hayeondev.gatsbyjs.io/221031-requestAnimationFrame/
https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame
https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver
https://heropy.blog/2019/10/27/intersection-observer/
'react' 카테고리의 다른 글
리액트 라이브러리 없이 캘린더, 달력 구현하기 (4) | 2023.10.31 |
---|---|
실습과 함께 배우는 리액트 쿼리로 낙관적 업데이트 하는 법 (1) | 2023.10.15 |
what is react server components 이..이거 뭐냐? (2) | 2023.08.07 |
리액트를 사용하는 이유는 무엇일까? (2) | 2023.08.04 |
reactquery useQuery 자동 가져오기 막는 법 (0) | 2023.07.31 |