폰트나 이미지 등의 로딩이 되기전에 사용자는 미완성된 레이아웃을 마주하므로 좋지 않은 경험을 하게 됩니다.
폰트 로딩에 있어서 이런 현상을 FOIT, FOUT라고 합니다.
브라우저가 웹 글꼴을 다운로드하기 전에 텍스트가 보이지 않는 현상
스타일이 지정되지 않은 텍스트의 플래시 - 대체 폰트(따로 정하지 않는다면 기본 폰트)가 출력되는 현상
이미지 또한 뒤늦게 로딩이 되면 layout shift
가 발생할수도 있으며, 사용자는 이미지가 있는지 조차 모를 수도 있습니다.
위 현상들을 해결하기 위한 방안은 뭘까요?
대체로 로딩 전까지 글꼴대체, 숨기기, 스켈레톤 UI 적용 등의 방법이 있습니다.
오늘은 리소스 로딩이 될때까지 컴포넌트를 숨겼다가 나타내는 방법을 소개합니다.
결국 중요한 것은 리소스를 기다리는 것인데, 어떻게 효율적으로 훅으로 사용할 것인지 알아보겠습니다.
저는 폰트와 이미지를 기다리는 훅을 작성했습니다.
import { useEffect, useState } from 'react';
let fontFace: Promise<void> | undefined;
const loadResources = (srcArr: string[]) => {
if (!fontFace)
fontFace = new Promise<void>((resolve, reject) => {
const font = new FontFace(
'폰트명',
'url("폰트경로")',
);
font.load().then(() => {
document.fonts.add(font);
resolve();
});
});
const promiseArr: Promise<void>[] = srcArr.map(src => {
return new Promise<void>((resolve, reject) => {
const image = new Image();
image.src = `이미지경로`;
image.onload = () => resolve();
image.onerror = () => reject();
});
});
return Promise.all([fontFace, ...promiseArr]);
};
const useLoading = (srcArr: string[] = []) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
loadResources(srcArr).then(() => setLoaded(true));
}, []);
return loaded;
};
export default useLoading;
각 리소스에 대한 로딩을 병렬로 처리하여 Promise
를 반환하는 loadResources
함수를 정의합니다.
fontFace = new Promise<void>((resolve, reject) => {
const font = new FontFace(
'폰트명',
'url("폰트 경로")',
);
font.load().then(() => {
document.fonts.add(font);
resolve();
});
});
저는 모든 페이지에 공통으로 필요한 폰트 Promise
를 먼저 정의했습니다.
FontFace
인스턴스를 생성하고 load
함수를 사용하여 폰트의 로딩이 끝나면 document
에 폰트를 추가하고 Promise를 resolve
이행해줍니다.
그럼 이제 폰트 로딩을 기다리는 Promise
가 생성됐습니다.
여기서 짚고 넘어가야할 것이, fontFace
변수를 두어 조건에 따른 대입연산을 하도록 작성한 것입니다.
캐싱에 대해 아는 사람이라면,
이미 캐싱된 리소스기 때문에 어차피
font.load
를 해도 캐싱된 값을 쓰니까 딱히 조건문을 걸어줄 필요가 있나?
라는 생각을 할 수 있습니다. 맞는 말입니다.
그러나 매번 새로운 FontFace
인스턴스를 생성하면서 web api
와 task queue
를 거치는 불필요한 로직을 실행하기 때문에 해당 promise
를 변수에 캐싱합니다.
let fontFace: Promise<void> | undefined;
모듈은 최초 로드 시점에 캐싱되기 때문에, 어떤 컴포넌트에서 해당 모듈을 쓰든
fontFace
변수는 공유됩니다.
이제 폰트 로딩에 대한 준비는 끝났습니다.
다음은 이미지 리소스를 기다리는 Promise
입니다.
const promiseArr: Promise<void>[] = srcArr.map(src => {
return new Promise<void>((resolve, reject) => {
const image = new Image();
image.src = src;
image.onload = () => resolve();
image.onerror = () => reject();
});
});
한 화면에 중요한 이미지를 2개 이상 로딩해야 할 수도 있으므로 배열로 생성했습니다.
map
을 돌면서 Image
객체를 생성(이미지 경로는 인수로 전달받는다)하고 로딩이 되면 resolve
합니다.
return Promise.all([fontFace, ...promiseArr]);
완성된 폰트 Promise
와 이미지 Promise
배열을 Promise.all
로 병렬 처리합니다.
이제 리소스를 기다리는 Promise
는 완성됐습니다.
const useLoading = (srcArr: string[] = []) => {
const [loaded, setLoaded] = useState(false);
useEffect(() => {
loadResources(srcArr).then(() => setLoaded(true));
}, []);
return loaded;
};
로드 유무를 판별할 state
를 선언해줍니다.
마운트 후 loadResources
함수를 기다린 후 loaded
를 true
로 바꿔줍니다.
이제 이 훅은 리소스 로딩전엔 false
, 로딩 후엔 true
를 리턴합니다.
import useLoading from '../../hooks/useLoading';
const TestComponent = () => {
const imgArr = ['mainImage.png', 'mainImage2.png'];
const isLoaded = useLoading(imgArr);
return (
<div>
{isLoaded || <로딩중컴포넌트 />}
{imgArr.map(img => (
<img src={img} key={img} />
))}
</div>
);
};
export default TestComponent;
이미지 두 개가 로딩될 때까지 기다려야하는 TestComponent
를 작성했습니다.
이미지 경로 배열을 훅에 넘기고 로딩 상태값을 받습니다.
로딩 상태에 따라서 로딩 중(false)
일 경우 로딩중임을 나타내는 컴포넌트를 렌더링합니다.
이 경우에 로딩 컴포넌트에 gif같은 것을 사용할 경우 로딩 시간이 있기 때문에, html,css만을 이용한 스피너를 만드는 것을 추천드립니다.
그리고 이미지 배열을 순회하며 img
엘리먼트를 생성합니다.
이미지의 경우 useLoading에서 이미지 객체를 생성하면서 캐싱하기 때문에, 실제 사용되는 TestComponent에서는 이미지 로딩이 따로 필요하지 않습니다. 이를 이용해 이미지 preload를 구현하기도 합니다.
이렇게 커스텀훅을 사용하면 기다리고자 하는 리소스를 다 로딩한 후에 화면을 노출하여 사용자 경험을 향상 시킬 수 있습니다.
우와 굉장한 실력이시네요 !
하트 꾸욱 누르고 갑니닷