이미지 로딩 스켈레톤으로 나타내기

1. React Query, React Suspense 활용

2022.10.01 - [React] - React Suspense 사용하기(with. React-Query)

 

React Suspense와 함께 활용해 image 주소로 직접 fetch 요청을 보내 blob 객체로 이미지를 불러오는 방법도 있음. 하지만, 이 방법을 이용하면 CORS 오류를 해결해야 하고, 캐시된 이미지를 불러올 때는 스켈레톤 화면이 보이지 않는 문제도 있는 한계가 있음.

 

 

2. Image onLoad 이벤트 활용

<img> 요소의 onLoad 이벤트를 활용해 아직 로드되지 않았을 때는 스켈레톤을 보여주고, 로드가 완료되었을 때 State를 변경해 원래 이미지를 보여주는 방식을 이용할 수 있음. 한편, 이미지를 불러올 때 오류가 발생할 수 있는데 계속 스켈레톤을 보여주는 것보다는 기본 이미지를 보여주는 것으로 onError 이벤트를 활용할 수 있음.

onLoad 이벤트를 활용해 이미지 스켈레톤을 표시

import { memo } from "react";
import { useImageEventHandler } from "./useImageEventHandler";  // 커스텀 훅

interface ImageWithSkeletonProps {
   image: string | undefined;
   fallbackImage: string;
   width: string;
   height: string;
}

export const ImageWithSkeleton = memo(
   ({ image, fallbackImage, width, height }: ImageWithSkeletonProps) => {
      const { isLoading, onLoad, onError } = useImageEventHandler(fallbackImage);

      return (
         <Box width={width} height={height}>
            {isLoading && (
               <Skeleton width="100%" height="100%" bgColor="#F2F3F7" />
            )}
            <Image
               src={image || fallbackImage}
               isLoading={isLoading}
               onLoad={onLoad}
               onError={onError}
            />
         </Box>
      );
   }
);
const Box = styled.div<Pick<ImageWithSkeletonProps, "width" | "height">>`
  width: ${({ width }) => width};
  height: ${({ height }) => height};
  border-radius: 12px;
  overflow: hidden;
`;

// Loading 상태일 때, 본래 컨텐츠 내용을 숨김
const Image = styled.img<{ isLoading: boolean }>`
  width: 100%;
  height: 100%;
  object-fit: cover;

  ${({ isLoading }) =>
      isLoading &&
      css`
      display: none;
    `}
`;
import { SyntheticEvent, useCallback, useState } from "react";

export const useImageEventHandler = (fallbackImage: string) => {
   // 초기값 true: 이미지 불러오는 중일 때
   const [isLoading, setIsLoading] = useState(true);

   // 이미지가 불러와졌을 때 isLoading false로 변경
   const onLoad = useCallback(() => {
      setIsLoading(false);
   }, []);

   // 이미지를 불러올 때 오류가 발생했다면 기본 이미지로 변경
   const onError = useCallback(
      (e: SyntheticEvent<HTMLImageElement>) => {
         e.currentTarget.src = fallbackImage;
      },
      [fallbackImage]
   );

   return { isLoading, onLoad, onError };
};

 

 

 

A. Skeleton을 Props로 전달했을 때

스켈레톤 컴포넌트를 이미지 컴포넌트 내부에 넣는 대신 Props로 넘겨서 재사용성을 극대화하는 방법은 어떨까?

const ProductList = ({ products }: ProductListProps) => (
   <UList>
      {products.map((product) => (
         <ProductItem product={product} skeleton={<ProductItemSkeleton />} key={product.id} />
      ))}
   </UList>
);

 

현재 불러온 페이지 이전의 ProductItem 컴포넌트의 skeleton 속성이 변경되어 재렌더링될 필요가 없는 이전 ProductItem 컴포넌트까지 재렌더링되어 100여개의 ProductItem을 렌더링하는 시간이 33ms나 걸림. 

 

왜 이전 ProductItem까지 skeleton Props가 변경되는 걸까?

const ProductList = () => {
   return products.map((e, i) => React.createElement(ProductItem, {
      skeleton: React.createElement("div", null),
      key: i
   }));
};

ProductList 컴포넌트에서 ProductItem을 렌더링할 때, Props로 전달하는 <ProductItemSkeleton />는 React.createElement() 메서드로 생성되기 때문에 이전 ProductItem 컴포넌트의 skeleton Props가 달라지게 됨.(새로운 객체를 생성해 주소값이 달라졌으므로) JSX 문법인 <Component />는 Component()를 표현한 것이라고 생각하면 간단함.

 

따라서, ProductItemSkeleton를 Props로 넘겨주는 것보다 ProductItem 내부에 직접 넣어주는 것이 좋음.

 

 

B. Skeleton을 컴포넌트 내부에 넣었을 때

import { memo, useState } from 'react';

const ProductItem = memo(
   ({ product: { name, thumbnail, price }, skeleton }: ProductItemProps) => {
      const [imageLoaded, setImageLoaded] = useState(false);

      return (
         <List>
            {!imageLoaded && <ProductItemSkeleton />}
            <Container isLoading={!imageLoaded}>
               <Thumbnail
                  onLoad={() => setImageLoaded(true)}
                  src={thumbnail}
               />
               <Name>{name}</Name>
               <Price>{price}원</Price>
            </Container>
         </List>
      );
   }
);

A에서와 달리 이번에는 이전에 렌더링되었던 ProductItem이 재렌더링되지 않기 때문에 전체적인 렌더링 시간도 줄어들었음을 확인할 수 있음.

 

 

 

 

 

[참고]

React onLoad event on image tag is not getting called when using conditional render

How to detect when a image is loaded, that is provided via props, and change state in React?