Reactで、画像をBase64のデータURLに変換する方法

​​React​FileReader,Playwright,TypeScript

画像ファイルを Base64 のデータURL(data:image/png;base64,… の形の文字列)にするには、FileReader の readAsDataURL() を使います。ファイルの中身をそのまま Base64 にするので、データURLから戻したバイト列は元のファイルと同じになります。

ここでは、readAsDataURL() を Promise で包んだ関数と、ファイルを選ぶとデータURLを表示してコピーできるコンポーネントを作りました。あわせて、canvas の toDataURL() を使うと中身が変わること、Uint8Array の toBase64() でも同じ文字列を作れることを確かめています。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。画像は Pillow 10.2.0 で描いた PNG(240×160)と JPEG(240×160)、手で書いた SVG の3つで、写真ではありません。Playwright で Chromium を動かし、ファイルを選んでコピーするところまで操作しました。Firefox と Safari、スマートフォンのブラウザ、大きな画像(数 MB 以上)は試していません。

ファイルを選ぶボタンを押して sample.png を選ぶと、画像のプレビュー、元のファイルのバイト数とデータURLの文字数、データURLの文字列が表示される。sample.jpg に選び直すと表示が入れ替わり、「コピーする」を押すと横に「コピーしました」と出る。変わった表示は、その瞬間だけピンクになる。ファイルを選ぶボタンは、検証環境のブラウザでは英語で表示された
画像を選んでデータURLにし、コピーする様子。カーソルと操作の表示は GIF 用に重ねたもの

readAsDataURL() を Promise にする

FileReader は、読み終わると load イベントを出し、result にデータURLの文字列を入れます。イベントのままでは await で待てないので、Promise で包んだ関数にしておきます。引数は Blob にしているので、<input type="file"> で選んだ File も、fetch() で取ってきた Blob も渡せます。

ts
// File や Blob の中身を、そのまま Base64 のデータURL(data:<MIMEタイプ>;base64,…)にする
export function fileToDataURL(file: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onload = () => resolve(reader.result as string);
    reader.onerror = () => reject(reader.error);
    reader.readAsDataURL(file);
  });
}

データURLの先頭の data:image/png;base64, は、Blob の MIME タイプから付きます。検証では PNG が data:image/png;base64,、JPEG が data:image/jpeg;base64,、SVG が data:image/svg+xml;base64, になりました。MIME タイプが空の Blob では data:application/octet-stream;base64, になりました。Base64 の部分だけが欲しいときは、MDN の説明にあるとおり、先頭の data:*/*;base64, を取り除きます。

選んだ画像をデータURLにして、表示とコピーをする

App は、ファイルが選ばれたら fileToDataURL で変換し、プレビューの img、文字数、データURLの文字列を表示します。「コピーする」ボタンを押すと、navigator.clipboard.writeText() で文字列をクリップボードに入れます。

tsx
import { useState } from 'react';
import type { ChangeEvent } from 'react';
import { fileToDataURL } from './dataUrl';

type Converted = { name: string; size: number; dataUrl: string };

export function App() {
  const [converted, setConverted] = useState<Converted | null>(null);
  const [error, setError] = useState('');
  const [copied, setCopied] = useState(false);

  async function handleFile(e: ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    setCopied(false);
    try {
      const dataUrl = await fileToDataURL(file);
      setConverted({ name: file.name, size: file.size, dataUrl });
      setError('');
    } catch {
      setConverted(null);
      setError('ファイルを読み込めませんでした');
    }
  }

  async function handleCopy() {
    if (!converted) return;
    await navigator.clipboard.writeText(converted.dataUrl);
    setCopied(true);
  }

  return (
    <main>
      <input type="file" accept="image/*" aria-label="画像を選ぶ" onChange={handleFile} />
      {error && <p role="alert" className="error">{error}</p>}
      {converted && (
        <>
          <img className="preview" src={converted.dataUrl} alt="選んだ画像のプレビュー" />
          <p className="info">
            {converted.name}:元のファイル {converted.size.toLocaleString()} バイト → データURL {converted.dataUrl.length.toLocaleString()} 文字
          </p>
          <textarea className="output" readOnly rows={4} value={converted.dataUrl} aria-label="データURL" />
          <div className="toolbar">
            <button type="button" onClick={handleCopy}>コピーする</button>
            <span className="copied" aria-live="polite">{copied ? 'コピーしました' : ''}</span>
          </div>
        </>
      )}
    </main>
  );
}

PNG、JPEG、SVG の順に選んでコピーしたところ、どれもプレビューが表示されました。欄に出た文字列とクリップボードに入った文字列は、fileToDataURL を直接呼んだ結果と同じでした。

text
sample.png: sample.png:元のファイル 4,960 バイト → データURL 6,638 文字 | プレビュー 表示できた 240x160 | 欄の文字列が readAsDataURL の結果と同じ: true | コピーした文字列が同じ: true
sample.jpg: sample.jpg:元のファイル 13,486 バイト → データURL 18,007 文字 | プレビュー 表示できた 240x160 | 欄の文字列が readAsDataURL の結果と同じ: true | コピーした文字列が同じ: true
sample.svg: sample.svg:元のファイル 185 バイト → データURL 274 文字 | プレビュー 表示できた 120x80 | 欄の文字列が readAsDataURL の結果と同じ: true | コピーした文字列が同じ: true

コピーしたあとに別のファイルを選ぶと、setCopied(false) で「コピーしました」の表示が消えます。writeText() は、安全なコンテキスト(HTTPS のページや、localhost・127.0.0.1 のページ)でしか使えません。検証では http://127.0.0.1 のページで動かしました。

読み込みに失敗したときの表示は、readAsDataURL() を error イベントを出すだけのものに差し替えたページで確かめました。「ファイルを読み込めませんでした」と表示され、プレビューは出ませんでした。

データURLから戻すと、元のファイルと同じバイト列になる

readAsDataURL() が返した文字列から先頭を取り除き、Node.js の Buffer.from(…, 'base64') で戻して、元のファイルと比べました。3つとも、戻したバイト列は元のファイルと同じでした。

text
sample.png: 元 4960 バイト | 先頭 data:image/png;base64, | データURL 6638 文字(うち Base64 6616 文字、4×ceil(4960/3) = 6616) | 戻したバイト列が元と同じ: true(sha256 1c87496920c16d34 / 1c87496920c16d34)
sample.jpg: 元 13486 バイト | 先頭 data:image/jpeg;base64, | データURL 18007 文字(うち Base64 17984 文字、4×ceil(13486/3) = 17984) | 戻したバイト列が元と同じ: true(sha256 3d58ad7047819064 / 3d58ad7047819064)
sample.svg: 元 185 バイト | 先頭 data:image/svg+xml;base64, | データURL 274 文字(うち Base64 248 文字、4×ceil(185/3) = 248) | 戻したバイト列が元と同じ: true(sha256 439632eb4ffd6039 / 439632eb4ffd6039)

Base64 は、3バイトを4文字で表します。検証でも、Base64 の部分の文字数は、3つとも元のバイト数を3で割って切り上げ、4をかけた数と同じでした。4,960 バイトの PNG なら 6,616 文字で、先頭の data:image/png;base64, の22文字を足すと 6,638 文字です。データURLは元のファイルより3分の1ほど長くなるので、大きな画像を HTML や JSON に埋め込むと、そのぶん重くなります。

canvas の toDataURL() では、中身が描き直される

画像を canvas に描いて toDataURL() を呼んでも、データURLは作れます。ただし、これは canvas の画素から画像を作り直す書き方です。比べるために、createImageBitmap() で読み込んだ画像を同じ大きさの canvas に描き、toDataURL() で書き出しました。

text
sample.png → toDataURL(): 先頭 data:image/png;base64, | 8370 文字(中のファイル 6261 バイト、元は 4960 バイト) | 元と同じバイト列: false
sample.jpg → toDataURL(): 先頭 data:image/png;base64, | 115486 文字(中のファイル 86597 バイト、元は 13486 バイト) | 元と同じバイト列: false
sample.jpg → toDataURL('image/jpeg', 0.92): 先頭 data:image/jpeg;base64, | 20035 文字(中のファイル 15008 バイト、元は 13486 バイト) | 元と同じバイト列: false

3つとも、データURLに入っている画像ファイルは、元と違うバイト列になりました。形式を指定しないと PNG で書き出されるので、JPEG を渡すと PNG になり、13,486 バイトから 86,597 バイトに増えました。'image/jpeg' を指定しても、元のファイルと同じにはならず、15,008 バイトになりました。縮小や切り抜きをしてから送りたいときは canvas を使い、選んだファイルをそのまま文字列にしたいときは readAsDataURL() を使う、と分けると迷いません。

Uint8Array の toBase64() でも、同じ文字列を作れる

Uint8Array の toBase64() を使うと、イベントを待たずに書けます。Blob の arrayBuffer() でバイト列を取り出し、先頭の data:<MIMEタイプ>;base64, は自分で付けます。MIME タイプが空のときは、readAsDataURL() と同じ application/octet-stream にしています。

ts
// Uint8Array の toBase64() で、データURLを組み立てる書き方
export async function fileToDataURLWithToBase64(file: Blob): Promise<string> {
  const bytes = new Uint8Array(await file.arrayBuffer());
  return `data:${file.type || 'application/octet-stream'};base64,${bytes.toBase64()}`;
}

PNG、JPEG、SVG と、MIME タイプを空にした PNG の4つで、readAsDataURL() の結果と1文字も違わない文字列になりました。

text
sample.png: 同じ文字列: true(6638 文字)
sample.jpg: 同じ文字列: true(18007 文字)
sample.svg: 同じ文字列: true(274 文字)
sample.png(MIME タイプなし): 同じ文字列: true(6653 文字)

toBase64() は新しいメソッドで、MDN の対応表では Chrome 140、Firefox 133、Safari 18.2 から使えます。TypeScript 7.0 では、tsconfig.json の lib を ESNext にすると型チェックが通りました。ES2022 や ES2025 では、どちらも次のエラーになりました。

text
src/toBase64.ts(4,74): error TS2339: Property 'toBase64' does not exist on type 'Uint8Array<ArrayBuffer>'.

古いブラウザでも動かしたいなら、readAsDataURL() の書き方を使います。

画像の URL からデータURLにする

画像の URL から作るときは、fetch() で Blob を取ってきて、同じ fileToDataURL に渡します。

ts
// 画像の URL から読み込んで、データURLにする(別のオリジンの画像は、CORS で許可されていないと読めない)
export async function urlToDataURL(url: string): Promise<string> {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`読み込めませんでした(${res.status})`);
  return fileToDataURL(await res.blob());
}

ページと同じオリジンの画像は、ファイルを選んだときと同じ 6,638 文字のデータURLになりました。ポート番号だけを変えた別のオリジンのサーバーから読むと、Access-Control-Allow-Origin を返さないサーバーでは TypeError: Failed to fetch で失敗しました。Access-Control-Allow-Origin: * を返すサーバーからは読めました。別のオリジンの画像は、CORS で読み込みが許可されていないと、fetch() で中身を取れません。

text
/sample.png: 成功 6638 文字
http://127.0.0.1:5174/sample.png: 失敗 TypeError: Failed to fetch
http://127.0.0.1:5175/sample.png: 成功 6638 文字
/missing.png: 失敗 Error: 読み込めませんでした(404)

fetch() は、サーバーが 404 を返しても失敗にはなりません。そのため、res.ok を見て自分でエラーにしています。ない画像の URL を渡すと、読み込めませんでした(404) のエラーになりました。

​Posted by 86