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 フレームワークまたはライブラリ (AngularReact など) と、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 を使用するには、以下をインストールします。

  • Node.js (バージョン 6 以降) と npm

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

      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 ファイルが aspnet-prerendering npm パッケージの createServerRenderer 関数と RenderResult 型を使用して 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-data タグ ヘルパーと asp-prerender-module タグ ヘルパーと組み合わせると、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 シリアル化構成が、この違いに対応します。

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

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();
                });
            });
        });
    });
});

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

global postList variable attached to window object

Webpack Dev ミドルウェア

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

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

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

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

npm i -D aspnet-webpack

Webpack Dev ミドルウェアの構成

Webpack Dev ミドルウェアは、Startup.cs ファイルの Configure メソッドにある次のコードによって、HTTP 要求パイプラインに登録されます。

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

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

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

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

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

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

npm i -D webpack-hot-middleware

ホット モジュール置換の構成

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

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

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

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

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

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

Hot Module Replacement connected message

ルーティング ヘルパー

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

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

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

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

npm i -S @angular/router

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

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

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 Core CLI 経由でインストールできます。

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

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

テンプレート 短い形式の名前 言語 Tags
MVC ASP.NET Core with Angular angular [C#] Web/MVC/SPA
MVC ASP.NET Core with React.js react [C#] Web/MVC/SPA
MVC ASP.NET Core with React.js and 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 Core CLI で実行する

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

dotnet restore && npm i

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

dotnet run

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

Visual Studio 2017 で実行する

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

アプリのテスト

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

例として、Angular アプリケーションを使用します。counter.component.spec.ts ファイルの CounterComponent に対し、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.jsfiles 配列で実行するテスト ファイルを指定できます。

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

アプリの発行

Azure への発行の詳細については、こちらの GitHub の問題のページを参照してください。

生成されたクライアント側アセットと発行された 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

その他の技術情報