잘 만들어진 인터랙티브 웹들을 보며 저건 어떻게 만드는걸까 항상 궁금했었다. 인프런을 둘러보다가 몇 줄로 끝내는 인터랙티브 웹 개발 노하우라는 강의를 발견했고, 기초적인 것들을 만들어봐야겠다고 생각했다.
가장 첫 실습인 마우스를 따라다니는 원 만들기를 해보려고 한다.
VanillaJS로 구현해보기
HTML
<div class="box"></div>
먼저 box라는 클래스명을 가진 div를 하나 만들어준다.
CSS
.box {
background-color: lightgray;
border-radius: 50%;
width: 80px;
height: 80px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1001;
pointer-events: none;
}
- position: absolute; 지정
- top: 50%; left:50%; 지정: box의 오른쪽 위 모서리가 body의 가운데에 올 수 있도록 해준다.
- transform: translate(-50%, -50%); 지정: 요소의 가운데 부분이 가운데로 올 수 있도록 한다.
- pointer-events: none; 지정: 마우스 클릭 시 box 뒤에 있는 요소를 클릭할 수 있도록 한다.
- z-index를 높게 설정해주어 다른 요소들보다 위에 올라올 수 있도록 한다.
원이 마우스를 따라가도록 만들기
const box = document.querySelector(".box");
window.addEventListener("mousemove", (e) => {
box.style.top = e.pageY + "px";
box.style.left = e.pageX + "px";
});
box를 querySelector를 통해 가져온 후 mousemove의 event 값을 console.log로 찍어보면 pageX, clientX, offsetX, screenX 등 여러 값들이 출력되는 것을 확인할 수 있다.
page? client? offset? screen?
- pageX, pageY: 스크롤 화면을 포함한 전체 문서를 기준으로 x, y좌표를 반환한다.
- clientX, clientY: 브라우저 화면에서의 가로, 세로 좌표를 제공한다. 이 때 스크롤은 무시하고 해당 페이지의 상단을 0으로 측정한다. (현재 보이는 화면 상에서 어느 지점에 위치하는 지를 의미하기 때문에 스크롤 해도 값은 변하지 않음)
- offsetX, offsetY: 이벤트 대상 객체에서의 상대적 마우스 좌표 위치를 반환한다. (화면 중간에 있는 박스 내부에서 클릭한 위치를 찾을 때 해당 박스의 왼쪽 모서리 좌표가 0이 된다.)
- screenX, screenY: 브라우저 화면이 아닌 모니터 화면을 기준으로 좌표를 제공한다.
브라우저 화면 내에서 스크롤 화면을 포함한 전체 문서에서 따라다니도록 만들 것이기 때문에 여기서는 pageX, pageY 값을 사용해주면 된다.
그래서 window에서 mousemove 이벤트가 발생할 때마다 box style의 top과 left를 해당 좌표로 이동시켜주면 된다.
부드럽게 따라다니기
원이 좀 더 부드럽게 마우스를 따라다니도록 만들어보자.
requestAnimationFrame?
window.requestAnimationFrame(callback);
직접 구현해보기 전에 여기에서 사용할 requestAnimationFrame에 대해 알아보자.
배경
자바스크립트에는 이벤트 루프가 있어 지속적으로 자바스크립트를 실행한다. 과거에는 애니메이션을 구현하기 위해 setTimeout() 또는 setInterval()을 사용했다.
하지만 이는 아무리 정확히 시간을 예측하더라도 브라우저가 다른 작업 중인 경우 setTimeout이 repaint 시간에 맞추지 못해 다음 사이클로 지연될 수 있었다. 그리고 이렇게 되면 애니메이션이 다음 사이클에 두 번 실행되기 때문에 자연스럽지 못한 애니메이션으로 보일 수 있다.
이런 문제점을 해결하기 위해 requestAnimationFrame이 등장했다.
그래서 그게 뭔데?
requestAnimationFrame은 브라우저에게 수행하길 원하는 애니메이션을 알려주고 다음 repaint가 진행되기 전에 해당 애니메이션을 업데이트하는 함수를 호출한다. (애니메이션 전용 API는 아니지만 대부분 애니메이션 활용을 위해 사용된다.)
let request
const performAnimation = () => {
request = requestAnimationFrame(performAnimation)
//animate something
}
requestAnimationFrame(performAnimation)
//...
cancelAnimationFrame(request) //stop the animation
위와 같이 사용할 수 있다. 이는 setTimeout, setInterval을 이용한 애니메이션 구현과 비슷해보이지만 매우 다르게 동작한다.
Timeline
아래 그림은 setTimeout이나 setInterval을 사용했을 때의 타임라인이다. 보라색은 render, 초록색은 paint, 노란색은 setTimeout/setInterval 코드의 실행을 의미한다.
setTimeout, setInterval은 paint, render가 일어난 후에 일어난다. 위의 그림에서는 하나의 프레임에 한 번만 사용되었기 때문에 정상적으로 동작한다.
하지만 애니메이션 호출이 지연될 경우, 이벤트 루프의 코드 블로킹 때문에 특정 프레임에서 setTimeout(setInterval)이 실행되지 않는 경우가 생길 수 있다. 그리고 이 경우 애니메이션이 끊기는 현상이 발생한다.
게다가 실행되지 않은 애니메이션이 다음 프레임에서 여러 번 호출되면서 애니메이션이 버벅거리는 결과가 나올 수 있다.
requestAnimationFrame은 아래와 같이 동작한다.
모든 requestAnimationFrame의 애니메이션은 항상 렌더링과 페인트 이전에 실행되기 때문에 조금 더 예측 가능한 애니메이션을 만들 수 있다. 그리고 이벤트 루프의 지연 및 블로킹 현상이 나타나지 않아 setTimeout, setInterval에 비해 더 부드러운 애니메이션을 제공한다.
setTimeout, setInterval은 탭이 보이지 않는 상태에서도 계속 돌아간다. 하지만 requestAnimationFrame은 cpu에 매우 친화적이기 때문에 현재 창이나 탭이 보이지 않으면 애니메이션이 중지된다. 이는 cpu 전력 소모를 줄일 수 있고, 배터리 절약에도 성공적이기 때문에 requestAnimationFrame을 사용하면 브라우저는 리소스 사용을 최적화하고 애니메이션을 더 부드럽게 만들 수 있다.
직접 적용해보자!
const box = document.querySelector(".box");
let x = 0;
let y = 0;
let targetX = 0;
let targetY = 0;
const speed = 0.03; // 따라오는 속도
window.addEventListener("mousemove", (e) => {
x = e.pageX;
y = e.pageY;
});
const loop = () => {
// 애니메이션의 가속, 감속
targetX += (x - targetX) * speed;
targetY += (y - targetY) * speed;
box.style.top = targetY + "px";
box.style.left = targetX + "px";
// 재귀적으로 호출
window.requestAnimationFrame(loop);
};
loop();
mousemove 이벤트가 발생할 때는 마우스의 x, y 변수만 바꿔주고, requestAnimationFrame에서 바뀐 x, y로 이동을 시켜주는 식으로 코드를 짜주면 된다. (이 때 mousemove 함수 안에서 requestAnimationFrame을 사용하지 않도록 주의하자. 이렇게 되면 함수가 계속 중첩되어 실행된다.)
React로 구현해보기
React 프로젝트에도 한번 적용해보고 싶어서 만들어봤다.
routes/index.tsx
import { useRef } from 'react'
const App = () => {
const mouseRef = useRef<HTMLDivElement>(null)
return(
<div className={styles.pageContainer}>
<div className={styles.mouseMoveDiv} ref={mouseRef} />
</div>
)
}
export default App
div를 만들고, 마우스의 움직임에 따라 이 div를 직접 움직여줄 것이기 때문에 ref를 걸어준다.
그리고 기본적인 스타일을 div에 적용해준다.
routes.module.scss
.mouseMoveDiv {
position: absolute;
top: 50%;
left: 50%;
z-index: 1001;
width: 50px;
height: 50px;
pointer-events: none;
background-color: #000000;
border-radius: 50%;
opacity: 0.2;
transition: display 1000ms ease-in-out;
transform: translate(-50%, -50%);
}
routes/index.tsx
let x = 0
let y = 0
let targetX = 0
let targetY = 0
const speed = 0.05
const App = () => {
const mouseRef = useRef<HTMLDivElement>(null)
const requestRef = useRef(0)
const handleMouseMove: MouseEventHandler = (e) => {
x = e.pageX
y = e.pageY
}
const loop = useCallback(() => {
targetX += (x - targetX) * speed
targetY += (y - targetY) * speed
if (mouseRef.current) {
mouseRef.current.style.top = `${targetY}px`
mouseRef.current.style.left = `${targetX}px`
}
requestRef.current = requestAnimationFrame(loop)
}, [])
useEffect(() => {
requestRef.current = requestAnimationFrame(loop)
return () => cancelAnimationFrame(requestRef.current)
}, [loop])
return (
<div className={styles.pageContainer} onMouseMove={handleMouseMove}>
<div className={styles.mouseMoveDiv} ref={mouseRef} />
</div>
)
}
기본적인 구현은 바닐라js에서 만든 방식과 비슷하지만 리액트에서는 requestAnimationFrame을 useEffect안에서 실행해준다.
https://css-tricks.com/using-requestanimationframe-with-react-hooks/
추가적인 효과
간단하지만 추가적으로 마우스를 눌렀을 때, 마우스가 브라우저 밖으로 나갔을 때 이벤트를 추가해주었다.
routes/index.tsx
const App = () => {
// ...
// 마우스 누르고 있을 때 div 색 변경
const handleMouseDown = () => {
if (mouseRef.current) {
mouseRef.current.style.backgroundColor = '#ea3a4b'
}
}
// 마우스를 눌렀다가 뗐을 때 다시 원래 색으로 변경
const handleMouseUp = () => {
if (mouseRef.current) {
mouseRef.current.style.backgroundColor = '#000000'
}
}
// 마우스가 브라우저 안에 들어올 때
const handleMouseEnter = () => {
if (mouseRef.current) {
mouseRef.current.style.display = 'block'
}
}
// 마우스가 브라우저 밖으로 나갈 때 div의 display를 none으로 변경
const handleMouseLeave = () => {
if (mouseRef.current) {
mouseRef.current.style.display = 'none'
}
}
return (
<div
className={styles.pageContainer}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className={styles.mouseMoveDiv} ref={mouseRef} />
</div>
)
}
CSS-in-JS를 사용하고 있다면 직접 style을 설정해줄 수 있었겠지만, scss에서는 따로 props를 넘길 수 없기 때문에 그냥 ref에서 직접 스타일을 변경해주었다.
전체 코드
let x = 0
let y = 0
let targetX = 0
let targetY = 0
const speed = 0.05
const App = () => {
const mouseRef = useRef<HTMLDivElement>(null)
const requestRef = useRef(0)
const handleMouseMove: MouseEventHandler = (e) => {
x = e.pageX
y = e.pageY
}
const loop = useCallback(() => {
targetX += (x - targetX) * speed
targetY += (y - targetY) * speed
if (mouseRef.current) {
mouseRef.current.style.top = `${targetY}px`
mouseRef.current.style.left = `${targetX}px`
}
requestRef.current = requestAnimationFrame(loop)
}, [])
useEffect(() => {
requestRef.current = requestAnimationFrame(loop)
return () => cancelAnimationFrame(requestRef.current)
}, [loop])
const handleMouseDown = () => {
if (mouseRef.current) {
mouseRef.current.style.backgroundColor = '#ea3a4b'
}
}
const handleMouseUp = () => {
if (mouseRef.current) {
mouseRef.current.style.backgroundColor = '#000000'
}
}
const handleMouseEnter = () => {
if (mouseRef.current) {
mouseRef.current.style.display = 'block'
}
}
const handleMouseLeave = () => {
if (mouseRef.current) {
mouseRef.current.style.display = 'none'
}
}
return (
<div
className={styles.pageContainer}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className={styles.mouseMoveDiv} ref={mouseRef} />
</div>
)
}
References
https://developer.mozilla.org/ko/docs/Web/API/window/requestAnimationFrame
https://flaviocopes.com/requestanimationframe/
https://velog.io/@khy226/requestAnimationFrame으로-자연스러운-애니메이션-만들기-feat-react-spring
'FE' 카테고리의 다른 글
번들러(Bundler)와 트랜스파일러(Transpiler) (0) | 2022.04.01 |
---|---|
신입 FE 개발자가 되려면 무엇을 학습해야 할까? (0) | 2021.11.25 |
크로미움 브라우저(Chromium Browser)란 무엇일까? (0) | 2021.08.23 |