Создание одностраничных приложений в ASP.NET Core с помощью служб JavaScript

Фияз Хасан

Предупреждение

Функции, описанные в этой статье, устарели с версии ASP.NET Core 3.0. В пакете NuGet Microsoft.AspNetCore.SpaServices.Extensions реализован более простой механизм интеграции с платформами SPA. Дополнительные сведения см. в объявлении об устаревании Microsoft.AspNetCore.SpaServices и Microsoft.AspNetCore.NodeServices.

Одностраничные приложения (SPA) — это популярный тип веб-приложений из-за присущих им широких возможностей пользовательского интерфейса. Интеграция клиентских платформ и библиотек SPA, таких как Angular или React, с серверными платформами, такими как ASP.NET Core, может представлять сложность. Чтобы упростить интеграцию, были разработаны службы JavaScript. Они обеспечивают эффективное взаимодействие между различными стеками клиентских и серверных технологий.

Что такое службы JavaScript

Службы JavaScript — это набор технологий на стороне клиента для ASP.NET Core. Его целью является позиционирование ASP.NET Core в качестве предпочтительной серверной платформы для разработки одностраничных приложений.

В состав служб JavaScript входят два отдельных пакета NuGet:

Эти пакеты полезны в следующих случаях:

  • выполнение JavaScript на сервере;
  • использование платформы или библиотеки SPA;
  • создание ресурсов на стороне клиента с помощью Webpack.

В этой статье основное внимание уделяется использованию пакета SpaServices.

Что такое SpaServices

Пакет SpaServices был создан с целью позиционирования ASP.NET Core в качестве предпочтительной серверной платформы для разработки одностраничных приложений. Он не обязателен для разработки одностраничных приложений с помощью ASP.NET Core и не ограничивает выбор разработчиков определенной клиентской платформой.

Пакет SpaServices предоставляет полезные инфраструктурные возможности, в том числе:

В совокупности эти компоненты инфраструктуры улучшают как процесс разработки, так и выполнение приложений. Их можно внедрять по отдельности.

Необходимые условия для использования SpaServices

Для работы со SpaServices установите перечисленные ниже компоненты.

  • Node.js (версии 6 или более поздней) с npm

    • Чтобы проверить, установлены ли эти компоненты, выполните в командной строке следующую команду:

      node -v && npm -v
      
    • При развертывании на веб-сайте Azure никаких действий не требуется — платформа Node.js уже установлена и доступна в серверных средах.

  • Пакет SDK для .NET Core 2.0. или более поздней версии

    • В ОС Windows с Visual Studio 2017 для установки пакета SDK нужно выбрать рабочую нагрузку Кроссплатформенная разработка .NET Core.
  • Пакет NuGet Microsoft.AspNetCore.SpaServices

Предварительная обработка на стороне сервера

Универсальное приложение (также называемое изоморфным) — это приложение JavaScript, которое может выполняться как на сервере, так и в клиенте. Angular, React и другие популярные платформы обеспечивают возможности для разработки приложений в таком стиле. Главный принцип заключается в том, что компоненты платформы сначала обрабатываются на сервере с помощью Node.js, после чего передаются в клиент для дальнейшего выполнения.

Вспомогательные функции тегов ASP.NET Core, предоставляемые пакетом SpaServices, упрощают реализацию предварительной обработки на стороне сервера путем вызова функций JavaScript на сервере.

Необходимые условия для предварительной обработки на стороне сервера

Установите пакет npm aspnet-prerendering:

npm i -S aspnet-prerendering

Настройка предварительной обработки на стороне сервера

Вспомогательные функции тегов становятся доступными для обнаружения с помощью регистрации пространства имен в файле проекта _ViewImports.cshtml :

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

Эти функции позволяют абстрагироваться от сложностей, связанных с взаимодействием с низкоуровневыми интерфейсами API напрямую, благодаря использованию синтаксиса в стиле HTML в представлении Razor:

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

Вспомогательная функция тегов asp-prerender-module

Вспомогательная функция тегов asp-prerender-module, используемая в предыдущем примере кода, выполняет файл ClientApp/dist/main-server.js на сервере с помощью Node.js. Для ясности main-server.js файл является артефактом задачи транспилирования TypeScript в JavaScript в процессе сборки Webpack . В Webpack определен псевдоним точки входа main-server. Обход схемы зависимостей для этого псевдонима начинается с файла ClientApp/boot-server.ts:

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

В следующем примере для Angular файл ClientApp/boot-server.ts использует функцию createServerRenderer и тип RenderResult из пакета npm aspnet-prerendering для настройки серверной обработки с помощью Node.js. Разметка HTML, предназначенная для обработки на стороне сервера, передается в вызов функции resolve, который заключен в строго типизированный объект JavaScript Promise. Важность объекта Promise заключается в том, что он асинхронно передает HTML-разметку странице для внедрения в элемент-заполнитель модели 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();
                });
            });
        });
    });
});

Вспомогательная функция тегов asp-prerender-data

При использовании в сочетании с функцией asp-prerender-module вспомогательную функцию тегов asp-prerender-data можно использовать для передачи контекстных сведений из представления 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 по умолчанию.

Если продолжить предыдущий пример кода, данные можно передать с сервера в представление путем расконсервации свойства globals, предоставляемого функции 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();
                });
            });
        });
    });
});

Массив postList, определенный внутри объекта globals, присоединяется к глобальному объекту window браузера. Перевод переменной в глобальную область позволяет избежать лишних усилий, особенно когда одни и те же данные загружаются сначала на сервере, а затем в клиенте.

global postList variable attached to window object

ПО промежуточного слоя Webpack для разработки

ПО промежуточного слоя Webpack для разработки упрощает процесс разработки благодаря сборке ресурсов средством Webpack по запросу. Оно автоматически компилирует и предоставляет ресурсы на стороне клиента при перезагрузке страницы в браузере. Альтернативный подход заключается в вызове Webpack вручную через скрипт сборки npm проекта при изменении сторонней зависимости или пользовательского кода. Скрипт сборки npm в package.json файле показан в следующем примере:

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

Необходимые условия для использования ПО промежуточного слоя Webpack для разработки

Установите пакет npm aspnet-webpack:

npm i -D aspnet-webpack

Настройка ПО промежуточного слоя Webpack для разработки

ПО промежуточного слоя Webpack для разработки регистрируется в конвейере HTTP-запросов с помощью следующего кода в методе Configure в файле Startup.cs:

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

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

Метод расширения UseWebpackDevMiddleware должен вызываться перед регистрацией размещения статического файла с помощью метода расширения UseStaticFiles. В целях безопасности регистрировать ПО промежуточного слоя следует только при запуске приложения в режиме разработки.

Свойство output.publicPath в файле webpack.config.js предписывает ПО промежуточного слоя отслеживать изменения в папке dist:

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

Горячая замена модулей

Функцию горячей замены модулей (HMR) в Webpack можно рассматривать как дальнейшее развитие ПО промежуточного слоя Webpack для разработки. HMR дает те же преимущества, но еще более упрощает процесс разработки благодаря автоматическому обновлению содержимого страницы после компиляции изменений. Не путайте это с обновлением содержимого браузера, которое нарушает текущее состояние в памяти и сеанс отладки приложения SPA. Между службой ПО промежуточного слоя Webpack для разработки и браузером существует динамическая связь, благодаря которой изменения передаются в браузер.

Необходимые условия для горячей замены модулей

Установите пакет npm webpack-hot-middleware:

npm i -D webpack-hot-middleware

Настройка горячей замены модулей

Компонент HMR необходимо зарегистрировать в конвейере HTTP-запросов MVC в методе Configure:

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

Так же как и в случае с ПО промежуточного слоя Webpack для разработки, метод расширения UseWebpackDevMiddleware должен вызываться до метода расширения UseStaticFiles. В целях безопасности регистрировать ПО промежуточного слоя следует только при запуске приложения в режиме разработки.

В файле webpack.config.js должен определяться массив plugins, даже если он будет пустым:

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

После загрузки приложения в браузере на вкладке "Консоль" средств разработчика выводится подтверждение активации HMR.

Hot Module Replacement connected message

Вспомогательные методы маршрутизации

В большинстве одностраничных приложений на основе ASP.NET Core, помимо маршрутизации на стороне сервера, часто требуется маршрутизация на стороне клиента. Системы маршрутизации SPA и MVC могут работать независимо, не мешая друг другу. Однако существует один пограничный случай, вызывающий трудности: идентификация 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 предоставляют предварительно настроенные шаблоны приложений. Пакет SpaServices используется в них в сочетании с различными платформами и библиотеками, таким как Angular, React и Redux.

Эти шаблоны можно установить с помощью .NET Core CLI, выполнив следующую команду:

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

Отобразится список доступных шаблонов SPA.

Шаблоны Короткое имя Язык Теги
MVC ASP.NET Core с Angular angular [C#] MVC/Веб/SPA
MVC ASP.NET Core с React.js react [C#] MVC/Веб/SPA
MVC ASP.NET Core с React.js и Redux reactredux [C#] MVC/Веб/SPA

Чтобы создать проект с помощью одного из шаблонов SPA, включите короткое имя шаблона в команду dotnet new. Следующая команда создает приложение Angular с MVC ASP.NET Core, настроенное как серверное:

dotnet new angular

Задание режима конфигурации среды выполнения

Существует два основных режима конфигурации среды выполнения:

  • Разработка:
    • включает сопоставители с исходным кодом для упрощения отладки;
    • не оптимизирует производительность кода на стороне клиента.
  • Рабочая среда:
    • исключает сопоставители с исходным кодом;
    • оптимизирует код на стороне клиента посредством объединения и минификации.

В ASP.NET Core режим конфигурации хранится в переменной среды ASPNETCORE_ENVIRONMENT. Дополнительные сведения см. в разделе Указание среды.

Запуск с помощью .NET Core CLI

Восстановите необходимые пакеты NuGet и npm, выполнив следующую команду в корневом каталоге проекта:

dotnet restore && npm i

Выполните сборку и запуск приложения:

dotnet run

Приложение запускается на узле localhost в соответствии с режимом конфигурации среды выполнения. При переходе по адресу http://localhost:5000 в браузере открывается целевая страница.

Запуск с помощью Visual Studio 2017

Откройте файл, .csproj созданный командой dotnet new . Необходимые пакеты NuGet и npm восстанавливаются автоматически при открытии проекта: Процесс восстановления может занять несколько минут. По его завершении приложение будет готово к запуску. Нажмите зеленую кнопку запуска или клавиши Ctrl + F5, и в браузере откроется целевая страница приложения. Приложение запускается на узле localhost в соответствии с режимом конфигурации среды выполнения.

Тестирование приложения

В шаблонах SpaServices предварительно настроено выполнение тестов на стороне клиента с помощью Karma и Jasmine. Jasmine — это популярная платформа модульного тестирования для JavaScript, а Karma — это средство запуска тестов. Средство Karma настроено для работы с ПО промежуточного слоя Webpack для разработки, так что разработчику не нужно останавливать и снова запускать тест каждый раз, когда вносится изменение. Независимо от того, выполняется ли сам тестовый случай или код для тестового случая, тест проводится автоматически.

Возьмем в качестве примера приложение Angular. В файле counter.component.spec.ts уже имеется два тестовых случая Jasmine для CounterComponent:

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.conf.js файле. Помимо прочих параметров, в файле karma.conf.js определены тестовые файлы, которые должны выполняться через массив files:

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

Публикация приложения

Дополнительные сведения о публикации в Azure см. в описании этой проблемы в GitHub.

Объединение созданных клиентских ресурсов и опубликованных артефактов ASP.NET Core в пакет, готовый к развертыванию, может быть непростой задачей. К счастью, пакет SpaServices координирует весь процесс публикации с помощью настраиваемого целевого объекта MSBuild с именем 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>

Целевой объект MSBuild отвечает за следующие задачи:

  1. восстановление пакетов npm;
  2. создание сборки сторонних клиентских ресурсов для рабочей среды;
  3. создание сборки пользовательских клиентских ресурсов для рабочей среды;
  4. копирование ресурсов, созданных средством Webpack, в папку публикации.

Целевой объект MSBuild вызывается при выполнении следующей команды:

dotnet publish -c Release

Дополнительные ресурсы