[React] 이미지가 완전히 load 되었는지 확인하는 방법

박기영·2023년 5월 18일
2

React

목록 보기
29/32

img 태그는 src에 있는 url에서 보여주고자 하는 이미지를 가져온다.
필자는 이미지와 이미지의 설명을 동시에 보여주고 싶었다.
그런데, img 태그는 존재하는데 src가 아직 불려와지지 않아서
이미지에 대한 설명과 이미지가 따로 load되는 경우가 있다.
UX를 해치는 부분이라고 생각이 들어 이미지 load에 맞춰 설명을 같이 보여주려고 했다.
어떻게 하면 src까지 완전히 load된 것을 파악할 수 있을까?

문제 상황

문제 상황을 간단하게 영상으로 살펴보자.

참고 동영상

이미지가 나오기 전에 텍스트가 먼저 나와버린다.
이미지와 텍스트 데이터는 한번의 API 통신으로 다 받아왔기 때문에
텍스트는 바로 렌더링 되고, 이미지는 src의 url에 들어가 접근하기 때문에 조금 더 시간이 걸리는 모양이다.

코드

// CocktailContainer.tsx

// ... //

function CocktailContainer(props: ImgContainerInterface) {
  // ... //
  
  return (
    <div>
      {props.upward && isImgLoaded && <h2>{props.name}</h2>}
      
      <div className={styles.image_div}>
        <img
          alt={props.alt}
          src={props.src}
          className={styles.image}
          ref={imgRef}
        />
      </div>
      
      // ... //
    </div>
  );
}

export default CocktailContainer;

부모 컴포넌트에서 API 통신을 한 결과를 props로 받아오고, 이를 보여주는 방식이다.
h2 컴포넌트는 바로 렌더링 되는 반면, img 태그는 불러오는데 시간이 걸린다.
큰 문제는 없어 보이지만...거슬린다!

img의 complete

img가 완전히 불려와졌는지를 판단하는 아주 좋은 방법이 있다.
바로 complete라는 속성을 활용하는 것이다.

The read-only HTMLImageElement interface's complete attribute is a Boolean value which indicates whether or not the image has completely loaded.
- MDN docs -

MDN 공식문서에서는 complete 속성은 boolean 타입의 값으로 읽기만 가능하다고 한다.
또한, 이미지가 완전히 불려와졌는지 아닌지를 알려주는 속성이라고 한다.

사용법은 대략 아래와 같다.

const img = document.querySeletor("img");

if(img.complete){
  console.log("이미지 로딩 완료");
} else {
  console.log("이미지 로딩 중");
}

img의 naturalHeight

좀 더 확실하게 하고 싶다면, imgnaturalHeight를 사용하는 것도 방법이다.

If the intrinsic height is not available—either because the image does not specify an intrinsic height or because the image data is not available in order to obtain this information, naturalHeight returns 0.
- MDN docs -

만약, imgnaturalHeight 속성 값을 얻을 수 없다면 0을 반환한다.
이를 활용해서 img가 불려왔는지, 렌더링 되고 있는지 판단할 수 있을 것이다.

이제 React 내에서 어떻게 활용할지 살펴보자.

React에서의 활용

방법

  1. img 태그의 정보를 가져올 필요가 있다. useRef를 사용하면 될 것 같다.
  2. img 태그에 이벤트 리스너를 달아준다. load 이벤트를 바라보면 될 것 같다.
  3. 콜백 함수에 completenaturalHeight를 사용해 이미지 load를 판단한다.

코드

// CocktailContainer.tsx

// ... //

function CocktailContainer(props: ImgContainerInterface) {
  // img 태그 정보를 가져오기 위한 useRef
  const imgRef = useRef<HTMLImageElement>(null);

  // 완전히 load 된 상태인지를 담고 있는 useState
  const [isImgLoaded, setIsImgLoaded] = useState(false);

  // ref를 바라보며, 변경이 생길 때마다 load status를 업데이트 하기위한 useEffect
  useEffect(() => {
    if (!imgRef.current) {
      return;
    }

    // complete와 naturalHeight를 이용해 완전한 load를 판단하는 함수
    const updateStatus = (img: HTMLImageElement) => {
      const isLoaded = img.complete && img.naturalHeight !== 0;

      setIsImgLoaded(isLoaded);
    };

    // load 이벤트를 바라본다.
    // 익명 함수를 사용했기 때문에 once 속성을 사용해서 한번 실행 후 제거한다.
    imgRef.current.addEventListener(
      "load",
      () => updateStatus(imgRef.current as HTMLImageElement),
      { once: true }
    );
  }, [imgRef]);

  return (
    <div>
      {props.upward && isImgLoaded && <h2>{props.name}</h2>}
      
      <div className={styles.image_div}>
        <img
          alt={props.alt}
          src={props.src}
          className={styles.image}
          ref={imgRef}
        />
      </div>
      
      // ... //
    </div>
  );
}

export default Information;

결과를 살펴보자.

참고 동영상

이제 이미지가 완전히 load 된 후에 텍스트가 보인다!

custom hook으로 분리

이 기능은 다른 img 태그에도 적용할 것 같다.
그래서 로직을 분리해서 따로 관리하고 싶었다.

// CocktailContainer.tsx

// ... //

function CocktailContainer(props: ImgContainerInterface) {
  const imgRef = useRef<HTMLImageElement>(null);

  // custom hook에 ref 전달
  // 완전히 load 되기 전에는 false, 된 후에는 true를 반환
  const isImgLoaded = useImgLoadStatus(imgRef);

  return (
    <div>
      {props.upward && isImgLoaded && <h2>{props.name}</h2>}
      
      <div className={styles.image_div}>
        <img
          alt={props.alt}
          src={props.src}
          className={styles.image}
          ref={imgRef}
        />
      </div>
      
      // ... //
    </div>
  );
}

export default CocktailContainer;
// useImgLoadStatus.ts

import { useState, useEffect, RefObject } from "react";

/**
 * 이미지의 load 상태를 파악하는 커스텀 hook
 * @param ref load 상태를 파악하고자 하는 대상
 * @returns 이미지 load 상태
 */
export function useImgLoadStatus(ref: RefObject<HTMLElement>) {
  const [isImgLoaded, setIsImgLoaded] = useState(false);

  useEffect(() => {
    if (!ref.current) {
      return;
    }

    const updateStatus = (img: HTMLImageElement) => {
      const isLoaded = img.complete && img.naturalHeight !== 0;

      setIsImgLoaded(isLoaded);
    };

    ref.current.addEventListener(
      "load",
      () => updateStatus(ref.current as HTMLImageElement),
      { once: true }
    );
  }, [ref]);

  return isImgLoaded;
}

이제 필요한 이 기능을 적용하고 싶은 img 태그에 이 hook을 사용하면 된다.
isImgLoaded가 load에 따라 업데이트 될 것이고, 이 값을 받아 렌더링에 사용한다.

문제점

useEffect 내에서 인자로 받아온 refload 이벤트를 감지하고,
img의 완전한 load 여부를 state로 관리하는 것이 목표이다.

그러나...!
잘 된다.

???????

정확히는 처음 렌더링 될 때는 잘 된다.
그런데, 이미지를 변경하는 과정에서 문제가 생겼다.
imgsrc가 변경되면, refdependency로 가지고 있는 useEffect가 실행될거라고 생각했는데, 그게 아니었다.
useEffect는 첫 렌더링에서만 실행되었고, img 태그에 변화가 있더라도 다시 실행되는 일은 없었다.
이는 dependencyref.current로 변경해도 마찬가지였다.

ref는 변경을 알려주지 않는다.

We didn’t choose useRef in this example because an object ref doesn’t notify us about changes to the current ref value.
- React 공식 문서 -

DOM node에 접근하여 값을 얻는 예제를 설명하는 공식 문서에서 발췌한 내용이다.
대략..useRef를 통해 얻은 refref.current의 변화를 우리에게 알려주지 않는다고 한다.

!!!!

변화를 알려주지 않았기 때문에 useEffectdependency로 넣어놔도 감지를 못하는 거였다!

개선 방법

일단 useRefuseEffectdependency로 활용할 수 없다는 사실을 알게 되었다.
문제는, 필자가 이미 custom hook을 저 hooks를 사용했다는 것이다...
어떻게 해야 img 태그의 변경을 감지하도록 만들 수 있을까?

방법은 생각보다 간단했다.
1. srcdependency로 사용하는 것
2. useEffect가 실행될 때 isImgLoadedfalse로 초기화하는 것

// CocktailContainer.tsx

const isImgLoaded = useImgLoadStatus(imgRef, props.src);
// useImgLoadStatus.ts

import { useState, useEffect, RefObject } from "react";

/**
 * 이미지의 load 상태를 파악하는 커스텀 hook
 * @param ref load 상태를 파악하고자 하는 대상
 * @returns 이미지 load 상태
 */
export function useImgLoadStatus(
  ref: RefObject<HTMLImageElement>,
  src: string
): boolean {
  const [isImgLoaded, setIsImgLoaded] = useState(false);

  // ref가 아닌 src를 dependency로 가진다.
  useEffect(() => {
    if (!ref.current) {
      return;
    }

    // 한번 load된 상태이기 때문에 isImgLoaded가 true로 되어 있는데,
    // 그 것을 초기화하여, 새로운 이미지로 변경되고 있음을 보여준다.
    if (isImgLoaded) {
      setIsImgLoaded(false);
    }

    const updateStatus = (img: HTMLImageElement) => {
      const isLoaded = img.complete && img.naturalHeight !== 0;

      setIsImgLoaded(isLoaded);
    };

    ref.current.addEventListener(
      "load",
      () => updateStatus(ref.current as HTMLImageElement),
      { once: true }
    );
  }, [src]);

  return isImgLoaded;
}

이제 imgsrc가 변경될 때마다 useEffect가 실행되며,
그 때마다 로딩 상태를 초기화해서 false에서 true로 변경되게 한다.

왜 false로 초기화를 하는가?

React 특성 상, 새로고침이나 페이지 이동이 없으면 useState가 초기화되지 않기 때문이다.
필자는 하나의 페이지 내에서 특정 값이 변경될 때 실행되는 custom hook을 사용 중일 뿐이다.
일반적인 함수였다면 호출될 때마다 내부의 모든 값이 초기화되겠지만,
custom hook으로 만들어서 사용 중이므로 이런 차이가 발생하는 것으로 생각된다.

결과

결과는 다음과 같다. UI만 다소 변경되었다.

참고 동영상

이제 이미지의 변경이 매번 감지되고 있고,
이름을 보여주는 컴포넌트는 이미지 로딩 상태에 따라 보여졌다가, 감춰졌다가 한다.

만약 여기서 더 깔끔한 UI를 만들고자한다면,
src가 변경될 때, 아예 이미지를 지웠다가 보여주는 방법을 사용해볼 수도 있겠다.

참고 자료

techiedelight 게시글
Alejandro Martinez님 게시글
MDN docs - img 태그 complete
MDN docs - img 태그 naturalHeight

profile
나를 믿는 사람들을, 실망시키지 않도록

0개의 댓글