使用 JavaScript Services 在 ASP.NET Core 中创建单页应用程序Use JavaScript Services to Create Single Page Applications in ASP.NET Core

本文内容

作者:Scott AddieFiyaz Hasan

单页应用程序 (SPA) 因其固有的丰富用户体验而成为一种常用的 Web 应用程序。将客户端 SPA 框架或库(例如 AngularReact)与服务器端框架(例如 ASP.NET Core)集成在一起可能会很困难。开发 JavaScript Services 就是为了减少集成过程中的摩擦。使用它可以在不同的客户端和服务器技术堆栈之间无缝操作。

警告

本文所述的功能自 ASP.NET Core 3.0 起被弃用。Microsoft.AspNetCore.SpaServices.Extensions NuGet 包提供了一种更简单的 SPA 框架集成机制。有关详细信息,请参阅 [Announcement] Obsoleting Microsoft.AspNetCore.SpaServices and Microsoft.AspNetCore.NodeServices([公告] 弃用 Microsoft.AspNetCore.SpaServices 和 Microsoft.AspNetCore.NodeServices)。

什么是 JavaScript ServicesWhat is JavaScript Services

JavaScript Services 是用于 ASP.NET Core 的客户端技术集合。其目标是将 ASP.NET Core 定位为开发人员生成 SPA 时的首选服务器端平台。

JavaScript Services 由两个不同的 NuGet 包组成:

这些包在以下情况下很有用:

  • 在服务器上运行 JavaScript
  • 使用 SPA 框架或库
  • 通过 Webpack 生成客户端资产

本文重点介绍了如何使用 SpaServices 包。

什么是 SpaServicesWhat is SpaServices

创建 SpaServices 的目的是将 ASP.NET Core 定位为开发人员生成 SPA 时的首选服务器端平台。使用 ASP.NET Core 开发 SPA 时不一定要使用 SpaServices,SpaServices 也不会将开发人员束缚在特定的客户端框架中。

SpaServices 可提供有用的基础结构,例如:

将这些基础结构组件结合使用时,可增强开发工作流和运行时体验。这些组件也可单独使用。

使用 SpaServices 的先决条件Prerequisites for using SpaServices

若要使用 SpaServices,请安装以下各项:

  • 带有 npm 的 Node.js(版本 6 或更高版本)

    • 若要确保已安装并且可找到这些组件,请从命令行运行以下命令:
  1. node -v && npm -v
  • 如果部署到 Azure 网站,则无需执行任何操作 — 已经在服务器环境中安装 Node.js,并且 Node.js 可供使用。

服务器端预呈现Server-side prerendering

通用(也称为同构)应用程序是一种能够在服务器和客户端上运行的 JavaScript 应用程序。Angular、React 和其他常用框架针对这种应用程序开发风格提供一个通用平台。其思路是先通过 Node.js 在服务器上呈现框架组件,然后将进一步的执行任务委托给客户端。

SpaServices 提供的 ASP.NET Core 标记帮助程序通过调用服务器上的 JavaScript 函数来简化服务器端预呈现的实现。

服务器端预呈现的先决条件Server-side prerendering prerequisites

安装 aspnet-prerendering npm 包:

  1. npm i -S aspnet-prerendering

服务器端预呈现配置Server-side prerendering configuration

可以通过在项目的 _ViewImports.cshtml 文件中注册命名空间来发现标记帮助程序:

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

这些标记帮助程序通过在 Razor 视图中利用类似 HTML 的语法来抽象化与低级 API 直接通信的复杂性:

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

asp-prerender-module 标记帮助程序asp-prerender-module Tag Helper

上面的代码示例中使用的 asp-prerender-module 标记帮助程序通过 Node.js 在服务器上执行 ClientApp/dist/main-server.js为清楚起见,main-server.js 文件是 Webpack 生成过程中 TypeScript 到 JavaScript 转译任务的产物。Webpack 定义了入口点别名 main-server;此别名的依赖项关系图遍历始于 ClientApp/boot-server.ts 文件:

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

在以下 Angular 示例中,ClientApp/boot-server.ts 文件利用 createServerRenderer 函数和 aspnet-prerendering npm 包的 RenderResult 类型通过 Node.js 来配置服务器呈现。用于服务器端呈现的 HTML 标记传递到解析函数调用,该调用包装在强类型的 JavaScript Promise 对象中。Promise 对象的意义在于,它以异步方式将 HTML 标记提供给页面,以注入到 DOM 的占位符元素中。

  1. import { createServerRenderer, RenderResult } from 'aspnet-prerendering';
  2. export default createServerRenderer(params => {
  3. const providers = [
  4. { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
  5. { provide: 'ORIGIN_URL', useValue: params.origin }
  6. ];
  7. return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
  8. const appRef = moduleRef.injector.get(ApplicationRef);
  9. const state = moduleRef.injector.get(PlatformState);
  10. const zone = moduleRef.injector.get(NgZone);
  11. return new Promise<RenderResult>((resolve, reject) => {
  12. zone.onError.subscribe(errorInfo => reject(errorInfo));
  13. appRef.isStable.first(isStable => isStable).subscribe(() => {
  14. // Because 'onStable' fires before 'onError', we have to delay slightly before
  15. // completing the request in case there's an error to report
  16. setImmediate(() => {
  17. resolve({
  18. html: state.renderToString()
  19. });
  20. moduleRef.destroy();
  21. });
  22. });
  23. });
  24. });
  25. });

asp-prerender-data 标记帮助程序asp-prerender-data Tag Helper

asp-prerender-module 标记帮助程序结合使用时,asp-prerender-data 标记帮助程序可用于将上下文信息从 Razor 视图传递到服务器端 JavaScript。例如,以下标记将用户数据传递到 main-server 模块:

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

收到的 UserName 参数使用内置的 JSON 序列化程序进行序列化,并存储在 params.data 对象中。在以下 Angular 示例中,该数据用于在 h1 元素内构造个性化问候语:

  1. import { createServerRenderer, RenderResult } from 'aspnet-prerendering';
  2. export default createServerRenderer(params => {
  3. const providers = [
  4. { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
  5. { provide: 'ORIGIN_URL', useValue: params.origin }
  6. ];
  7. return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
  8. const appRef = moduleRef.injector.get(ApplicationRef);
  9. const state = moduleRef.injector.get(PlatformState);
  10. const zone = moduleRef.injector.get(NgZone);
  11. return new Promise<RenderResult>((resolve, reject) => {
  12. const result = `<h1>Hello, ${params.data.userName}</h1>`;
  13. zone.onError.subscribe(errorInfo => reject(errorInfo));
  14. appRef.isStable.first(isStable => isStable).subscribe(() => {
  15. // Because 'onStable' fires before 'onError', we have to delay slightly before
  16. // completing the request in case there's an error to report
  17. setImmediate(() => {
  18. resolve({
  19. html: result
  20. });
  21. moduleRef.destroy();
  22. });
  23. });
  24. });
  25. });
  26. });

在标记帮助程序中传递的属性名称用 PascalCase 表示法表示。与之相反,JavaScript 用 camelCase 表示相同的属性名称。默认的 JSON 序列化配置是造成这种差异的原因所在。

若要扩展上面的代码示例,可以通过解冻提供给 resolve 函数的 globals 属性,将数据从服务器传递到视图:

  1. import { createServerRenderer, RenderResult } from 'aspnet-prerendering';
  2. export default createServerRenderer(params => {
  3. const providers = [
  4. { provide: INITIAL_CONFIG, useValue: { document: '<app></app>', url: params.url } },
  5. { provide: 'ORIGIN_URL', useValue: params.origin }
  6. ];
  7. return platformDynamicServer(providers).bootstrapModule(AppModule).then(moduleRef => {
  8. const appRef = moduleRef.injector.get(ApplicationRef);
  9. const state = moduleRef.injector.get(PlatformState);
  10. const zone = moduleRef.injector.get(NgZone);
  11. return new Promise<RenderResult>((resolve, reject) => {
  12. const result = `<h1>Hello, ${params.data.userName}</h1>`;
  13. zone.onError.subscribe(errorInfo => reject(errorInfo));
  14. appRef.isStable.first(isStable => isStable).subscribe(() => {
  15. // Because 'onStable' fires before 'onError', we have to delay slightly before
  16. // completing the request in case there's an error to report
  17. setImmediate(() => {
  18. resolve({
  19. html: result,
  20. globals: {
  21. postList: [
  22. 'Introduction to ASP.NET Core',
  23. 'Making apps with Angular and ASP.NET Core'
  24. ]
  25. }
  26. });
  27. moduleRef.destroy();
  28. });
  29. });
  30. });
  31. });
  32. });

globals 对象中定义的 postList 数组附加到浏览器的全局 window 对象。将此变量提升到全局范围可消除重复的工作,特别是在服务器上加载了一次数据,之后又在客户端上加载相同的数据时。

附加到 window 对象的全局 postList 变量

Webpack 开发中间件Webpack Dev Middleware

Webpack 开发中间件引入了简化的开发工作流,Webpack 可根据该工作流按需生成资源。在浏览器中重新加载页面时,该中间件会自动编译并提供客户端资源。另一种方法是在第三方依赖项或自定义代码发生更改时,通过项目的 npm 生成脚本手动调用 Webpack。以下示例显示了 package.json 文件中的 npm 生成脚本:

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

Webpack 开发中间件的先决条件Webpack Dev Middleware prerequisites

安装 aspnet-webpack npm 包:

  1. npm i -D aspnet-webpack

Webpack 开发中间件配置Webpack Dev Middleware configuration

Webpack 开发中间件通过 Startup.cs 文件的 Configure 方法中的以下代码注册到 HTTP 请求管道中:

  1. if (env.IsDevelopment())
  2. {
  3. app.UseDeveloperExceptionPage();
  4. app.UseWebpackDevMiddleware();
  5. }
  6. else
  7. {
  8. app.UseExceptionHandler("/Home/Error");
  9. }
  10. // Call UseWebpackDevMiddleware before UseStaticFiles
  11. app.UseStaticFiles();

通过 UseStaticFiles 扩展方法注册静态文件托管之前,必须先调用 UseWebpackDevMiddleware 扩展方法。出于安全原因,仅在应用以开发模式运行时才注册该中间件。

webpack.config.js 文件的 output.publicPath 属性指示中间件监视 dist 文件夹中的更改:

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

热模块更换Hot Module Replacement

可将 Webpack 的热模块更换 (HMR) 功能视作 Webpack 开发中间件的进化版。HMR 引入了所有相同的优点,但是通过在编译更改后自动更新页面内容,进一步简化了开发工作流。不要将其与浏览器的刷新功能混淆,后者会干扰 SPA 的当前内存中状态和调试会话。Webpack 开发中间件服务与浏览器之间有一个实时链接,这意味着系统会将更改推送到浏览器。

热模块更换的先决条件Hot Module Replacement prerequisites

安装 webpack-hot-middleware npm 包:

  1. npm i -D webpack-hot-middleware

热模块更换配置Hot Module Replacement configuration

必须在 Configure 方法中将 HMR 组件注册到 MVC 的 HTTP 请求管道:

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

Webpack 开发中间件一样,调用 UseStaticFiles 扩展方法之前,必须先调用 UseWebpackDevMiddleware 扩展方法。出于安全原因,仅在应用以开发模式运行时才注册该中间件。

webpack.config.js 文件必须定义一个 plugins 数组,即便将其留空亦可:

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

在浏览器中加载应用后,开发人员工具的“控制台”选项卡会提供 HMR 激活确认:

已连接热模块更换消息

路由帮助程序Routing helpers

在大多数基于 ASP.NET Core 的 SPA 中,除服务器端路由外,通常还需要进行客户端路由。SPA 和 MVC 路由系统可以独立工作而互不干扰。但是,有一种极端情况带来了挑战:标识 404 HTTP 响应。

以使用 /some/page 的无扩展路由的情况为例。假设请求的模式与服务器端路由不匹配,但与客户端路由匹配。现在以针对 /images/user-512.png 的传入请求为例,该请求通常需要在服务器上查找映像文件。如果请求的资源路径与任何服务器端路由或静态文件都不匹配,则客户端应用程序不太可能处理它 — 通常需要返回 404 HTTP 状态代码。

路由帮助程序的先决条件Routing helpers prerequisites

安装客户端路由 npm 包。以 Angular 为例:

  1. npm i -S @angular/router

路由帮助程序配置Routing helpers configuration

Configure 方法中使用名为 MapSpaFallbackRoute 的扩展方法:

  1. app.UseMvc(routes =>
  2. {
  3. routes.MapRoute(
  4. name: "default",
  5. template: "{controller=Home}/{action=Index}/{id?}");
  6. routes.MapSpaFallbackRoute(
  7. name: "spa-fallback",
  8. defaults: new { controller = "Home", action = "Index" });
  9. });

系统按路由配置顺序评估路由。因此,上面的代码示例中的 default 路由先用于模式匹配。

创建新项目Create a new project

JavaScript Services 提供预配置的应用程序模板。在这些模板中,SpaServices 与各种框架和库(例如 Angular、React 和 Redux)结合使用。

可以通过使用 .NET Core CLI 运行以下命令来安装这些模板:

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

系统会显示可用 SPA 模板的列表:

模板短名称语言Tags
含 Angular 的 MVC ASP.NET Coreangular[C#]Web/MVC/SPA
含 React.js 的 MVC ASP.NET Corereact[C#]Web/MVC/SPA
含 React.js 和 Redux 的 MVC ASP.NET Corereactredux[C#]Web/MVC/SPA

若要使用其中一个 SPA 模板创建新项目,请在 dotnet new 命令中包含该模板的短名称以下命令将使用为服务器端配置的 ASP.NET Core MVC 创建 Angular 应用程序:

  1. dotnet new angular

设置运行时配置模式Set the runtime configuration mode

存在两种主要运行时配置模式:

  • 开发
    • 包含源映射以简化调试。
    • 不优化客户端代码的性能。
  • 生产
    • 不包含源映射。
    • 通过捆绑和缩小来优化客户端代码。

ASP.NET Core 使用名为 ASPNETCORE_ENVIRONMENT 的环境变量来存储配置模式。有关详细信息,请参阅设置环境

使用 .NET Core CLI 运行Run with .NET Core CLI

通过在项目根目录下运行以下命令来还原所需的 NuGet 和 npm 包:

  1. dotnet restore && npm i

生成并运行应用程序:

  1. dotnet run

应用程序根据运行时配置模式在 localhost 上启动。在浏览器中导航到 http://localhost:5000 会显示登陆页面。

使用 Visual Studio 2017 运行Run with Visual Studio 2017

打开由 dotnet new 命令生成的 .csproj 文件。所需的 NuGet 和 npm 包在项目打开时会自动还原。此还原过程可能需要几分钟的时间,应用程序在此过程完成后即可运行。单击绿色的运行按钮或按 Ctrl + F5,浏览器将打开到应用程序的登陆页面。应用程序根据运行时配置模式在 localhost 上运行。

测试应用Test the app

SpaServices 模板已预先配置为使用 KarmaJasmine 运行客户端测试。Jasmine 是适用于 JavaScript 的常用单元测试框架,而 Karma 是这些测试的测试运行程序。Karma 配置为使用 Webpack 开发中间件,使开发人员无需在每次进行更改时都停止并运行测试。无论是针对测试用例运行的代码还是测试用例本身,测试都会自动运行。

以 Angular 应用程序为例,系统已经为 counter.component.spec.ts 文件中的 CounterComponent 提供了两个 Jasmine 测试用例:

  1. it('should display a title', async(() => {
  2. const titleText = fixture.nativeElement.querySelector('h1').textContent;
  3. expect(titleText).toEqual('Counter');
  4. }));
  5. it('should start with count 0, then increments by 1 when clicked', async(() => {
  6. const countElement = fixture.nativeElement.querySelector('strong');
  7. expect(countElement.textContent).toEqual('0');
  8. const incrementButton = fixture.nativeElement.querySelector('button');
  9. incrementButton.click();
  10. fixture.detectChanges();
  11. expect(countElement.textContent).toEqual('1');
  12. }));

ClientApp 目录中打开命令提示符。运行下面的命令:

  1. npm test

该脚本将启动 Karma 测试运行程序,而后者将读取 karma.conf.js 文件中定义的设置。除其他设置外,karma.conf.js 还通过其 files 数组标识要执行的测试文件:

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

发布应用Publish the app

有关发布到 Azure 的详细信息,请参阅此 GitHub 问题

将生成的客户端资产和已发布的 ASP.NET Core 项目组合成一个可即时部署的包的过程可能会很繁琐。值得庆幸的是,SpaServices 可使用名为 RunWebpack 的自定义 MSBuild 目标来协调整个发布过程:

  1. <Target Name="RunWebpack" AfterTargets="ComputeFilesToPublish">
  2. <!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
  3. <Exec Command="npm install" />
  4. <Exec Command="node node_modules/webpack/bin/webpack.js --config webpack.config.vendor.js --env.prod" />
  5. <Exec Command="node node_modules/webpack/bin/webpack.js --env.prod" />
  6. <!-- Include the newly-built files in the publish output -->
  7. <ItemGroup>
  8. <DistFiles Include="wwwroot\dist\**; ClientApp\dist\**" />
  9. <ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
  10. <RelativePath>%(DistFiles.Identity)</RelativePath>
  11. <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
  12. </ResolvedFileToPublish>
  13. </ItemGroup>
  14. </Target>

该 MSBuild 目标具有以下职责:

  • 还原 npm 包。
  • 创建第三方客户端资产的生产级生成。
  • 创建自定义客户端资产的生产级生成。
  • 将 Webpack 生成的资产复制到发布文件夹。
    运行以下命令时将调用该 MSBuild 目标:
  1. dotnet publish -c Release

其他资源Additional resources