Blazorで商品(Tシャツ)一覧を編集する画面を作ると、「各行ごとにユーザーPCの画像ファイルを選択して、その場でプレビュー表示したい」という要望がよく出ます。この記事では、<InputFile>で受け取った画像をBase64(data URI)に変換し、ループ表示している複数アイテムそれぞれへ正しく紐付けて表示する実装と、実務でハマりやすい注意点までまとめます。
Blazorで「ローカル画像ファイルを<img>に表示」する時に最初に知っておくこと
結論から言うと、ユーザーのPCにある画像をそのまま <img src="C:\Users\... "> のように指定して表示することはできません。ブラウザはセキュリティの都合で、Webページからユーザーのローカルパスへ自由にアクセスできないためです。
そのため、Blazorの画面でローカル画像を表示するには、次のどれかの形に「ブラウザが表示できるURL(またはURL相当)」へ変換して渡す必要があります。
| 方式 | 概要 | メリット | デメリット / 注意 | 向いている場面 |
|---|---|---|---|---|
| Base64(data URI) | 画像をバイト配列→Base64文字列化し、data:image/...;base64,.... を src に設定 | Blazorだけで完結、実装が簡単、即プレビューしやすい | 文字列が大きい(容量が増える)、メモリ負荷が高い、永続化には向かない | 編集画面の「その場プレビュー」、小〜中サイズ画像 |
Object URL(URL.createObjectURL) | JSで一時URLを生成し src に設定(使い終わったら破棄) | Base64より軽いことが多い、変換コストが少ない | JS interopが必要、破棄(revoke)管理が必要 | 大きめ画像を快適にプレビューしたい |
| サーバーへアップロードしてURL表示 | APIへ送信して保存し、返ってきたURLを src に設定 | 本番運用で再利用しやすい、DBはURL/パスだけで済む | バックエンド実装が必要、通信が発生 | 商品画像として保存・再利用したい |
この記事の主役は「Base64(data URI)で、その場でプレビュー表示する」方式です。まずは最短で動く形を作り、その後に実務向けの改善点(サイズ制限、リサイズ、エラーハンドリング、サーバー保存)まで拡張していきます。
実現したい要件を整理(今回のケース)
/view-pageというBlazorページで、Tシャツを複数件ループ表示している- 各Tシャツには
ImagePath/Name/Priceがある ImagePathは空文字で初期化されている- 各行でユーザーのPCから画像ファイルを選択し、その行の
<img>にプレビュー表示したい - Tシャツは複数件あるため、「どの行で選択された画像か」を確実に判別したい
最短で動く実装:<InputFile>で受け取り、Base64(data URI)で表示
画面側(Razor):ループ内に<InputFile>を置き、行インデックスを渡す
ポイントは、ループ内で index をローカル変数に受けてラムダに渡すことです。これにより「どのTシャツの画像が選択されたか」を確実に特定できます。
@page "/view-page"
<h3>Blank View Page</h3>
@for (int i = 0; i < tshirtItems.Count; i++)
{
var index = i; // 重要:この index を UploadImage に渡す
<div class="tshirt-section" style="border:1px solid #ddd; padding:12px; margin:12px 0;">
<div style="display:flex; gap:16px; align-items:flex-start;">
<div>
<img src="@(string.IsNullOrWhiteSpace(tshirtItems[i].ImagePath) ? placeholderImage : tshirtItems[i].ImagePath)"
alt="T-Shirt Image"
style="max-width:150px; max-height:150px; border:1px solid #eee; object-fit:cover;" />
<div style="margin-top:8px;">
<label>画像アップロード:</label><br />
<InputFile OnChange="e => UploadImage(e, index)" accept="image/*" />
</div>
@if (!string.IsNullOrWhiteSpace(tshirtItems[i].ImageError))
{
<div style="margin-top:8px; color:#b00020;">@tshirtItems[i].ImageError</div>
}
</div>
<div style="flex:1;">
<div style="margin-bottom:8px;">
<label>T-Shirt Name:</label><br />
<input type="text" @bind="tshirtItems[i].Name" style="width:100%;" />
</div>
<div>
<label>Price:</label><br />
<input type="number" @bind="tshirtItems[i].Price" style="width:160px;" />
</div>
</div>
</div>
</div>
}
補足として、プレビュー画像が未設定(空文字)の時に見栄えが崩れないよう、placeholderImage(ダミー画像)へフォールバックしています。WordPressの記事としても読み手が理解しやすくなるので、実務寄りのサンプルとして載せています。
C#側:ファイルを読み込み、data URIとしてImagePathへセット
実務で使うなら、次の点を最初から入れておくのが安全です。
- 画像だけ許可(ContentTypeチェック)
- サイズ上限(
OpenReadStreamの上限指定) - 例外時に「どの行で失敗したか」分かるよう、行ごとにエラー文字列を持つ
@code {
// 未設定時に表示するプレースホルダー(data URIのSVGなどにしておくと手軽です)
private string placeholderImage = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNTAiIGhlaWdodD0iMTUwIj48cmVjdCB3aWR0aD0iMTUwIiBoZWlnaHQ9IjE1MCIgZmlsbD0iI2Y2ZjZmNiIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBkeT0iLjM1ZW0iIHRleHQtYW5jaG9yPSJtaWRkbGUiIGZpbGw9IiM5OTkiIGZvbnQtc2l6ZT0iMTIiPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==";
private const long MaxFileSize = 5 * 1024 * 1024; // 5MB(要件に合わせて調整)
private List<TShirt> tshirtItems = new()
{
new TShirt { ImagePath = "", Name = "Shirt 1", Price = 0 },
new TShirt { ImagePath = "", Name = "Shirt 2", Price = 0 },
new TShirt { ImagePath = "", Name = "Shirt 3", Price = 0 },
new TShirt { ImagePath = "", Name = "Shirt 4", Price = 0 },
new TShirt { ImagePath = "", Name = "Shirt 5", Price = 0 },
new TShirt { ImagePath = "", Name = "Shirt 6", Price = 0 }
};
private async Task UploadImage(InputFileChangeEventArgs e, int index)
{
// 行ごとのエラーをクリア
tshirtItems[index].ImageError = "";
var file = e.File;
if (file is null)
{
tshirtItems[index].ImageError = "ファイルが選択されていません。";
return;
}
// 画像タイプの簡易チェック(厳密にするなら拡張子やシグネチャ検証も検討)
if (string.IsNullOrWhiteSpace(file.ContentType) || !file.ContentType.StartsWith("image/"))
{
tshirtItems[index].ImageError = "画像ファイル(image/*)を選択してください。";
return;
}
if (file.Size <= 0)
{
tshirtItems[index].ImageError = "ファイルサイズが不正です。";
return;
}
if (file.Size > MaxFileSize)
{
tshirtItems[index].ImageError = $"ファイルが大きすぎます(上限 {MaxFileSize / 1024 / 1024}MB)。";
return;
}
try
{
// ストリームを開いてメモリに読み込み
await using var stream = file.OpenReadStream(MaxFileSize);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var base64Image = Convert.ToBase64String(ms.ToArray());
// ContentType を使って data URI を安全に構築
tshirtItems[index].ImagePath = $"data:{file.ContentType};base64,{base64Image}";
}
catch (Exception ex)
{
// 本番ではログに ex を残し、UIには簡易メッセージだけ出す方が安全
tshirtItems[index].ImageError = "画像の読み込みに失敗しました。別の画像でお試しください。";
}
}
private class TShirt
{
public string ImagePath { get; set; } = "";
public string Name { get; set; } = "";
public decimal Price { get; set; }
public string ImageError { get; set; } = "";
}
}
この実装の流れはシンプルです。
<InputFile>のOnChangeで選択されたファイルがe.Fileとして渡ってくるOpenReadStreamで読み込み、メモリに取り込むConvert.ToBase64StringでBase64化するdata:{ContentType};base64,...をImagePathにセットする<img src="...">がdata URIを参照し、即座にプレビュー表示される
複数行のループで「画像が別行に入る」「最後の行に上書きされる」を防ぐコツ
Blazorに限らずC#のラムダでありがちな落とし穴が「ループ変数キャプチャ」です。ループ内の変数 i をそのままラムダ内で使うと、意図しないタイミングで値が変わったり、結果として別行扱いになったりします。
対策は今回のサンプルの通りで、ループ内でローカル変数に退避してから渡します。
- NG例(避けたい):
<InputFile OnChange="e => UploadImage(e, i)" /> - OK例:
var index = i;を作り、UploadImage(e, index)を呼ぶ
さらに実務では、DOM差分更新でUIが意図せず入れ替わるのを防ぐために @key を使うのも有効です。例えばTシャツにIDがあるなら、行コンテナへ @key を付けます。
@for (int i = 0; i < tshirtItems.Count; i++)
{
var index = i;
<div @key="tshirtItems[i].Id">
...
</div>
}
このあたりを入れておくと、「入力中に行が増減した」「並び替えた」などの状況でも事故が起きにくくなります。
実務で必ず検討したい注意点
Base64(data URI)は便利だが、画像が大きいと一気に重くなる
Base64は文字列なので、元のバイナリよりサイズが増えます。さらにBlazorのコンポーネント状態として保持すると、再レンダリング時の負荷やメモリ使用量が増えがちです。
対策の方向性は次の通りです。
- プレビュー用途は「上限サイズを決める」(例:2MB〜5MB)
- アップロード時にリサイズして軽くする(後述)
- 永続化が必要ならサーバー保存+URL運用に切り替える
サイズ上限を指定しないと、環境や設定によっては例外になりやすい
OpenReadStream は巨大ファイルを無制限に読めるわけではありません。必ず要件に合わせて maxAllowedSize を指定し、ユーザーに分かるエラー文言を出すのが運用上の正解です。
| チェック項目 | 例 | 狙い |
|---|---|---|
| ContentType | file.ContentType.StartsWith("image/") | 画像以外を弾く(最低限) |
| ファイルサイズ | file.Size > MaxFileSize | 重さ・メモリ問題を防ぐ |
| ストリーム上限 | file.OpenReadStream(MaxFileSize) | 想定外の巨大ファイル読み込みを防止 |
「PNGと仮定」は避け、ContentTypeを使ってdata URIを作る
サンプル実装でありがちなのが data:image/png;base64,... と固定してしまうことです。ユーザーはJPEGやWebPを選ぶこともあるので、基本は file.ContentType を使って動的に埋め込む方が安全です。
tshirtItems[index].ImagePath = $"data:{file.ContentType};base64,{base64Image}";
より実務的:アップロード時にリサイズしてプレビューを軽くする
「ユーザーが高解像度の写真をそのまま選んでしまう」ケースは本当に多いです。商品画像のプレビューで4K画像をそのままBase64化すると、表示も状態保持も重くなります。
Blazorでは、画像ファイルを指定サイズへ変換(リサイズ)した IBrowserFile を取得できるため、プレビューは小さめに揃える設計が現場向きです。
private const long MaxFileSize = 5 * 1024 * 1024;
private async Task UploadImage(InputFileChangeEventArgs e, int index)
{
tshirtItems[index].ImageError = "";
var file = e.File;
if (file is null) return;
if (!file.ContentType.StartsWith("image/"))
{
tshirtItems[index].ImageError = "画像ファイルを選択してください。";
return;
}
// プレビュー用に最大 600x600 に抑える(値は要件で調整)
var previewFile = await file.RequestImageFileAsync(file.ContentType, 600, 600);
if (previewFile.Size > MaxFileSize)
{
tshirtItems[index].ImageError = "画像が大きすぎます。";
return;
}
await using var stream = previewFile.OpenReadStream(MaxFileSize);
using var ms = new MemoryStream();
await stream.CopyToAsync(ms);
var base64Image = Convert.ToBase64String(ms.ToArray());
tshirtItems[index].ImagePath = $"data:{previewFile.ContentType};base64,{base64Image}";
}
この方式にしておくと、ユーザー体験が一気に良くなります。
- プレビュー表示が速い
- 画面が重くなりにくい
- 「スマホ写真をそのまま入れたら落ちた」系の事故が減る
よくあるトラブル集(原因と対策をセットで)
| 症状 | よくある原因 | 対策 |
|---|---|---|
| 画像が表示されない(空白のまま) | ImagePath が空、または data URI の形式が不正 | data:{ContentType};base64,... を確認。未設定時はプレースホルダーを出す |
| 別の行の画像が変わる | ループ変数キャプチャ、またはDOM差分更新で行が入れ替わっている | var index=i; を徹底。IDがあるなら @key を付ける |
| 大きい画像で例外・フリーズ | サイズ上限なし、Base64化でメモリ圧迫 | OpenReadStream の上限指定、サイズチェック、リサイズ(RequestImageFileAsync) |
| jpegなのにpng固定で保存してしまう | data:image/png を固定で組んでいる | file.ContentType を使い、形式依存の固定値をやめる |
| ユーザーがPDFやZIPを選べてしまう | accept 属性なし、またはチェック不足 | accept="image/*" を付け、ContentType でも弾く |
サーバー保存が必要な場合の考え方(プレビューと永続化を分ける)
今回のBase64(data URI)は「その場でプレビューする」用途に最適です。一方で、商品画像として保存・再利用したい場合、画面の状態にBase64を抱え続けるより、最終的にはサーバーへアップロードしてURL運用に切り替える方が扱いやすくなります。
実務でよく採る設計は次の2段構えです。
- 画面上:選択直後はBase64(またはObject URL)で即プレビュー
- 保存ボタン押下時:サーバーへファイル送信し、戻ってきたURL/パスをDBに保存
「プレビュー=一時表示」「保存=サーバーに置く」を分けると、要件変更にも強くなります。例えば、プレビューは軽量な縮小版、サーバー保存は高品質版、という運用もできます。
(例)サーバー保存に寄せるときのデータ設計
| 項目 | おすすめ | 理由 |
|---|---|---|
| DBに保存するもの | 画像URL(または相対パス) | DB肥大化を避けやすい。CDN運用にも移行しやすい |
| ファイル保存先 | ストレージ(例:サーバーディスク、Blob等) | バックアップや配信最適化がしやすい |
| 画面プレビュー | 縮小版(リサイズ) | レスポンス改善、離脱防止 |
UI/UXを上げる小技(公開する管理画面ほど効く)
accept="image/*"を付けて、ユーザーが画像以外を選びにくくする- ファイル名・サイズを表示して「選べている感」を出す
- 行ごとにエラーメッセージ領域を持ち、どのTシャツで失敗したか一目で分かるようにする
- 未設定時はプレースホルダー画像を表示し、レイアウトがガタつかないようにする
- 後で並び替えや削除が入るなら、ID+
@keyを前提に設計しておく
まとめ:Blazorで複数行の画像アップロード&プレビューを安定させる要点
- ローカルファイルはそのまま
<img>に指定できないため、Base64(data URI)等に変換して表示する - ループ内の
<InputFile>からは「行インデックス」を渡して、どのアイテムか特定する - キャプチャ問題を避けるために
var index = i;を挟む - 実務では
ContentTypeチェック、サイズ上限、リサイズ、行ごとのエラー表示が効く - 永続化が必要なら「プレビューは即時」「保存はサーバーへアップロード」と役割を分離する

コメント