【JavaScript応用講座】なぜ画像が表示されない?BlobとcreateObjectURLの落とし穴

はじめに

<img src="blob:http://localhost:5500/17c29a3a-…"> ```

「blob…? なんだこれ?」「fetchしても読めない」「DBに保存したら壊れた!」

私もこの問題で何日もハマりました。
実はこの blob: URL は、JavaScriptが動的に生成する仮想URL。
そして、使いどころと扱い方を間違えると、思わぬバグ地獄に迷い込むことになります。

この記事では、実際に起きたバグ例をもとに、

  • createObjectURL() の仕組みと罠
  • BlobとFileの違い
  • IndexedDB保存時に壊れる理由
  • fetch() で爆発する構造的原因

を、順を追って解説していきます。

1. なぜ <img src="blob:…"> が現れるのか?

createObjectURLとは URL.createObjectURL(blob) は、JavaScriptのBlobやFileを DOM(HTML)で <img> や <video> に表示するための手段です。

生成されるのは「一時的な仮想URL」で、実際のファイルパスではありません。

メモリ上の仮想URLとは? この blob: URL はブラウザが一時的に割り当てた参照ポイントで、 実体はメモリ内に存在します。

そのため:

  • Network タブには出てこない
  • HTMLソースには表示されない
  • セッションが終了すると自動で失効する

つまり、「どこにも保存されていない、見えないURL」なのです。

2. BlobとFileの違いと罠

JavaScriptでは、Blob(Binary Large Object)と File は非常に似た扱いをされます。
どちらもバイナリデータを扱えるオブジェクトで、たとえば画像や音声、動画などの中身を表現できます。

ですが、この2つには微妙で致命的な違いがあります。
この違いを理解していないと、fetch() が動かない、保存できない、型が合わないといったバグに遭遇します。

🔍 Blobとは?

Blob は純粋なバイナリデータの塊です。
メモリ上に作られた仮想のファイルのようなもので、ファイル名やパスの概念はありません。

const blob = new Blob(["Hello, world!"], { type: "text/plain" });

これは text/plain の内容を持ったBlobですが、名前もパスも持ちません。

📄 Fileとは?

一方、File は Blob を継承しつつ、名前・最終更新日時・ファイルパスなどのメタ情報を持つ拡張型です。

const file = new File(["Hello, world!"], "hello.txt", { type: "text/plain" });

この file は blob として使える一方で、名前 hello.txt や型情報も持っています。

⚠️ この違いが引き起こす罠

1. fetch(blob) ができない!

Blobが fetch() に渡せるように見えて、実はURL文字列しか受け取れません。

const blob = new Blob(["hello"]);
await fetch(blob); // ❌ TypeError: Failed to fetch

→ fetch() は Blob ではなく string | Request しか受け付けない!

2. createObjectURL(blob) のURLは一時的で保存できない

const url = URL.createObjectURL(blob);

この url は “blob:http://…” の形式で作られた一時的なURL。
保存したり再利用したりしてはいけません。
(IndexedDBに保存すると後で使えなくなります)

3. Fileは fetch() できる(理由:内部的に File URL になる)

実は File オブジェクトを fetch() に渡すと動く場合があります。
これはブラウザが File に対して裏で createObjectURL() を発行して処理しているからです。

ですが、この挙動に頼るのは非推奨です。理由は以下の通り:

  • ブラウザによって動作が微妙に違う
  • セキュリティや同一生成元制約で失敗することがある
  • そもそも fetch() の意図からズレている

✅ 正しく使うための心得

やりたいこと 使い方
画像・動画などを表示 URL.createObjectURL(blob)
JSONなど構造データを読む new Response(blob).json() または .text()
Blobを保存する IndexedDBなどで Blobのまま 保存する
File名や拡張子を扱いたい File を使う(アップロード時など)

💬 この「BlobとFileの違い」を知っているかどうかで、 後の非同期処理・データ保存・メモリ管理の設計そのものが変わってきます。

次章では、この知識を元に「なぜIndexedDBに保存して壊れたのか?」を解説します。

3. IndexedDBに保存して壊れた理由

BlobやFileを扱っていると、「ブラウザのローカルDB(IndexedDB)に保存したい」という場面に多く出会います。
画像・動画・JSON・ユーザーデータなど、さまざまなものをブラウザに保存しておきたいのは自然な流れです。

しかし、そこで思わぬ落とし穴が待っています。

❗ なぜ保存して壊れるのか?

それは、Blobそのものを保存したつもりが、“URL文字列”を保存していたからです。

たとえば、以下のような処理は一見正しそうに見えます:

const imageMap = {
  "thumbnail.webp": URL.createObjectURL(blob),
  "manifest.json": URL.createObjectURL(manifestBlob),
};

await saveToStore("CardPieces", "myData", imageMap);

しかし、ここで保存された中身は「Blob」ではなく、こんなもの:

{
  "thumbnail.webp": "blob:http://127.0.0.1:5500/9c5d90b1-...",
  "manifest.json": "blob:http://127.0.0.1:5500/5a37fdaf-..."
}

これは単なる一時的なURL文字列であり、 保存された後に再度アクセスしようとしても、すでに失効していて使えません。

🔥 実際に起こる症状

  • <img src="blob:…"> が真っ白なまま表示されない
  • new Response(blob).json() が失敗する(blobが文字列になってる)
  • fetch(blob) が Failed to fetch を返す
  • manifest.json の中に “thumbnail”: “blob:…” など、見たことない文字列が入ってる

✅ 本当の意味での「壊れた」

「壊れた」とはどういう状態か?

  • データとして破損しているわけではない
  • 意味が失われた構造として保存されてしまった状態

たとえば、サムネイルのURLを “thumbnail.webp” ではなく

“blob:http://…” で保存してしまったら、表示時に二度と再現できないのです。

✅ 解決方法:「Blobを保存し、表示のときだけURL化」

// 保存時
imageMap["thumbnail.webp"] = blob;
await saveToStore("CardPieces", "MyZipData", imageMap);
// 読み込み時(表示のときだけURLを生成)
const blob = zipData["thumbnail.webp"];
const imgURL = URL.createObjectURL(blob);
img.src = imgURL;

こうすることで:

  • IndexedDBでは純粋なBlobとして安全に保存
  • 表示時には一時的なURLを自分で生成して使う
  • manifest.json も “thumbnail”: “thumbnail.webp” のまま維持できる

✅ あえてまとめると…

やったこと どうなるか
createObjectURL() して保存 一時URLが保存され、後で使えなくなる
Blob をそのまま保存 再利用可能、再表示可能
表示時に createObjectURL() だけ使う データ設計が壊れず、URLもその場限りで安全に使える

✅ 余談:DevToolsで見える「blob:URL」の正体

IndexedDBに保存されたデータをDevToolsで見たとき、 サムネイルの値が blob: URLだったことに違和感を覚えた人は多いと思います。

あれこそが「壊れている」サインです。

一時URLが保存されていたという、静かなるバグの証拠です。

💬 この章のポイントは、こうです:

Blobは保存できる。でも createObjectURL() された文字列は保存してはいけない。

そして、表示と保存は役割を分けるべきだという、設計上の大原則に繋がります。

4. fetch()で爆発する構造的理由と正しい使い方

BlobやFileを扱っていると、「fetchで中身を取り出したい」という場面がよくあります。

たとえばこんな感じ:

const manifestBlob = zipData["manifest.json"];
const manifest = await (await fetch(manifestBlob)).json(); // ❌

一見、これで中身のJSONが取れそうに見えます。

でも、実行するとこうなります:

TypeError: Failed to fetch

または:

SyntaxError: Unexpected token 'b', "blob:http:..." is not valid JSON

🔥 何が起きているのか?

答えは明快です:

fetch() は「URL文字列」しか受け取れない。

Blobを直接渡すと壊れる。

✅ fetchの構造的制約

declare function fetch(
  input: RequestInfo, // ← string or Request オブジェクト
  init?: RequestInit
): Promise<Response>

ここでの RequestInfo に Blob は含まれません。

つまり、fetch(blob) や fetch(undefined) のようなコードは、表面的には動きそうで、内部的には完全に無効なのです。

💣 さらに厄介なパターン

✘ 1. blob: URL を文字列として fetch してしまう

const blobURL = "blob:http://127.0.0.1:5500/abcde";
const res = await fetch(blobURL); // ❌ Not found

この blob: URL は一時的なローカルURLで、

呼び出し元のメモリスコープ以外では失効している可能性が高いため、404 相当のエラーになります。

✘ 2. Fileのつもりで fetch していた

const file = new File(["{...}"], "manifest.json", { type: "application/json" });
await fetch(file); // ❌ 実は動かない

File は Blob と同様、fetch() の対象になりません。

一部ブラウザでは動いてしまうことがありますが、これはブラウザが勝手にURL化しているだけで、信頼できる挙動ではありません。

✅ 正しい方法:Response コンストラクタを使う

Blobの中身をJSONとして読み取りたいとき、正解はこちら:

const manifest = await (new Response(manifestBlob)).json();

その他の用途別:

処理内容 正しい方法
JSONを読む new Response(blob).json()
テキストを読む new Response(blob).text()
画像を表示する URL.createObjectURL(blob)img.src =
ファイルとして保存 a.href = URL.createObjectURL(blob)

🧠 これは設計思想の話でもある

fetch() は「ネットワーク or URLに対するリクエスト手段」
Response() は「既にあるデータ(Blobなど)をレスポンスに変換する手段」

この構造を理解すると、「どちらを使うべきか?」が明確になります。

✅ まとめ:fetchで爆発する理由と対策

やったこと 結果 正しい方法
fetch(blob) ❌ TypeError new Response(blob) を使う
fetch("blob:http://...") ❌ Failed to fetch blob URL は fetch に不適
fetch(undefined) ❌ 型が不正 nullチェック or try-catch
new Response(blob).json() ✔ 安定的に読み込める Blobを構造化データとして処理できる

🗒 実体験を活かすポイント

この問題は一見「コードの書き方」っぽいけれど、
実は「Blobのスコープ、URLの生存期間、APIの責務分離」という構造的な問題。

だからこそ、表面のエラーメッセージだけでは気づきにくい。
でも、今のあなたはこの構造を実感として理解できています。

次回予告!?

この章が終わったことで、画像/JSON/動画を使うJavaScriptアプリの「土台」が完成しています。

次に進めるとしたら、

  • createObjectURL() vs readAsDataURL() の比較
  • 表示と保存の責務分離設計のコツ
  • 最終まとめとチェックリスト

などがあります。

関連リンク