Reactで、アップロード前に画像を縮小してプレビューする方法
選んだ画像は、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 以外の形式の画像は試していません。

createImageBitmap と canvas で縮小して JPEG にする
resizeImage は、受け取った File を縮小し、JPEG の File にして返す関数です。React には依存していないので、どのコンポーネントからでも呼べます。
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 に書き出されました。
--- 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 に送ります。
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 で読み込まれていました。送信すると、サーバーには縮小後の画像だけが届きました。
--- 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 のものでした。
--- 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 にしたときの大きさを比べました。

--- 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 にして、左上の隅と真ん中の色を読みました。白で塗らない版では、透明だった左上が黒になりました。
--- 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度回して読み込まれています。
--- 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 でメッセージを出し、送信ボタンは押せないままでした。
--- 5. 画像ではないファイル(中身が文字の not-image.jpg)---
表示: "この画像は読み込めませんでした"
送信ボタン: "押せない"
createImageBitmap のエラー: "InvalidStateError: The source image could not be decoded."
ブラウザでの縮小は、送る量を減らすための工夫で、サーバーに届く画像を保証するものではありません。MDN の accept 属性の説明にもあるとおり、受け取ったファイルの種類はサーバーの側でも確かめます。





