ASP.NET Core で JavaScript サービスを使用してシングル ページ アプリケーションを作成する

作成者: Fiyaz Hasan

警告

この記事で説明されている機能は、ASP.NET Core 3.0 では廃止されています。 より単純な SPA フレームワーク統合メカニズムが Microsoft.AspNetCore.SpaServices.Extensions NuGet パッケージで利用できます。 詳細については、Microsoft.AspNetCore.SpaServices と Microsoft.AspNetCore.NodeServices の廃止に関するお知らせのページを参照してください。

シングル ページ アプリケーション (SPA) は、本質的に高度なユーザー エクスペリエンスを提供できることから、広く使われているタイプの Web アプリケーションです。 クライアント側の SPA フレームワークまたはライブラリ (Angular や React など) と、ASP.NET Core などのサーバー側のフレームワークの統合は、困難な場合があります。 JavaScript サービスは、統合プロセスの手間を減らすために開発されました。 これにより、さまざまなクライアントやサーバーのテクノロジ スタック間でシームレスな操作を行うことができます。

JavaScript サービスとは

JavaScript サービスは、ASP.NET Core のクライアント側テクノロジのコレクションです。 その目的は、ASP.NET Core を開発者の SPA 構築に有用なサーバー側プラットフォームとして位置付けることにあります。

JavaScript サービスは、次の 2 つの異なる NuGet パッケージで構成されています。

これらのパッケージは、次のシナリオで役立ちます。

  • サーバーで JavaScript を実行する
  • SPA フレームワークまたは SPA ライブラリを使用する
  • Webpack を使用してクライアント側の資産を構築する

この記事では、SpaServices パッケージを使用することに重点を置いて説明します。

SpaServices とは

SpaServices は、ASP.NET Core を開発者の SPA 構築に有用なサーバー側プラットフォームとして位置付けるために作成されました。 SpaServices は、ASP.NET Core で SPA を開発する際に必ず必要になるのものではありません。また、SpaServices を使っても、開発者が特定のクライアント フレームワークにロックされることはありません。

SpaServices は、次のような便利なインフラストラクチャを提供します。

これらのインフラストラクチャ コンポーネントを集めると、開発ワークフローとランタイム エクスペリエンスの両方を強化することができます。 このコンポーネントは、個別に採用できます。

SpaServices を使用するための前提条件

SpaServices を使用するには、以下をインストールします。

  • npm を使用した Node.js (バージョン 6 以降)

    • これらのコンポーネントがインストールされていて検出可能であることを確認するには、コマンド ラインから次のコマンドを実行します。

      node -v && npm -v
      
    • Azure Web サイトにデプロイする場合、アクションは必要ありません。Node.js はインストールされ、サーバー環境で使用できます。

  • .NET Core SDK 2.0 以降

    • Visual Studio 2017 を使用している Windows では、 .NET Core クロスプラットフォーム開発ワークロードを選択すると SDK がインストールされます。
  • Microsoft.AspNetCore.SpaServices NuGet パッケージ

サーバー側の事前レンダリング

ユニバーサル (アイソモーフィックとも呼ばれます) アプリケーションは、サーバーとクライアントの両方で実行できる JavaScript アプリケーションです。 Angular、React などの一般的なフレームワークは、このアプリケーション開発スタイル用のユニバーサル プラットフォームを提供します。 最初に、Node.jsを使用してサーバー上にフレームワーク コンポーネントをレンダリングし、次にクライアントにさらに実行を委任するという考え方です。

SpaServices が提供する ASP.NET Core タグ ヘルパーは、サーバーで JavaScript 関数を呼び出すことで、サーバー側の事前レンダリングの実装が簡単にします。

サーバー側の事前レンダリングの前提条件

aspnet-prerendering npm パッケージをインストールします。

npm i -S aspnet-prerendering

サーバー側の事前レンダリングの構成

プロジェクトの _ViewImports.cshtml ファイルに名前空間を登録すると、タグ ヘルパーを検出できるようになります。

@using SpaServicesSampleApp
@addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers"
@addTagHelper "*, Microsoft.AspNetCore.SpaServices"

タグ ヘルパーは、Razor ビューで HTML に似た構文を利用することで、低レベルの API と直接通信する複雑な部分を抽象化します。

<app asp-prerender-module="ClientApp/dist/main-server">Loading...</app>

asp-prerender-module タグヘルパー

前のコード例で使用した asp-prerender-module タグ ヘルパーは、Node.jsを介してサーバー上で ClientApp/dist/main-server.js を実行します。 わかりやすくするために、main-server.js ファイルは、Webpack ビルド プロセスで TypeScript から JavaScript へのトランスパイル タスクの成果物とします。 Webpack では main-server のエントリ ポイントの別名が定義されています。また、この別名の依存関係グラフの走査は、ClientApp/boot-server.ts ファイルから開始します。

entry: { 'main-server': './ClientApp/boot-server.ts' },

次の Angular の例では、ClientApp/boot-server.ts ファイルは、createServerRenderer npm パッケージの RenderResult 関数と aspnet-prerendering の種類を利用して、Node.jsを介してサーバーのレンダリングを構成します。 サーバー側のレンダリング用の HTML マークアップは、resolve 関数呼び出しに渡されます。これは、厳密に型指定された JavaScript の Promise オブジェクトでラップされています。 Promise オブジェクトは、DOM のプレースホルダー要素に注入する HTML マークアップを非同期にページに渡すという重要な役割を果たしています。

import { createServerRenderer, RenderResult } from 'aspnet-prerendering';

export default createServerRenderer(params => {
    const providers = [
        { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
        { provide: 'ORIGIN_URL', useValue: params.origin }
    ];

    return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
        const appRef = moduleRef.injector.get(ApplicationRef);
        const state = moduleRef.injector.get(PlatformState);
        const zone = moduleRef.injector.get(NgZone);
        
        return new Promise<RenderResult>((resolve, reject) => {
            zone.onError.subscribe(errorInfo => reject(errorInfo));
            appRef.isStable.first(isStable => isStable).subscribe(() => {
                // Because 'onStable' fires before 'onError', we have to delay slightly before
                // completing the request in case there's an error to report
                setImmediate(() => {
                    resolve({
                        html: state.renderToString()
                    });
                    moduleRef.destroy();
                });
            });
        });
    });
});

asp-prerender-data タグ ヘルパー

asp-prerender-module タグ ヘルパーと asp-prerender-data タグ ヘルパーと組み合わせると、Razor ビューからサーバー側 JavaScript にコンテキスト情報を渡すことができます。 たとえば、次のマークアップは、ユーザー データを main-server モジュールに渡します。

<app asp-prerender-module="ClientApp/dist/main-server"
        asp-prerender-data='new {
            UserName = "John Doe"
        }'>Loading...</app>

渡された UserName 引数は、組み込みの JSON シリアライザーによってシリアル化され、params.data オブジェクトに格納されます。 次の Angular の例では、このデータを使用して、h1 の要素内に独自の挨拶文を作成します。

import { createServerRenderer, RenderResult } from 'aspnet-prerendering';

export default createServerRenderer(params => {
    const providers = [
        { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
        { provide: 'ORIGIN_URL', useValue: params.origin }
    ];

    return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
        const appRef = moduleRef.injector.get(ApplicationRef);
        const state = moduleRef.injector.get(PlatformState);
        const zone = moduleRef.injector.get(NgZone);
        
        return new Promise<RenderResult>((resolve, reject) => {
            const result = `<h1>Hello, ${params.data.userName}</h1>`;

            zone.onError.subscribe(errorInfo => reject(errorInfo));
            appRef.isStable.first(isStable => isStable).subscribe(() => {
                // Because 'onStable' fires before 'onError', we have to delay slightly before
                // completing the request in case there's an error to report
                setImmediate(() => {
                    resolve({
                        html: result
                    });
                    moduleRef.destroy();
                });
            });
        });
    });
});

タグ ヘルパーで渡されるプロパティ名は、パスカルケース表記で表されます。 同じプロパティ名がキャメルケースで表される JavaScript とは対照的です。 既定の JSON シリアル化構成が、この違いに対応します。

上のコード例を発展させると、サーバーからビューにデータを渡すことができます。そのためには、globals 関数に渡す resolve プロパティを設定します。

import { createServerRenderer, RenderResult } from 'aspnet-prerendering';

export default createServerRenderer(params => {
    const providers = [
        { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
        { provide: 'ORIGIN_URL', useValue: params.origin }
    ];

    return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
        const appRef = moduleRef.injector.get(ApplicationRef);
        const state = moduleRef.injector.get(PlatformState);
        const zone = moduleRef.injector.get(NgZone);
        
        return new Promise<RenderResult>((resolve, reject) => {
            const result = `<h1>Hello, ${params.data.userName}</h1>`;

            zone.onError.subscribe(errorInfo => reject(errorInfo));
            appRef.isStable.first(isStable => isStable).subscribe(() => {
                // Because 'onStable' fires before 'onError', we have to delay slightly before
                // completing the request in case there's an error to report
                setImmediate(() => {
                    resolve({
                        html: result,
                        globals: {
                            postList: [
                                'Introduction to ASP.NET Core',
                                'Making apps with Angular and ASP.NET Core'
                            ]
                        }
                    });
                    moduleRef.destroy();
                });
            });
        });
    });
});

postList オブジェクト内で定義されている globals 配列は、ブラウザーのグローバル window オブジェクトにアタッチされます。 この変数はグローバル スコープに含められ、それによって作業の重複がなくなります。具体的に言えば、同じデータをサーバーで一度、クライアントで再度読み込むことがなくなるためです。

window オブジェクトにアタッチされたグローバル postList 変数

Webpack 開発ミドルウェア

Webpack 開発ミドルウェア は、Webpack が必要に応じてリソースを構築する合理化された開発ワークフローを導入します。 ページがブラウザーに再読み込みされると、ミドルウェアはクライアント側のリソースを自動的にコンパイルして提供します。 別の方法として、サードパーティの依存関係またはカスタム コードが変更されたときに、プロジェクトの npm ビルド スクリプトを介して Webpack を手動で呼び出す方法もあります。 次の例では、package.json ファイル内の npm ビルド スクリプトを示しています。

"build": "npm run build:vendor && npm run build:custom",

Webpack 開発ミドルウェアの前提条件

aspnet-webpack npm パッケージをインストールします。

npm i -D aspnet-webpack

Webpack 開発ミドルウェアの構成

Webpack 開発ミドルウェアは、 Startup.cs ファイルの Configure メソッドの次のコードを使用して HTTP 要求パイプラインに登録されます。

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseWebpackDevMiddleware();
}
else
{
    app.UseExceptionHandler("/Home/Error");
}

// Call UseWebpackDevMiddleware before UseStaticFiles
app.UseStaticFiles();

UseWebpackDevMiddleware 拡張メソッドを使用して静的ファイル ホスティングを登録する前に、UseStaticFiles 拡張メソッドを呼び出す必要があります。 セキュリティ上の理由から、アプリが開発モードで実行されている場合にのみ、ミドルウェアを登録してください。

webpack.config.js ファイルの output.publicPath プロパティは、dist フォルダーの変更を監視するようにミドルウェアに指示します。

module.exports = (env) => {
        output: {
            filename: '[name].js',
            publicPath: '/dist/' // Webpack dev middleware, if enabled, handles requests for this URL prefix
        },

ホットモジュールリプレースメント

Webpack の ホット モジュール交換 (HMR) 機能は 、Webpack 開発ミドルウェアの進化と考えてください。 HMR は同じ利点をすべて実現しますが、変更をコンパイルした後にページ コンテンツを自動的に更新するので、開発ワークフローがさらに効率化されます。 これをブラウザーの更新と混同しないでください。ブラウザーの更新は、現在のメモリ内の状態と SPA のデバッグ セッションに影響します。 Webpack 開発ミドルウェア サービスとブラウザーの間にはライブ リンクがあります。つまり、変更がブラウザーにプッシュされます。

ホット モジュール置換の前提条件

webpack-hot-middleware npm パッケージをインストールします。

npm i -D webpack-hot-middleware

ホットモジュールリプレースメントの設定

HMR コンポーネントは、Configure メソッドで MVC の HTTP 要求パイプラインに登録する必要があります。

app.UseWebpackDevMiddleware(new WebpackDevMiddlewareOptions {
    HotModuleReplacement = true
});

webpack 開発ミドルウェアの場合と同様に、UseWebpackDevMiddleware拡張メソッドは、UseStaticFiles拡張メソッドの前に呼び出す必要があります。 セキュリティ上の理由から、アプリが開発モードで実行されている場合にのみ、ミドルウェアを登録してください。

webpack.config.js ファイルでは、plugins 配列を定義する必要があります。これは空のままでもかまいません。

module.exports = (env) => {
        plugins: [new CheckerPlugin()]

ブラウザーでアプリを読み込むと、開発者ツールのコンソールのタブに HMR がアクティブ化されたことの確認が表示されます。

ホット モジュール置換の接続メッセージ

ルーティングヘルパー

ほとんどの ASP.NET Core ベースの SPA では、サーバー側のルーティングに加えて、クライアント側のルーティングが必要になります。 SPA および MVC のルーティング システムは、干渉せずに個別に動作させることができます。 しかし、1 つのエッジケースでは、HTTP 応答 404 を認識するという課題があります。

拡張子のない /some/page というルートを使用するシナリオを考えてみましょう。 要求がサーバー側ルートとはパターン一致せず、クライアント側のルートと一致すると仮定します。 ここで、/images/user-512.png の受信要求について考えてみます。通常は、サーバー上のイメージ ファイルを検索する必要があります。 要求されたリソース パスがどのサーバー側ルートまたは静的ファイルとも一致しない場合、クライアント側アプリケーションがこれを処理することはほとんどありません。通常は、HTTP 状態コード 404 が返されます。

ルーティングヘルパーの前提条件

クライアント側ルーティング npm パッケージをインストールします。 Angular を使用する場合の例を示します。

npm i -S @angular/router

ルーティング ヘルパーの構成

MapSpaFallbackRoute メソッドで Configure という名前の拡張メソッドを使用します。

app.UseMvc(routes =>
{
    routes.MapRoute(
        name: "default",
        template: "{controller=Home}/{action=Index}/{id?}");

    routes.MapSpaFallbackRoute(
        name: "spa-fallback",
        defaults: new { controller = "Home", action = "Index" });
});

ルートは、構成された順序で評価されます。 そのため、上のコード例の default ルートは、最初にパターン マッチングに使用されます。

新しいプロジェクトを作成する

JavaScript サービスには、事前構成済みのアプリケーション テンプレートが用意されています。 これらのテンプレートでは、Angular、React、Redux などのさまざまなフレームワークやライブラリと共に SpaServices を使用します。

これらのテンプレートは、次のコマンドを実行して .NET CLI 経由でインストールできます。

dotnet new --install Microsoft.AspNetCore.SpaTemplates::*

使用可能な SPA テンプレートの一覧が表示されます。

テンプレート 短縮名 Language Tags
Angular を使用した MVC ASP.NET Core angular [C#] Web/MVC/SPA
React.js を使用した MVC ASP.NET Core react [C#] Web/MVC/SPA
MVC ASP.NET Core と React.js、および Redux を使用したシステム reactredux [C#] Web/MVC/SPA

SPA テンプレートの 1 つを使用して新しいプロジェクトを作成するには、dotnet new コマンドでテンプレートの短い形式の名前を指定します。 次のコマンドは、サーバー側用に構成された ASP.NET Core MVC を使用して、Angular アプリケーションを作成します。

dotnet new angular

ランタイム構成モードの設定

2 つのプライマリ ランタイム構成モードがあります。

  • 開発:
    • デバッグを容易にするソース マップが含まれています。
    • パフォーマンス向上のためにクライアント側のコードを最適化することはしません。
  • 運用:
    • ソース マップを除外します。
    • バンドルと縮小を使用して、クライアント側のコードを最適化します。

ASP.NET Core では、ASPNETCORE_ENVIRONMENT という名前の環境変数を使用して構成モードを格納します。 詳細については、「環境を設定する」を参照してください。

.NET CLI で実行する

プロジェクト ルートで次のコマンドを実行して、必要な NuGet パッケージと npm パッケージを復元します。

dotnet restore && npm i

アプリケーションをビルドして実行します。

dotnet run

アプリケーションは、ランタイム構成モードに従って、localhost で開始されます。 ブラウザーで http://localhost:5000 に移動すると、ランディング ページが表示されます。

Visual Studio 2017 で実行する

.csproj コマンドによって生成された ファイルを開きます。 必要な NuGet および npm パッケージは、プロジェクトを開いたときに自動的に復元されます。 この復元プロセスには数分かかる場合があり、これが完了するとアプリケーションを実行する準備ができています。 緑色の実行ボタンをクリックするか Ctrl + F5 キーを押すと、ブラウザーが開いてアプリケーションのランディング ページが表示されます。 アプリケーションは、ランタイム構成モードに従って、localhost で実行されます。

アプリのテスト

SpaServices テンプレートは、Karma と Jasmine を使用してクライアント側のテストを実行するように事前構成されています。 Jasmine は JavaScript 用の一般的な単体テスト フレームワークであるのに対し、Karma はそれらのテストのテスト ランナーです。 Karma は Webpack 開発ミドルウェア と連携するように構成されているため、変更が行われるたびに開発者がテストを停止して実行する必要はありません。 テスト ケースまたはテスト ケース自体に対してコードが実行されているかどうかにかかわらず、テストは自動的に実行されます。

例として、Angular アプリケーションを使用します。CounterComponent ファイルの counter.component.spec.ts に対し、2 つの Jasmine テスト ケースが既に提供されています。

it('should display a title', async(() => {
    const titleText = fixture.nativeElement.querySelector('h1').textContent;
    expect(titleText).toEqual('Counter');
}));

it('should start with count 0, then increments by 1 when clicked', async(() => {
    const countElement = fixture.nativeElement.querySelector('strong');
    expect(countElement.textContent).toEqual('0');

    const incrementButton = fixture.nativeElement.querySelector('button');
    incrementButton.click();
    fixture.detectChanges();
    expect(countElement.textContent).toEqual('1');
}));

ClientApp ディレクトリで、コマンド プロンプトを開きます。 次のコマンドを実行します。

npm test

このスクリプトは、Karma テスト ランナーを起動します。これにより、karma.conf.js ファイルで定義されている設定が読み取られます。 その他の設定として、karma.conf.js の files 配列で実行するテスト ファイルを指定できます。

module.exports = function (config) {
    config.set({
        files: [
            '../../wwwroot/dist/vendor.js',
            './boot-tests.ts'
        ],

アプリを公開する

Azure への公開の詳細については、この GitHub issueを参照してください。

生成されたクライアント側アセットと発行された ASP.NET Core 成果物をすぐにデプロイ可能なパッケージにまとめると、煩雑になることがあります。 ありがたいことに、SpaServices は、RunWebpack という名前のカスタム MSBuild ターゲットを使用して、発行プロセス全体を調整します。

<Target Name="RunWebpack" AfterTargets="ComputeFilesToPublish">
  <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
  <Exec Command="npm install" />
  <Exec Command="node node_modules/webpack/bin/webpack.js --config webpack.config.vendor.js --env.prod" />
  <Exec Command="node node_modules/webpack/bin/webpack.js --env.prod" />

  <!-- Include the newly-built files in the publish output -->
  <ItemGroup>
    <DistFiles Include="wwwroot\dist\**; ClientApp\dist\**" />
    <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
      <RelativePath>%(DistFiles.Identity)</RelativePath>
      <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
    </ResolvedFileToPublish>
  </ItemGroup>
</Target>

この MSBuild ターゲットには、次の役割があります。

  1. npm パッケージを復元する
  2. サードパーティ製クライアント側アセットの実稼働レベルのビルドを作成する
  3. カスタムのクライアント側アセットの実稼働レベルのビルドを作成する
  4. Webpack が生成したアセットを発行フォルダーにコピーする

次を実行すると、MSBuild ターゲットが呼び出されます。

dotnet publish -c Release