.NET 9 MAUI Blazor Hybrid & WebでJavaScriptと大容量ファイルを共通管理する方法

.NET 9 の「MAUI Blazor Hybrid & Web」構成は、同じ Razor コンポーネントをネイティブと Web で共有できる一方、wwwroot や index.html の“担当”がプロジェクトごとに分かれていて迷子になりがちです。JavaScript と LLM/音声などの大容量ファイルを、1か所で管理しつつ各ターゲットへ確実に届ける方法を整理します。

目次

.NET 9 の「MAUI Blazor Hybrid & Web」で“置き場所迷子”が起きる理由

まず前提として、この構成は「1つのアプリ」ではなく「複数のホスト(実行環境)を持つ1つの UI」を作るのが本質です。具体的には、.NET MAUI(ネイティブ)と Blazor Web App(Web)のそれぞれがホストになり、UI は Razor class library(RCL)で共有します。Microsoft Learn のチュートリアルでも、MAUI と Web を同時に作るソリューションテンプレートが用意され、共有 UI を RCL に集約する方針が示されています。

ここで混乱の元になるのが、ホストごとに「起点となる HTML(HostPage)」と「静的ファイルのルート(wwwroot)」の意味が違うことです。

  • MAUI(BlazorWebView)は、ローカルの wwwroot/index.html を HostPage として読み込み、そこから Blazor を起動します。
  • Web(Blazor Web App)は、Web サーバー側のパイプラインで静的ファイルや RCL の静的アセットを配布します(プロジェクトによっては Web 側に wwwroot が空、あるいは無いように見える構成もあり得ます)。
  • Shared(RCL)は「UI と共通ロジックと共通アセットの入れ物」なので、index.html を持ちません。index.html は “ホストの責務” だからです。

つまり、「wwwroot がどこにあるか」ではなく「誰が HostPage を持つホストなのか」で整理すると一気に分かりやすくなります。

プロジェクト構成の整理:誰がホストで、どこに何があるべきか

Microsoft Learn の移行手順では、例として MauiBlazorWeb.Maui(MAUI)、MauiBlazorWeb.Web(Web)、MauiBlazorWeb.Shared(RCL)という命名で説明されています。

プロジェクト役割index.html の有無wwwroot の意味ここに置くと整理しやすいもの
(例)App.Mauiネイティブホスト(Android/iOS/Windows/Mac)あり(HostPage)BlazorWebView が読むローカル web ルートMAUI 固有の HostPage 調整、MAUI 固有の静的ファイル
(例)App.WebWeb ホスト(Blazor Web App)構成による(App.razor 等で head を組む場合も)Web で公開する静的ファイル(必要なら)Web 固有の静的ファイル、サーバー側ファイル置き場、API
(例)App.Shared(RCL)共有 UI(Razor)と共有アセットなし共有静的アセット(Static Web Assets)共通 JS/CSS/画像、共通 Razor コンポーネント、共通サービス

この表の通り、Shared(RCL)に index.html が無いのは正常です。代わりに、Shared の wwwroot に置いた静的アセットは、ホスト側から特別なパスで参照します(後述)。

結論:JavaScript は「共有したいなら RCL(.Shared)の wwwroot」に置く

JavaScript の置き場所で一番ハマりやすいのが、「.Web に wwwroot が無いから置けない」「.Shared に wwwroot はあるけど index.html が無いから読み込めない」という状態です。ここは発想を変えるのがポイントです。

Shared(RCL)に JS を置く=共有アセットとして配布する、という考え方が最もブレません。RCL の wwwroot に置いた静的アセットは、アプリ側から _content/{PACKAGE ID}/{PATH} 形式で参照します。

さらに Blazor Hybrid でも、RCL の静的アセットは _content/... パスで扱える前提が整理されています。HostPage(通常 wwwroot/index.html)を web ルート直下に置くのが推奨で、こうするとアプリ自身の wwwroot と RCL の静的アセットを一番素直に組み合わせられます。

推奨のファイル配置例(JS を共有する)

Solution
├─ App.Maui
│  └─ wwwroot
│     └─ index.html   (MAUI の HostPage)
├─ App.Web
│  └─ (Web ホスト。必要なら wwwroot を作っても OK)
└─ App.Shared   (Razor class library / RCL)
   └─ wwwroot
      └─ js
         ├─ app.js
         └─ speech.js

MAUI 側(App.Maui)の index.html から参照する

MAUI の HostPage(App.Maui/wwwroot/index.html)で、RCL の JS を読み込みます。CSS を RCL 参照に切り替える手順は Microsoft Learn の移行手順でも紹介されており、JS でも同じ発想で整理できます。

<!-- App.Maui/wwwroot/index.html -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />

  <!-- 共有 CSS 例(RCL) -->
  <link rel="stylesheet" href="_content/App.Shared/css/app.css" />

  <!-- 共有 JS 例(RCL) -->
  <script src="_content/App.Shared/js/app.js"></script>
</head>
<body>
  <div id="app">Loading...</div>

  <script src="_framework/blazor.webview.js"></script>
</body>
</html>

Web 側(App.Web)で参照する位置

Blazor Web App(.NET 8/9 系の “Razor Components” ベース)では、従来の _Host.cshtml や index.html ではなく、Components/App.razor などで head のリンクを組む構成が一般的です。Microsoft Learn の手順でも、Web 側の App.razor でスタイル参照を RCL の _content/... に差し替える例が載っています。

<!-- App.Web/Components/App.razor(例) -->
<head>
  <link rel="stylesheet" href="_content/App.Shared/css/app.css" />
  <script src="_content/App.Shared/js/app.js"></script>
</head>

この形にしておくと、.Web 側に wwwroot が無い/空に見える構成でも「JS は RCL の静的アセットとして共有」という一本化ができ、迷いが減ります。

“どの JS を RCL に置くべきか”の判断基準

JS の種類おすすめの置き場理由
UI と一緒に使う共通 JS(JS interop、共通ユーティリティ)App.Shared(RCL)の wwwrootWeb と MAUI で同じパス(_content)で参照でき、重複が消える
ホスト固有の初期化(MAUI のみ、Web のみ)各ホスト(App.Maui / App.Web)ホスト依存の挙動は共有すると逆に管理が難しくなる
ページ単位の小さな JS(JS isolation 的に扱いたい)基本は RCL、必要ならコンポーネント近くで管理部品化しやすく、将来のターゲット追加でも破綻しにくい

大きな参照ファイル(LLM・音声・辞書など)は「共有」と「配布」の問題を分けて考える

JS と違って、LLM モデルや長尺音声などの大きなファイルは “置き場所” だけでなく “配布の仕方” がトラブルの原因になります。特に Web でクライアント配布してしまうと、次のような問題が起きやすいです。

  • 配布サイズが膨らみ、初回ロードが重くなる
  • キャッシュや更新戦略が難しくなる(ユーザーが古いモデルを握り続ける等)
  • ライセンス上、配布してよいのか判断が必要
  • 流出リスク(クライアントに配れば基本的に回収できない)

そのため、実務では次のどれかに分岐させるのが堅実です。

パターンA:ネイティブに同梱、Web はサーバー側に置いて API 経由で使う

多くのケースで一番安全です。ネイティブ(MAUI)には同梱してオフライン対応し、Web はサーバー側ストレージ(サーバーのディスク、Blob、S3 など)に置き、認証付き API で推論や音声生成だけ提供します。

Blazor Hybrid 側の静的ファイルは「アプリ資産」として扱われ、.NET MAUI では raw assets を MauiAsset として扱い、ファイルシステム系のヘルパーでアクセスする考え方が整理されています。

パターンB:どうしても Web クライアントに配布する(公開ディレクトリは慎重に)

Web でクライアントにモデルや音声を配布する場合、単純に wwwroot に置くと “誰でも直接ダウンロードできる公開資産” になります。社内向けツールならまだしも、一般公開のサービスでここを許容するのは慎重に判断した方がよいです。

パターンC:ソースは1か所、ビルド時にターゲット別の正しい場所へコピーする

今回の「できれば同じ場所に置いたまま使い回したい」という要件に一番フィットするのがこれです。ポイントは次の2つです。

  • ソース(元ファイル)を“共通プロジェクト”または共通フォルダに集約する
  • ビルド時(または publish 時)に、各ターゲットが期待する場所へ自動配置する

こうしておくと、ターゲットが増えても「コピー先のルール」を調整するだけで済み、運用が安定します。

実践:共通フォルダを1か所に集約し、MSBuild で自動配置する

ここからは “確実に動く” ことを重視して、共有フォルダ+MSBuild で解決する具体例を紹介します。Visual Studio の Pre-build イベントでも可能ですが、チーム開発や CI を考えると MSBuild の Target と Copy タスクで完結させたほうが再現性が高いです。

おすすめの共有フォルダ設計

Solution
├─ SharedAssets
│  ├─ wwwroot
│  │  ├─ js
│  │  └─ media
│  └─ References
│     └─ raw
│        ├─ models
│        └─ audio
├─ App.Maui
├─ App.Web
└─ App.Shared

この設計のメリットは、「JS などの Web 系アセット」と「大容量バイナリ」を明確に分離できる点です。Web 系は RCL の wwwroot に寄せ、巨大ファイルは “共有フォルダに置いて必要なターゲットへだけ配る” にしておくと事故が減ります。

方法1:Link+CopyToOutputDirectory で「プロジェクト外のファイル」を取り込む

まずはカスタム Target を書かずに済む、手堅い方法です。ソースは共通フォルダに置いたまま、ビルド成果物にコピーします。

Web(サーバー側)に “公開せずに” 置きたい場合は、wwwroot ではなく Web プロジェクト配下の専用ディレクトリ(例:App_Data や Data)にコピーするのが安全です。

<!-- App.Web/App.Web.csproj(例) -->
<ItemGroup>
  <Content Include="..\SharedAssets\References\raw\**\*.*"
           Link="App_Data\References\raw\%(RecursiveDir)%(Filename)%(Extension)"
           CopyToOutputDirectory="PreserveNewest"
           CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

この形なら、サーバーのディスク上にファイルは載りますが、静的ファイルとして公開しない限り URL で直接取られることはありません(公開するかどうかは Web 側のミドルウェア設定次第です)。

MAUI に同梱したい場合は、MauiAsset として取り込むのが分かりやすいです。Android などでサイズが厳しい場合、.NET for Android では asset packs を使って大容量アセットを分離する仕組みもあり、MauiAsset に AssetPack メタデータを付与できることが説明されています。

<!-- App.Maui/App.Maui.csproj(例) -->
<ItemGroup>
  <MauiAsset Include="..\SharedAssets\References\raw\**\*.*"
             LogicalName="References\raw\%(RecursiveDir)%(Filename)%(Extension)" />

  <!-- Android でさらに大きい場合の例(必要なときだけ) -->
  <MauiAsset Update="..\SharedAssets\References\raw\models\**\*.*"
             AssetPack="mlmodels" />
</ItemGroup>

MAUI 側のコードは、基本的に “パッケージに同梱したファイルをストリームとして開く” 発想になります。Blazor Hybrid の静的ファイルはアプリ資産として扱う整理になっているため、MauiAsset+ファイルシステムヘルパーで読む、という方向性がぶれにくいです。

方法2:MSBuild Target(Copy タスク)で「各ターゲットが期待する場所」へ同期する

次に、あなたの要件により直結する「ビルド時に正しい場所へコピーする」方法です。これをやると、ソースは1か所(SharedAssets)に置いたまま、

  • MAUI には App.Maui/wwwroot や Resources/Raw へ
  • Web には App.Web/wwwroot または App_Data へ

のように、ターゲットごとの “正しい置き場” を自動で作れます。

例として、Solution 直下に Directory.Build.targets を置き、各プロジェクトで共通 Target を使えるようにします(これができると、後からターゲットが増えてもルール追加が簡単です)。

<!-- Solution/Directory.Build.targets(例) -->
<Project>
  <PropertyGroup>
    <SharedAssetsRoot>$(MSBuildThisFileDirectory)SharedAssets\</SharedAssetsRoot>
  </PropertyGroup>

  <Target Name="SyncSharedWwwrootToMaui"
          BeforeTargets="Build"
          Condition="'$(MSBuildProjectName)' == 'App.Maui'">

    <ItemGroup>
      <_SharedJs Include="$(SharedAssetsRoot)wwwroot\js\**\*.js" />
    </ItemGroup>

    <Copy SourceFiles="@(_SharedJs)"
          DestinationFiles="@(_SharedJs->'$(MSBuildProjectDirectory)\wwwroot\js\%(RecursiveDir)%(Filename)%(Extension)')"
          SkipUnchangedFiles="true" />
  </Target>

  <Target Name="SyncSharedReferencesToWeb"
          BeforeTargets="Build"
          Condition="'$(MSBuildProjectName)' == 'App.Web'">

    <ItemGroup>
      <_SharedRefs Include="$(SharedAssetsRoot)References\raw\**\*.*" />
    </ItemGroup>

    <Copy SourceFiles="@(_SharedRefs)"
          DestinationFiles="@(_SharedRefs->'$(MSBuildProjectDirectory)\App_Data\References\raw\%(RecursiveDir)%(Filename)%(Extension)')"
          SkipUnchangedFiles="true" />
  </Target>
</Project>

この形の強みは、ソースを1か所に固定したまま、コピー先だけをターゲットごとに変えられる点です。構成変更やターゲット追加(例:WebAssembly クライアント、別のデスクトップホスト)があっても、ルールを増やすだけで追従できます。

コピー戦略で失敗しがちなポイント

  • コピー先を “公開フォルダ(wwwroot)” にしない:巨大ファイルは原則として Web 公開しない(必要なら API で制御)。
  • SkipUnchangedFiles を使う:毎回コピーするとビルドが遅くなり、開発体験が悪化します。
  • Publish 時も意識する:ビルドだけでなく publish 出力に含めたい場合は BeforeTargets="Publish" を追加するか、CopyToPublishDirectory を併用します。
  • Git 管理の方針を決める:LLM などは Git に入れず、Git LFS や別ストレージに置く運用のほうが現実的なことも多いです。

ターゲット別:ファイル種類ごとの “正しい置き場” 早見表

ファイル種類共通保管(ソース)MAUI 側の配置先Web 側の配置先備考
共有 JS/CSS/画像App.Shared/wwwrootそのまま _content 参照(HostPage から読み込む)そのまま _content 参照(App.razor 等から読み込む)RCL の静的アセットは _content/{PackageId}/… で参照
MAUI だけの JS(ネイティブ固有)(必要なら)SharedAssets/wwwrootApp.Maui/wwwroot不要共有したいなら Copy タスクで同期
LLM モデル/大容量音声SharedAssets/References/rawMauiAsset として同梱、必要なら Android asset packApp_Data 等の非公開フォルダ(または外部ストレージ)Android の大容量は asset packs も検討
Web 公開してよいメディアSharedAssets/wwwroot/media必要なら App.Maui/wwwroot にコピーApp.Web/wwwroot公開範囲を明確化(キャッシュ/更新戦略も)

“index.html が無いから参照できない”を卒業する考え方

RCL(.Shared)に index.html が無いのは、設計として正しいです。RCL は「共有する部品」なので、HTML の起点は常に “ホスト(MAUI/Web)側” にあります。

Microsoft Learn の手順でも、MAUI の wwwroot/index.html から RCL の CSS を _content/... 参照に切り替える具体例が示されています。つまり、“参照を書く場所” はホストにあり、“参照される資産” は RCL に置く、という役割分担が最初から想定されています。

ここが腑に落ちると、JavaScript も同じで、

  • 置き場:Shared(RCL)の wwwroot
  • 読み込み:MAUI の index.html/Web の App.razor 等

という “一本道” が作れます。

最終チェックリスト(移行・拡張でハマらないために)

  • 共有したい JS/CSS/画像は RCL の wwwroot に集約したか
  • ホスト(MAUI/Web)の参照が _content/{PackageId}/... になっているか
  • HostPage(MAUI の index.html)は wwwroot 直下に置いているか(サブフォルダにすると参照が破綻しやすい)
  • 巨大ファイルを Web の wwwroot に置いて “公開” していないか
  • 巨大ファイルは「サーバー側に置く」か「ビルドでターゲット別に配る」かを決めたか
  • MSBuild の Copy/Link で “ソース1か所” を維持できているか

この運用にしておけば、構成が変わっても「ソースは1か所、配布ルールだけ直す」で済むため、.NET 9 の MAUI Blazor Hybrid & Web を長期運用しやすくなります。

この記事を書いた人

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

コメント

コメントする

目次