Blazor Server ではカルチャの切替だけでリソース文字列も日時書式も一気に更新されるのに、Blazor WebAssembly(WASM)へ移行した途端、DateTime.ToString() が英語(en‑US)のまま……。この“なぜ”を根から断ち切るには、WASM 特有の Invariant Globalization と ICU データの扱いを正しく理解し、プロジェクト設定と起動順序を整えることが重要です。本稿では原因の仕組みから実装手順、動的切替の設計、サイズ最適化、検証方法、落とし穴まで実務に直結する解決策をまとめます。
症状と原因の全体像:なぜ WASM だけ DateTime が英語になるのか
Blazor Server の実行環境(ASP.NET Core/サーバー上の .NET)には各種カルチャのローカライズデータ(ICU)が標準で含まれているため、次のようにランタイムでカルチャを切り替えるだけで、リソース文字列(.resx)も日付・数値の書式も一体となって更新されます。
CultureInfo.CurrentUICulture = new CultureInfo("fa-IR");
しかし Blazor WASM では、初期ダウンロードサイズを削るために Invariant Globalization が既定で有効です。これは「カルチャ固有の書式情報(ICU)を読まない」動作であり、DateTime や数値のフォーマットが en‑US から変わらなくなります。結果として、.resx のローカライズは切り替わるのに、DateTime.ToString() や decimal.ToString() などのカルチャ依存フォーマットだけが英語のまま固定される、というズレが発生します。
つまり、原因は「WASM 側で ICU データが読み込まれていないこと」。解決には ICU を読み込ませ、かつアプリ起動前にカルチャを正しく適用する必要があります。
最短の解決手順(すぐ直したい人向け)
| 手順 | 作業 | 要点 |
|---|---|---|
| 1 | ICU を読み込む設定を *.csproj に追加 | WASM でカルチャ固有の書式を使うには ICU が必須。まず Invariant を無効化し、必要なら全カルチャを読み込む。 |
| 2 | カルチャをアプリ起動前に適用 | DefaultThreadCurrentCulture / DefaultThreadCurrentUICulture を RunAsync より前に設定。 |
| 3 | UI からの動的切替が必要ならサービス化 | シングルトンの状態管理でカルチャ変更イベントを発行し、コンポーネントを再描画。 |
| 4 | 適切なカルチャ名を使う | fa ではなく fa-IR のようにリージョン付きで指定すると書式が期待どおりになる。 |
| 5 | 永続化は Cookie ではなく Local Storage を推奨 | WASM からは Cookie の API がないため、JSInterop で Local Storage に保存・復元するのが簡単。 |
1) *.csproj に ICU 読み込み設定を追加
最も簡単なのは「Invariant を無効化」し、必要に応じて「全カルチャの ICU を読み込む」指定を行うことです。
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<!-- Invariant を無効化(ICU を使う) -->
<InvariantGlobalization>false</InvariantGlobalization>
<!-- すべてのカルチャの ICU を読み込む(必要に応じて) -->
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
<BlazorWebAssemblyLoadAllGlobalizationData>true</...> を外せばダウンロードサイズを減らせますが、対象外カルチャの書式は不足します。多言語切替を UI で提供するなら、まずは true で正しく動くことを確認するのがおすすめです。
2) アプリ起動前にカルチャを適用
カルチャは RunAsync 前に設定しておくのが確実です。Local Storage に保存してあるカルチャ名を読み、既定カルチャに適用してから起動します。
<!-- wwwroot/cultureInterop.js -->
window.blazorCulture = {
get: () => window.localStorage.getItem('blazorCulture'),
set: (value) => window.localStorage.setItem('blazorCulture', value)
};
// Program.cs
using System.Globalization;
using Microsoft.JSInterop;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
var host = builder.Build();
var js = host.Services.GetRequiredService<IJSRuntime>();
var stored = await js.InvokeAsync<string?>("blazorCulture.get");
var culture = string.IsNullOrWhiteSpace(stored) ? new CultureInfo("fa-IR") : new CultureInfo(stored);
// ここで既定カルチャを適用(RunAsync より前)
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
await host.RunAsync();
3) UI から動的に切り替える(任意)
// CultureState.cs
using System.Globalization;
public sealed class CultureState
{
public CultureInfo Current { get; private set; } = CultureInfo.CurrentCulture;
public event Action? Changed;
public void Set(string cultureName)
{
var ci = new CultureInfo(cultureName);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
Current = ci;
Changed?.Invoke();
}
}
// Program.cs(続き)
builder.Services.AddSingleton<CultureState>();
<!-- CultureSwitcher.razor -->
@inject CultureState Culture
@inject IJSRuntime JS
<select @onchange="OnChange">
<option value="fa-IR">فارسی (Iran)</option>
<option value="ja-JP">日本語 (日本)</option>
<option value="en-US">English (US)</option>
</select>
@code {
private async Task OnChange(ChangeEventArgs e)
{
var cultureName = e.Value?.ToString() ?? "en-US";
Culture.Set(cultureName);
await JS.InvokeVoidAsync("blazorCulture.set", cultureName);
// 再描画は CultureState.Changed -> StateHasChanged で行われる
}
protected override void OnInitialized()
{
Culture.Changed += StateHasChanged;
}
public void Dispose() => Culture.Changed -= StateHasChanged;
}
4) 適切なカルチャ名を使う
"fa" のような言語コードのみではフォーマット情報が不足するケースがあります。"fa-IR"(ペルシャ語/イラン)のようにリージョン付きで指定してください。アラビア語や中国語なども同様です。
5) Cookie より Local Storage を推奨
Blazor WASM には Cookie を直接読み書きする .NET API がありません。永続化は Local Storage を使うのがシンプルで、JSInterop のみで完結します。サーバーと共有する必要がある場合は、ホスト側で Cookie に反映する仕組みを追加するとよいでしょう。
仕組みをもう少し深掘り:Invariant と ICU の関係
Invariant Globalization は「カルチャに依存しない共通フォーマットのみ」を提供します。たとえば DateTime.ToString("D") は英語の長い日付、ToString("N") は英語の区切りと小数点……という具合に、常に en‑US 風の結果に固定されます。
一方、ICU(International Components for Unicode) は各カルチャの書式・暦・数字・複数形ルールなどを大量に持つデータ群です。WASM で ICU を読み込めば、fa-IR ならペルシャ語の月名や区切り記号、必要に応じてペルシャ暦が用いられ、ja-JP なら和暦や日本語の曜日名などが利用可能になります。
このため WASM でローカライズを成立させるには、「Invariant を無効化+ICU を読み込み+起動前にカルチャを適用」の 3 点セットが不可欠です。
動作確認用サンプル(Before / After)
| コード | Invariant(ICUなし) | ICUあり(fa‑IR) |
|---|---|---|
DateTime.Now.ToString("D") | Wednesday, November 3, 2025 | دوشنبه ۱۲ آبان ۱۴۰۴ |
(12345.678m).ToString("N") | 12,345.678 | ۱۲٬۳۴۵٫۶۷۸ |
DateTime.Now.ToString("yyyy/MM/dd") | 2025/11/03 | ۱۴۰۴/۰۸/۱۲ (文化依存の暦設定次第) |
※ 和暦(ja-JP)やペルシャ暦(fa-IR)の利用可否は、カルチャの既定カレンダーや DateTimeFormat.Calendar の設定に依存します。必要に応じて明示的にカレンダーを差し替えます。
var culture = new CultureInfo("fa-IR");
culture.DateTimeFormat.Calendar = new PersianCalendar(); // 暦を明示
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
実務でそのまま使えるページ例
<!-- Pages/CultureDemo.razor -->
@inject CultureState Culture
現在のカルチャ: @Culture.Current.Name
長い日付("D"): @DateTime.Now.ToString("D")
時刻("T"): @DateTime.Now.ToString("T")
数値("N"): @(1234567.89m.ToString("N"))
@code {
protected override void OnInitialized()
{
Culture.Changed += StateHasChanged;
}
public void Dispose() => Culture.Changed -= StateHasChanged;
}
InputDate は別物:HTML5 の仕様に注意
Blazor の <InputDate>(type=”date”)はブラウザのネイティブ UI を使うため、表示や入力形式は HTML の仕様(ISO‑8601/Gregorian)とユーザーのブラウザ設定に従います。DateTime.ToString() のカルチャ適用とは独立して動く点に注意してください。ローカライズした日付文字列を見せたい場合は、通常の <input type="text"> やプレーンテキスト表示で .NET 側のフォーマット結果を出力するのが確実です。
多言語サイトのための設計指針(WASM ならではのポイント)
- 初回ロードの最適化:全カルチャの ICU を読み込むとファイルサイズが増えます。HTTP 圧縮(Brotli/Gzip)を有効化し、CDN/キャッシュ/Service Worker(PWA)の活用で体感を改善します。
- カルチャの初期決定:初回はブラウザの言語(
navigator.language)を JS で取得し、未保存時の既定として使うのが実用的です。以降は Local Storage を優先。 - コンポーネントの再描画:カルチャ変更時に
StateHasChangedを確実に呼ぶため、集中管理(CultureStateのイベント)がおすすめです。 - トリミングと AOT:.NET AOT/トリミングが有効な場合も、上記の ICU 設定が優先されます。リリースビルドで必ず動作確認を。
サイズ増加と最適化のトレードオフ
BlazorWebAssemblyLoadAllGlobalizationData を有効にすると、多言語サイトでは最も手堅く問題を解消できますが、ダウンロードサイズが増えます。初期段階は「正しく表示できること」を最優先し、運用フェーズで対象カルチャを分析して最適化(必要な ICU セットのみを読み込む設計)に進むのが現実的です。キャッシュが効けば、2 回目以降の体感差は小さくできるケースがほとんどです。
よくある落とし穴と対処
- CurrentUICulture だけを変えてしまう:書式は
CurrentCultureの担当です。DefaultThreadCurrentCultureとDefaultThreadCurrentUICultureをセットで変えるのが基本。 - 起動後にカルチャを適用:
RunAsync後だと初回レンダリングが英語のままになります。上記サンプルのように 起動前 に適用しましょう。 - カルチャ名の曖昧指定:
fa、zhなど言語のみでは情報不足。fa-IR、zh-TWなどリージョン付きが安全。 - 暦(Calendar)の誤解:カルチャによっては既定カレンダーが期待と異なる場合があります。必要に応じて
DateTimeFormat.Calendarを明示的に設定。 - InputDate の“ローカライズされない問題”:それは HTML の仕様。.NET ではなくブラウザの UI です。
- Cookie を直接触れない:WASM はブラウザ上で動くため、.NET からの Cookie API はありません。Local Storage で十分なことが多いです。
ユースケース別レシピ
アプリ全体を 1 言語に固定
*.csprojでInvariantGlobalization=falseを指定。Program.csで固定カルチャを適用。
var culture = new CultureInfo("ja-JP");
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
ログインユーザーごとにカルチャを記憶
- 選択時に Local Storage へ保存。
- 次回起動時に読み出して既定カルチャへ適用。
- サーバーと連携が必要なら、API コール時に
Accept-Languageとユーザー設定を送る。
ペルシャ暦・和暦など暦を強制したい
カルチャはそのまま、DateTimeFormat.Calendar だけを差し替えます。
var culture = new CultureInfo("ja-JP");
culture.DateTimeFormat.Calendar = new JapaneseCalendar();
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
テスト観点チェックリスト
- 初回起動(Local Storage 未設定)時に OS/ブラウザ言語から妥当な既定が選ばれる。
- カルチャ切替 UI 操作で、同一ページ内の日付・数値・リソース文字列が即時に更新される。
- ページ遷移・再読み込み後も選択が保持される。
- 異なるカルチャで
ToString("D","N","C")の結果が正しく変化する。 - InputDate の見た目/値がカルチャに影響されないことを把握している(仕様どおり)。
- リリースビルド(AOT/トリミング有効)でも同じ挙動になる。
トラブル時のデバッグ手順
- ブラウザのネットワークタブ:ICU データファイル(icudt*)がダウンロードされているかを確認。
- コンソール:起動直後に
CultureInfo.CurrentCultureのNameをConsole.WriteLineなどでロギング。 - プロジェクト設定:
InvariantGlobalization=falseとBlazorWebAssemblyLoadAllGlobalizationData=trueの記述ミスや条件付きプロパティを確認。 - キャッシュのクリア:Service Worker/PWA を導入している場合は更新反映に注意。
まとめ:原因は ICU 不在。設定と適用タイミングで解決
Blazor WASM で DateTime.ToString() がカルチャを無視する本質的な原因は、既定で Invariant Globalization が有効なために ICU データが読み込まれていないことです。プロジェクトファイルで ICU を読み込ませ(必要なら全カルチャ)、アプリ起動前に DefaultThreadCurrentCulture / DefaultThreadCurrentUICulture を適用し、UI からの切替は状態サービスで再描画する——この 3 点を押さえれば、Blazor Server と同等のローカライズ体験を WASM でも再現できます。
付録:全文サンプル(最小構成)
以下をそのまま適用すれば、fa‑IR と ja‑JP の切替で DateTime/decimal の表示が変わる最小サンプルになります。
*.csproj
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<InvariantGlobalization>false</InvariantGlobalization>
<BlazorWebAssemblyLoadAllGlobalizationData>true</BlazorWebAssemblyLoadAllGlobalizationData>
</PropertyGroup>
</Project>
wwwroot/index.html(または _Host.cshtml にスクリプトを追加)
<script src="cultureInterop.js"></script>
wwwroot/cultureInterop.js
window.blazorCulture = {
get: () => window.localStorage.getItem('blazorCulture'),
set: (value) => window.localStorage.setItem('blazorCulture', value)
};
Program.cs
using System.Globalization;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.JSInterop;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add("#app");
// CultureState の登録(動的切替)
builder.Services.AddSingleton();
var host = builder.Build();
// Local Storage からカルチャを読んで既定に適用
var js = host.Services.GetRequiredService();
var stored = await js.InvokeAsync("blazorCulture.get");
var culture = string.IsNullOrWhiteSpace(stored) ? new CultureInfo("ja-JP") : new CultureInfo(stored);
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;
await host.RunAsync();
CultureState.cs
using System.Globalization;
public sealed class CultureState
{
public CultureInfo Current { get; private set; } = CultureInfo.CurrentCulture;
public event Action? Changed;
public void Set(string name)
{
var ci = new CultureInfo(name);
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
Current = ci;
Changed?.Invoke();
}
}
Shared/CultureSwitcher.razor
@inject CultureState Culture
@inject IJSRuntime JS
日本語
فارسی
English
@code {
private async Task OnChange(ChangeEventArgs e)
{
var value = e.Value?.ToString() ?? "ja-JP";
Culture.Set(value);
await JS.InvokeVoidAsync("blazorCulture.set", value);
}
protected override void OnInitialized()
{
Culture.Changed += StateHasChanged;
}
public void Dispose() => Culture.Changed -= StateHasChanged;
}
Pages/Index.razor
<h1>Blazor WASM グローバリゼーション デモ</h1>
現在のカルチャ: @System.Globalization.CultureInfo.CurrentCulture.Name
長い日付("D"): @DateTime.Now.ToString("D")
短い日付("d"): @DateTime.Now.ToString("d")
数値("N"): @(1234567.89m.ToString("N"))
通貨("C"): @(1234.5m.ToString("C"))
おさらい(キーポイント)
- WASM で
DateTime.ToString()が英語のままなのは Invariant Globalization が既定のため。 *.csprojにInvariantGlobalization=falseと、必要ならBlazorWebAssemblyLoadAllGlobalizationData=true。RunAsync前にDefaultThreadCurrentCultureとDefaultThreadCurrentUICultureを適用。- UI からの切替は
CultureStateで発火&再描画。永続化は Local Storage。 - InputDate は HTML の仕様に従う(.NET の書式とは別)。

コメント