12k
All articles

アップロード前に画像サムネイルを作成する方法

アップロード前に画像サムネイルを作成。FileReaderのプレビュー、Canvas toBlobのリサイズ、FormData送信、multerのフィールド一致まで解説。

OpenReplay Team
OpenReplay Team
アップロード前に画像サムネイルを作成する方法

プレビューはユーザーが選択したファイルを小さいサイズで表示するだけで、アップロードされるのはまったく同じバイト列です。一方、サムネイルは実際に再エンコードされた小さい画像であり、オリジナルと一緒に、あるいはオリジナルの代わりにアップロードできます。

サムネイル機能を実装したつもりが、12 MB のスマホ写真がそのままサーバーへゆっくり上がっていくのを目にした経験があるなら、この違いが重要であることはすでにご存じでしょう。多くのチュートリアルはこの区別を曖昧にし、縮小表示された <img> を「サムネイル」と呼びますが、これは誤りです。CSS で画面上の画像を小さくしても、通信量は一切変わりません。本記事では両者を明確に区別したうえで、最もシンプルな FileReader によるプレビューから、アップロード可能な本物の Canvas リサイズ済みサムネイルまでを解説し、さらに本番環境でアップロード UI を壊しがちなクリーンアップとバリデーションの落とし穴も取り上げます。

要点

  • FileReader.readAsDataURL によるプレビューは表示専用です。アップロードされるのはオリジナルのバイト列そのままで、通信量は一切削減されません。
  • ファイルサイズを小さくするには、画像を <canvas> に再描画し、canvas.toBlob(callback, 'image/jpeg', 0.7) で再エンコードする必要があります。
  • canvas.toBlob() は非同期であり、Blob を返り値ではなくコールバックに渡します。そのため結果を await したい場合は Promise でラップしてください。
  • 両方のファイルを 1 つの FormData でアップロードし(オリジナルの File と、明示的なファイル名を付けたサムネイルの Blob)、サーバー側では multer の upload.fields() で受け取ります。
  • すべての URL.createObjectURL()URL.revokeObjectURL() とペアにしなければなりません。そうしないと、対象ファイルはドキュメントがアンロードされるまでメモリに保持され続けます。

プレビュー vs. 本物のサムネイル: どちらが必要か?

2 つのアプローチはロジックをほとんど共有しないため、コードを書き始める前に決めておきましょう。プレビューは、正しいファイルが選択されたことを視覚的に確認するためのものです。サムネイルは新しい小さな画像アセット(バイト数もサイズも小さい)であり、アップロード時間とサーバー側の処理を削減し、オリジナルを再処理することなくグリッド表示用画像として保存できます。

目的手法新しい小さいファイルを生成するか?使いどころ
選択した画像を即座に表示FileReader または URL.createObjectURL(file)いいえ視覚的な確認だけが必要な場合
アップロードするバイト数の削減 / 小さいバリアントの保存Canvas + toBlob()はいサーバーや CDN にアップロードする場合
ドラッグ&ドロップ、進捗表示、複数サイズ、バリデーションライブラリ (FilePond)はい一通りの機能が揃ったものが欲しい場合

FileReaderreadAsDataURL によるプレビューは、通信量を一切減らしません。アップロードされるバイト数を実際に削減するには、画像を <canvas> に再描画し、canvas.toBlob() で再エンコードする必要があります。

FileReader で画像をプレビューするには?

表示専用のプレビューであれば、file input の change イベントを購読し、FileReader.readAsDataURL でファイルを読み込み、reader.onload の中で得られた data URL を <img> に割り当てます。これが最も手早く実装できる方法ですが、ファイルのリサイズは行われません。

<input type="file" id="fileInput" accept="image/*" multiple>
<div id="previews"></div>
const input = document.getElementById('fileInput');
const previews = document.getElementById('previews');

input.addEventListener('change', (e) => {
  previews.innerHTML = '';
  Array.from(e.target.files).forEach((file) => {
    if (!file.type.startsWith('image/')) return;
    const reader = new FileReader();
    reader.onload = (ev) => {
      const img = new Image();
      img.src = ev.target.result;            // base64 data URL
      img.alt = `Preview of ${file.name}`;
      previews.appendChild(img);
    };
    reader.readAsDataURL(file);
  });
});

Array.from(files).forEach(...) というパターンに注目してください。よくある jQuery の複数ファイル向けチュートリアルでは、for ループの中で 1 つの reader 変数を使い回すため、すべての onload が最後のファイルをクロージャで捕捉してしまいます。結果として同じ画像が繰り返し表示されるクロージャのバグが発生します。各ファイルには反復処理の中でそれぞれ専用の FileReader が必要であり、forEach を使えばそれが自然に実現できます。

大きなファイルに対するより軽量な代替手段が URL.createObjectURL(file) です。base64 文字列ではなく短い blob URL を返すため、ファイル全体を data URL にエンコードする際のメモリ肥大を回避できます。ただし、後で必ず revokeObjectURL() を呼ぶ必要があります。

Canvas でリサイズ済みサムネイルを生成するには?

実際に小さなファイルを生成するには、画像を読み込み、アスペクト比を保ちながら寸法をスケーリングし、canvas に描画して再エンコードします。アスペクト比を保つには、幅と高さを個別に設定するのではなく、両方の寸法を同じ係数(maxSize / longestSide)でスケーリングします。

canvas.toBlob(callback, 'image/jpeg', 0.7) は非同期です。Blob を返り値ではなくコールバックに渡すため、サムネイルを await したい場合は Promise でラップしてください。通常の canvas には Promise を返す形式は存在せず、OffscreenCanvas.convertToBlob() のみがネイティブに Promise を返します。

function canvasToBlob(canvas, type, quality) {
  return new Promise((resolve, reject) => {
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
      type,
      quality
    );
  });
}

async function makeThumbnail(file, maxSize = 200) {
  const url = URL.createObjectURL(file);
  try {
    const img = await new Promise((res, rej) => {
      const i = new Image();
      i.onload = () => res(i);
      i.onerror = rej;
      i.src = url;
    });

    const scale = Math.min(1, maxSize / Math.max(img.width, img.height));
    const w = Math.round(img.width * scale);
    const h = Math.round(img.height * scale);

    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    canvas.getContext('2d').drawImage(img, 0, 0, w, h);

    return await canvasToBlob(canvas, 'image/jpeg', 0.7);
  } finally {
    URL.revokeObjectURL(url);
  }
}

toBlob の第 3 引数は、0 から 1 のスケールでエンコード品質を設定します。これを解釈するのは非可逆形式のみであるため、image/jpegimage/webp では出力が変化しますが、image/png では何も起こりません。実用上のスイートスポットは 0.6〜0.8 です。数メガピクセルの写真を 200px の JPEG に再エンコードすると、通常はサイズが 1〜2 桁削減されます。これこそがクライアント側でこの処理を行う本来の目的です。

サムネイルの表示と両ファイルのアップロード

生成した Blob は URL.createObjectURL(blob) で表示し、オリジナルと一緒にサーバーへ送信します。アップロードでは、両方のファイルを 1 つの FormData に append します。すなわち、オリジナルの File と、第 3 引数にファイル名を渡したサムネイルの Blob です。これを fetch で POST すると、サーバー側では multer の upload.fields() が 2 つのフィールドを個別に受け取ります。

async function upload(file) {
  const thumb = await makeThumbnail(file);

  const preview = new Image();
  preview.src = URL.createObjectURL(thumb);   // remember to revoke later
  document.body.appendChild(preview);

  const form = new FormData();
  form.append('originalFiles', file, file.name);
  form.append('thumbnails', thumb, `thumb-${file.name}.jpg`);

  await fetch('/api/upload', { method: 'POST', body: form });
}

バックエンドでは multer が multipart ボディをパースします。upload.fields() のフィールド名は FormData.append のキーと完全に一致しなければならず、一致しない場合そのフィールドは何のエラーもなく破棄されます。

const upload = multer({ dest: 'uploads/' });
app.post('/api/upload', upload.fields([
  { name: 'originalFiles', maxCount: 10 },
  { name: 'thumbnails', maxCount: 10 },
]), (req, res) => res.json({ ok: true }));

multer の 2.x 系には 1.x 系に欠けているセキュリティ修正が含まれており、その package.json が定める下限は Node 18 ではなく Node.js 10.16.0 です。Node 18 を最低要件とするのは 3.x 系ですが、こちらはまだアルファ版です。multer の changelog における最新の安定版リリースは 2.2.0 なので、バージョンを固定する前に確認してください。

ライブラリという選択肢: FilePond とその仲間たち

ドラッグ&ドロップ、アップロード進捗、バリデーション、複数のリサイズバリアントまで必要な場合は、すべてを自前で実装するのではなく、画像プラグインを備えた FilePond を使いましょう。filepond-plugin-image-preview プラグインがプレビューを描画し、filepond-plugin-image-resize がリサイズのメタデータを書き込み、filepond-plugin-image-transform が実際のリサイズを実行して出力 Blob を渡してくれます。

FilePond.registerPlugin(
  FilePondPluginImagePreview,
  FilePondPluginImageResize,
  FilePondPluginImageTransform
);
FilePond.create(document.querySelector('input[type="file"]'), {
  imageResizeTargetWidth: 256,
  imageResizeMode: 'contain',
});

FilePond のインストールドキュメントが推奨するとおり、FilePond はバージョン 4 系(@^4)に固定してください。安定版は 4.x 系(changelog 上では 4.32.12)であり、v5 はまだベータです。unpkg.com/filepond をバージョン指定なしで読み込むと latest タグを追跡するため、現時点では現行の安定版が配信されますが、次のメジャーが安定版に昇格した日にそちらへ切り替わってしまいます。

特に高品質な縮小処理が必要な場合は、Pica(10.0.2)が適切なリサンプリングフィルタを適用し、Web Worker 上で実行することもできます。アルゴリズムは filter オプションで選択します(例: { filter: 'lanczos3' })。browser-image-compression パッケージ(2.0.2)も選択肢の 1 つですが、最後のリリースが 2023 年 3 月であり、Snyk はそのメンテナンス状況を非アクティブと評価しているため、採用前にその点を考慮してください。

落とし穴とベストプラクティス

以下は、アップロード UI のセッションリプレイで頻繁に浮かび上がる失敗パターンです(気づかないうちに増えるメモリ、回転してしまうサムネイル、巨大ファイルでフリーズするタブなど)。

  • オブジェクト URL を revoke する。 プレビューを削除する際は必ず URL.revokeObjectURL() を呼び出してください。createObjectURL() は、URL が明示的に revoke されるかドキュメントがアンロードされるまで、対象ファイルをメモリに保持し続けます。無制限に増えるオブジェクト URL は、タブのメモリが徐々に増加する典型的な原因です。
  • FileList は読み取り専用。 FileList は読み取り専用のため、<input> から特定のファイルを取り除くことはできません。編集可能な配列を自前で管理し、そこからアップロード対象を再構築してください。
  • 処理前にバリデーションする。 accept="image/*" と、実行時の file.type.startsWith('image/') チェック、およびサイズ上限を組み合わせましょう。accept は UX 上のヒントであり、強制力はありません。
  • タブを守るために寸法に上限を設ける。 非常に大きな画像はデコード時にメモリを使い果たし、タブをクラッシュさせる可能性があります。一定のバイト数を超えるファイルは拒否し、描画前に maxSize を制限してください。
  • EXIF の回転。 canvas 経由でリサイズすると EXIF の向き情報が失われることがあり、スマホの縦向き写真が横倒しになる場合があります。必ずスマホで撮影した実際の縦向き写真でテストしてください。
  • 縮小時の品質。 大幅な縮小では、1 回の drawImage() だけでは粗く見えることがあります。段階的にリサイズするか、より鮮明なサムネイルのために適切なリサンプリングフィルタを適用する Pica のようなライブラリを使いましょう。
  • Canvas の汚染 (tainting) は、他ドメインから読み込んだクロスオリジン画像にのみ影響します。ユーザーが選択したファイルが canvas を汚染することはないため、ここでは crossOrigin の対応は不要です。
  • アクセシビリティ。 すべてのプレビュー <img> に意味のある alt テキストを与え、削除ボタンには ARIA でラベルを付けて、サムネイルが見えなくても UI を操作できるようにしてください。

まとめ

目的に合ったアプローチを選びましょう。視覚的な確認だけが必要なら FileReader またはオブジェクト URL によるプレビュー、通信量を実際に削減する必要があるなら Canvas の toBlob によるサムネイルです。まずは上記の Promise でラップした makeThumbnail ヘルパーから始め、オリジナルとサムネイルを 1 つの FormData にまとめてアップロードし、revokeObjectURL によるクリーンアップはメモリリークが表面化してから後付けするのではなく、最初のコミットから組み込んでおいてください。

FAQ

プレビューを作成すると、ユーザーがアップロードするファイルのサイズは小さくなりますか?

いいえ。FileReader の readAsDataURL によるプレビューも URL.createObjectURL によるプレビューも表示専用であり、オリジナルのバイト列がそのままアップロードされ、通信量の削減はまったくありません。アップロードするファイルを実際に小さくするには、画像をより小さい寸法で canvas に再描画し、canvas.toBlob で再エンコードしたうえで、その Blob をオリジナルの代わりに、またはオリジナルと一緒にアップロードする必要があります。

canvas で生成したサムネイルの向きがおかしいのはなぜですか?

canvas 経由で画像をリサイズすると、スマホが縦向き写真に保存する EXIF の向きフラグが失われることがあり、正しい向きのオリジナルがサムネイルでは横倒しになってしまいます。ブラウザは通常の img 要素であれば自動的に向きを補正しますが、drawImage はその向き情報を必ずしも canvas に引き継ぎません。横向きのテスト画像だけでなく、必ずスマホで撮影した実際の縦向き写真でリサイズ処理をテストしてください。

canvas.toBlob はコールバックしか受け取らないのに、どうやって await すればよいですか?

Promise でラップしてください。canvas.toBlob は非同期で、Blob を返り値ではなくコールバックに渡します。new Promise を返し、その中で canvas.toBlob を resolve とともに呼び出すヘルパーを作成し、コールバックが null を受け取った場合は reject するようにします。通常の canvas には Promise を返すネイティブな形式はなく、OffscreenCanvas.convertToBlob のみが直接 Promise を返します。

multer のバックエンドにサムネイルのフィールドが届かないのはなぜですか?

multer の upload.fields に渡すフィールド名は、クライアント側の FormData.append のキーと完全に一致しなければならず、一致しない場合そのフィールドはエラーもなく破棄されます。クライアントで 'thumbnails' を append しているなら、サーバー側でも upload.fields に name 'thumbnails' を宣言する必要があります。また、multer のバージョンも確認してください。2.x 系には 1.x 系に欠けているセキュリティ修正が含まれています。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.