Utiliser les services JavaScript pour créer des applications monopage dans ASP.NET Core

Par Fiyaz Hasan

Avertissement

Les fonctionnalités décrites dans cet article sont obsolètes à partir de ASP.NET Core 3.0. Un mécanisme d’intégration des infrastructures SPA plus simple est disponible dans le package NuGet Microsoft.AspNetCore.SpaServices.Extensions . Pour plus d’informations, consultez [Annonce] Rendre obsolètes Microsoft.AspNetCore.SpaServices et Microsoft.AspNetCore.NodeServices.

Une application monopage (SPA) est un type d’application web populaire en raison de sa riche expérience utilisateur inhérente. L’intégration de bibliothèques ou d’infrastructures SPA côté client, telles que Angular ou React, avec des infrastructures côté serveur telles que ASP.NET Core peut s’avérer difficile. JavaScript Services a été développés pour réduire les frictions dans le processus d’intégration. Il permet un fonctionnement fluide entre les différentes piles de technologies client et serveur.

Qu’est-ce que Services JavaScript ?

JavaScript Services est une collection de technologies côté client pour ASP.NET Core. Son objectif est de positionner ASP.NET Core en tant que plateforme côté serveur préférée des développeurs pour la création de SPA.

JavaScript Services se compose de deux packages NuGet distincts :

Ces packages sont utiles dans les scénarios suivants :

  • Exécuter JavaScript sur le serveur
  • Utiliser une infrastructure ou une bibliothèque SPA
  • Créer des ressources côté client avec Webpack

Dans cet article, l’accent est mis sur l’utilisation du package SpaServices.

Qu’est-ce que SpaServices ?

SpaServices a été créé pour positionner ASP.NET Core en tant que plateforme côté serveur préférée des développeurs pour la création de SPA. SpaServices n’est pas nécessaire pour développer des SPA avec ASP.NET Core, et il n’enferme pas les développeurs dans une infrastructure cliente particulière.

SpaServices fournit une infrastructure utile, telle que :

Collectivement, ces composants d’infrastructure améliorent à la fois le flux de travail de développement et l’expérience runtime. Il est possible d’adopter les composants individuellement.

Conditions préalables à l’utilisation de SpaServices

Pour utiliser SpaServices, installez les éléments suivants :

  • Node.js (version 6 ou ultérieure) avec npm

    • Pour vérifier que ces composants sont installés et sont disponibles, exécutez la commande suivante à partir de la ligne de commande :

      node -v && npm -v
      
    • Si vous effectuez un déploiement sur un site web Azure, aucune action n’est requise. Node.js est installé et disponible dans les environnements serveur.

  • Kit SDK .NET Core 2.0 ou version ultérieure

    • Sous Windows avec Visual Studio 2017, le SDK est installé en sélectionnant la charge de travail de développement multiplateforme .NET Core .
  • Package NuGet Microsoft.AspNetCore.SpaServices

Pré-affichage côté serveur

Une application universelle (également appelée isomorphe) est une application JavaScript capable de s’exécuter à la fois sur le serveur et sur le client. Angular, React et d’autres infrastructures populaires fournissent une plateforme universelle pour ce style de développement d’applications. L’idée est d’afficher d’abord les composants du framework sur le serveur via Node.js, puis de déléguer l’exécution au client.

Les Tag Helpers ASP.NET Core fournis par SpaServices simplifient l’implémentation du pré-affichage côté serveur en appelant les fonctions JavaScript sur le serveur.

Prérequis du pré-affichage côté serveur

Installez le package npm aspnet-prerendering :

npm i -S aspnet-prerendering

Configuration du pré-affichage côté serveur

Les Tag Helpers sont détectables via l’inscription de l’espace de noms dans le fichier _ViewImports.cshtml du projet :

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

Ces Tag Helpers éliminent les subtilités de la communication directe avec les API de bas niveau en tirant parti d’une syntaxe de type HTML à l’intérieur de la vue Razor :

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

Tag Helper asp-prerender-module

Le Tag Helper asp-prerender-module, utilisé dans l’exemple de code précédent, exécute ClientApp/dist/main-server.js sur le serveur via Node.js. Par souci de clarté, le fichier main-server.js est un artefact de la tâche de transpilation TypeScript vers JavaScript dans le processus de génération Webpack. Webpack définit un alias de point d’entrée de main-server ; et, le parcours du graphe des dépendances pour cet alias commence au niveau du fichier ClientApp/boot-server.ts :

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

Dans l’exemple Angular suivant, le fichier ClientApp/boot-server.ts utilise la fonction createServerRenderer et le type RenderResult du package aspnet-prerendering npm pour configurer le rendu du serveur via Node.js. Le balisage HTML destiné au rendu côté serveur est passé à un appel de fonction de résolution, qui est enveloppé dans un objet Promise JavaScript fortement typé. L’importance de l’objet Promise est qu’il fournit de façon asynchrone le balisage HTML à la page pour injection dans l’élément d’espace réservé du DOM.

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

Tag Helper asp-prerender-data

Lorsqu’il est associé au Tag Helper asp-prerender-module, le Tag Helper asp-prerender-data peut être utilisé pour transmettre des informations contextuelles de la vue Razor au JavaScript côté serveur. Par exemple, le balisage suivant transmet les données utilisateur au module main-server :

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

L’argument UserName reçu est sérialisé à l’aide du sérialiseur JSON intégré et est stocké dans l’objet params.data. Dans l’exemple Angular suivant, les données sont utilisées pour construire un message d’accueil personnalisé dans un élément 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();
                });
            });
        });
    });
});

Les noms de propriétés passés dans Tag Helpers sont représentés avec la notation PascalCase. Comparez cela à JavaScript, où les mêmes noms de propriétés sont représentés avec camelCase. La configuration de sérialisation JSON par défaut est responsable de cette différence.

Pour développer l’exemple de code précédent, les données peuvent être passées du serveur à la vue en hydratant la propriété globals fournie à la fonction 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();
                });
            });
        });
    });
});

Le tableau postList défini à l’intérieur de l’objet globals est attaché à l’objet window global du navigateur. Cette variable se hissant vers une étendue globale élimine la duplication d’efforts, en particulier en ce qui concerne le chargement des mêmes données une fois sur le serveur et de nouveau sur le client.

global postList variable attached to window object

Intergiciel Webpack Dev

L’intergiciel Webpack Dev introduit un flux de travail de développement simplifié par lequel Webpack génère des ressources à la demande. L’intergiciel compile et sert automatiquement les ressources côté client lorsqu’une page est rechargée dans le navigateur. L’autre approche consiste à appeler manuellement Webpack via le script de build npm du projet lorsqu’une dépendance tierce ou le code personnalisé change. L’exemple suivant montre un script de build npm dans le fichier package.json :

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

Prérequis de l’intergiciel Webpack Dev

Installez le package npm aspnet-webpack :

npm i -D aspnet-webpack

Configuration de l’intergiciel Webpack Dev

L’intergiciel Webpack Dev est inscrit dans le pipeline de requêtes HTTP via le code suivant dans la méthode Configure du fichier Startup.cs :

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

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

La méthode d’extension UseWebpackDevMiddleware doit être appelée avant d’inscrire l’hébergement de fichiers statiques via la méthode d’extension UseStaticFiles. Pour des raisons de sécurité, inscrivez l’intergiciel uniquement lorsque l’application s’exécute en mode développement.

La propriété output.publicPath du fichier webpack.config.js demande à l’intergiciel de vérifier s’il n’y a pas de modifications au dossier dist :

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

Remplacement de modules à chaud

Considérez la fonctionnalité HMR (Hot Module Replacement ou remplacement de modules à chaud) de Webpack comme une évolution de l’Intergiciel Webpack Dev. HMR présente tous les mêmes avantages, mais il simplifie davantage le flux de travail de développement en mettant automatiquement à jour le contenu de la page après la compilation des modifications. Ne confondez pas cela avec une actualisation du navigateur, qui interférerait avec l’état actuel en mémoire et la session de débogage du SPA. Il existe un lien en direct entre le service de l’intergiciel Webpack Dev et le navigateur, ce qui signifie que les modifications sont envoyées au navigateur.

Conditions préalables au remplacement des modules à chaud

Installez le package npm webpack-hot-middleware :

npm i -D webpack-hot-middleware

Configuration du remplacement de modules à chaud

Le composant HMR doit être inscrit dans le pipeline de requêtes HTTP de MVC dans la méthode Configure :

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

Comme c’était le cas avec l’intergiciel Webpack Dev, la méthode d’extension UseWebpackDevMiddleware doit être appelée avant la méthode d’extension UseStaticFiles. Pour des raisons de sécurité, inscrivez l’intergiciel uniquement lorsque l’application s’exécute en mode développement.

Le fichier webpack.config.js doit définir un tableau plugins, même s’il est laissé vide :

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

Après avoir chargé l’application dans le navigateur, l’onglet Console des outils de développement confirme l’activation de HMR :

Hot Module Replacement connected message

Assistances de routage

Dans la plupart des SPA ASP.NET Core, le routage côté client est souvent souhaité en plus du routage côté serveur. Les systèmes de routage SPA et MVC peuvent fonctionner indépendamment sans interférence. Toutefois, il existe un cas de périphérie qui pose problème : l’identification des réponses HTTP 404.

Considérez le scénario dans lequel un itinéraire sans extension de /some/page est utilisée. Supposons que la requête ne correspond pas à un itinéraire côté serveur, mais que son modèle correspond à un itinéraire côté client. Considérez maintenant une requête entrante pour /images/user-512.png, qui s’attend généralement à trouver un fichier image sur le serveur. Si ce chemin de ressource demandé ne correspond à aucun itinéraire côté serveur ou fichier statique, il est peu probable que l’application côté client le gère. En général, un code d’état HTTP 404 est retourné.

Prérequis pour les assistances de routage

Installez le package npm de routage côté client. En utilisant Angular comme exemple :

npm i -S @angular/router

Configuration des assistances de routage

Une méthode d’extension nommée MapSpaFallbackRoute est utilisée dans la méthode 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" });
});

Les itinéraires sont évalués dans l’ordre dans lequel ils sont configurés. Par conséquent, l’itinéraire default dans l’exemple de code précédent est utilisé en premier pour les critères spéciaux.

Créer un projet

Les services JavaScript fournissent des modèles d’application préconfigurés. SpaServices est utilisé dans ces modèles conjointement avec différentes infrastructures et bibliothèques, telles que Angular, React et Redux.

Ces modèles peuvent être installés via l’interface CLI .NET Core en exécutant la commande suivante :

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

Une liste des modèles SPA disponibles s’affiche :

Modèles Nom court Langage Étiquettes
ASP.NET Core MVC avec Angular angular [C#] Web/MVC/SPA
ASP.NET Core MVC avec React.js react [C#] Web/MVC/SPA
ASP.NET Core MVC avec React.js et Redux reactredux [C#] Web/MVC/SPA

Pour créer un projet à l’aide de l’un des modèles SPA, incluez le nom court du modèle dans la commande dotnet new. La commande suivante crée une application Angular avec ASP.NET Core MVC configuré pour le côté serveur :

dotnet new angular

Définir le mode de configuration du runtime

Il existe deux principaux modes de configuration du runtime :

  • Développement :
    • Inclut des mappages sources pour faciliter le débogage.
    • N’optimise pas le code côté client pour les performances.
  • Production :
    • Exclut les mappages de source.
    • Optimise le code côté client via le regroupement et la minimisation.

ASP.NET Core utilise une variable d’environnement nommée ASPNETCORE_ENVIRONMENT pour stocker le mode de configuration. Pour plus d’informations, consultez Définir l’environnement.

Exécuter avec l’interface CLI .NET Core

Restaurez les packages NuGet et npm requis en exécutant la commande suivante à la racine du projet :

dotnet restore && npm i

Créez et exécutez l’application :

dotnet run

L’application démarre sur localhost en fonction du mode de configuration du runtime. La navigation vers http://localhost:5000 dans le navigateur affiche la page de destination.

Exécuter avec Visual Studio 2017

Ouvrez le fichier .csproj généré par la commande dotnet new. Les packages NuGet et npm requis sont restaurés automatiquement lors de l’ouverture du projet. Ce processus de restauration peut prendre quelques minutes. Une fois qu’il est terminé, l’application est prête à s’exécuter. Cliquez sur le bouton vert d’exécution ou appuyez sur Ctrl + F5. Le navigateur s’ouvre sur la page de destination de l’application. L’application s’exécute sur localhost en fonction du mode de configuration du runtime.

Tester l’application

Les modèles SpaServices sont préconfigurés pour exécuter des tests côté client à l’aide de Karma et Jasmine. Jasmine est une infrastructure de test unitaire populaire pour JavaScript, tandis que Karma est un exécuteur de tests pour ces tests. Karma est configuré pour fonctionner avec l’intergiciel Webpack Dev, de sorte que le développeur n’est pas obligé d’arrêter et d’exécuter le test chaque fois que des modifications sont apportées. Qu’il s’agisse du code en cours d’exécution sur le cas de test ou du cas de test lui-même, le test s’exécute automatiquement.

En utilisant l’application Angular comme exemple, deux cas de test Jasmine sont déjà fournis pour le CounterComponent dans le fichier counter.component.spec.ts :

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

Ouvrez l’invite de commandes dans le répertoire ClientApp. Exécutez la commande suivante:

npm test

Le script lance l’exécuteur de test Karma, qui lit les paramètres définis dans le fichier karma.conf.js. Entre autres paramètres, le karma.conf.js identifie les fichiers de test à exécuter sur son tableau files :

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

Publier l’application

Pour plus d’informations sur la publication sur Azure, consultez ce problème GitHub.

La combinaison des ressources côté client générées et des artefacts ASP.NET Core publiés dans un package prêt à être déployé peut s’avérer fastidieuse. Heureusement, SpaServices orchestre l’ensemble du processus de publication avec une cible MSBuild personnalisée nommée RunWebpack :

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

La cible MSBuild a les responsabilités suivantes :

  1. Restaurer les packages npm.
  2. Créer une build de niveau production des ressources tierces côté client.
  3. Créer une build de niveau production des ressources tierces côté client.
  4. Copier les ressources générées par Webpack dans le dossier de publication.

La cible MSBuild est appelée lors de l’exécution de :

dotnet publish -c Release

Ressources supplémentaires