Blazor WebAssembly プログラムを IsTrimmable=true にして発行したら JavaScript 呼び出しの挙動が変わってしまった

## ブラウザの Intl.NumberFormat を呼び出す Blazor WebAssembly アプリケーション

Blazor の JavaScript 相互運用を使用し、Web ブラウザが備える `Intl.NumberFormat` オブジェクトを使って、数値を日本語の位で書式化した文字列に変換する機能を実装しました。

まず、JavaScript 側を以下のように実装します。

```js:*.js
export const toLocaleString = (value, locales, options) => {
    const numberFormat = new Intl.NumberFormat(locales, options)
    return numberFormat.format(value);
}
```

次に、`Intl.NumberFormat` オブジェクトのコンストラクタ引数に指定するオプション構成を表現した C# クラスを Blazor 側で実装します (下記)。

```cs:NumberFormatOptions.cs
public class NumberFormatOptions
{
    public string? CompactDisplay { get; set; }

    public string? Notation { get; set; }
}
```

あとは Blazor 側では、JavaScript 相互運用機能を利用して、上記 JavaScript コードを呼び出すのみです。

```cs:*.razor
@inject IJSRuntime JSRuntime
...
private async Task<string> ToLocaleString(int n) {
  await using var module = JSRuntime.InvokeAsync<IJSObjectReference>("import", "...js");
  return await module.InvokeAsync<string>(
      "toLocaleString",
      "ja-JP",
      new NumberFormatOptions
      {
          CompactDisplay = "short",
          Notation = "compact",
      }
  );
}
```

以上の実装で、下図のように書式化する Blazor WebAssembly アプリケーションを実装できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/16eefcfe-445b-39d1-0ef9-2d4dccd3e06b.png)

## トリミングを有効にして発行

Blazor WebAssembly の泣き所のひとつは、そのコンテンツサイズ、とくにアセンブリの大きさです。そこでビルド後の中間言語 (MSIL) 中から未使用のメンバーを削除することでアセンブリのサイズをなるべく小さくする、"IL トリミング" を有効にしてみました。JavaScript のバンドラーで言うところの、ツリーシェイキングに相当する処理ですね。

IL トリミングを有効にするには、`IsTrimmable` MSBuild プロパティを `true` に設定します。

プロジェクトファイル (.csproj) に書き込んでしまうなら、.csproj ファイル中、適当な `<PropertyGroup>` ノードの中に `<IsTrimmable>true</IsTrimmable>` を書き足します。

```xml:*.csproj
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  <PropertyGroup>
    ...
    <IsTrimmable>true</IsTrimmable> <!-- 👈これを追記 -->
  </PropertyGroup>
  ...
```

あるいは .NET CLI (dotnet コマンド) での発行時にコマンドラインから指定することもできます (下記実行例)。

```shell
dotnet publish -p IsTrimmable=true
```

## 発行後の挙動が変わっている...!

さて、このようにして IL トリミングを有効にして発行した Blazor WebAssembly の動作を確認したところ、`toLocaleString` JavaScript 関数を呼び出した結果が、たんなる3桁カンマ区切りの書式化になってしまい、日本語の位による短縮表記が適用されなくなってしまいました (下図)。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/d3d772ce-10d3-a429-41cf-f498e8d7f053.png)

いったいどういうことでしょうか?

## プロパティの getter がトリミング (削除) されていた!

試行錯誤しながらたどっていくうちに、原因が掴めました。IL トリミングを有効にしたせいで、`NumberFormatOptions` クラスの各プロパティから、**getter アクセッサーが削除**されていたのでした。トリミング後の `NumberFormatOptions` クラスの様子を C# ソースコードで表現すると以下のようになっています。

```cs:NumberFormatOptions.cs
public class NumberFormatOptions
{
    public string? CompactDisplay { set; } // 👈 "get;" が無い! 

    public string? Notation { set; } // 👈 "get;" が無い! 
}
```


たしかに、Blazor 側のコードを見ると、`NumberFormatOptions` クラスの各プロパティには値を設定するのみで、それらプロパティの値を取得・参照している箇所はありません。そのため、IL トリミング処理における静的解析の結果、「`NumberFormatOptions` クラスのプロパティは、設定されているだけで、読み取りされていないな」と判断され、トリミングされてしまったのでしょう。

しかし上記コードのようにトリミングされてしまった `NumberFormatOptions` オブジェクトを、JavaScript 相互運用機能に引き渡すために、JSON シリアル化しようとしたらどうなるでしょうか。JSON シリアライザは、getter のないプロパティからは値を取得できません。なので、それらプロパティはシリアル化処理で単純に無視されます。結果、シリアル化された JSON 文字列は `{}` というように、JavaScript の空オブジェクト表現になってしまうでしょう。

そのため、`Intl.NumberFormat` のコンストラクタ引数にはオプション構成として空オブジェクトが引き渡される結果となり、日本語の位を使った短縮表記が適用されず、ja-JP ロケールにおける既定の書式化、つまり3桁カンマ区切りが適用されるだけとなったのでしょう。

## 対応方法

`NumberFormatOptions` クラスに、トリミングしないよう属性を付与して指示することもできたはずですが、今回は別のアプローチを紹介します。

まず、下記の内容の XML ファイルをプロジェクトフォルダ直下に配置します。ファイル名はとりあえず `ILLink.Descriptors.xml` としておきましょう。

```xml:ILLink.Descriptors.xml
<?xml version="1.0" encoding="utf-8"?>
<linker>
  <assembly fullname="{このプロジェクトのアセンブリ名}">
    <type fullname="{名前空間}.NumberFormatOptions" preserve="all" />
  </assembly>
</linker>
```

この XML は、IL トリミングを実行する IL リンカーに対する指示書で、
「`<assembly fullname=...` で指定したアセンブリ中、`<type fullname=...` で指定した型について、すべてのメンバーをトリミングせず残しておきなさい」
と指示するものです。

次に、こうして用意した XML ファイルを、この Blazor WebAssembly アプリケーションのアセンブリに、リソース埋め込みします。具体的には、プロジェクトファイル (*.csproj) 中に、以下のように `<ItemGroup>` ノードを書き足します。

```xml:*.csproj
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  ...
  <!-- 👇 このノードを追加する -->
  <ItemGroup>
    <EmbeddedResource Include="ILLink.Descriptors.xml">
      <LogicalName>ILLink.Descriptors.xml</LogicalName>
    </EmbeddedResource>
  </ItemGroup>
  ...
```

こうすることで、用意した XML の内容が IL リンカーによって読み取られ、そこに記載の指示に従って IL トリミング処理を実施することとなります。
(IL リンカーは、"ILLink.Descriptors.xml" というリソース名で、トリミング対象のアセンブリ中に埋め込まれたリソースがないか探索し、発見するとこれを読み込む、という仕掛けです)

## まとめ

Blazor WebAssembly アプリケーションプロジェクトで IL トリミングを有効にすると、ビルド後のアセンブリの内容を中間言語 (MSIL) レベルで静的解析し、不要なメンバーを削除することで、アセンブリのサイズをなるべく小さくすることができます。

しかしながらその結果、「設定・書き込みはできるけど、参照・読み取りはできない」という不可思議なプロパティを持つクラスができあがったりすることがあります。このようなクラスのインスタンスに対し、JSON シリアル化処理のように実行時アクセスでプロパティを読み取ろうとすると読み取れなくなり、JavaScript 相互運用機能の利用にあたって「開発時は動作していたのに、発行したら正しく動作しない」といった不具合が生じることがあります。

これを回避する方法のひとつとして、IL トリミングを執り行う IL リンカーに対する指示書を XML 形式の所定の仕様に則って記述し、指定したクラスをトリミングから除外するよう指示することができます。

以上です。