Angular13 Angular 教程:为英雄之旅添加路由支持-里程碑 4:危机中心

2024-02-25 开发教程 Angular13 匿名 2

里程碑 4:危机中心

本节将向你展示如何在应用中添加子路由并使用相对路由。

要为应用当前的危机中心添加更多特性,请执行类似于 heroes 特性的步骤:

  • 在 ​src/app​ 目录下创建一个 ​crisis-center​ 子目录
  • 把 ​app/heroes​ 中的文件和目录复制到新的 ​crisis-center​ 文件夹中
  • 在这些新建的文件中,把每个 "hero" 都改成 "crisis",每个 "heroes" 都改成 "crises"
  • 把这些 NgModule 文件改名为 ​crisis-center.module.ts​ 和 ​crisis-center-routing.module.ts

使用 mock 的 crises 来代替 mock 的 heroes:

import { Crisis } from './crisis';
export const CRISES: Crisis[] = [
{ id: 1, name: 'Dragon Burning Cities' },
{ id: 2, name: 'Sky Rains Great White Sharks' },
{ id: 3, name: 'Giant Asteroid Heading For Earth' },
{ id: 4, name: 'Procrastinators Meeting Delayed Again' },
];

最终的危机中心可以作为引入子路由这个新概念的基础。你可以把英雄管理保持在当前状态,以便和危机中心进行对比。

遵循关注点分离(Separation of Concerns)原则,对危机中心的修改不会影响 ​AppModule ​或其它特性模块中的组件。

带有子路由的危机中心

本节会展示如何组织危机中心,来满足 Angular 应用所推荐的模式:

  • 把每个特性放在自己的目录中
  • 每个特性都有自己的 Angular 特性模块
  • 每个特性区都有自己的根组件
  • 每个特性区的根组件中都有自己的路由出口及其子路由
  • 特性区内的路由很少(也许永远不会)与其它特性区的路由产生交叉

如果你的应用具有多个特性区,那些特性的组件树可能由多个组件构成,每个都包含一些其它相关组件的分支。

子路由组件

在 ​crisis-center​ 目录下生成一个 ​CrisisCenter ​组件:

ng generate component crisis-center/crisis-center

使用如下代码更新组件模板:

<h2>Crisis Center</h2>
<router-outlet></router-outlet>

CrisisCenterComponent ​和 ​AppComponent ​有下列共同点:

  • 它是危机中心特性区的根,正如 ​AppComponent ​是整个应用的根
  • 它是危机管理特性区的壳,正如 ​AppComponent ​是管理高层工作流的壳

就像大多数的壳一样,​CrisisCenterComponent ​类是最小化的,因为它没有业务逻辑,它的模板中没有链接,只有一个标题和用于放置危机中心的子组件的 ​<router-outlet>​。

子路由配置

在 ​crisis-center​ 目录下生成一个 ​CrisisCenterHome ​组件,作为 "危机中心" 特性的宿主页面。

ng generate component crisis-center/crisis-center-home

用一条欢迎信息修改 ​Crisis Center​ 中的模板。

<h3>Welcome to the Crisis Center</h3>

把 ​heroes-routing.module.ts​ 文件复制过来,改名为 ​crisis-center-routing.module.ts​,并修改它。这次你要把子路由定义在父路由 ​crisis-center​ 中。

const crisisCenterRoutes: Routes = [
{
path: 'crisis-center',
component: CrisisCenterComponent,
children: [
{
path: '',
component: CrisisListComponent,
children: [
{
path: ':id',
component: CrisisDetailComponent
},
{
path: '',
component: CrisisCenterHomeComponent
}
]
}
]
}
];

注意,父路由 ​crisis-center​ 有一个 ​children ​属性,它有一个包含 ​CrisisListComponent ​的路由。​CrisisListModule ​路由还有一个带两个路由的 ​children ​数组。

这两个路由分别导航到了危机中心的两个子组件:​CrisisCenterHomeComponent ​和 ​CrisisDetailComponent​。

对这些子路由的处理中有一些重要的差异。

路由器会把这些路由对应的组件放在 ​CrisisCenterComponent ​的 ​RouterOutlet ​中,而不是 ​AppComponent ​壳组件中的。

CrisisListComponent ​包含危机列表和一个 ​RouterOutlet​,用以显示 ​Crisis Center Home​ 和 ​Crisis Detail​ 这两个路由组件。

Crisis Detail​ 路由是 ​Crisis List​ 的子路由。由于路由器默认会复用组件,因此当你选择了另一个危机时,​CrisisDetailComponent ​会被复用。 作为对比,回头看看 ​Hero Detail​ 路由,每当你从列表中选择了不同的英雄时,都会重新创建该组件。

在顶层,以 ​/​ 开头的路径指向的总是应用的根。但这里是子路由。它们是在父路由路径的基础上做出的扩展。在路由树中每深入一步,你就会在该路由的路径上添加一个斜线 ​/​(除非该路由的路径是空的)。

如果把该逻辑应用到危机中心中的导航,那么父路径就是 ​/crisis-center​。

  • 要导航到 ​CrisisCenterHomeComponent​,完整的 URL 是 ​/crisis-center​ (​/crisis-center​ + ​''​ + ​''​)
  • 要导航到 ​CrisisDetailComponent ​以展示 ​id=2​ 的危机,完整的 URL 是 ​/crisis-center/2​ (​/crisis-center​ + ​''​ + ​'/2'​)

本例子中包含站点部分的绝对 URL,就是:

localhost:4200/crisis-center/2

这里是完整的 ​crisis-center.routing.ts​ 及其导入语句。

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { CrisisCenterHomeComponent } from './crisis-center-home/crisis-center-home.component';
import { CrisisListComponent } from './crisis-list/crisis-list.component';
import { CrisisCenterComponent } from './crisis-center/crisis-center.component';
import { CrisisDetailComponent } from './crisis-detail/crisis-detail.component';
const crisisCenterRoutes: Routes = [
{
path: 'crisis-center',
component: CrisisCenterComponent,
children: [
{
path: '',
component: CrisisListComponent,
children: [
{
path: ':id',
component: CrisisDetailComponent
},
{
path: '',
component: CrisisCenterHomeComponent
}
]
}
]
}
];
@NgModule({
imports: [
RouterModule.forChild(crisisCenterRoutes)
],
exports: [
RouterModule
]
})
export class CrisisCenterRoutingModule { }

把危机中心模块导入到 AppModule 的路由中

就像 ​HeroesModule ​模块中一样,你必须把 ​CrisisCenterModule ​添加到 ​AppModule ​的 ​imports ​数组中,就在 ​AppRoutingModule ​前面:

  • src/app/crisis-center/crisis-center.module.ts
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { CommonModule } from '@angular/common';
import { CrisisCenterHomeComponent } from './crisis-center-home/crisis-center-home.component';
import { CrisisListComponent } from './crisis-list/crisis-list.component';
import { CrisisCenterComponent } from './crisis-center/crisis-center.component';
import { CrisisDetailComponent } from './crisis-detail/crisis-detail.component';
import { CrisisCenterRoutingModule } from './crisis-center-routing.module';
@NgModule({
imports: [
CommonModule,
FormsModule,
CrisisCenterRoutingModule
],
declarations: [
CrisisCenterComponent,
CrisisListComponent,
CrisisCenterHomeComponent,
CrisisDetailComponent
]
})
export class CrisisCenterModule {}
src/app/app.module.ts (import CrisisCenterModule)
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AppComponent } from './app.component';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
import { ComposeMessageComponent } from './compose-message/compose-message.component';
import { AppRoutingModule } from './app-routing.module';
import { HeroesModule } from './heroes/heroes.module';
import { CrisisCenterModule } from './crisis-center/crisis-center.module';
@NgModule({
imports: [
CommonModule,
FormsModule,
HeroesModule,
CrisisCenterModule,
AppRoutingModule
],
declarations: [
AppComponent,
PageNotFoundComponent
],
bootstrap: [ AppComponent ]
})
export class AppModule { }

这些模块的导入顺序是至关重要的,因为这些模块中定义的路由的顺序会影响路由的匹配顺序。如果先导入 ​AppModule​,它的通配符路由 (​path: '**'​)。

从 ​app.routing.ts​ 中移除危机中心的初始路由。因为现在是 ​HeroesModule ​和 ​CrisisCenter ​模块提供了这些特性路由。

app-routing.module.ts​ 文件中只有应用的顶层路由,比如默认路由和通配符路由。

import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { PageNotFoundComponent } from './page-not-found/page-not-found.component';
const appRoutes: Routes = [
{ path: '', redirectTo: '/heroes', pathMatch: 'full' },
{ path: '**', component: PageNotFoundComponent }
];
@NgModule({
imports: [
RouterModule.forRoot(
appRoutes,
{ enableTracing: true } // <-- debugging purposes only
)
],
exports: [
RouterModule
]
})
export class AppRoutingModule {}

相对导航

虽然构建出了危机中心特性区,你却仍在使用以斜杠开头的绝对路径来导航到危机详情的路由。

路由器会从路由配置的顶层来匹配像这样的绝对路径。

你固然可以继续像危机中心特性区一样使用绝对路径,但是那样会把链接钉死在特定的父路由结构上。如果你修改了父路径 ​/crisis-center​,那就不得不修改每一个链接参数数组。

通过改成定义相对于当前 URL 的路径,你可以把链接从这种依赖中解放出来。当你修改了该特性区的父路由路径时,该特性区内部的导航仍然完好无损。

路由器支持在链接参数数组中使用“目录式”语法来为查询路由名提供帮助:

目录式语法

详情

./

无前导斜线

形式是相对于当前级别的。

../

回到当前路由路径的上一级。

你可以把相对导航语法和一个祖先路径组合起来用。如果不得不导航到一个兄弟路由,你可以用 ​../<sibling>​ 来回到上一级,然后进入兄弟路由路径中。

用 ​Router.navigate​ 方法导航到相对路径时,你必须提供当前的 ​ActivatedRoute​,来让路由器知道你现在位于路由树中的什么位置。

在链接参数数组后面,添加一个带有 ​relativeTo ​属性的对象,并把它设置为当前的 ​ActivatedRoute​。这样路由器就会基于当前激活路由的位置来计算出目标 URL。

当调用路由器的 ​navigateByUrl()​ 时,总是要指定完整的绝对路径。

使用相对 URL 导航到危机列表

你已经注入了组成相对导航路径所需的 ​ActivatedRoute​。

如果用 ​RouterLink ​来代替 ​Router ​服务进行导航,就要使用相同的链接参数数组,不过不再需要提供 ​relativeTo ​属性。​ActivatedRoute ​已经隐含在了 ​RouterLink ​指令中。

修改 ​CrisisDetailComponent ​的 ​gotoCrises()​ 方法,来使用相对路径返回危机中心列表。

// Relative navigation back to the crises
this.router.navigate(['../', { id: crisisId, foo: 'foo' }], { relativeTo: this.route });

注意这个路径使用了 ​../​ 语法返回上一级。如果当前危机的 ​id ​是 ​3​,那么最终返回到的路径就是 ​/crisis-center/;id=3;foo=foo​。

用命名出口(outlet)显示多重路由

你决定给用户提供一种方式来联系危机中心。当用户点击“Contact”按钮时,你要在一个弹出框中显示一条消息。

即使在应用中的不同页面之间切换,这个弹出框也应该始终保持打开状态,直到用户发送了消息或者手动取消。显然,你不能把这个弹出框跟其它放到页面放到同一个路由出口中。

迄今为止,你只定义过单路由出口,并且在其中嵌套了子路由以便对路由分组。在每个模板中,路由器只能支持一个无名主路由出口。

模板还可以有多个命名的路由出口。每个命名出口都自己有一组带组件的路由。多重出口可以在同一时间根据不同的路由来显示不同的内容。

在 ​AppComponent ​中添加一个名叫“popup”的出口,就在无名出口的下方。

<div [@routeAnimation]="getAnimationData()">
<router-outlet></router-outlet>
</div>
<router-outlet name="popup"></router-outlet>

一旦你学会了如何把一个弹出框组件路由到该出口,那里就是将会出现弹出框的地方。

第二路由

命名出口是第二路由的目标。

第二路由很像主路由,配置方式也一样。它们只有一些关键的不同点。

  • 它们彼此互不依赖
  • 它们与其它路由组合使用
  • 它们显示在命名出口中

生成一个新的组件来组合这个消息。

ng generate component compose-message

它显示一个简单的表单,包括一个头、一个消息输入框和两个按钮:“Send”和“Cancel”。

下面是该组件及其模板和样式:

  • src/app/compose-message/compose-message.component.html
<h3>Contact Crisis Center</h3>
<div *ngIf="details">
{{ details }}
</div>
<div>
<div>
<label for="message">Enter your message: </label>
</div>
<div>
<textarea id="message" [(ngModel)]="message" rows="10" cols="35" [disabled]="sending"></textarea>
</div>
</div>
<p *ngIf="!sending">
<button type="button" (click)="send()">Send</button>
<button type="button" (click)="cancel()">Cancel</button>
</p>
src/app/compose-message/compose-message.component.ts
import { Component, HostBinding } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-compose-message',
templateUrl: './compose-message.component.html',
styleUrls: ['./compose-message.component.css']
})
export class ComposeMessageComponent {
details = '';
message = '';
sending = false;
constructor(private router: Router) {}
send() {
this.sending = true;
this.details = 'Sending Message...';
setTimeout(() => {
this.sending = false;
this.closePopup();
}, 1000);
}
cancel() {
this.closePopup();
}
closePopup() {
// Providing a `null` value to the named outlet
// clears the contents of the named outlet
this.router.navigate([{ outlets: { popup: null }}]);
}
}
src/app/compose-message/compose-message.component.css
textarea {
width: 100%;
margin-top: 1rem;
font-size: 1.2rem;
box-sizing: border-box;
}

它看起来几乎和你以前见过其它组件一样,但有两个值得注意的区别。

注意:
send()​ 方法通过在“发送”消息之前等待一秒并关闭弹出窗口来模拟延迟。

closePopup()​ 方法用把 ​popup ​出口导航到 ​null ​的方式关闭了弹出框,它在稍后的部分有讲解。

添加第二路由

打开 ​AppRoutingModule​,并把一个新的 ​compose ​路由添加到 ​appRoutes ​中。

{
path: 'compose',
component: ComposeMessageComponent,
outlet: 'popup'
},

除了 ​path ​和 ​component ​属性之外还有一个新的属性 ​outlet​,它被设置成了 ​'popup'​。这个路由现在指向了 ​popup ​出口,而 ​ComposeMessageComponent ​也将显示在那里。

为了给用户某种途径来打开这个弹出框,还要往 ​AppComponent ​模板中添加一个“Contact”链接。

<a [routerLink]="[{ outlets: { popup: ['compose'] } }]">Contact</a>

虽然 ​compose ​路由被配置到了 ​popup ​出口上,但这仍然不足以把该路由和 ​RouterLink ​指令联系起来。你还要在链接参数数组中指定这个命名出口,并通过属性绑定的形式把它绑定到 ​RouterLink ​上。

链接参数数组包含一个只有一个 ​outlets ​属性的对象,它的值是另一个对象,这个对象以一个或多个路由的出口名作为属性名。在这里,它只有一个出口名“popup”,它的值则是另一个链接参数数组,用于指定 ​compose ​路由。

换句话说,当用户点击此链接时,路由器会在路由出口 ​popup ​中显示与 ​compose ​路由相关联的组件。

当只需要考虑一个路由和一个无名出口时,外部对象中的这个 ​outlets ​对象是完全不必要的。
路由器假设这个路由指向了无名的主出口,并为你创建这些对象。
路由到一个命名出口会揭示一个路由特性:你可以在同一个 ​RouterLink ​指令中为多个路由出口指定多个路由。

第二路由导航:在导航期间合并路由

导航到危机中心并点击“Contact”,你将会在浏览器的地址栏看到如下 URL。

http://…/crisis-center(popup:compose)

这个 URL 中有意义的部分是 ​...​ 后面的这些:

  • crisis-center​ 是主导航。
  • 圆括号包裹的部分是第二路由。
  • 第二路由包括一个出口名称(​popup​)、一个冒号分隔符和第二路由的路径(​compose​)。

点击 Heroes 链接,并再次查看 URL。

http://…/heroes(popup:compose)

主导航的部分变化了,而第二路由没有变。

路由器在导航树中对两个独立的分支保持追踪,并在 URL 中对这棵树进行表达。

你还可以添加更多出口和更多路由(无论是在顶层还是在嵌套的子层)来创建一个带有多个分支的导航树。路由器将会生成相应的 URL。

通过像前面那样填充 ​outlets ​对象,你可以告诉路由器立即导航到一棵完整的树。然后把这个对象通过一个链接参数数组传给 ​router.navigate​ 方法。

清除第二路由

像常规出口一样,二级出口会一直存在,直到你导航到新组件。

每个第二出口都有自己独立的导航,跟主出口的导航彼此独立。修改主出口中的当前路由并不会影响到 ​popup ​出口中的。这就是为什么在危机中心和英雄管理之间导航时,弹出框始终都是可见的。

再看 ​closePopup()​ 方法:

closePopup() {
// Providing a `null` value to the named outlet
// clears the contents of the named outlet
this.router.navigate([{ outlets: { popup: null }}]);
}

单击 “send” 或 “cancel” 按钮可以清除弹出视图。​closePopup()​ 函数会使用 ​Router.navigate()​ 方法强制导航,并传入一个链接参数数组。

就像在 ​AppComponent ​中绑定到的 Contact ​RouterLink ​一样,它也包含了一个带 ​outlets ​属性的对象。 ​outlets ​属性的值是另一个对象,该对象用一些出口名称作为属性名。 唯一的命名出口是 ​'popup'​。

但这次,​'popup'​ 的值是 ​null​。​null ​不是一个路由,但却是一个合法的值。把 ​popup ​这个 ​RouterOutlet ​设置为 ​null ​会清除该出口,并且从当前 URL 中移除第二路由 ​popup​。