本文要点:
本文是我们的.NET教育系列文章的一部分,这个系列探索了该技术的优势,设法做到不仅有助于传统的.NET开发人员,而且有助于所有需要把健壮、高性能和经济的解决方案推向市场的技术人员。
随着.NET Core 3.0的发布,微软拥有了通用、模块化、跨平台和开源平台的下一个重要版本,.NET Core最初发布于2016年。创建.NET Core的最初目的是为了下一代的ASP.NET解决方案,但是现在,其驱动并成为很多其他场景的基础,这些场景包括物联网、云和下一代移动解决方案。.NET Core 3.0版添加了大量经常需要功能,如对WinForms、WPF和Entity Framework 6的支持。
开始使用Angular和ASP.NET Core的最简单的方法是,使用微软提供的Visual Studio模板。该模板可以让我们迅速启动并运行,但有一个很大的限制——Angular接管了UI,把ASP.NET保留在后台,并提供API。如果我们希望.NET服务某些页面而Angular服务其他页面,那么,我们需要在ASP.NET Core和Angular中都复制外观和菜单结构。或者,我们可以让单个Angular应用程序服务整个UI,但接下来,我们必须在Angular SPA中实现所有的页面,包括琐碎的静态内容,如Contact As、Licensing等等。
我想要的设置是一个充当门户的ASP.NET站点,然后把Angular工件嵌入ASP.NET页面。一般来说,有两种架构设计模式。在第一种设计模式下,我有一个带路由的Angular应用程序,想嵌入到ASP.NET视图中,其中具有Angular提供的子菜单和提供顶层菜单的ASP.NET站点。在第二种设计模式下,我有Angular组件,这些组件不一定是成熟的Angular应用程序,但是,仍然需要把它们嵌入ASP.NET视图。例如,假设我想在一个ASP.NET视图中嵌入一个组件,以显示当前时间。在Angular中开发这么一个组件很容易,但把它嵌入MVC视图就比较难。最后,我希望实现尽可能多的代码重用,我希望能够在Angular应用程序中重用组件,并能够把相同的组件嵌入ASP.NET视图。本文演示了如何引导ASP.NET和Angular项目以适应这些架构设计模式。如果要看最终的代码,请参考GitHub上的Multi App Demo存储库。
总结一下,这就是我们要构建的:
我用的是Visual Studio 2019 社区版,可以从微软那里免费下载。从2019版开始,用于选择模板的向导和以前的版本有所不同,但无论使用哪个版本,步骤基本上是一样的。
在Solution Explorer中,我们的项目视图应如下所示:
由于我们使用了ASP.NET MVC模板而不是SPA模板,因此,我们需要添加Microsoft.AspNetCore.SpaServices.Extensions NuGet 包。为了安装这个包,请打开软件包管理器控制台(Package Manager Console)并运行以下语句:
Install-Package Microsoft.AspNetCore.SpaServices.Extensions
请确保以下软件都已安装(全部是免费的):
npm install -g @angular/cli
我在使用Angular v8。用更早的版本也可以,只要可以访问createCustomElement API就行。 Angular v7中也有这些功能。
为了创建一个Angular解决方案以在我们的ASP.NET MVC项目中使用,请打开命令提示符,转到包含用于MVC项目的项目文件(扩展名.csproj)所在的文件夹。到达之后,通过命令提示符执行以下命令以创建Angular项目:
ng new Apps
路由选择N,样式选CSS。
把目录改为Apps,并输入以下内容(包括结尾的点):
code .
现在,在Visual Studio Code中应该有我们已经打开的Angular项目了。
这里的想法是,把这个根项目做为可重用组件的存储库,其他Angular 应用程序(我们稍后创建它们)可以把这些可重用组件作为普通Angular组件使用,并作为Web组件提供给MVC视图(也称为Angular元素)。
那么,什么是web component?在webcomponents.org中,是这么定义的:
Web组件是一组web平台API,这些API允许我们创建新的自定义、可重用、封装的HTML标记以用于web页面和web应用程序。
Angular提供一种方法,通过被称为Angular元素的API把Angular组件打包成web组件。例如,如果我们创建一个显示当前时间的Angular组件, 并引导该组件作为Angular元素当前时间,那么,我们接着可以在纯HTML页面中包含这个标签。在Angular官方网站上有更多相关信息。
在VS Code中打开我们的Angular项目。打开一个终端窗口,输入一个命令以生成时钟组件,并把该组件添加到app.module.ts中:
ng g c current-time
现在,我们应该在src/app下有一个名为current-time的文件夹,包含一些组成我们的时钟组件的文件。把app/current-time/current-time.component.html改成具有以下标记:
<p>{{ time }}</p>
把app/current-time/current-time.component.ts改成具有以下代码:
import { Component, OnInit, OnDestroy } from '@angular/core';
@Component({
selector: 'app-current-time',
templateUrl: './current-time.component.html',
styleUrls: ['./current-time.component.css']
})
export class CurrentTimeComponent implements OnInit, OnDestroy {
timer: any;
time: string
constructor() { }
ngOnInit() {
var setTime = () => {
var today = new Date();
this.time = ("0" + today.getHours()).slice(-2) + ":" +
("0" + today.getMinutes()).slice(-2) + ":" +
("0" + today.getSeconds()).slice(-2);
};
setTime();
this.timer = setInterval(setTime, 500);
}
ngOnDestroy(){
if (this.timer){
clearTimeout(this.timer);
}
}
}
这个实现相当简单。我们有个每半秒钟触发一次的计时器。该计时器用一个代表当前时间的字符串更新时间属性,并且HTML模板绑定到该字符串。
把以下样式粘贴到我们的app/current-time/current-time.component.css
p {
background-color: darkslategray;
color: lightgreen;
font-weight: bold;
display: inline-block;
padding: 7px;
border: 4px solid black;
border-radius: 5px;
font-family: monospace;
}
现在,保存所有修改后的文件,让我们把这个时钟组件作为web组件引导:
ng add @angular/elements
import { createCustomElement } from '@angular/elements';
import { NgModule, Injector } from '@angular/core';
constructor(private injector: Injector) {
}
ngDoBootstrap(){
customElements.define('current-time', createCustomElement(CurrentTimeComponent,
{injector: this.injector}));
}
现在我们还需要做一件事情,稍后当我们在一个不同的Angular应用程序中导入CurrentTimeComponents时就会用到。我们需要从这个模块导出该组件。在providers上方添加导出属性就可以实现:
exports: [
CurrentTimeComponent
],
我们的整个app.module.ts应如下所示:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, Injector } from '@angular/core';
import { AppComponent } from './app.component';
import { createCustomElement } from '@angular/elements';
import { CurrentTimeComponent } from './current-time/current-time.component';
@NgModule({
declarations: [
AppComponent,
CurrentTimeComponent
],
imports: [
BrowserModule
],
exports: [
CurrentTimeComponent
],
providers: [],
entryComponents: [CurrentTimeComponent]
})
export class AppModule {
constructor(private injector: Injector) {
}
ngDoBootstrap(){
customElements.define('current-time', createCustomElement(CurrentTimeComponent,
{injector: this.injector}));
}
}
现在,我们来测试一下我们的解决方案是否有用。转到src\index.html,用替换。在终端窗口输入 ng serve --open 以运行该项目。现在,我们应该在浏览器窗口看到当前时间。
接下来,要让我们的当前时间组件在ASP.NET MVC Core项目中可用。在Visual Studio中打开ASP.NET就可以了。在Views/Shares/_Layout.cshtml中的结束标签前粘贴以下代码:
<environment include="Development">
<script type="text/javascript" src="http://localhost:4200/runtime.js"></script>
<script type="text/javascript" src="http://localhost:4200/polyfills.js"></script>
<script type="text/javascript" src="http://localhost:4200/styles.js"></script>
<script type="text/javascript" src="http://localhost:4200/scripts.js"></script>
<script type="text/javascript" src="http://localhost:4200/vendor.js"></script>
<script type="text/javascript" src="http://localhost:4200/main.js"></script>
</environment>
<environment exclude="Development">
<script asp-src-include="~/Apps/dist/core/runtime-es2015.*.js" type="module"></script>
<script asp-src-include="~/Apps/dist/core/polyfills-es2015.*.js" type="module"></script>
<script asp-src-include="~/Apps/dist/core/runtime-es5.*.js" nomodule></script>
<script asp-src-include="~/Apps/dist/core/polyfills-es5.*.js" nomodule></script>
<script asp-src-include="~/Apps/dist/core/scripts.*.js"></script>
<script asp-src-include="~/Apps/dist/core/main-es2015.*.js" type="module"></script>
<script asp-src-include="~/Apps/dist/core/main-es5.*.js" nomodule></script>
</environment>
前面的代码段显示了两个块,一个用于开发,一个用于非开发。当我们在开发时,我们托管web组件的Angular项目将运行于从VS Code启动的4200端口。当我们投入生产时,该Angular项目将被编译到wwwroot/apps/core文件夹,并带有附加哈希值命名的javascript文件。为了正确地引用这些文件,我们需要使用asp-src-include标记助手。
接下来,在_Layout.cshtml中,在结束标记后面直接添加。
测试我们的开发配置是否有用:
ng serve --liveReload=false
Web 组件很棒,也许是web UI的未来,但是,就今天来说,Angular项目作为单个页面应用程序(Single Page Applications,简称SPAs)引导仍有自己的生存空间。
Angular是围绕着模块的概念设计的,它的一些特性,特别是路由,是与模块而不是组件保持一致的。在混合Angular和ASP.NET开发时,我的目标是在MVC视图中托管Angular应用程序。我希望ASP.NET MVC提供顶层菜单结构,SPA提供它们自己的菜单和路由结构,这些都驻留在更大的MVC应用程序中 。此外,我希望实现代码重用,这些代码可以在解决方案中的多个SPAs中共享,也可以作为web组件包含在非Angular页面中。
第一步是在Angular中创建一个新的应用程序。最简单的实现方法是使用Angular CLI(命令行接口)。如果还没有这个,在VS Code中打开Angular项目,并启动一个新的终端窗口。在终端窗口,执行该命令:
ng g application App1 --routing=true
这将在配置了路由模块的Apps\projects\App1下生成新的Angular应用程序。让我们生成两个组件,并设置路由,这样我们可以路由到某处去。从终端窗口执行以下命令:
ng g c Page1 --project=App1
ng g c Page2 --project=App1
现在,我们应该在Apps/Projects/App1/src/app下看到两个新的组件文件夹page1和page2。
现在,让我们来为这些组件设置路由。把Apps/Projects/App1/src/app下的app.component.html改成具有这个标记:
<h2>App1</h2>
<a routerLink="/page1" routerLinkActive="active">Page1</a>
<a routerLink="/page2" routerLinkActive="active">Page2</a>
<router-outlet></router-outlet>
并用以下代码更新Apps/projects/App1/src/app下的 app-routing.module.ts:
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { Page1Component } from './page1/page1.component';
import { Page2Component } from './page2/page2.component';
const routes: Routes = [
{path: '', redirectTo: 'page1', pathMatch: 'full'},
{path: 'page1', component: Page1Component},
{path: 'page2', component: Page2Component}];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
这只是标准的路由代码。关于Angular路由的评论,请访问该页面。
现在,让我们来测试我们的新应用程序是否配置得正确。打开一个新的终端窗口,输入以下命令:
ng serve App1 --port 4201 --open
我们的浏览器窗口应该打开,我们应该能看到类似以下的内容:
请注意,现在我们在用端口4201,和我们用于根Angular项目的不同。我们创建的每个应用程序都将需要开发环境中一个不同的端口服务于它,但是,在非开发环境中,所有的应用程序、ASP.NET和Angular都将运行于同一个端口上。
现在,该演示的一个目标是实现代码重用。让我们在App1中重用来自基础项目的Angular组件。为了实现这个目标,要在App1的主模块中包含CurrentTimeComponent的导入。
转到Apps/projects/App1/src/app下的app.modules.ts,添加以下导入语句:
import { CurrentTimeComponent } from '../../../../src/app/current-time/current-time.component';
这里正在发生的事是,我们从根项目中导入CurrentTimeComponent。或者,我们可以从根项目中导入整个AppModule。
接下来,把CurrentTimeComponent添加到声明列表中:
declarations: [
AppComponent,
Page1Component,
Page2Component,
CurrentTimeComponent
],
现在,转到App1中的 app.component.html,并为当前时间添加标签,就添加在路由器出口的正下方。
<h2>App1</h2>
<a routerLink="/page1" routerLinkActive="active">Page1</a>
<a routerLink="/page2" routerLinkActive="active">Page2</a>
<router-outlet></router-outlet>
<app-current-time></app-current-time>
请注意,我们为这个组件使用了Angular标签(app-current-time),而不是web组件标签名(current-time)。原因是,我们把该组件作为Angular组件包含在内了。App1完全不知道这个Angular组件在其他地方用作web组件。
保存所有的文件并检查浏览器。我们的App1页面现在应该显示当前时间组件。
在这个演练中,我们要做的最后一件事是,把App1作为单页面应用程序合并到ASP.NET MVC应用程序 。我们希望有以下特性:
首先,让我们在主控制器上(Home Controller)上设置一个名为App1的常规MVC视图。
在我们的MVC项目中,转到Controllers/HomeController.cs,并添加以下代码:
[Route("app1/{*url}")]
public IActionResult App1(string url)
{
return View("App1", url);
}
这个在路由(Route)属性中的{*url}构造告诉ASP.NET捕获在url变量中/app1/ 段右侧的一切内容。然后,将其传到Angular应用程序。 现在,右键单击View()令牌,然后选择添加视图。调用视图App1,并点击Add按钮。这应该在Views/Home中创建一个名为App1.cshtml的文件。确保该文件有以下标记:
@{
ViewData["Title"] = "App1";
}
This is the view for App1.
转到Shared/_Layout.cshtml,并给该视图添加一个链接,就添加在到隐私(Privacy)视图链接的下方。最简单的方法是,复制这个隐私链接标记,并用“App1”这个词替换“Privacy”这个词。
<ul class="navbar-nav flex-grow-1">
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home"
asp-action="Index">Home</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home"
asp-action="Privacy">Privacy</a>
</li>
<li class="nav-item">
<a class="nav-link text-dark" asp-area="" asp-controller="Home"
asp-action="App1">App1</a>
</li>
</ul>
在_Layout.cshtml中时,让我们多做一个更改。让我们web组件周围添加一些标记,以直观地指明这是一个web组件而不是Angular组件。添加
和注释就可以做到:
<div class="container">
<partial name="_CookieConsentPartial" />
<main role="main" class="pb-3">
@RenderBody()
</main>
<hr />
This is a web component<br />
<current-time></current-time>
</div>
接下来,我们来测试一下这个应用程序。点击F5键,确保可以通过App1链接跳转到App1视图。
下一步是把App1应用程序嵌入App1 MVC视图。我们准备使用一个iframe,它指向在同一个域的URL。使用iframe的好处是可以把App1封装在其自身的容器中,但也带来两个挑战:
我们将使用JavaScript来解决这两个挑战。因为iframe指向同一个域,所以,这是唯一可行的方法,从而避免了跨域限制。
但是,在我们这么做之前,我们仍然需要在.NET代码中做更多的修改。
首先,我们在Startup中配置App1。打开Startup.cs,并把以下代码添加到配置(Configure)方法中:
app.Map("/apps/app1", builder => {
builder.UseSpa(spa =>
{
if (env.IsDevelopment())
{
spa.UseProxyToSpaDevelopmentServer($"http://localhost:4201/");
}
else
{
var staticPath = Path.Combine(
Directory.GetCurrentDirectory(), $"wwwroot/Apps/dist/app1");
var fileOptions = new StaticFileOptions
{ FileProvider = new PhysicalFileProvider(staticPath) };
builder.UseSpaStaticFiles(options: fileOptions);
spa.Options.DefaultPageStaticFileOptions = fileOptions;
}
});
});
该段代码告诉.NET核心运行时,把应用程序映射到/apps/app1路径,以代理到开发中的端口4201,并期望在非开发环境中的wwwroot/apps/app1可用编译后的文件。
但是,我们不希望/apps/app1 的用户使用我们的应用程序。我们希望我们的应用程序在用户转到App1视图时可用,App1视图可以是/home/app1或只是/app1 URL。
这里是我们打算使用iframe的地方。打开App1.cshtml,并添加以下标记:
<iframe src="/apps/app1/@Model" class="app-container" frameborder="0" scrolling="no"></iframe>
请注意@Model构造。它被映射到组件中的{*url},我们把路径的一部分从顶部窗口传到App1右侧的iframe,因此,路由在Angular应用程序内部进行。
现在,我们可以测试这个应用程序了。转到VS Code,并从一个可用的终端窗口执行以下serve命令:
ng serve App1 --port 4201 --servePath / --baseHref /apps/app1/ --publicHost http://localhost:4201
该命令在4201端口启动App1。由于我们知道准备从apps/app1给它提供服务,因此,它设置了基础HREF,并且,它指示Angular使用localhost:4201而不是使用相对的URL进行实时重载。
转到Visual Studio,并点击F5键。在ASP.NET站点出现在浏览器窗口后,转到App1菜单。如果看到和下面类似的屏幕,那就意味着该应用程序已经正确地连接上了。
尽管App1 Angular应用程序确实出现在App1视图中,但是没有内容。如果点击Page 1和Page 2的链接,可以看到在Angluar组件中跳转是正常工作的,但是,在浏览器顶部的地址栏没有反映出跳转的当前状态。让我们来解决这两个问题。
为了在启动时以及iframe的内容有变化时调整iframe的大小,我们将使用名为iFrame Resizer的JavaScript组件,iFrame Resizer是由David Bradshaw创建的。
为了让该组件工作,我们需要执行这三个步骤。
在_Layout.cshtml中,把以下脚本标签粘贴到指向site.js的脚本标签的正上方
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.1.1/iframeResizer.min.js">
</script>
给位于 wwwroot/js的site.js添加以下代码行。
$('.app-container').iFrameResize({ heightCalculationMethod: 'documentElementOffset' });
接着,转到VS Code,并在结束标签的上方给位于Apps/projects/App1/src的Index.html添加以下脚本标签:
<script src="https://cdnjs.cloudflare.com/ajax/libs/iframe-resizer/4.1.1/iframeResizer.contentWindow.min.js">
</script>
保存所有的文件,我们来重新测试一下这个应用程序。App1现在应该如下所示:
请注意,内容不再消失了。iFrame Resizer的这一点做得很不错,在iframe初始加载后,它将不断调整iframe的大小以适合内容。
现在,我们来解决这个问题:当点击Angular路由器链接时,地址栏没有更新。因为App1在iframe中运行,因此,地址栏没有更新。iframe的地址在改变,但是,我们看不到,原因是,我们看到的地址栏是用于顶部浏览器窗口的。
请记住,我们已经有代码可以捕捉/app1 URL段右侧的路径,并存入{*ulr}变量,再把它传给iframe。我们需要添加的代码是另一个方式,当路由在Angular应用程序中进行时,我们希望把变化传播到顶层地址栏。
我们需要把代码添加到App1应用程序中的路由模块中来实现。
打开Apps/projects/App1/src/app中的 app-routing.module.ts。在AppRouting Module的构造函数中添加以下代码:
constructor(private route:Router){
var topHref = window.top.location.href != window.location.href ?
window.top.location.href.substring(0,
window.top.location.href.indexOf('/app1') + 5) :
null;
this.route.events.subscribe(e => {
if(e instanceof NavigationEnd){
if (topHref){
window.top.history.replaceState(window.top.history.state,
window.top.document.title, topHref + e.url);
}
}
});
}
该代码通过比较顶部窗口的HREF和当前窗口的HREF来确定应用程序是否在iframe中运行。如果应用程序在iframe中运行,那么,代码把顶部窗口的HREF保存在一个局部变量中,但是去掉了指向/app1段右侧的HREF部分。然后,代码进入NavigationEnd事件,并把路由过的URL追加到顶部窗口的HREF的后面。
我们还将需要给导入添加Router和NavigationEnd。整个app-routing.module.ts应该如下所示:
import { NgModule } from '@angular/core';
import { Routes, RouterModule, Router, NavigationEnd } from '@angular/router';
import { Page1Component } from './page1/page1.component';
import { Page2Component } from './page2/page2.component';
const routes: Routes = [
{path: '', redirectTo: 'page1', pathMatch: 'full'},
{path: 'page1', component: Page1Component},
{path: 'page2', component: Page2Component}];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule {
constructor(private route:Router){
var topHref = window.top.location.href != window.location.href ?
window.top.location.href.substring(0,
window.top.location.href.indexOf('/app1') + 5) :
null;
this.route.events.subscribe(e => {
if(e instanceof NavigationEnd){
if (topHref){
window.top.history.replaceState(window.top.history.state,
window.top.document.title, topHref + e.url);
}
}
});
}
}
为了测试该应用程序,请从Visual Studio启动它。点击Page 1或Page 2的链接。观察到顶部URL现在在变化。我们还可以复制修改过的URL,并把它粘贴到一个独立的窗口,App1将路由到顶部URL中指定的组件。
还有最后一件事要做。我们需要修改项目文件,以将Angular构建任务纳入发布过程。为此,转到ASP.NET项目,右键单击项目文件,选择Edit .csproj。项目文件应该与如下所示的类似:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>netcoreapp3.0</TargetFramework>
<TypeScriptToolsVersion>3.3</TypeScriptToolsVersion>
<SpaRoot>Apps\</SpaRoot>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<WarningLevel>0</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Content Remove="$(SpaRoot)**" />
<None Remove="$(SpaRoot)**" />
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="3.0.0" />
</ItemGroup>
<Target Name="PublishApps" AfterTargets="ComputeFilesToPublish">
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build -- --prod --outputPath=./dist/core" />
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build App1 -- --prod --base-href=/apps/app1/ --outputPath=./dist/app1" />
<ItemGroup>
<DistFiles Include="$(SpaRoot)dist\**" />
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
<RelativePath>wwwroot\%(DistFiles.Identity)</RelativePath>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</ResolvedFileToPublish>
</ItemGroup>
</Target>
</Project>
这里有个有趣的部分,就是Target标签。我们指示构建过程运行npm安装,然后构建两个Angular项目,接着,复制dist文件夹输出到ASP.NET站点的wwwroot文件夹。
为了测试我们的发布配置是否有用:
在这个过程的最后,我们应该看到在Output窗口中发布的新文件的文件夹的整个路径。为了测试发布的站点:
我们创建了一个ASP.NET站点,把两个Angular项目与它集成在一起,并把Angular工件嵌入MVC视图。如果我们想试用这个解决方案,建议从GitHub中克隆项目。尝试添加App2,并从不同的MVC视图中为它提供服务,或者尝试创建更多的web组件。
30年来,Evgueni Tsygankov一直在编写软件,从80年代的Commodore 64一直到如今的云计算。目前,他在Effita领导其中的一支开发团队,Effita是总部在密苏里州圣路易斯的一家软件公司。在空闲的时候,Evgueni把时间用于陪伴他的两个孩子以及打冰球和踢足球。
原文链接:
领取专属 10元无门槛券
私享最新 技术干货