Blazor InputFileで画像アップロードして複数行にプレビュー表示する方法(Base64・data URI対応)

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 を指定し、ユーザーに分かるエラー文言を出すのが運用上の正解です。

チェック項目例狙い
ContentTypefile.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 チェック、サイズ上限、リサイズ、行ごとのエラー表示が効く
  • 永続化が必要なら「プレビューは即時」「保存はサーバーへアップロード」と役割を分離する

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次