Verwenden von JavaScript Services zum Erstellen von Single-Page-Anwendungen in ASP.NET Core

Von Scott Addie und Fiyaz Hasan

Eine Single-Page-Anwendung (SPA) ist aufgrund ihrer inhärenten umfassenden Benutzerumgebung ein gängiger Webanwendungstyp. Die Integration von clientseitigen SPA-Frameworks oder -Bibliotheken wie Angular oder React mit serverseitigen Frameworks wie ASP.NET Core kann schwierig sein. JavaScript Services wurde entwickelt, um eine reibungslose Integration zu ermöglichen. Die Lösung ermöglicht einen nahtlosen Betrieb zwischen den verschiedenen Client- und Servertechnologiestacks.

Warnung

Die in diesem Artikel beschriebenen Funktionen sind ab ASP.NET Core 3.0 veraltet. Im NuGet-Paket Microsoft.AspNetCore.SpaServices.Extensions steht ein einfacherer Integrationsmechanismus für SPA-Frameworks zur Verfügung. Weitere Informationen finden Sie unter [Announcement] Obsoleting Microsoft.AspNetCore.SpaServices and Microsoft.AspNetCore.NodeServices ([Ankündigung] Ablösung von Microsoft.AspNetCore.SpaServices und Microsoft.AspNetCore.NodeServices).

Was ist JavaScript Services?

JavaScript Services sind eine Sammlung von clientseitigen Technologien für ASP.NET Core. Ziel ist es, ASP.NET Core als bevorzugte serverseitige Plattform für den Aufbau von SPAs zu positionieren.

JavaScript Services besteht aus zwei unterschiedlichen NuGet-Paketen:

Diese Pakete sind in folgenden Szenarios nützlich:

  • Ausführen von JavaScript auf dem Server
  • Verwenden eines SPA-Frameworks bzw. einer SPA-Bibliothek
  • Erstellen von clientseitigen Ressourcen mit Webpack

Der Schwerpunkt dieses Artikels liegt auf der Verwendung des SpaServices-Pakets.

Was ist SpaServices?

SpaServices wurde konzipiert, um ASP.NET Core als bevorzugte serverseitige Plattform für den Aufbau von SPAs zu positionieren. SpaServices ist für die Entwicklung von SPAs mit ASP.NET Core nicht erforderlich und bindet Entwickler nicht an ein bestimmtes Clientframework.

SpaServices bietet nützliche Infrastrukturen wie:

Zusammengenommen verbessern diese Infrastrukturkomponenten sowohl den Entwicklungsvorgang als auch die Laufzeitumgebung. Die Komponenten können individuell angepasst werden.

Voraussetzungen für das Verwenden von SpaServices

Für die Arbeit mit SpaServices muss Folgendes installiert sein:

  • Node.js (Version 6 oder höher) mit npm

    • Um sicherzustellen, dass diese Komponenten installiert sind und gefunden werden können, führen Sie den folgenden Befehl über die Befehlszeile aus:

      node -v && npm -v
      
    • Wenn die Bereitstellung auf einer Azure-Website erfolgt, ist keine Aktion erforderlich. Node.js ist installiert und in den Serverumgebungen verfügbar.

  • .NET Core SDK 2.0 oder höher

    • Unter Windows mit Visual Studio 2017 wird das SDK installiert, indem Sie die Workload Plattformübergreifende .NET Core-Entwicklung auswählen.
  • NuGet-Paket Microsoft.AspNetCore.SpaServices

Serverseitiges Prerendering

Eine universelle (oder auch isomorphe) Anwendung ist eine JavaScript-Anwendung, die sowohl auf dem Server als auch auf dem Client ausgeführt werden kann. Angular, React und andere beliebte Frameworks bieten eine universelle Plattform für diese Art der Anwendungsentwicklung. Das Konzept besteht darin, zunächst die Frameworkkomponenten auf dem Server über Node.js zu rendern und dann die weitere Ausführung an den Client zu delegieren.

Taghilfsprogramme für ASP.NET Core von SpaServices vereinfachen die Implementierung von serverseitigem Prerendering, indem die JavaScript-Funktionen auf dem Server aufgerufen werden.

Voraussetzungen für serverseitiges Prerendering

Installieren Sie das npm-Paket aspnet-prerendering:

npm i -S aspnet-prerendering

Konfiguration des serverseitigen Prerenderings

Die Taghilfsprogramme werden durch die Registrierung des Namespaces in der Datei _ViewImports.cshtml des Projekts zugänglich gemacht:

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

Diese Taghilfsprogramme beseitigen die Komplexität der direkten Kommunikation mit Low-Level-APIs, indem sie eine HTML-ähnliche Syntax innerhalb der Razor-Ansicht verwenden:

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

Taghilfsprogramm „asp-prerender-module“

Das Taghilfsprogramm asp-prerender-module, das im vorherigen Codebeispiel verwendet wurde, führt ClientApp/dist/main-server.js auf dem Server über Node.js aus. Der Übersichtlichkeit halber ist die Datei main-server.js ein Artefakt der TypeScript-zu-JavaScript-Transpilationstask im Webpack-Erstellungsprozess. Webpack definiert den Einstiegspunktalias main-server. Dieser Alias durchläuft das Abhängigkeitsdiagramm beginnend bei der Datei ClientApp/boot-server.ts:

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

Im folgenden Angular-Beispiel verwendet die Datei ClientApp/boot-server.ts die createServerRenderer-Funktion und den Typ RenderResult aus dem npm-Paket aspnet-prerendering, um das Serverrendering über Node.js zu konfigurieren. Das HTML-Markup, das für das serverseitige Rendering bestimmt ist, wird an einen Auflösungsfunktionsaufruf übergeben, der ein stark typisiertes JavaScript-Promise-Objekt umschließt. Wichtig ist, dass das Promise-Objekt das HTML-Markup asynchron an die Seite übermittelt, damit es in das DOM-Platzhalterelement eingefügt werden kann.

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

Taghilfsprogramm „asp-prerender-data“

In Verbindung mit dem Taghilfsprogramm asp-prerender-module kann das Taghilfsprogramm asp-prerender-data verwendet werden, um kontextbezogene Informationen aus der Razor-Ansicht an den serverseitigen JavaScript-Code zu übergeben. Das folgende Markup übergibt z. B. Benutzerdaten an das main-server-Modul:

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

Das empfangene UserName-Argument wird mit dem integrierten JSON-Serialisierungsmodul serialisiert und im params.data-Objekt gespeichert. Im folgenden Angular-Beispiel werden die Daten verwendet, um einen personalisierten Gruß innerhalb eines h1-Elements zu erstellen:

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

Eigenschaftsnamen, die in Taghilfsprogramme übergeben werden, werden mit PascalCase-Notation dargestellt. Dies steht im Gegensatz zu JavaScript, wo die gleichen Eigenschaftsnamen mit camelCase dargestellt werden. Dieser Unterschied ist auf die Standardkonfiguration der JSON-Serialisierung zurückzuführen.

Um das vorherige Codebeispiel zu erweitern, können Daten vom Server an die Ansicht durch Umwandlung der globals-Eigenschaft, die der resolve-Funktion zur Verfügung gestellt wird, übergeben werden:

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

Das innerhalb des globals-Objekts definierte postList-Array wird an das globale window-Objekt des Browsers angefügt. Durch dieses Verschieben der Variablen in den globalen Bereich wird doppelter Arbeitsaufwand vermieden, insbesondere wenn dieselben Daten einmal auf den Server und ein zweites Mal auf den Client geladen werden.

global postList variable attached to window object

Webpack Dev Middleware

Webpack Dev Middleware führt einen optimierten Entwicklungsworkflow ein, wobei Webpack Ressourcen nach Bedarf erstellt. Die Middleware kompiliert und bedient automatisch die clientseitigen Ressourcen, wenn eine Seite im Browser neu geladen wird. Der alternative Ansatz besteht darin, Webpack manuell über das npm-Buildskript des Projekts aufzurufen, wenn sich eine Abhängigkeit von einem Drittanbieter oder der benutzerdefinierte Code ändert. Ein npm-Buildskript in der Datei package.json wird im folgenden Beispiel gezeigt:

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

Voraussetzungen für Webpack Dev Middleware

Installieren Sie das npm-Paket aspnet-webpack:

npm i -D aspnet-webpack

Konfiguration von Webpack Dev Middleware

Webpack Dev Middleware wird in der HTTP-Anforderungspipeline über den folgenden Code in der Datei Startup.cs mit der Configure-Methode registriert:

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

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

Die UseWebpackDevMiddleware-Erweiterungsmethode muss aufgerufen werden, bevor das Registrieren von statischem Dateihosting über die UseStaticFiles-Erweiterungsmethode erfolgt. Aus Sicherheitsgründen sollten Sie die Middleware nur registrieren, wenn die App im Entwicklungsmodus ausgeführt wird.

Die output.publicPath-Eigenschaft der Datei webpack.config.js weist die Middleware an, den Ordner dist auf Änderungen zu überwachen:

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

Hot Module Replacement

Stellen Sie sich die Funktion Hot Module Replacement (HMR) von Webpack als eine Weiterentwicklung von Webpack Dev Middleware vor. HMR bietet dieselben Vorteile, optimiert aber den Entwicklungsworkflow zusätzlich, indem der Seiteninhalt nach der Zusammenstellung der Änderungen automatisch aktualisiert wird. Verwechseln Sie dies nicht mit einer Aktualisierung des Browsers, die den aktuellen In-Memory-Status und die Debugsitzung der SPA beeinträchtigen würde. Es gibt einen Livelink zwischen dem Webpack Dev Middleware-Dienst und dem Browser, was bedeutet, dass Änderungen in den Browser per Push übertragen werden.

Voraussetzungen für Hot Module Replacement

Installieren Sie das npm-Paket webpack-hot-middleware:

npm i -D webpack-hot-middleware

Konfiguration von Hot Module Replacement

Die HMR-Komponente muss in der HTTP-Anforderungspipeline von MVC in der Configure-Methode registriert sein:

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

Wie bei der Webpack Dev Middleware muss die UseWebpackDevMiddleware-Erweiterungsmethode vor der UseStaticFiles-Erweiterungsmethode aufgerufen werden. Aus Sicherheitsgründen sollten Sie die Middleware nur registrieren, wenn die App im Entwicklungsmodus ausgeführt wird.

Die Datei webpack.config.js muss ein plugins-Array definieren, auch wenn dieses leer bleibt:

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

Nach dem Laden der Anwendung im Browser sehen Sie auf der Registerkarte „Konsole“ der Entwicklertools eine Bestätigung der HMR-Aktivierung:

Hot Module Replacement connected message

Hilfsprogramme für das Routing

In den meisten ASP.NET Core-basierten SPAs wird neben dem serverseitigen Routing oft auch ein clientseitiges Routing gewünscht. Die SPA- und MVC-Routingsysteme können unabhängig und ohne Beeinträchtigung betrieben werden. Es gibt jedoch einen Grenzfall, der eine Herausforderung darstellt: die Identifizierung von 404-HTTP-Antworten.

Denken Sie an das Szenario, in dem eine Route ohne Erweiterung von /some/page verwendet wird. Angenommen, die Anforderung stimmt nicht mit dem Muster einer serverseitigen Route überein, aber deren Muster stimmt mit einer clientseitigen Route überein. Stellen Sie sich nun eine eingehende Anforderung für /images/user-512.png vor, bei der in der Regel davon ausgegangen wird, dass sie eine Bilddatei auf dem Server findet. Wenn dieser angeforderte Ressourcenpfad mit keiner serverseitigen Route oder statischen Datei übereinstimmt, ist es unwahrscheinlich, dass die clientseitige Anwendung ihn verarbeitet, da normalerweise ein 404-HTTP-Statuscode zurückgegeben werden soll.

Voraussetzungen für Hilfsprogramme für das Routing

Installieren Sie das clientseitige npm-Paket für das Routing. Verwenden von Angular als Beispiel:

npm i -S @angular/router

Konfiguration der Hilfsprogramme für das Routing

Eine Erweiterungsmethode mit dem Namen MapSpaFallbackRoute wird in der Configure-Methode verwendet:

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

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

Routen werden in der Reihenfolge ausgewertet, in der sie konfiguriert sind. Folglich wird die default-Route im vorangehenden Codebeispiel zuerst für den Musterabgleich verwendet.

Erstellt ein neues Projekt

JavaScript Services stellt vorkonfigurierte Anwendungsvorlagen bereit. SpaServices wird in diesen Vorlagen zusammen mit verschiedenen Frameworks und Bibliotheken wie Angular, React und Redux verwendet.

Diese Vorlagen können mit dem folgenden Befehl über die .NET Core-CLI installiert werden:

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

Es wird eine Liste der verfügbaren SPA-Vorlagen angezeigt:

Vorlagen Kurzname Sprache Tags
MVC ASP.NET Core mit Angular angular [C#] Web/MVC/SPA
MVC ASP.NET Core mit React.js react [C#] Web/MVC/SPA
MVC ASP.NET Core mit React.js und Redux reactredux [C#] Web/MVC/SPA

Wenn Sie ein neues Projekt mit einer der SPA-Vorlagen erstellen möchten, schließen Sie den Kurznamen der Vorlage in den dotnet new-Befehl ein. Der folgende Befehl erstellt eine Angular-Anwendung mit ASP.NET Core MVC, die für die Serverseite konfiguriert ist:

dotnet new angular

Festlegen des Laufzeitkonfigurationsmodus

Es gibt zwei primäre Laufzeitkonfigurationsmodi:

  • Entwicklung:
    • Enthält Quellzuordnungsdateien zum Vereinfachen des Debuggens.
    • Der clientseitige Code wird nicht optimiert, um die Leistung zu verbessern.
  • Produktion:
    • Schließt Quellzuordnungsdateien aus.
    • Optimiert den clientseitigen Code über Bündelung und Minimierung.

ASP.NET Core verwendet eine Umgebungsvariable namens ASPNETCORE_ENVIRONMENT, um den Konfigurationsmodus zu speichern. Weitere Informationen finden Sie unter Festlegen der Umgebung.

Ausführen mit der .NET Core-CLI

Stellen Sie die erforderlichen NuGet- und npm-Pakete wieder her, indem Sie den folgenden Befehl im Stammverzeichnis des Projekts ausführen:

dotnet restore && npm i

Erstellen Sie die Anwendung, und führen Sie sie aus:

dotnet run

Die Anwendung startet auf Localhost entsprechend dem Laufzeitkonfigurationsmodus. Wenn Sie im Browser zu http://localhost:5000 navigieren, wird die Landing Page angezeigt.

Ausführen mit Visual Studio 2017

Öffnen Sie die Datei .csproj, die mit dem dotnet new-Befehl generiert wurde. Die erforderlichen NuGet- und npm-Pakete werden beim Öffnen des Projekts automatisch wiederhergestellt. Dieser Wiederherstellungsprozess kann einige Minuten in Anspruch nehmen, und die Anwendung ist nach Abschluss des Vorgangs einsatzbereit. Klicken Sie auf die grüne Schaltfläche zum Ausführen, oder drücken Sie Ctrl + F5, und der Browser öffnet die Landing Page der Anwendung. Die Anwendung wird auf Localhost entsprechend dem Laufzeitkonfigurationsmodus ausgeführt.

Testen der App

SpaServices-Vorlagen sind vorkonfiguriert, um clientseitige Tests mit Karma und Jasmine auszuführen. Jasmine ist ein beliebtes Komponententestframework für JavaScript, wohingegen Karma ein Test Runner für diese Tests ist. Karma ist für die Verwendung mit Webpack Dev Middleware konfiguriert, sodass Entwickler nicht bei jeder Änderung den Test anhalten und ausführen müssen. Der Test wird automatisch ausgeführt, unabhängig davon, ob es sich um den Code mit dem Testfall oder den Testfall selbst handelt.

Wenn Sie beispielsweise die Angular-Anwendung verwenden, werden damit zwei Jasmine-Testfälle für CounterComponent in der Datei counter.component.spec.ts bereitgestellt:

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

Öffnen Sie die Eingabeaufforderung im Verzeichnis ClientApp. Führen Sie den folgenden Befehl aus:

npm test

Das Skript startet den Karma-Test Runner, der die Einstellungen liest, die in der Datei karma.conf.js definiert sind. Neben anderen Einstellungen identifiziert karma.conf.js über das files-Array die auszuführenden Testdateien:

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

Veröffentlichen der App

Weitere Informationen zur Veröffentlichung in Azure finden Sie in diesem GitHub-Thema.

Die Zusammenstellung der generierten clientseitigen Ressourcen und der veröffentlichten ASP.NET Core-Artefakte zu einem einsatzbereiten Paket kann umständlich sein. Daher ist es gut, dass SpaServices diesen gesamten Veröffentlichungsprozess mit einem benutzerdefinierten MSBuild-Target namens RunWebpack orchestriert:

<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>

Das MSBuild-Ziel hat die folgenden Zuständigkeiten:

  1. Wiederherstellen der npm-Pakete
  2. Erstellen eines produktionsfähigen Builds der clientseitigen Drittanbieterressourcen
  3. Erstellen eines produktionsfähigen Builds der benutzerdefinierten, clientseitigen Ressourcen
  4. Kopieren der von Webpack generierten Ressourcen in den Ordner für die Veröffentlichung

Das MSBuild-Ziel wird aufgerufen, wenn Folgendes ausgeführt wird:

dotnet publish -c Release

Zusätzliche Ressourcen