Usar Serviços JavaScript para criar aplicativos de página única no ASP.NET Core

Por Fiyaz Hasan

Aviso

Os recursos descritos neste artigo estão obsoletos desde o ASP.NET Core 3.0. Um mecanismo de integração de estruturas SPA mais simples está disponível no pacote NuGet Microsoft.AspNetCore.SpaServices.Extensions. Para obter mais informações, consulte [Comunicado] Microsoft.AspNetCore.SpaServices e Microsoft.AspNetCore.NodeServices obsoletos.

Um SPA (Aplicativo de Página Única) é um tipo popular de aplicativo Web devido à sua experiência de usuário avançada inerente. A integração de estruturas ou bibliotecas SPA do lado do cliente, como Angular ou React, com estruturas do lado do servidor, como ASP.NET Core, pode ser difícil. Os Serviços JavaScript foram desenvolvidos para reduzir o atrito no processo de integração. Eles permitem uma operação contínua entre as diferentes pilhas de tecnologia de cliente e servidor.

O que são os Serviços JavaScript

Os Serviços JavaScript são uma coleção de tecnologias do lado do cliente para ASP.NET Core. Sua meta é tornar o ASP.NET Core a plataforma preferencial do lado do servidor dos desenvolvedores para a criação de SPAs.

Os Serviços JavaScript consistem em dois pacotes NuGet distintos:

Esses pacotes são úteis nos seguintes cenários:

  • Executar JavaScript no servidor
  • Usar uma estrutura ou biblioteca SPA
  • Criar ativos do lado do cliente com o Webpack

Este artigo foca, principalmente, no uso do pacote SpaServices.

O que é o SpaServices

O SpaServices foi criado para tornar o ASP.NET Core a plataforma preferencial do lado do servidor dos desenvolvedores para a criação de SPAs. O SpaServices não é necessário para desenvolver SPAs com ASP.NET Core e não bloqueia os desenvolvedores em uma estrutura de cliente específica.

O SpaServices fornece uma infraestrutura útil, como:

Coletivamente, esses componentes de infraestrutura aprimoram o fluxo de trabalho de desenvolvimento e a experiência de runtime. Os componentes podem ser adotados individualmente.

Pré-requisitos para usar o SpaServices

Para trabalhar com o SpaServices, instale o seguinte:

  • Node.js (versão 6 ou posterior) com npm

    • Para verificar se esses componentes estão instalados e podem ser encontrados, execute o seguinte na linha de comando:

      node -v && npm -v
      
    • Se estiver implantando em um site do Azure, nenhuma ação será necessária— o Node.js já está instalado e disponível nos ambientes do servidor.

  • SDK 2.0 ou posterior do .NET Core

    • No Windows usando o Visual Studio 2017, o SDK é instalado selecionando a carga de trabalho de desenvolvimento multiplataforma do .NET Core.
  • Pacote NuGet Microsoft.AspNetCore.SpaServices

Pré-renderização do lado do servidor

Um aplicativo universal (também conhecido como isomórfico) é um aplicativo JavaScript capaz de ser executado no servidor e no cliente. Angular, React e outras estruturas populares fornecem uma plataforma universal para esse estilo de desenvolvimento de aplicativos. A ideia é primeiro renderizar os componentes da estrutura no servidor por meio de Node.js e, em seguida, delegar uma execução adicional ao cliente.

Os Auxiliares de Marcas do ASP.NET Core fornecidos pelo SpaServices simplificam a implementação da pré-renderização do lado do servidor invocando as funções JavaScript no servidor.

Pré-requisitos de pré-renderização do lado do servidor

Instale o pacote npm aspnet-prerendering:

npm i -S aspnet-prerendering

Configuração de pré-renderização do lado do servidor

Os Auxiliares de Marcas são descobertos por meio do registro de namespace no arquivo _ViewImports.cshtml do projeto:

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

Esses Auxiliares de Marcas abstraem a complexidade da comunicação direta com APIs de baixo nível, aproveitando uma sintaxe semelhante a HTML dentro da exibição Razor:

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

Auxiliar de Marcas asp-prerender-module

O Auxiliar de Marcas asp-prerender-module, usado no exemplo de código anterior, executa ClientApp/dist/main-server.js no servidor por meio de Node.js. Para fins de clareza, o arquivo main-server.js é um artefato da tarefa de transpilação TypeScript para JavaScript no processo de build do Webpack. O Webpack define um alias de ponto de entrada de main-server; e, a passagem do grafo de dependência para esse alias começa no arquivo ClientApp/boot-server.ts:

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

No exemplo Angular a seguir, o arquivo ClientApp/boot-server.ts utiliza a função createServerRenderer e o tipo RenderResult do pacote npm aspnet-prerendering para configurar a renderização do servidor por meio de Node.js. A marcação HTML destinada à renderização do lado do servidor é passada para uma chamada de função resolve, que é encapsulada em um objeto JavaScript Promise fortemente tipado. A significância do objeto Promise é que ele fornece de forma assíncrona a marcação HTML para a página para injeção no elemento de espaço reservado do 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();
                });
            });
        });
    });
});

Auxiliar de Marcas asp-prerender-data

Quando combinado com o Auxiliar de Marcas asp-prerender-module, o Auxiliar de Marcas asp-prerender-data pode ser usado para passar informações contextuais da exibição Razor para o JavaScript do lado do servidor. Por exemplo, a marcação a seguir passa os dados do usuário para o módulo main-server:

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

O argumento UserName recebido é serializado usando o serializador JSON interno e é armazenado no objeto params.data. No exemplo de Angular a seguir, os dados são usados para construir uma saudação personalizada dentro de um 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();
                });
            });
        });
    });
});

Os nomes de propriedade passados em Auxiliares de Marcas são representados com notação PascalCase. Contraste isso com JavaScript, em que os mesmos nomes de propriedade são representados com camelCase. A configuração de serialização JSON padrão é responsável por essa diferença.

Para expandir o exemplo de código anterior, os dados podem ser passados do servidor para a exibição hidratando a propriedade globals fornecida para a função 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();
                });
            });
        });
    });
});

A matriz postList definida dentro do objeto globals é anexada ao objeto global window do navegador. Essa variável de içamento para o escopo global elimina a duplicação de esforço, especialmente porque se refere ao carregamento dos mesmos dados no servidor e novamente no cliente.

global postList variable attached to window object

Middleware de Desenvolvimento do Webpack

O Middleware de Desenvolvimento do Webpack apresenta um fluxo de trabalho de desenvolvimento simplificado pelo qual o Webpack cria recursos sob demanda. O middleware compila e atende automaticamente aos recursos do lado do cliente quando uma página é recarregada no navegador. A abordagem alternativa é invocar manualmente o Webpack por meio do script de build npm do projeto quando uma dependência de terceiros ou o código personalizado é alterado. Um script de build npm no arquivo package.json é exibido no exemplo a seguir:

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

Pré-requisitos do Middleware de Desenvolvimento do Webpack

Instale o pacote npm aspnet-webpack:

npm i -D aspnet-webpack

Configuração do Middleware de Desenvolvimento do Webpack

O Middleware de Desenvolvimento do Webpack é registrado no pipeline de solicitação HTTP por meio do seguinte código no método Configure do arquivo Startup.cs:

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

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

O método de extensão UseWebpackDevMiddleware deve ser chamado antes de registrar a hospedagem de arquivo estático por meio do método de extensão UseStaticFiles. Por motivos de segurança, registre o middleware somente quando o aplicativo for executado no modo de desenvolvimento.

A propriedade output.publicPath do arquivo webpack.config.js instrui o middleware a inspecionar a pasta dist para alterações:

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

Substituição de módulo frequente

Pense no recurso HMR (Substituição de Módulo Frequente) do Webpack como uma evolução do Middleware de Desenvolvimento do Webpack. O HMR apresenta os mesmos benefícios, mas simplifica ainda mais o fluxo de trabalho de desenvolvimento atualizando automaticamente o conteúdo da página depois de compilar as alterações. Não confunda isso com uma atualização do navegador, o que interferiria no estado atual na memória e na sessão de depuração do SPA. Há um link dinâmico entre o serviço do Middleware de Desenvolvimento do Webpack e o navegador, o que significa que as alterações são enviadas por push para o navegador.

Pré-requisitos da Substituição de Módulo Frequente

Instale o pacote npm webpack-hot-middleware:

npm i -D webpack-hot-middleware

Configuração de Substituição de Módulo Frequente

O componente HMR deve ser registrado no pipeline de solicitação HTTP do MVC no método Configure:

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

Como foi true com o Middleware de Desenvolvimento do Webpack, o método de extensão UseWebpackDevMiddleware deve ser chamado antes do método de extensão UseStaticFiles. Por motivos de segurança, registre o middleware somente quando o aplicativo for executado no modo de desenvolvimento.

O arquivo webpack.config.js deve definir uma matriz plugins, mesmo que ela seja deixada vazia:

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

Depois de carregar o aplicativo no navegador, a guia Console das ferramentas de desenvolvedor fornece a confirmação da ativação do HMR:

Hot Module Replacement connected message

Auxiliares de roteamento

Na maioria dos SPAs baseados em ASP.NET Core, o roteamento do lado do cliente geralmente é desejado, além do roteamento do lado do servidor. Os sistemas de roteamento SPA e MVC podem funcionar de forma independente sem interferência. No entanto, há um caso de borda que apresenta desafios: identificar respostas para HTTP 404.

Considere o cenário no qual uma rota sem extensão de /some/page é usada. Suponha que o padrão da solicitação não corresponda a uma rota do lado do servidor, mas sim a uma rota do lado do cliente. Agora considere uma solicitação de entrada para /images/user-512.png, que geralmente espera encontrar um arquivo de imagem no servidor. Se esse caminho de recurso solicitado não corresponder a nenhuma rota do lado do servidor ou arquivo estático, é improvável que o aplicativo do lado do cliente o manipule, geralmente retornando um código de status HTTP 404.

Pré-requisitos de auxiliares de roteamento

Instale o pacote npm de roteamento do lado do cliente. Usando Angular como exemplo:

npm i -S @angular/router

Configuração de auxiliares de roteamento

Um método de extensão chamado MapSpaFallbackRoute é usado no método 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" });
});

As rotas são avaliadas na ordem em que estão configuradas. Consequentemente, a rota default no exemplo de código anterior é usada primeiro para padrões correspondentes.

Criar um projeto

Os Serviços JavaScript fornecem modelos de aplicativo pré-configurados. O SpaServices é usado nesses modelos em conjunto com diferentes estruturas e bibliotecas, como Angular, React e Redux.

Esses modelos podem ser instalados por meio da CLI do .NET Core executando o seguinte comando:

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

Uma lista de modelos SPA disponíveis é exibida:

Modelos Nome curto Idioma Marcações
ASP.NET Core MVC com Angular angular [C#] Web/MVC/SPA
ASP.NET Core MVC com React.js react [C#] Web/MVC/SPA
ASP.NET Core MVC com React.js e Redux reactredux [C#] Web/MVC/SPA

Para criar um novo projeto usando um dos modelos de SPA, inclua o Nome Curto do modelo no comando dotnet new. O comando a seguir cria um aplicativo Angular com ASP.NET Core MVC configurado para o lado do servidor:

dotnet new angular

Definir o modo de configuração de runtime

Existem dois modos de configuração de runtime primários:

  • Desenvolvimento:
    • Inclui mapas de origem para facilitar a depuração.
    • Não otimiza o código do lado do cliente para desempenho.
  • Produção:
    • Exclui source maps.
    • Otimiza o código do lado do cliente por meio de agrupamento e minificação.

O ASP.NET Core usa uma variável de ambiente chamada ASPNETCORE_ENVIRONMENT para armazenar o modo de configuração. Para obter mais informações, confira Configurar o ambiente.

Executar com a CLI do .NET Core

Restaure os pacotes NuGet e npm necessários executando o seguinte comando na raiz do projeto:

dotnet restore && npm i

Crie e execute o aplicativo:

dotnet run

O aplicativo começa no localhost de acordo com o modo de configuração de runtime. O acesso a http://localhost:5000 no navegador exibe a página de aterrissagem.

Executar com o Visual Studio 2017

Abra o arquivo .csproj gerado pelo comando dotnet new. Os pacotes NuGet e npm necessários são restaurados automaticamente após a abertura do projeto. Esse processo de restauração pode levar até alguns minutos e o aplicativo está pronto para ser executado quando for concluído. Clique no botão de execução verde ou pressione Ctrl + F5 e o navegador será aberto na página de aterrissagem do aplicativo. O aplicativo começa no localhost de acordo com o modo de configuração de runtime.

Testar o aplicativo

Os modelos do SpaServices são pré-configurados para executar testes do lado do cliente usando Karma e Jasmine. Jasmine é uma estrutura de teste de unidade popular para JavaScript, enquanto Karma é um executor de teste para esses testes. O Karma está configurado para trabalhar com o Middleware de Desenvolvimento do Webpack de modo que o desenvolvedor não seja obrigado a parar e executar o teste sempre que forem feitas alterações. Seja o código em execução no caso de teste ou o caso de teste em si, o teste será executado automaticamente.

Usando o aplicativo Angular como exemplo, dois casos de teste de Jasmine já são fornecidos para o CounterComponent no arquivo 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 o prompt de comando no diretório ClientApp. Execute o comando a seguir:

npm test

O script inicia o executor de teste Karma, que lê as configurações definidas no arquivo karma.conf.js. Entre outras configurações, o karma.conf.js identifica os arquivos de teste a serem executados por meio de sua matriz files:

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

Publicar o aplicativo

Consulte este problema do GitHub para obter mais informações sobre como publicar no Azure.

Pode ser difícil combinar os ativos gerados do lado do cliente e os artefatos de ASP.NET Core publicados em um pacote pronto para implantação. Felizmente, o SpaServices orquestra todo esse processo de publicação com um destino personalizado do MSBuild nomeado 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>

O destino do MSBuild tem as seguintes responsabilidades:

  1. Restaurar os pacotes npm.
  2. Criar um build de nível de produção dos ativos do lado do cliente de terceiros.
  3. Criar um build de nível de produção dos ativos personalizados do lado do cliente.
  4. Copiar os ativos gerados pelo Webpack para a pasta de publicação.

O destino do MSBuild é invocado ao executar:

dotnet publish -c Release

Recursos adicionais