Reactで、アップロード前に画像を縮小してプレビューする方法

​​React​Canvas API,File API,Playwright,TypeScript

選んだ画像は、createImageBitmap() で読み込み、小さな canvas に描いてから toBlob() で書き出せば、ブラウザの中で縮小できます。書き出した Blob を File にしてプレビューに使い、そのまま FormData に入れて送れば、サーバーには縮小後の画像だけが届きます。

ここでは、長いほうの辺を 1280px までに縮める resizeImage と、それを使うフォームの ImageResizeForm を作りました。React の側で気をつけるのは、縮小が終わる前に別の画像を選び直されたときの扱いです。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作してファイルを選びました。検証に使った画像は、カメラで撮った写真ではなく、グラデーションと円とノイズを Pillow で描いて作ったものです。以下では、写真の代わりに作った 4032×3024 の画像を「写真」と呼びます。Firefox と Safari、スマートフォンのブラウザ、HEIC など JPEG・PNG 以外の形式の画像は試していません。

4032×3024 の写真を選ぶと 1280×960 に縮んだプレビューが出て、送信すると「送信しました」と出る。続けて背景が透明な PNG を選ぶと、薄い水色のページの上に、背景が白になったプレビューが出る
画像を選んで送信する操作を2回行ったときの動き(変わった表示をピンクで強調。ファイル選択のボタンは、検証に使ったブラウザの表示のまま英語)

createImageBitmap と canvas で縮小して JPEG にする

resizeImage は、受け取った File を縮小し、JPEG の File にして返す関数です。React には依存していないので、どのコンポーネントからでも呼べます。

ts
export type Resized = {
  file: File;
  width: number;
  height: number;
  sourceWidth: number;
  sourceHeight: number;
};

// 長いほうの辺が maxSize 以下になるように縮小し、JPEG の File にして返す
export async function resizeImage(file: File, maxSize: number, quality = 0.8): Promise<Resized> {
  const bitmap = await createImageBitmap(file);
  const { width: sourceWidth, height: sourceHeight } = bitmap;
  // 小さい画像は拡大しない
  const scale = Math.min(1, maxSize / Math.max(sourceWidth, sourceHeight));
  const width = Math.round(sourceWidth * scale);
  const height = Math.round(sourceHeight * scale);

  const canvas = document.createElement('canvas');
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext('2d');
  if (!ctx) throw new Error('canvas が使えません');
  // JPEG は透明を持てないので、先に白で塗っておく
  ctx.fillStyle = '#fff';
  ctx.fillRect(0, 0, width, height);
  ctx.drawImage(bitmap, 0, 0, width, height);
  bitmap.close();

  const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
  if (!blob) throw new Error('画像を書き出せませんでした');
  const name = file.name.replace(/\.[^.]+$/, '') + '.jpg';
  return {
    file: new File([blob], name, { type: 'image/jpeg' }),
    width,
    height,
    sourceWidth,
    sourceHeight,
  };
}

MDN の createImageBitmap() の説明にあるとおり、createImageBitmap() には Blob を渡せます。File は Blob の一種なので、input で選んだファイルをそのまま渡せます。結果は Promise で返ります。

縮小の倍率は、長いほうの辺を maxSize に合わせて決めます。Math.min(1, …) にしているのは、もともと小さい画像を拡大しないためです。検証で 800×600 の画像を選ぶと、大きさはそのままで JPEG に書き出されました。

text
--- 3. 小さい画像(800×600)は拡大しない ---
元の画像:800×600、82 KB
送信する画像:800×600、36 KB

toBlob() は結果をコールバックで渡すので、Promise で包んでいます。MDN の toBlob() の説明によれば、2つ目の引数で形式を、3つ目の引数で JPEG や WebP の品質を 0〜1 で指定します。画像を作れなかったときは、コールバックに null が渡されます。

toBlob() で受け取るのは Blob で、ファイル名を持っていません。File のコンストラクターで名前と種類を付けておくと、送ったときにサーバーがファイル名を受け取れます。ここでは元の名前の拡張子を .jpg に付け替えています。

選んだ画像を縮小してプレビューするフォーム

ImageResizeForm は、input type="file" で選んだ画像を resizeImage で縮小し、プレビューと大きさを出すフォームです。送信ボタンを押すと、縮小した画像を FormData に入れて /upload に送ります。

tsx
import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react';
import { resizeImage, type Resized } from './resizeImage';

const MAX_SIZE = 1280;

// File から表示用の URL を作り、使わなくなったら解放する
function useObjectUrl(file: File | undefined) {
  const [url, setUrl] = useState<string>();
  useEffect(() => {
    if (!file) return;
    const objectUrl = URL.createObjectURL(file);
    setUrl(objectUrl);
    return () => {
      URL.revokeObjectURL(objectUrl);
      setUrl(undefined);
    };
  }, [file]);
  return url;
}

const kb = (bytes: number) => `${Math.round(bytes / 1024).toLocaleString()} KB`;

export function ImageResizeForm() {
  const [original, setOriginal] = useState<File>();
  const [resized, setResized] = useState<Resized>();
  const [error, setError] = useState('');
  const [message, setMessage] = useState('');
  const previewUrl = useObjectUrl(resized?.file);

  useEffect(() => {
    if (!original) return;
    let ignore = false;
    resizeImage(original, MAX_SIZE)
      .then((result) => {
        if (!ignore) setResized(result);
      })
      .catch(() => {
        if (!ignore) setError('この画像は読み込めませんでした');
      });
    // 別の画像を選び直したら、前の画像の結果は使わない
    return () => {
      ignore = true;
    };
  }, [original]);

  const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
    setOriginal(e.target.files?.[0]);
    setResized(undefined);
    setError('');
    setMessage('');
  };

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (!resized) return;
    const body = new FormData();
    body.append('image', resized.file);
    const res = await fetch('/upload', { method: 'POST', body });
    setMessage(res.ok ? '送信しました' : `送信できませんでした(${res.status})`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="image">画像を選ぶ</label>
      <input id="image" type="file" accept="image/*" onChange={handleChange} />
      {original && !resized && !error && <p>縮小しています…</p>}
      {error && <p role="alert">{error}</p>}
      {original && resized && previewUrl && (
        <figure>
          <img src={previewUrl} alt="送信する画像のプレビュー" width={resized.width} height={resized.height} />
          <figcaption>
            元の画像:{resized.sourceWidth}×{resized.sourceHeight}、{kb(original.size)}
            <br />
            送信する画像:{resized.width}×{resized.height}、{kb(resized.file.size)}
          </figcaption>
        </figure>
      )}
      <button type="submit" disabled={!resized}>
        送信する
      </button>
      <p role="status">{message}</p>
    </form>
  );
}

プレビューには、縮小した後の File から作った URL を使います。送る画像と同じものを見せるためです。URL は useObjectUrl の effect の中で作り、後片付けで解放しています。この書き方を選んだ理由は、以前の記事「Reactで、貼り付けた画像を受け取って表示する方法」に書きました。検証でも、別の画像を選び直したあとに前のプレビューの URL を fetch で読むと、読めなくなっていました。

img には縮小後の幅と高さを渡しています。width 属性に 1280 を渡しているので、CSS で指定しないと表示も 1280px の幅になります。そこで、検証用のページでは CSS で max-width: 100%; height: auto を指定して、フォームの幅に収めています。

検証で 4032×3024、1,665 KB の写真を選ぶと、1280×960、52 KB の JPEG になり、プレビューの img も 1280×960 で読み込まれていました。送信すると、サーバーには縮小後の画像だけが届きました。

text
--- 1. 4032×3024 の写真を選ぶ ---
元の画像:4032×3024、1,665 KB
送信する画像:1280×960、52 KB
プレビューの img: {"naturalWidth":1280,"naturalHeight":960,"src":"blob:http://localhost:5173/<id>"}
--- 2. 送信する ---
サーバーが受け取ったファイル: {"name":"photo.jpg","type":"image/jpeg","size":52773,"requestBytes":52957}

requestBytes は、サーバーが受け取ったリクエスト全体の大きさです。画像の 52,773 バイトより 184 バイト多いだけでした。

縮小中に選び直したら、前の結果を捨てる

縮小は非同期なので、大きな画像を選んで縮小が終わる前に別の画像を選び直すと、2つの縮小が同時に進みます。後から選んだ画像のほうが小さいと、先に終わることがあります。そのまま両方の結果を setResized に渡すと、遅れて終わった前の画像の結果で上書きされます。

ImageResizeForm の effect は、後片付けで ignore を true にしています。original が変わると前の effect の後片付けが動くので、前の画像の結果は setResized に渡されません。これは、useEffect のリファレンスにある、effect でデータを取得する例と同じ書き方です。

検証では、8000×6000 の画像を選んだ直後に 800×600 の画像に選び直し、3秒待ってから表示を確かめました。ignore を立てない版では、選んでいるのは small.jpg なのに、表示されている結果は huge.jpg のものでした。

text
--- 9. 大きな画像を選んですぐ小さな画像に選び直す ---
ImageResizeForm: {"選んでいる画像":"small.jpg","表示している結果":"元の画像:800×600、82 KB\n送信する画像:800×600、36 KB"}
ignore を立てない版: {"選んでいる画像":"small.jpg","表示している結果":"huge.jpg"}

ImageResizeForm は resized の画像を送ります。ignore がなければ resized が前の画像の結果で上書きされるので、送信すると、選び直す前の画像が送られることになります。プレビューの結果をそのまま送るフォームでは、前の結果を捨てる処理が欠かせません。

JPEG の品質を変えると、大きさはどれだけ変わるか

resizeImage の quality を変えて、同じ 4032×3024 の画像を 1280×960 の JPEG にしたときの大きさを比べました。

1280×960 の JPEG の大きさ。quality 0.5 は 27 KB、0.6 は 30 KB、0.7 は 36 KB、0.8 は 52 KB、0.9 は 106 KB、1 は 643 KB。元の写真は 1,665 KB
quality ごとの JPEG の大きさ(検証用の画像での値)
text
--- 6. JPEG の品質ごとの大きさ(4032×3024 の写真を 1280×960 にしたとき)---
quality 0.5: 27631 バイト(27 KB)
quality 0.6: 30837 バイト(30 KB)
quality 0.7: 36561 バイト(36 KB)
quality 0.8: 52773 バイト(52 KB)
quality 0.9: 108453 バイト(106 KB)
quality 1: 658346 バイト(643 KB)
元の写真: 1704461 バイト

0.5 から 0.8 までは 27〜52 KB でしたが、0.9 では 0.8 の約2倍、1 では 0.9 の約6倍に増えました。ただし、この画像はノイズを混ぜて描いた検証用のもので、カメラで撮った写真では値が変わります。どこまで下げても見た目に差が出ないかは、実際に扱う画像で確かめる必要があります。

透明な PNG は、白で塗ってから描く

JPEG には透明な部分を持たせられません。HTML の仕様の、canvas の画像をファイルに書き出す節では、透明を持てない形式に書き出すときは、不透明な黒の背景に重ねると決められています。

resizeImage は、画像を描く前に canvas 全体を白で塗っています。検証で、背景が透明な PNG を JPEG にして、左上の隅と真ん中の色を読みました。白で塗らない版では、透明だった左上が黒になりました。

text
--- 7. 背景が透明な PNG を JPEG にする ---
白で塗ってから描いた版(resizeImage): {"size":"600×400","corner":"255,255,255","center":"0,200,233"}
塗らずに描いた版: {"size":"600×400","corner":"0,0,0","center":"0,200,233"}

透明な部分を残したいときは、toBlob() の形式を image/png にします。その場合は白で塗る処理を外し、ファイル名の拡張子も合わせて変えます。この記事では、PNG で書き出す版は試していません。

EXIF で向きを指定した JPEG は、向きを直した大きさで読み込まれる

カメラで撮った写真には、撮ったときの向きを EXIF に書いたものがあります。MDN の createImageBitmap() の説明によれば、createImageBitmap() の imageOrientation の既定値は from-image で、EXIF の向きの情報があればそれに従います。

検証では、画素が 400×300(左が水色、右がピンク)で、Orientation に 6 を書き込んだ JPEG を作りました。検証した Chromium では、createImageBitmap() で読み込むと 300×400 になり、縮小した結果も、上の端が水色、下の端がピンクの縦長の画像でした。左にあった水色が上に来ているので、時計回りに90度回して読み込まれています。

text
--- 8. EXIF の Orientation=6 が付いた JPEG(画素は 400×300、左が水色・右がピンク)---
createImageBitmap(file) の大きさ: "300×400"
imageOrientation: "none" を付けたとき: "300×400"
resizeImage の結果(上の端と下の端の色): {"size":"300×400","top":"0,200,233","bottom":"216,89,119"}

resizeImage は、向きを直した状態の画像を canvas に描くので、書き出した JPEG も縦長になります。

imageOrientation: 'none' を付けても、検証した Chromium では 300×400 のままでした。MDN の createImageBitmap() の説明には、none は向きの情報を無視すると書かれています。一方、HTML の仕様の imageOrientation の説明では、以前の none は from-image に名前が変わり、none は今後、別の意味で加え直すと書かれています。向きの情報を無視したいときに none を当てにするのは避けたほうが安全です。

画像として読めないファイルが選ばれたとき

accept="image/*" を付けると、ファイルを選ぶ画面で画像が選びやすくなります。ただし、MDN の accept 属性の説明にあるとおり、この属性は、どの種類のファイルを選ばせたいかをブラウザに伝えるだけです。選ばれたファイルの種類を確かめるものではありません。

検証で、拡張子は .jpg でも中身は文字のファイルを選ぶと、createImageBitmap() が InvalidStateError で失敗しました。ImageResizeForm は catch でメッセージを出し、送信ボタンは押せないままでした。

text
--- 5. 画像ではないファイル(中身が文字の not-image.jpg)---
表示: "この画像は読み込めませんでした"
送信ボタン: "押せない"
createImageBitmap のエラー: "InvalidStateError: The source image could not be decoded."
ポイント

ブラウザでの縮小は、送る量を減らすための工夫で、サーバーに届く画像を保証するものではありません。MDN の accept 属性の説明にもあるとおり、受け取ったファイルの種類はサーバーの側でも確かめます。

​Posted by 86