最近仕事で使ったテクニック

仕事で使ったテクニックを個人のブログに記しておかないと退職時に失ってしまうので書き写しておく。「ブラウザが webp をサポートしているかどうか判定」、「画像が読み込み終わったことを検知する」、「IntersectionObserver を使うための React hook」について。

ブラウザが webp をサポートしているかどうか

画像を表示するなら <picture> を使えばいい話だが、CSS 側でどうしても background-image を使わざるを得なくて、そこで webp と png を出し分けたい場合などにブラウザ側で webp サポートを判定して data 属性にセットしたりして使う。

/**
 * ブラウザが webp をサポートしているかどうか
 *
 * @returns webp をサポートしているなら true そうでないなら false
 */
export const supportsWebp = async () => {
  return new Promise<boolean>((resolve) => {
    const img = new Image();
    img.onload = () => {
      resolve(img.width > 0);
    };
    img.onerror = () => {
      resolve(false);
    };
    img.src =
      'data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA';
  });
};

なおググってみると @supports (background-image: url('logo.webp')) で webp 対応判定できるという情報が見つかるが、実際に試すと判定できないので鵜呑みにしてはいけない。

私は iOS Simulator で webp 対応・非対応の両バージョンの Safari で試し、上記の supportsWebp 関数が意図通りに動くことを確認した。

また img.src を指定した後に img.onload を書くとキャッシュから画像が読み込まれたとき上手くいかないので、img.src は最後に書くという順番が大事である。

画像が読み込み終わったことを検知する

画像がキャッシュされている場合、<img> に指定した onLoad は発火しないので、imgRef.current?.complete も見ることが必要。

import { useCallback, useEffect, useRef, useState } from 'react';

export function Page() {
  const [isLoaded, setIsLoaded] = useState(false);
  const imgRef = useRef<HTMLImageElement>(null);

  const onLoad = useCallback(() => {
    setIsLoaded(true);
  }, []);

  useEffect(() => {
    if (imgRef.current?.complete) {
        onLoad();
    }
  }, [onLoad]);

  return (
    <img
      ref={imgRef}
      src={"/image/dummy.jpg"}
      alt=""
      width={960}
      height={600}
      onLoad={onLoad}
    />
  );
};

IntersectionObserver を使うための React hook

ビューポートに要素が入った瞬間に何か実行したいときに使う。

import { useEffect } from 'react';

type useIntersectionObserverProps = {
  /** ルート要素。nullの場合はビューポート。 */
  rootRef: React.RefObject<HTMLDivElement> | null;
  /** ターゲット要素 */
  targetRef: React.RefObject<HTMLDivElement>;
  /** 交差したときの動作 */
  intersectionCallBack: () => void;
  /**
   * 交差率(0.0〜1.0)
   * @default 0.1
   */
  threshold?: number;
};

export const useIntersectionObserver = ({
  rootRef,
  targetRef,
  intersectionCallBack,
  threshold = 0.1,
}: useIntersectionObserverProps) => {
  useEffect(() => {
    if ((rootRef && !rootRef.current) || !targetRef.current) return;

    const callback = (entries: IntersectionObserverEntry[]) => {
      if (entries[0].isIntersecting) intersectionCallBack();
    };
    const observer = new IntersectionObserver(callback, {
      root: rootRef ? rootRef.current : null,
      rootMargin: '0px',
      threshold,
    });
    const target = targetRef.current;
    observer.observe(target);
    return () => {
      observer.unobserve(target);
    };
  }, [rootRef, intersectionCallBack, targetRef, threshold]);
};