Blazor WebAssemblyでCultureInfoがDateTime.ToStringに反映されない原因とICU設定の完全解説(Invariant Globalization対策)

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 を読み込ませ、かつアプリ起動前にカルチャを正しく適用する必要があります。

最短の解決手順(すぐ直したい人向け)

手順作業要点
1ICU を読み込む設定を *.csproj に追加WASM でカルチャ固有の書式を使うには ICU が必須。まず Invariant を無効化し、必要なら全カルチャを読み込む。
2カルチャをアプリ起動前に適用DefaultThreadCurrentCulture / DefaultThreadCurrentUICulture を RunAsync より前に設定。
3UI からの動的切替が必要ならサービス化シングルトンの状態管理でカルチャ変更イベントを発行し、コンポーネントを再描画。
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 言語に固定

  1. *.csproj で InvariantGlobalization=false を指定。
  2. Program.cs で固定カルチャを適用。
var culture = new CultureInfo("ja-JP");
CultureInfo.DefaultThreadCurrentCulture = culture;
CultureInfo.DefaultThreadCurrentUICulture = culture;

ログインユーザーごとにカルチャを記憶

  1. 選択時に Local Storage へ保存。
  2. 次回起動時に読み出して既定カルチャへ適用。
  3. サーバーと連携が必要なら、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/トリミング有効)でも同じ挙動になる。

トラブル時のデバッグ手順

  1. ブラウザのネットワークタブ:ICU データファイル(icudt*)がダウンロードされているかを確認。
  2. コンソール:起動直後に CultureInfo.CurrentCulture の Name を Console.WriteLine などでロギング。
  3. プロジェクト設定:InvariantGlobalization=false と BlazorWebAssemblyLoadAllGlobalizationData=true の記述ミスや条件付きプロパティを確認。
  4. キャッシュのクリア: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 の書式とは別)。

この記事を書いた人

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

コメント

コメントする

目次