Uso de servicios de JavaScript para crear aplicaciones de página única en ASP.NET Core

Por Fiyaz Hasan

Advertencia

Las características descritas en este artículo están obsoletas a partir de ASP.NET Core 3.0. Un mecanismo de integración de marcos de SPA más sencillo está disponible en el paquete NuGet Microsoft.AspNetCore.SpaServices.Extensions. Para obtener más información, vea [Anuncio] Microsoft.AspNetCore.SpaServices y Microsoft.AspNetCore.NodeServices quedan obsoletos.

Una aplicación de página única (SPA) es un tipo popular de aplicación web debido a su inherente experiencia de usuario mejorada. La integración de bibliotecas o marcos de SPA del lado cliente, como Angular o React, con marcos del lado servidor como ASP.NET Core puede ser difícil. Los servicios de JavaScript se desarrollaron para reducir la fricción en el proceso de integración. Permite que haya un funcionamiento sin problemas entre las distintas pilas de tecnología de cliente y servidor.

Qué son los servicios de JavaScript

Los servicios de JavaScript son una colección de tecnologías del lado cliente para ASP.NET Core. Su objetivo es colocar ASP.NET Core como la plataforma del lado servidor preferida de los desarrolladores para compilar SPA.

Los servicios de JavaScript constan de dos paquetes NuGet distintos:

Estos paquetes son útiles en los escenarios siguientes:

  • Ejecutar JavaScript en el servidor
  • Usar una biblioteca o un marco de SPA
  • Compilar recursos del lado cliente con Webpack

Este artículo se centra sobre todo en usar el paquete SpaServices.

Qué es SpaServices

SpaServices se ha creado para posicionar ASP.NET Core como la plataforma del lado servidor preferida de los desarrolladores para compilar SPA. SpaServices no es necesario para desarrollar SPA con ASP.NET Core y no obliga a los desarrolladores a usar un marco de cliente determinado.

SpaServices proporciona una infraestructura útil como la siguiente:

En conjunto, estos componentes de infraestructura mejoran el flujo de trabajo de desarrollo y la experiencia en tiempo de ejecución. Los componentes se pueden adoptar individualmente.

Requisitos previos para usar SpaServices

Para trabajar con SpaServices, instale lo siguiente:

  • Node.js (versión 6 o posterior) con npm

    • Para comprobar que estos componentes están instalados y se pueden encontrar, ejecute lo siguiente desde la línea de comandos:

      node -v && npm -v
      
    • Si se implementa en un sitio web de Azure, no se necesita ninguna acción, ya que Node.js está instalado y disponible en los entornos de servidor.

  • .NET Core SDK 2.0 o posterior

    • En Windows, al usar Visual Studio 2017, el SDK se instala al seleccionar la carga de trabajo Desarrollo multiplataforma de .NET Core.
  • Paquete NuGet Microsoft.AspNetCore.SpaServices

Representación previa del lado servidor

Una aplicación universal (también conocida como isomorfa) es una aplicación JavaScript capaz de ejecutarse tanto en el servidor como en el cliente. Angular, React y otros marcos populares proporcionan una plataforma universal para este estilo de desarrollo de aplicaciones. La idea es representar primero los componentes del marco en el servidor mediante Node.js y luego delegar la ejecución en el cliente.

Los Asistentes de etiquetas de ASP.NET Core proporcionados por SpaServices simplifican la implementación de la representación previa del lado servidor al invocar las funciones de JavaScript en el servidor.

Requisitos previos de la representación previa del lado servidor

Instale el paquete de npm aspnet-prerendering:

npm i -S aspnet-prerendering

Configuración de la representación previa del lado servidor

Los asistentes de etiquetas se pueden descubrir mediante el registro del espacio de nombres en el archivo _ViewImports.cshtml del proyecto:

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

Estos asistentes de etiquetas abstraen las complejidades de la comunicación directa con las API de bajo nivel al aprovechar una sintaxis similar a HTML dentro de la vista de Razor:

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

Asistente de etiquetas asp-prerender-module

El asistente de etiquetas asp-prerender-module, que se usa en el ejemplo de código anterior, ejecuta ClientApp/dist/main-server.js en el servidor mediante Node.js. Por motivos de claridad, el archivo main-server.js es un artefacto de la tarea de transpilación de TypeScript a JavaScript en el proceso de compilación de Webpack. Webpack define un alias de punto de entrada de main-server; y el recorrido del gráfico de dependencias de este alias comienza en el archivo ClientApp/boot-server.ts:

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

En el siguiente ejemplo de Angular, el archivo ClientApp/boot-server.ts usa la función createServerRenderer y el tipo RenderResult del paquete npm de aspnet-prerendering para configurar la representación del servidor mediante Node.js. El marcado HTML destinado a la representación del lado servidor se pasa a una llamada a la función resolve, que se encapsula en un objeto Promise de JavaScript fuertemente tipado. La importancia del objeto Promise es que proporciona de forma asincrónica el marcado HTML a la página para que se inserte en el elemento de marcador de posición del 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();
                });
            });
        });
    });
});

Asistente de etiquetas asp-prerender-data

Cuando se combina con el asistente de etiquetas asp-prerender-module, el asistente de etiquetas asp-prerender-data se puede usar para pasar información contextual de la vista de Razor al código JavaScript del lado servidor. Por ejemplo, el marcado siguiente pasa los datos de usuario al módulo main-server:

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

El argumento UserName recibido se serializa mediante el serializador params.dataON integrado y se almacena en el objeto JS. En el siguiente ejemplo de Angular, los datos se usan para construir un saludo personalizado en un elemento 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();
                });
            });
        });
    });
});

Los nombres de propiedad pasados en los asistentes de etiquetas se representan con la notación PascalCase. Compare esto con JavaScript, donde los mismos nombres de propiedad se representan con la notación camelCase. La configuración de serialización de JSON predeterminada es responsable de esta diferencia.

Para ampliar el ejemplo de código anterior, los datos se pueden pasar del servidor a la vista mediante la hidratación de la propiedad globals proporcionada a la función 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();
                });
            });
        });
    });
});

La matriz postList definida en el objeto globals se adjunta al objeto window global del explorador. Esta variable que se eleva al ámbito global elimina la duplicación del esfuerzo, sobre todo en lo que respecta a la carga de los mismos datos una vez en el servidor y de nuevo en el cliente.

global postList variable attached to window object

Middleware de desarrollo de Webpack

El middleware de desarrollo de Webpack incorpora un flujo de trabajo de desarrollo simplificado, mediante el que Webpack compila los recursos a petición. El middleware compila y atiende automáticamente los recursos del lado cliente cuando se recarga una página en el explorador. El enfoque alternativo consiste en invocar manualmente a Webpack mediante el script de compilación de npm del proyecto cuando cambia una dependencia de terceros o el código personalizado. En el ejemplo siguiente se muestra un script de compilación de npm en el archivo package.json:

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

Requisitos previos del middleware de desarrollo de Webpack

Instale el paquete de npm aspnet-webpack:

npm i -D aspnet-webpack

Configuración del middleware de desarrollo de Webpack

El middleware de desarrollo de Webpack se registra en la canalización de solicitudes HTTP mediante el siguiente código en el método Configure del archivo Startup.cs:

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

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

Se debe llamar al método de extensión UseWebpackDevMiddleware antes de registrar el hospedaje de archivos estáticos mediante el método de extensión UseStaticFiles. Por motivos de seguridad, registre el middleware solo cuando la aplicación se ejecute en modo de desarrollo.

La propiedad output.publicPath del archivo webpack.config.js indica al middleware que observe si se producen cambios en la carpeta dist:

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

Sustitución del módulo de acceso frecuente

Piense en la característica de sustitución del módulo de acceso frecuente (HMR) de Webpack como una evolución del middleware de desarrollo de Webpack. HMR presenta las mismas ventajas, pero optimiza aún más el flujo de trabajo de desarrollo al actualizar automáticamente el contenido de la página después de compilar los cambios. No lo confunda con una actualización del explorador, lo que interferiría con el estado en memoria actual y la sesión de depuración de la SPA. Hay un vínculo activo entre el servicio de middleware de desarrollo de Webpack y el explorador, lo que significa que los cambios se insertan en el explorador.

Requisitos previos de la sustitución del módulo de acceso frecuente

Instale el paquete de npm webpack-hot-middleware:

npm i -D webpack-hot-middleware

Configuración de la sustitución del módulo de acceso frecuente

El componente HMR debe estar registrado en la canalización de solicitudes HTTP de MVC en el método Configure:

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

Tal y como pasaba con el middleware de desarrollo de Webpack, se debe llamar al método de extensión UseWebpackDevMiddleware antes del método de extensión UseStaticFiles. Por motivos de seguridad, registre el middleware solo cuando la aplicación se ejecute en modo de desarrollo.

El archivo webpack.config.js debe definir una matriz plugins, aunque se deje vacío:

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

Después de cargar la aplicación en el explorador, la pestaña de la consola de las herramientas de desarrollo proporciona la confirmación de la activación de HMR:

Hot Module Replacement connected message

Aplicaciones auxiliares de enrutamiento

En la mayoría de las SPA basadas en ASP.NET Core, a menudo se prefiere el enrutamiento del lado cliente además del enrutamiento del lado servidor. Los sistemas de enrutamiento de SPA y MVC pueden funcionar de forma independiente sin interferencias. Aun así, hay un caso perimetral que plantea desafíos: identificar las respuestas HTTP 404.

Piense en el escenario en el que se usa una ruta sin extensión de /some/page. Supongamos que la solicitud no coincide con el patrón de una ruta del lado servidor, pero su patrón coincide con una ruta del lado cliente. Ahora piense en una solicitud entrante de /images/user-512.png, que suele esperar encontrar un archivo de imagen en el servidor. Si esa ruta de acceso a recursos solicitada no coincide con ninguna ruta o archivo estático del lado servidor, es poco probable que la aplicación del lado cliente la administre (como regla general, se prefiere devolver un código de estado HTTP 404).

Requisitos previos de las aplicaciones auxiliares de enrutamiento

Instale el paquete de npm de enrutamiento del lado cliente. Se usa Angular como ejemplo:

npm i -S @angular/router

Configuración de las aplicaciones auxiliares de enrutamiento

En el método Configure se usa un método de extensión denominado 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" });
});

Las rutas se evalúan en el orden en que están configuradas. Por consiguiente, la ruta default del ejemplo de código anterior se usa primero en la coincidencia de patrones.

Crear un proyecto nuevo

Los servicios de JavaScript proporcionan plantillas de aplicación preconfiguradas. SpaServices se usa en estas plantillas con diferentes marcos y bibliotecas, como Angular, React y Redux.

Estas plantillas se pueden instalar mediante la CLI de .NET Core al ejecutar el siguiente comando:

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

Se muestra una lista de las plantillas de SPA disponibles:

Plantillas Nombre corto Lenguaje Etiquetas
MVC ASP.NET Core con Angular angular [C#] Web/MVC/SPA
MVC ASP.NET Core con React.js react [C#] Web/MVC/SPA
MVC ASP.NET Core con React.js y Redux reactredux [C#] Web/MVC/SPA

Para crear un proyecto con una de las plantillas de SPA, incluya el nombre corto de la plantilla en el comando dotnet new. El siguiente comando crea una aplicación Angular con el MVC de ASP.NET Core configurado en el lado servidor:

dotnet new angular

Establecimiento del modo de configuración de ejecución

Hay dos modos de configuración de ejecución principales:

  • Desarrollo:
    • Incluye mapas de origen para facilitar la depuración.
    • No optimiza el código del lado cliente para el rendimiento.
  • Producción:
    • Excluye los mapas de origen.
    • Optimiza el código del lado cliente mediante la unión y la minificación.

ASP.NET Core usa una variable de entorno denominada ASPNETCORE_ENVIRONMENT para almacenar el modo de configuración. Para obtener más información, consulte Establecimiento del entorno.

Ejecución con la CLI de .NET Core

Restaure los paquetes NuGet y de npm necesarios mediante la ejecución del siguiente comando en la raíz del proyecto:

dotnet restore && npm i

Compile y ejecute la aplicación:

dotnet run

La aplicación se inicia en localhost según el modo de configuración de ejecución. Al ir a http://localhost:5000 en el explorador, se muestra la página de aterrizaje.

Ejecución con Visual Studio 2017

Abra el archivo .csproj generado por el comando dotnet new. Los paquetes NuGet y de npm necesarios se restauran automáticamente al abrir el proyecto. Este proceso de restauración puede tardar unos minutos y, cuando se complete, la aplicación estará lista para ejecutarse. Haga clic en el botón verde de ejecución o presione Ctrl + F5 y el explorador se abrirá en la página de aterrizaje de la aplicación. La aplicación se ejecuta en localhost según el modo de configuración de ejecución.

Prueba de la aplicación

Las plantillas de SpaServices están preconfiguradas para ejecutar pruebas del lado cliente mediante Karma y Jasmine. Jasmine es un conocido marco de pruebas unitarias para JavaScript, mientras que Karma es un ejecutor de pruebas para llevar a cabo estas pruebas. Karma está configurado para funcionar con el middleware de desarrollo de Webpack de modo que no es necesario que el desarrollador detenga y ejecute la prueba cada vez que se hagan cambios. La prueba se ejecuta de forma automática, ya sea el código que se ejecuta en el caso de prueba o el caso de prueba en sí.

Si se usa la aplicación Angular como ejemplo, ya se proporcionan dos casos de prueba de Jasmine de CounterComponent en el archivo 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');
}));

Abra el símbolo del sistema en el directorio ClientApp. Ejecute el siguiente comando:

npm test

El script inicia el ejecutor de pruebas de Karma, que lee la configuración definida en el archivo karma.conf.js. Entre otras opciones, el archivo karma.conf.js identifica los archivos de prueba que se van a ejecutar mediante su matriz files:

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

Publicar la aplicación

Consulte este problema de GitHub para obtener más información sobre cómo publicar en Azure.

Combinar los recursos del lado cliente generados y los artefactos de ASP.NET Core publicados en un paquete listo para implementar puede resultar complicado. Afortunadamente, SpaServices orquesta todo el proceso de publicación con un destino de MSBuild personalizado denominado 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>

El destino de MSBuild tiene las siguientes responsabilidades:

  1. Restaurar los paquetes de npm
  2. Crear una compilación de nivel de producción de los recursos del lado cliente de terceros
  3. Crear una compilación de nivel de producción de los recursos del lado cliente personalizados
  4. Copiar los recursos generados por Webpack en la carpeta de publicación

El destino de MSBuild se invoca cuando se ejecuta lo siguiente:

dotnet publish -c Release

Recursos adicionales