使用 JavaScript Services 在 ASP.NET Core 中创建单页应用程序
作者:Fiyaz Hasan
警告
本文所述的功能自 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)。
单页应用程序 (SPA) 因其固有的丰富用户体验而成为一种常用的 Web 应用程序。 将客户端 SPA 框架或库(例如 Angular 或 React)与服务器端框架(例如 ASP.NET Core)集成在一起可能会很困难。 开发 JavaScript Services 就是为了减少集成过程中的摩擦。 使用它可以在不同的客户端和服务器技术堆栈之间无缝操作。
什么是 JavaScript Services
JavaScript Services 是用于 ASP.NET Core 的客户端技术集合。 其目标是将 ASP.NET Core 定位为开发人员生成 SPA 时的首选服务器端平台。
JavaScript Services 由两个不同的 NuGet 包组成:
- Microsoft.AspNetCore.NodeServices (NodeServices)
- Microsoft.AspNetCore.SpaServices (SpaServices)
这些包在以下情况下很有用:
- 在服务器上运行 JavaScript
- 使用 SPA 框架或库
- 通过 Webpack 生成客户端资产
本文重点介绍了如何使用 SpaServices 包。
什么是 SpaServices
创建 SpaServices 的目的是将 ASP.NET Core 定位为开发人员生成 SPA 时的首选服务器端平台。 使用 ASP.NET Core 开发 SPA 时不一定要使用 SpaServices,SpaServices 也不会将开发人员束缚在特定的客户端框架中。
SpaServices 可提供有用的基础结构,例如:
将这些基础结构组件结合使用时,可增强开发工作流和运行时体验。 这些组件也可单独使用。
使用 SpaServices 的先决条件
若要使用 SpaServices,请安装以下各项:
带有 npm 的 Node.js(版本 6 或更高版本)
若要确保已安装并且可找到这些组件,请从命令行运行以下命令:
node -v && npm -v
如果部署到 Azure 网站,则无需执行任何操作 - Node.js 已安装并在服务器环境中可用。
-
- 在使用 Visual Studio 2017 的 Windows 上,通过选择“.NET Core 跨平台开发”工作负载来安装该 SDK。
Microsoft.AspNetCore.SpaServices NuGet 包
服务器端预呈现
通用(也称为同构)应用程序是一种能够在服务器和客户端上运行的 JavaScript 应用程序。 Angular、React 和其他常用框架针对这种应用程序开发风格提供一个通用平台。 其思路是先通过 Node.js 在服务器上呈现框架组件,然后将进一步的执行任务委托给客户端。
SpaServices 提供的 ASP.NET Core 标记帮助程序通过调用服务器上的 JavaScript 函数来简化服务器端预呈现的实现。
服务器端预呈现的先决条件
安装 aspnet-prerendering npm 包:
npm i -S aspnet-prerendering
服务器端预呈现配置
可以通过在项目的 _ViewImports.cshtml
文件中注册命名空间来发现标记帮助程序:
@using SpaServicesSampleApp
@addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers"
@addTagHelper "*, Microsoft.AspNetCore.SpaServices"
这些标记帮助程序通过在 Razor 视图中利用类似 HTML 的语法来抽象化与低级 API 直接通信的复杂性:
<app asp-prerender-module="ClientApp/dist/main-server">Loading...</app>
asp-prerender-module 标记帮助程序
上面的代码示例中使用的 asp-prerender-module
标记帮助程序通过 Node.js 在服务器上执行 ClientApp/dist/main-server.js
。 为清楚起见,main-server.js
文件是 Webpack 生成过程中 TypeScript 到 JavaScript 转译任务的产物。 Webpack 定义了入口点别名 main-server
;此别名的依赖项关系图遍历始于 ClientApp/boot-server.ts
文件:
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 的占位符元素中。
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();
});
});
});
});
});
在标记帮助程序中传递的属性名称用 PascalCase 表示法表示。 与之相反,JavaScript 用 camelCase 表示相同的属性名称。 默认的 JSON 序列化配置是造成这种差异的原因所在。
若要扩展上面的代码示例,可以通过解冻提供给 resolve
函数的 globals
属性,将数据从服务器传递到视图:
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();
});
});
});
});
});
globals
对象中定义的 postList
数组附加到浏览器的全局 window
对象。 将此变量提升到全局范围可消除重复的工作,特别是在服务器上加载了一次数据,之后又在客户端上加载相同的数据时。
Webpack 开发中间件
Webpack 开发中间件引入了简化的开发工作流,Webpack 可根据该工作流按需生成资源。 在浏览器中重新加载页面时,该中间件会自动编译并提供客户端资源。 另一种方法是在第三方依赖项或自定义代码发生更改时,通过项目的 npm 生成脚本手动调用 Webpack。 以下示例显示了 package.json
文件中的 npm 生成脚本:
"build": "npm run build:vendor && npm run build:custom",
Webpack 开发中间件的先决条件
安装 aspnet-webpack npm 包:
npm i -D aspnet-webpack
Webpack 开发中间件配置
Webpack 开发中间件通过 Startup.cs
文件的 Configure
方法中的以下代码注册到 HTTP 请求管道中:
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
app.UseWebpackDevMiddleware();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
// Call UseWebpackDevMiddleware before UseStaticFiles
app.UseStaticFiles();
通过 UseStaticFiles
扩展方法注册静态文件托管之前,必须先调用 UseWebpackDevMiddleware
扩展方法。 出于安全原因,仅在应用以开发模式运行时才注册该中间件。
webpack.config.js
文件的 output.publicPath
属性指示中间件监视 dist
文件夹中的更改:
module.exports = (env) => {
output: {
filename: '[name].js',
publicPath: '/dist/' // Webpack dev middleware, if enabled, handles requests for this URL prefix
},
热模块更换
可将 Webpack 的热模块更换 (HMR) 功能视作 Webpack 开发中间件的进化版。 HMR 引入了所有相同的优点,但是通过在编译更改后自动更新页面内容,进一步简化了开发工作流。 不要将其与浏览器的刷新功能混淆,后者会干扰 SPA 的当前内存中状态和调试会话。 Webpack 开发中间件服务与浏览器之间有一个实时链接,这意味着系统会将更改推送到浏览器。
热模块更换的先决条件
安装 webpack-hot-middleware npm 包:
npm i -D webpack-hot-middleware
热模块更换配置
必须在 Configure
方法中将 HMR 组件注册到 MVC 的 HTTP 请求管道:
app.UseWebpackDevMiddleware(new WebpackDevMiddlewareOptions {
HotModuleReplacement = true
});
与 Webpack 开发中间件一样,调用 UseStaticFiles
扩展方法之前,必须先调用 UseWebpackDevMiddleware
扩展方法。 出于安全原因,仅在应用以开发模式运行时才注册该中间件。
webpack.config.js
文件必须定义一个 plugins
数组,即便将其留空亦可:
module.exports = (env) => {
plugins: [new CheckerPlugin()]
在浏览器中加载应用后,开发人员工具的“控制台”选项卡会提供 HMR 激活确认:
路由帮助程序
在大多数基于 ASP.NET Core 的 SPA 中,除服务器端路由外,通常还需要进行客户端路由。 SPA 和 MVC 路由系统可以独立工作而互不干扰。 但是,有一种极端情况带来了挑战:标识 404 HTTP 响应。
以使用 /some/page
的无扩展路由的情况为例。 假设请求的模式与服务器端路由不匹配,但与客户端路由匹配。 现在以针对 /images/user-512.png
的传入请求为例,该请求通常需要在服务器上查找映像文件。 如果请求的资源路径与任何服务器端路由或静态文件都不匹配,则客户端应用程序不太可能处理它 - 通常需要返回 404 HTTP 状态代码。
路由帮助程序的先决条件
安装客户端路由 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 Services 提供预配置的应用程序模板。 在这些模板中,SpaServices 与各种框架和库(例如 Angular、React 和 Redux)结合使用。
可以在 .NET CLI 中运行以下命令来安装这些模板:
dotnet new --install Microsoft.AspNetCore.SpaTemplates::*
系统会显示可用 SPA 模板的列表:
模板 | 短名称 | 语言 | Tags |
---|---|---|---|
含 Angular 的 MVC ASP.NET Core | angular | [C#] | Web/MVC/SPA |
含 React.js 的 MVC ASP.NET Core | react | [C#] | Web/MVC/SPA |
含 React.js 和 Redux 的 MVC ASP.NET Core | reactredux | [C#] | Web/MVC/SPA |
若要使用其中一个 SPA 模板创建新项目,请在 dotnet new 命令中包含该模板的短名称。 以下命令将使用为服务器端配置的 ASP.NET Core MVC 创建 Angular 应用程序:
dotnet new angular
设置运行时配置模式
存在两种主要运行时配置模式:
- 开发:
- 包含源映射以简化调试。
- 不优化客户端代码的性能。
- 生产:
- 不包含源映射。
- 通过捆绑和缩小来优化客户端代码。
ASP.NET Core 使用名为 ASPNETCORE_ENVIRONMENT
的环境变量来存储配置模式。 有关详细信息,请参阅设置环境。
通过 .NET CLI 运行
通过在项目根目录下运行以下命令来还原所需的 NuGet 和 npm 包:
dotnet restore && npm i
生成并运行应用程序:
dotnet run
应用程序根据运行时配置模式在 localhost 上启动。 在浏览器中导航到 http://localhost:5000
会显示登陆页面。
使用 Visual Studio 2017 运行
打开由 dotnet new 命令生成的 .csproj
文件。 所需的 NuGet 和 npm 包在项目打开时会自动还原。 此还原过程可能需要几分钟的时间,应用程序在此过程完成后即可运行。 单击绿色的运行按钮或按 Ctrl + F5
,浏览器将打开到应用程序的登陆页面。 应用程序根据运行时配置模式在 localhost 上运行。
测试应用
SpaServices 模板已预先配置为使用 Karma 和 Jasmine 运行客户端测试。 Jasmine 是适用于 JavaScript 的常用单元测试框架,而 Karma 是这些测试的测试运行程序。 Karma 配置为使用 Webpack 开发中间件,使开发人员无需在每次进行更改时都停止并运行测试。 无论是针对测试用例运行的代码还是测试用例本身,测试都会自动运行。
以 Angular 应用程序为例,系统已经为 counter.component.spec.ts
文件中的 CounterComponent
提供了两个 Jasmine 测试用例:
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 测试运行程序,而后者将读取 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 可使用名为 RunWebpack
的自定义 MSBuild 目标来协调整个发布过程:
<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 目标具有以下职责:
- 还原 npm 包。
- 创建第三方客户端资产的生产级生成。
- 创建自定义客户端资产的生产级生成。
- 将 Webpack 生成的资产复制到发布文件夹。
运行以下命令时将调用该 MSBuild 目标:
dotnet publish -c Release