🖇️

【React.js】プリフェッチした画像を表示する

2022/11/15に公開

動機

React.jsで実装しているノベルゲームにおいて、アクセスするユーザーのネット環境によって画像が表示されないといった問題を防ぐため開始前にシナリオ内で使用する全ての画像を読み込む必要がありました。シナリオで使用する画像をプリフェッチするコンポーネントを定義します。

実装

まず、プリフェッチした画像のURLを保持する為のエンティティを定義します。

interface Image {
  key: string;
  src: string;
}

keyは後述するContextに保管されたURLを取り出す為の画像に紐づいたキーになり、srcは実際に配置されている画像のURLです。

プリフェッチした画像にアクセスする為にblobURLを作成しますがそのストアとしてContextを利用します。

const ImageContext = createContext<{ [key: string]: string }>({});

連想配列として定義をし、keyは先ほど定義したImagekeyを紐付けます。値は画像をプリフェッチした際に生成したBlobURLがセットされる予定です。

プリフェッチ用のコンポーネントを実装する

このコンポーネントの大きな役割は画像の取得と取得した画像のBlobURLをContextに渡す事です。
props経由で渡されたImageの配列をみて画像を取得、URL.createObjectURLにてblobURLを生成します。最後にContext Providerを使用し下層のコンポーネントへblobURLを渡します。

const images: Image[];
const [blobURLMap, setBlobURLMap] = useState({});

useEffect(() => {
    let urls: string[] = [];
    setBlobURLMap({});
    Promise.all(
      images.map((image) =>
        fetch(image.src)
          .then((res) => res.blob())
          .then((blob) => [image.key, URL.createObjectURL(blob)])
      )
    ).then((urlSets) => {
      const urlMap = Object.fromEntries(urlSets);
      urls = Object.values(stack);
      setBlobURLMap(urlMap);
    });

    return () => {
      //blob urlの解放
      urls.forEach((url) => {
        URL.revokeObjectURL(url);
      });
    };
  }, [images]);
  
<ImageContext.Provider value={blobURLMap}>
    {children}
</ImageContext.Provider>

全体の実装は以下の通り。上記の実装にAbortControllerとプリフェッチ中はfallbackを表示するような仕組みを入れています。

interface Props {
  images: Image[];
  fallback?: ReactNode;
}

const Prefetch: FC<PropsWithChildren<Props>> = ({
  children,
  fallback,
  images,
}) => {
  const [isLoaded, setIsLoaded] = useState(false);
  const [blobURLMap, setBlobURLMap] = useState({});
  useEffect(() => {
    let urlLists: string[] = [];
    setBlobURLMap({});
    setIsLoaded(false);
    const controller = new AbortController();
    const signal = controller.signal;

    Promise.all(
      images.map((image) =>
        fetch(image.src, { signal })
          .then((res) => res.blob())
          .then((blob) => [image.key, URL.createObjectURL(blob)])
      )
    )
      .then((lists) => {
        const stack = Object.fromEntries(lists);
        urlLists = Object.values(stack);
        setBlobURLMap(Object.fromEntries(lists));
        setIsLoaded(true);
      })
      .catch((error) => {
        if (error.name === "AbortError") {
          ///Aborted
        } else {
          throw error;
        }
      });

    return () => {
      if (urlLists.length > 0) {
        urlLists.forEach((url) => {
          URL.revokeObjectURL(url);
        });
      } else {
        controller.abort();
      }
    };
  }, [images]);

  if (isLoaded) {
    return (
      <ImageContext.Provider value={blobURLMap}>
        {children}
      </ImageContext.Provider>
    );
  } else {
    return <>{fallback}</>;
  }
};

URLを使用する

useContextを使用して取得したblobURLを参照します。この時、利用するコンポーネントのPropsにはImageのkeyだけでなくImage全体を渡す事で、Prefetch用のコンポーネントをラップしていない状態でもコンテンツを表示することができます。(Storybookで個別に表示をする場合など)

const urlMap = useContext(ImageContext);
<img src={urlMap[props.image.key] ?? props.image.src} />

まとめ

今回はblobURLを生成しコンポーネントに渡すような実装をしましたが、基本的にはキャッシュされるので取得した後にblobURLではなく、同じURLを使用してコンポーネントを表示するだけでも改善されそうです。

Discussion