January 2019

Volume 34 Number 1

[Cutting Edge]

Blazor のテンプレート ベースのコンポーネント

Dino Esposito | January 2019

Dino Esposito2018 年初頭に Blazor の最初のパブリック ビルドがリリースされてから、1 年近く経ちました。ホスト ブラウザー内から C# および .NET コードを実行できるクライアント側 Web フレームワークとして設計されたこのプラットフォームは、さまざまな方向に進化してきました。

Blazor は、到達可能なバックエンド サービスからダウンロードしたデータを処理するプレーンなクライアント側フレームワークであることに変わりはありませんが、SignalR 接続経由で全体がサーバーから実行されるよう基盤の変更も行われました。最近の SignalR Azure サービスの導入は、最新の開発プラットフォームとして Blazor をさらに推進するという Microsoft の意図の表れにほかならないと私は考えます。サーバー アプリケーションと無数のクライアントとの間にスケーラブルなクラウド サービスが存在するので、.NET Core コードがサーバー上で効果的にホストされることや、JavaScript ではなく C# の仲介によってクライアント上で対話的に実行されることが保証されます。

クライアント側の Web フレームワークとして、Blazor (一部は次の ASP.NET Core 3.0 リリースに同梱予定) は、コンポーネントなしではうまく機能しません。Blazor チームは、バージョン 0.6.0 のフレームワークで、テンプレート ベースのコンポーネントという特定のフレーバーを導入しました。この記事では、これまでの 2 つのコラムで取り上げた先行入力の例をテンプレート ベースのコンポーネントに変更することで、それらがどのように機能するかを詳しく説明します。

Blazor にテンプレートを追加する

単純なコンポーネントならプロパティで設定できても、現実のコンポーネントはたいていレンダリングの柔軟性がもっと必要です。テンプレートはそれを実現する標準的な方法となります。たとえば、データ グリッド コンポーネントを考えてみましょう。Razor などのデータ バインディング インフラストラクチャを使用すると、既知のデータ ソースにリンクされたデータ テーブルを簡単に構築することができます。ここに、データ項目のコレクションから HTML テーブルを構築する方法の簡単な例を示します。

<table>
@foreach(var item in Items)
{
  <tr>
    <td>@item.FirstName</td>
    <td>@item.LastName</td>
  </tr>
}
</table>

この例は迅速かつ簡単ですが、再利用できるものはそれほど多くありません。では、上部にヘッダー、フッター、検索バーなどを、下部にページャー バーを追加して、グリッド構造をより充実させることを想像してみましょう。検索バーとページャー バーの背後にあるグラフィカルなレイアウトとコードは、検索されたりページ処理されたりする実際のデータによって変わることはありません。しかし、データ グリッドを使用して異なる種類のデータを表示するたびに、検索バーとページャー バーをそれぞれ書き換えることになります。

この最小レベルの抽象化では、国のグリッドと顧客のグリッドはまったく異なるエンティティですが、さまざまな内部要素の背後にあるコア コードはほぼ同じです。テンプレート ベースのコンポーネントはこの特定のシナリオに対応し、単一の DataGrid コンポーネントで単一のコードベースで国と顧客の両方を表示、検索、ページ処理できるようにする方法を示します。

たとえば、充実したグリッド コンポーネントを Blazor のフロントエンドのビューに追加するとします。図 1 は、まったく新しいテンプレート ベースの Blazor の DataSource コンポーネントのソース コードを示しています。

図 1 DataSource テンプレート コンポーネント

@typeparam TItem
@inject HttpClient HttpExecutor
<div style="border: solid 4px #111;">   
  <div class="table-responsive">
    <table class="table table-hover">
      <thead>
        <tr>
          @if (HeaderTemplate != null)
          {
            @HeaderTemplate
          }
        </tr>
      </thead>
      <tbody>
        @foreach (var item in Items)
        {
          <tr>
           @RowTemplate(item)
          </tr>
        }
      </tbody>
      <tfoot>
        <tr>
          @if (FooterTemplate != null)
          {
            @FooterTemplate(Items)
          }
        </tr>
      </tfoot>
    </table>
  </div>
</div>
@functions {
  [Parameter]
  RenderFragment HeaderTemplate { get; set; }
  [Parameter]
  RenderFragment<TItem> RowTemplate { get; set; }
  [Parameter]
  RenderFragment<IList<TItem>> FooterTemplate { get; set; }
  [Parameter]
  IList<TItem> Items { get; set; }
}

ご覧のように、DataSource コンポーネントは HTML テーブルのスケルトンを中心に構築されており、バインドされたコレクション内のデータ レコードに対してテーブル行を反復処理してテーブルの本体が構成されます。ヘッダーとフッターはテーブル行として定義され、実際のコンテンツの詳細はクライアント ページに任されます。クライアント ページは、@functions セクションで定義されたパブリック インターフェイスを介してコンポーネントと対話し、これをさらにカスタマイズできます。ここでは、HeaderTemplate、FooterTemplate、RowTemplate という 3 つのテンプレートと、Items という 1 つのプロパティを定義しました。これらはコンポーネントの実際のデータ ソースおよびデータ プロバイダーとして機能します。

固定データ型にテンプレート ベースのコンポーネントをバインドすることも、宣言で指定されたジェネリック データ型にコンポーネントをバインドすることもできます。現実に即して、異なるデータのコレクションを表示、検索、ページ処理するために、同じグリッドのコンポーネントを使用できます。コンポーネントにこの機能を持たせるには、Blazor では次のように @typeparam ディレクティブを使用します。

@typeparam TItem

Razor ソース コードにある TItem モニカーへの参照は、動的に決定される C# ジェネリック クラスの型への参照として扱われます。コンポーネント内で使用される実際の型は、TItem プロパティで指定します。図 2 は、クライアント ページがどのようにして DataSource テンプレート コンポーネントを宣言するかを示します。

図 2 DataSource テンプレート コンポーネントの宣言

<DataSource Items="@Countries" TItem="Country">
  <HeaderTemplate>
    <th>Name</th>
    <th>Capital</th>
  </HeaderTemplate>
  <RowTemplate>
    <td>@context.CountryName</td>
    <td>@context.Capital</td>
  </RowTemplate>
  <FooterTemplate>
    <td colspan="2">
      @context.Count countries found.
    </td>
  </FooterTemplate>
</DataSource>

ジェネリック型プロパティの名前 (前述のコード スニペットでは TItem) は、コンポーネントのソース コード内の @typeparam ディレクティブを介して宣言された型パラメーターの名前と一致します。ジェネリック型パラメーターは、フレームワークによって推論されるだけで、指定されないことが多いことにご注意ください。

テンプレートの要素のプログラミング

Blazor テンプレートは、RenderFragment 型のインスタンスです。別の言い方をすれば、それは Razor ビュー エンジンによってレンダリングされるマークアップのまとまりであり、.NET 型の普通のインスタンスのように扱うことができます。ほとんどのテンプレートはパラメーターがありませんが、汎用化することもできます。汎用テンプレートは、指定された型のインスタンスを引数として受け取り、その内容を使って出力をレンダリングできます。図 2 のサンプルでは、ヘッダー テンプレートにはパラメーターがありませんが、行テンプレートとフッター テンプレートは汎用的です。

具体的には、RowTemplate プロパティは TItem のインスタンスを受け取るのに対し、FooterTemplate プロパティは TItem インスタンスのコレクションを受け取ります。必要に応じて、固定型のインスタンスを受け取るためのテンプレートを定義することもできます。たとえば、代わりに、ページ内にレンダリングされるアイテム数を示す整数のみを FooterTemplate に渡すこともできます。これを次のコードに示します。

RenderFragment<TItem> RowTemplate { get; set; }
RenderFragment<IList<TItem>> FooterTemplate { get; set; }

テンプレートをクライアント ページで実装するかどうかはオプションにすることができ、そのためには、使用前に簡単な存在確認を行うよう、その呼び出しをラップします。Blazor コンポーネントがどのようにしてテンプレート プロパティの 1 つをオプションにできるかを次に示します。

@if (FooterTemplate != null)
{
  @FooterTemplate(Items)
}

パラメトリック テンプレートをレンダリングするときは、テンプレートの引数を参照するために “context” 暗黙名を使用します。たとえば、RowTemplate プロパティを使用してテーブル行をレンダリングするときは、次のようにコンテキスト パラメーターを使用して、レンダリングするアイテムを参照します。

<RowTemplate>
  <td>@context.CountryName</td>
  <td>@context.Capital</td>
</RowTemplate>

次に示すように、コンテキストの引数名は、テンプレートのコンテキスト プロパティを使用して宣言によって変更できます。

<RowTemplate Context="dataItem">
  <td>@dataItem.CountryName</td>
  <td>@dataItem.Capital</td>
</RowTemplate>

その結果、図 3 に示すように、同じ Blazor ビューで DataSource 汎用コンポーネントを使用して、さまざまなデータ型のデータ グリッドにデータを読み込めます。記述したコードの構造は、次のように要約されます。

<DataSource Items="@Countries" TItem="Country">
  ...
</DataSource>
<DataSource Items="@Forecasts" TItem="WeatherForecast">
  ...
</DataSource>

DataSource 汎用コンポーネント
図 3 DataSource 汎用コンポーネント

表示されるグリッドの周囲のマークアップやコード (ページャー バー、検索バー、並べ替えボタンなど) はすべて完全に再利用されます。個人的な話になりますが、こうした詳細なレベルのマークアップのカスタマイズは昔の ASP.NET Web フォームを思い起こさせます。当時はカスタム サーバー コントロールがテンプレートやカスタム プロパティを介して独自のドメイン固有言語を定義していました。そのおかげで、目的の UI の概要を示すプロセスは非常にスムーズで容易でした。MVC の到来と、それに続いて起こったプレーンなクライアント側 Web 開発への移行により、私たちは HTML の機構に接近する一方で、抽象化からは遠ざかることになりました。フレームワークのコンポーネントは、あのレベルの表現力を取り戻そうとする試みにほかなりません。

Blazor の TypeAhead コンポーネントの書き換え

先月 (msdn.com/magazine/mt830376)、私は全体が Blazor で書かれた先行入力コンポーネントを紹介しました。これは、人気の (そして JavaScript ベースの) Twitter TypeAhead プラグインと同じ機能を提供するものです。あの実装では、ヒントを返す役目のサーバー エンドポイントは、実際には 3 つのプロパティを持つコンパクトな汎用データ転送オブジェクトを返す必要がありました。すなわち、クエリで識別されたオブジェクトの一意の ID、表示テキスト、そして 3 つ目のプロパティとしてヒントごとにドロップダウン ボックスに表示されるマークアップのレンダリングです。

先月のデモでは、先行入力コンポーネントを使用して、国名を検索しました。サーバー エンドポイントは、ISO 国コード、国名、HTML スニペット (国名、首都、大陸を含む) から成るオブジェクトを返しました。ただし、HTML スニペットはサーバー実装の完全な管理下にあったため、クライアント ページの作成者が先行入力コンポーネントを使用して HTML スニペットのレイアウトを制御することはできませんでした。これは、今までのような基本的なデモの領域から外に出て、テンプレート ベースのコンポーネントが実際に動作する様子を観察するための完全なシナリオです。

図 4 に、新しい先行入力コンポーネントの HTML レイアウトを示します。これは先月と同じコードですが、注目すべき例外があります。それはヒントのプロバイダーから返されるデータの形式です。最初の実装で、ヒントのプロバイダー (サンプル コントローラー) はデータ転送オブジェクトを返していたため、これがユーザーによって選択される実際のデータを制御する唯一のポイントとなりました。ヒントのプロバイダーに合わせた先行入力項目のリストではなく国のリストを返させることで、Blazor コンポーネントは ItemTemplate プロパティを公開でき、呼び出し元が各ドロップダウン メニュー項目のレイアウトを決定できるようになります。コンポーネントは国のリストを受け取ると OnSelection イベントを発生させ、選択されたデータ項目のインスタンスを直接、関心のあるリスナーに渡すことができます。

図 4 テンプレート ベースの TypeAhead コンポーネント

<div>
  <div class="input-group">
    <input type="text" class="@Class"
           oninput="this.blur(); this.focus();"
           bind="@SelectedText"
           onblur="@(ev => TryAutoComplete(ev))" />
    <div class="input-group-append">
      <button class="btn dropdown-toggle"
              type="button"
              data-toggle="dropdown"
              style="display: none;">
        <i class="fa fa-chevron-down"></i>
      </button>
      <div class="dropdown-menu @(_isOpen ? "show" : "")"
           style="width: 100%;">
        <h6 class="dropdown-header">
          @Items.Count item(s)
        </h6>
        @foreach (var item in Items)
        {
          <a class="dropdown-item"
           onclick="@(() => TrySelect(item))">
            @ItemTemplate(item)
          </a>
        }
      </div>
    </div>
  </div>
</div>

厳密には、選択したデータ項目の一意の ID をコードで収集する隠しフィールドが依然として必要な場合があります。これにより、先行入力コンポーネントが HTML フォームで使用されると、そのコンテンツをブラウザーの通常のチャネルを通して正常にポストすることが保証されます。しかし、より自然なこととして、今やこの隠しフィールドを先行入力コンポーネントの境界の外側に配置することができるようになったので、これをクライアントの開発者の完全な管理下に置くことができます。

先行入力コンポーネントは、ItemTemplate というテンプレート プロパティを定義します。これは次のように定義されます。

[Parameter]
RenderFragment<TItem> ItemTemplate { get; set; }

TItem パラメーターは、呼び出し元によって定義されます。概要として、項目テンプレートを持つ先行入力コンポーネントをセットアップするマークアップをここに示します。

<Typeahead TItem="Country"
           url="/hint/countries"
           name="country"
           onSelectionMade="@ShowSelection">
  <ItemTemplate>
    <span>@context.CountryName</span>&nbsp;
    <b>(@context.ContinentName)</b>
    <small class="pull-right">@context.Capital</small>
  </ItemTemplate>
</Typeahead>

TItem パラメーターは、Country 型のオブジェクトを処理し、指定された URL からヒントを受け取ることをコンポーネントに指示します。入力したテキストに基づくヒントが受け取られるたびに、それらのヒントは、ItemTemplate セクションのマークアップを使用して動的なドロップダウン リストにレンダリングされます。仕様により、項目テンプレートは現在のデータ項目のインスタンスを受け取り、HTML の行を作成します。言うまでもありませんが、ドロップダウン リストの形状は、現在は完全にページ作成者の管理下にあります。これは大きな前進です (図 5 参照)。

国のドロップダウン リスト
図 5 国のドロップダウン リスト

まとめ

TypeAhead コンポーネントは、Blazor のコンポーネント プログラミングの面白い例です。HTTP プロトコルを介してデータを取得するロジックだけでなく、テンプレートや、要素 (入力フィールドとドロップダウン リスト) 間の内部対話が含まれています。また、イベントを使用して外部と通信します。

人の心を引き付ける実験として誕生した Blazor は大きく成長していますが、方向性は完全には明らかになっておらず、今後数か月間で変わる可能性があります。現時点では、チームの主な目的は、WebAssembly を介してブラウザーで Blazor のクライアント側を実行するためのサポートを提供することです。

同時に、ASP.NET Core に Blazor を埋め込むことには多くの利点があります。その中でも主要なものとしては、アプリケーションの読み込み時間が非常に速いことが挙げられます。ASP.NET Core 3.0 に統合される Blazor コンポーネントは、名前が Razor コンポーネントに変更されます。これは、物事をわかりやすくし、ブラウザーで実行されるものと、サーバーで実行されてクライアント向けに出力を生成するものとの間の混同を避けるために選択された、単なる別名です。いずれにしても、コンポーネント モデルは、サーバーまたはクライアントのどちらで実行しているかに関係なく、同じままである必要があります。


Dino Esposito  氏は、彼の 25 年間のキャリアの中で、20 冊を超える本と 1,000 以上の記事の著者となっています。劇形式の作品『The Sabbatical Break』の著者である Esposito は、BaxEnergy のデジタル ストラテジストとして、より良い世界を構築するためにソフトウェアの記述に取り組んでいます。彼には Twitter (@despos、英語) から連絡できます。

この記事のレビューに協力してくれたマイクロソフト技術スタッフのDaniel Roth