Oteto Blogのロゴ

【React】アップロードした画像のプレビュー・リサイズ機能を実装する

やりたいこと

  • input[type="file"]で選択した画像ファイルを表示したい
    • 単一の画像ファイルを想定
  • プレビューする前に画像をリサイズしたい
    • submit・POST云々の処理は実装せず、あくまでプレビューまで

1. 画像のバリデーション

const SIZE_LIMIT = 1024 * 1024 * 2;
const MIME_TYPES = ['image/png', 'image/jpeg'];

type Result =
  | {
      success: true;
    }
  | {
      success: false;
      message: string;
    };

export function validateImg({ type, size }: Blob): Result {
  if (!MIME_TYPES.includes(type)) {
    return {
      success: false,
      message: 'PNG・JPEG以外の画像はアップロードできません',
    };
  }
  if (size >= SIZE_LIMIT) {
    return {
      success: false,
      message: '2MB以上のファイルはアップロードできません',
    };
  }

  return { success: true };
}

画像のサイズ・MIMEタイプを検証する関数を実装しておく。

2. カスタムフックの実装

画像のアップロード・プレビューのためのカスタムフックを実装する。

export default function useImgUploader() {
  const [img, setImg] = useState<Blob | null>(null);

  const previewImg = async (
    { target }: ProgressEvent<FileReader>,
    type: string,
  ) => {
    const result = target?.result;
    if (!result || typeof result === 'string') return;
    const selectedImg = new Blob([result], { type });

    setImg(selectedImg);
  };

  const readImg = useCallback(
    ({ target: { files } }: ChangeEvent<HTMLInputElement>) => {
      if (!files) return;
      const file = files[0];

      // validation
      const validationResult = validateImg(file);
      if (!validationResult.success) {
        alert(validationResult.message);
        return;
      }

      // read
      const reader = new FileReader();
      reader.onloadend = (e) => previewImg(e, file.type);
      reader.readAsArrayBuffer(file);
    },
    [],
  );

  return { img, readImg };
}
  • プレビュー用の画像をBlob形式でstateとして保持
    • 後述のリサイズ処理、ひいてはsubmitすることを考慮
  • FileReaderクラスを利用し、読み込みが完了した際にstateを更新

3. DOMの実装

export default function ImgUploader(): ReactElement {
  const { img, readImg } = useImgUploader();

  return (
    <>
      <input type="file" accept=".png,.jpeg,.jpg" onChange={readImg} />
      {img && <img src={URL.createObjectURL(img)} alt="プレビュー" />}
    </>
  );
}

あとは先ほどのカスタムフックを利用し、画像選択・プレビュー用のDOMを実装するだけ。

BlobからオブジェクトURLを生成すれば、選択した画像ファイルがプレビューとして表示される。

4. 画像のリサイズ機能を追加

サイズや通信量を考慮し、submitする前、ひいてはプレビュー前に画像リサイズしたい場合。

クライアント側で実装することになるのでjimpの利用が候補に挙がるが、こちらの記事のCanvasを使った方法でお手軽にできそうだったので参考にさせていただいた。

export async function resizeImg(
  imgBlob: Blob,
  afterWidth: number,
): Promise<Blob | null> {
  try {
    const context = document.createElement('canvas').getContext('2d');
    if (context == null) return null;

    const imgElm: HTMLImageElement = await new Promise((resolve, reject) => {
      const img = new Image();
      img.addEventListener('load', () => resolve(img));
      img.addEventListener('error', reject);
      img.src = URL.createObjectURL(imgBlob);
    });
    const { naturalHeight: beforeHeight, naturalWidth: beforeWidth } = imgElm;

    const afterHeight = Math.floor(beforeHeight * (afterWidth / beforeWidth));
    context.canvas.width = afterWidth;
    context.canvas.height = afterHeight;

    context.drawImage(imgElm, 0, 0, afterWidth, afterHeight);

    return await new Promise((resolve) => {
      context.canvas.toBlob(resolve, imgBlob.type, 0.9);
    });
  } catch (e) {
    return null;
  }
}

Blobとして画像を受け取り、任意の幅にリサイズしてからBlobとして返す。

const previewImg = async (
  { target }: ProgressEvent<FileReader>,
  type: string,
) => {
  const result = target?.result;
  if (!result || typeof result === 'string') return;
  const selectedImg = new Blob([result], { type });

  // リサイズ
  const resizedImage = await resizeImg(selectedImg, 200);
  if (resizedImage === null) {
    alert('画像の更新に失敗しました。');
    return;
  }

  setImg(selectedImg);
  setImg(resizedImage);
};

あとはその処理をカスタムフックのpreviewImg()内に追加すれば完了。これでリサイズしたものがプレビューとして表示されるようになった。

もしこのリサイズした画像を送信したい場合は、このBlobformDataにsetしたりfetch()のbodyに渡すことで実現できる。