Angular9-高级教程-十一-

113 阅读24分钟

Angular9 高级教程(十一)

原文:Pro Angular 9

协议:CC BY-NC-SA 4.0

二十六、路由和导航:第二部分

在前一章中,我介绍了 Angular URL 路由系统,并解释了如何使用它来控制显示给用户的组件。路由系统有很多特性,我将在本章和第二十七章中继续描述。本章的重点是创建更复杂的路由,包括匹配任何 URL 的路由、将浏览器重定向到其他 URL 的路由、在组件内导航的路由以及选择多个组件的路由。表 26-1 总结了这一章。

表 26-1。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 用单个路由匹配多个 URL | 使用路由通配符 | 1–9 | | 将一个 URL 重定向到另一个 URL | 使用重定向路由 | Ten | | 在组件内导航 | 使用相对 URL | Eleven | | 当激活的 URL 改变时接收通知 | 使用由ActivatedRoute类提供的Observable对象 | Twelve | | 特定管线处于活动状态时设置元素的样式 | 使用routerLinkActive属性 | 13–16 | | 使用布线系统显示嵌套元件 | 定义子路由并使用router-outlet元素 | 17–21 |

准备示例项目

对于这一章,我将继续使用在第二十二章中创建的 exampleApp 项目,并在随后的每一章中对其进行修改。为了准备本章,我在 repository 类中添加了两个方法,如清单 26-1 所示。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
import { Observable } from "rxjs";
import { RestDataSource } from "./rest.datasource";

@Injectable()
export class Model {
    private products: Product[] = new Array<Product>();
    private locator = (p: Product, id: number) => p.id == id;

    constructor(private dataSource: RestDataSource) {
        this.dataSource.getData().subscribe(data => this.products = data);
    }

    getProducts(): Product[] {
        return this.products;
    }

    getProduct(id: number): Product {
        return this.products.find(p => this.locator(p, id));
    }

    getNextProductId(id: number): number {
        let index = this.products.findIndex(p => this.locator(p, id));
        if (index > -1) {
            return this.products[this.products.length > index + 2
                ? index + 1 : 0].id;
        } else {
            return id || 0;
        }
    }

    getPreviousProductid(id: number): number {
        let index = this.products.findIndex(p => this.locator(p, id));
        if (index > -1) {
            return this.products[index > 0
                ? index - 1 : this.products.length - 1].id;
        } else {
            return id || 0;
        }
    }

    saveProduct(product: Product) {
        if (product.id == 0 || product.id == null) {
            this.dataSource.saveProduct(product)
                .subscribe(p => this.products.push(p));
        } else {
            this.dataSource.updateProduct(product).subscribe(p => {
                let index = this.products
                    .findIndex(item => this.locator(item, p.id));
                this.products.splice(index, 1, p);
            });
        }
    }

    deleteProduct(id: number) {
        this.dataSource.deleteProduct(id).subscribe(() => {
            let index = this.products.findIndex(p => this.locator(p, id));
            if (index > -1) {
                this.products.splice(index, 1);
            }
        });
    }
}

Listing 26-1.Adding Methods in the repository.model.ts File in the src/app/model Folder

新方法接受一个 ID 值,定位相应的产品,然后返回存储库用来收集数据模型对象的数组中的下一个和上一个对象的 ID。我将在本章的后面使用这个特性来允许用户浏览数据模型中的对象集。

为了简化示例,清单 26-2 删除了表单组件中的语句,这些语句接收产品的详细信息,以便使用可选的路由参数进行编辑。我还更改了构造函数参数的访问级别,这样我就可以在组件的模板中直接使用它们。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute, Router } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(public model: Model, activeRoute: ActivatedRoute,
        public router: Router) {

        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        let id = activeRoute.snapshot.params["id"];
        if (id != null) {
            Object.assign(this.product, model.getProduct(id) || new Product());
        }
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.router.navigateByUrl("/");
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 26-2.Removing Optional Parameters in the form.component.ts File in the src/app/core Folder

清单 26-3 从表格组件的模板中删除可选参数,这样它们就不会包含在编辑按钮的导航 URL 中。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 26-3.Removing Route Parameters in the table.component.html File in the src/app/core Folder

向项目中添加组件

我需要在应用中添加一些组件来演示本章中涉及的一些特性。这些组件很简单,因为我关注的是路由系统,而不是为应用添加有用的功能。我在src/app/core文件夹中创建了一个名为productCount.component.ts的文件,并用它来定义清单 26-4 中所示的组件。

Tip

如果一个组件只通过路由系统显示,那么可以从@Component装饰器中省略selector属性。我倾向于添加它,这样我也可以使用 HTML 元素来应用组件。

import {
    Component, KeyValueDiffer, KeyValueDiffers, ChangeDetectorRef
} from "@angular/core";
import { Model } from "../model/repository.model";

@Component({
    selector: "paProductCount",
    template: `<div class="bg-info text-white p-2">There are
                  {{count}} products
               </div>`
})
export class ProductCountComponent {
    private differ: KeyValueDiffer<any, any>;
    count: number = 0;

    constructor(private model: Model,
        private keyValueDiffers: KeyValueDiffers,
        private changeDetector: ChangeDetectorRef) { }

    ngOnInit() {
        this.differ = this.keyValueDiffers
            .find(this.model.getProducts())
            .create();
    }

    ngDoCheck() {
        if (this.differ.diff(this.model.getProducts()) != null) {
            this.updateCount();
        }
    }

    private updateCount() {
        this.count = this.model.getProducts().length;
    }
}

Listing 26-4.The Contents of the productCount.component.ts File in the src/app/core Folder

这个component使用一个内联模板来显示数据模型中的产品数量,当数据模型改变时,这个模板会更新。接下来,我在src/app/core文件夹中添加了一个名为categoryCount.component.ts的文件,并定义了清单 26-5 中所示的组件。

import {
    Component, KeyValueDiffer, KeyValueDiffers, ChangeDetectorRef
} from "@angular/core";
import { Model } from "../model/repository.model";

@Component({
    selector: "paCategoryCount",
    template: `<div class="bg-primary p-2 text-white">
                    There are {{count}} categories
               </div>`
})
export class CategoryCountComponent {
    private differ: KeyValueDiffer<any, any>;
    count: number = 0;

    constructor(private model: Model,
        private keyValueDiffers: KeyValueDiffers,
        private changeDetector: ChangeDetectorRef) { }

    ngOnInit() {
        this.differ = this.keyValueDiffers
            .find(this.model.getProducts())
            .create();
    }

    ngDoCheck() {
        if (this.differ.diff(this.model.getProducts()) != null) {
            this.count = this.model.getProducts()
                .map(p => p.category)
                .filter((category, index, array) => array.indexOf(category) == index)
                .length;
        }
    }
}

Listing 26-5.The Contents of the categoryCount.component.ts File in the src/app/core Folder

该组件使用一个差异来跟踪数据模型中的变化,并计算唯一类别的数量,这是使用一个简单的内联模板显示的。对于最后一个组件,我在src/app/core文件夹中添加了一个名为notFound.component.ts的文件,并用它来定义清单 26-6 中所示的组件。

import { Component } from "@angular/core";

@Component({
    selector: "paNotFound",
    template: `<h3 class="bg-danger text-white p-2">Sorry, something went wrong</h3>
               <button class="btn btn-primary" routerLink="/">Start Over</button>`
})
export class NotFoundComponent {}

Listing 26-6The notFound.component.ts File in the src/app/core Folder

当路由系统出现问题时,该组件会显示一条静态消息。清单 26-7 向核心模块添加了新的组件。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
import { RouterModule } from "@angular/router";
import { ProductCountComponent } from "./productCount.component";
import { CategoryCountComponent } from "./categoryCount.component";
import { NotFoundComponent } from "./notFound.component";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe,
        ProductCountComponent, CategoryCountComponent, NotFoundComponent],
    exports: [ModelModule, TableComponent, FormComponent]
})
export class CoreModule { }

Listing 26-7.Declaring Components in the core.module.ts File in the src/app/core Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 26-1 所示的内容。

img/421542_4_En_26_Fig1_HTML.jpg

图 26-1。

运行示例应用

使用通配符和重定向

应用中的路由配置会很快变得复杂,并包含冗余和奇怪的内容,以迎合应用的结构。Angular 提供了两个有用的工具,可以帮助简化路由,也可以在出现问题时进行处理,如以下部分所述。

在路由中使用通配符

角路由系统支持一个特殊的路径,由两个星号(**字符)表示,允许路由匹配任何 URL。通配符路径的基本用途是处理导航,否则会产生路由错误。清单 26-8 向表格组件的模板中添加了一个按钮,该按钮导航到一个尚未由应用的路由配置定义的路由。

<table class="table table-sm table-bordered table-striped">
    <tr>
        <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
    </tr>
    <tr *ngFor="let item of getProducts()">
        <td>{{item.id}}</td>
        <td>{{item.name}}</td>
        <td>{{item.category}}</td>
        <td>{{item.price | currency:"USD" }}</td>
        <td class="text-center">
            <button class="btn btn-danger btn-sm mr-1"
                    (click)="deleteProduct(item.id)">
                Delete
            </button>
            <button class="btn btn-warning btn-sm"
                [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>
<button class="btn btn-danger m-1" routerLink="/does/not/exist">
    Generate Routing Error
</button>

Listing 26-8.Adding a Button in the table.component.html File in the src/app/core Folder

单击该按钮将要求应用导航到 URL /does/not/exist,因为没有为其配置路由。当一个 URL 与一个 URL 不匹配时,会抛出一个错误,然后由错误处理类拾取并处理,这导致消息组件显示一个警告,如图 26-2 所示。

img/421542_4_En_26_Fig2_HTML.jpg

图 26-2。

默认导航错误

这不是处理未知路由的有用方法,因为用户不知道什么是路由,也可能没有意识到应用试图导航到有问题的 URL。

更好的方法是使用通配符 route 来处理尚未定义的 URL 的导航,并选择一个组件来为用户提供更有用的消息,如清单 26-9 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "", component: TableComponent },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-9.Adding a Wildcard Route in the app.routing.ts File in the src/app Folder

清单中的新路由使用通配符选择NotFoundComponent,当点击生成路由错误按钮时,显示如图 26-3 所示的消息。

img/421542_4_En_26_Fig3_HTML.jpg

图 26-3。

使用通配符路由

单击重新开始按钮导航到/ URL,这将选择要显示的表格组件。

在路由中使用重定向

路由不必选择组件;它们也可以用作别名,将浏览器重定向到不同的 URL。重定向是使用路由中的redirectTo属性定义的,如清单 26-10 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    { path: "table", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-10.Using Route Redirection in the app.routing.ts File in the src/app Folder

redirectTo属性用于指定浏览器将被重定向到的 URL。定义重定向时,还必须指定pathMatch属性,使用表 26-2 中描述的值之一。

表 26-2。

路径匹配值

|

名字

|

描述

| | --- | --- | | prefix | 该值配置路由,使其匹配以指定路径开头的 URL,忽略任何后续的段。 | | full | 该值配置路由,使其仅匹配由path属性指定的 URL。 |

在清单 26-10 中添加的第一个路由指定了一个prefixpathMatch值和一个does的路径,这意味着它将匹配任何第一段是does的 URL,比如通过生成路由错误按钮导航到的/does/not/exist URL。当浏览器导航到具有该前缀的 URL 时,路由系统会将其重定向到/form/create URL,如图 26-4 所示。

img/421542_4_En_26_Fig4_HTML.jpg

图 26-4。

执行路由重定向

清单 26-10 中的其他路由将空路径重定向到显示表格组件的/table URL。这是一种使 URL 模式更明显的常用技术,因为它匹配默认 URL ( http://localhost:4200/)并将其重定向到对用户来说更有意义和更容易记住的内容(http://localhost:4200/table)。在这种情况下,pathMatch属性值是full,尽管这没有任何影响,因为它已经应用于空路径。

在组件内导航

上一章中的示例在不同的组件之间导航,因此单击表格组件中的按钮可以导航到表单组件,反之亦然。

这不是唯一可能的导航方式。您还可以在组件内导航。为了演示,清单 26-11 向表单组件添加了按钮,允许用户编辑上一个或下一个数据对象。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
    <!-- Last Event: {{ stateEvents | async | formatState }} -->
</div>

<div *ngIf="editing" class="p-2">
    <button class="btn btn-secondary m-1"
            [routerLink]="['/form', 'edit', model.getPreviousProductid(product.id)]">
        Previous
    </button>
    <button class="btn btn-secondary"
            [routerLink]="['/form', 'edit', model.getNextProductId(product.id)]">
        Next
    </button>
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)" (reset)="resetForm()" >

    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name"
               [(ngModel)]="product.name" required />
    </div>

    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
               [(ngModel)]="product.category" required />
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price"
               [(ngModel)]="product.price"
               required pattern="^[0-9\.]+$" />
    </div>

    <button type="submit" class="btn btn-primary m-1"
            [class.btn-warning]="editing" [disabled]="form.invalid">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="reset" class="btn btn-secondary m-1" routerLink="/">
            Cancel
    </button>
</form>

Listing 26-11.Adding Buttons to the form.component.html File in the src/app/core Folder

这些按钮绑定了针对数据模型中前一个和下一个对象的表达式的routerLink指令。这意味着,例如,如果您单击救生衣表格中的编辑按钮,下一个按钮将导航到编辑足球的 URL,上一个按钮将导航到 kayak 的 URL。

响应正在进行的路由更改

尽管单击“上一步”和“下一步”按钮时 URL 会发生变化,但显示给用户的数据不会发生变化。Angular 试图在导航过程中保持高效,它知道“上一步”和“下一步”按钮导航到的 URL 是由当前显示给用户的同一个组件处理的。它不是创建组件的新实例,而是简单地告诉组件所选的路由已经更改。

这是一个问题,因为表单组件没有设置为接收更改通知。它的构造函数接收 Angular 用来提供当前路由细节的ActivatedRoute对象,但是只使用它的snapshot属性。当 Angular 更新ActivatedRoute对象中的值时,组件的构造函数早已被执行,这意味着它错过了通知。当应用的配置意味着每次用户想要创建或编辑产品时都要创建一个新的表单组件时,这种方法是有效的,但是这已经不够了。

幸运的是,ActivatedRoute类定义了一组属性,允许相关方通过反应扩展Observable对象接收通知。这些属性对应于由快照属性返回的ActivatedRouteSnapshot对象提供的属性(在第二十五章中描述),但是当有任何后续变化时发送新的事件,如表 26-3 中所述。

表 26-3。

ActivatedRoute 类的可观察属性

|

名字

|

描述

| | --- | --- | | url | 该属性返回一个Observable<UrlSegment[]>,它在每次路由改变时提供一组 URL 段。 | | params | 该属性返回一个Observable<Params>,它在每次路由改变时提供 URL 参数。 | | queryParams | 该属性返回一个Observable<Params>,它在每次路由改变时提供 URL 查询参数。 | | fragment | 该属性返回一个Observable<string>,它在每次路由改变时提供 URL 片段。 |

这些属性可以被需要处理导航变化的组件使用,这些变化不会导致向用户显示不同的组件,如清单 26-12 所示。

Tip

如果您需要组合来自路由的不同数据元素,例如同时使用路段和参数,那么为一个数据元素订阅Observer,并使用snapshot属性获取您需要的其余数据。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute, Router } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();

    constructor(public model: Model, activeRoute: ActivatedRoute,
        public  router: Router) {

        activeRoute.params.subscribe(params => {
            this.editing = params["mode"] == "edit";
            let id = params["id"];
            if (id != null) {
                Object.assign(this.product, model.getProduct(id) || new Product());
            }
        })
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.router.navigateByUrl("/");
        }
    }

    resetForm() {
        this.product = new Product();
    }
}

Listing 26-12.Observing Route Changes in the form.component.ts File in the src/app/core Folder

组件订阅了Observer<Params>,每次活动路由改变时,它都会向订阅者发送一个新的Params对象。由ActivatedRoute属性返回的Observer对象在调用 subscribe 方法时发送最近一次路由更改的细节,确保组件的构造函数不会错过导致它被调用的初始导航。

结果是,组件可以对不会导致 Angular 创建新组件的路由更改做出反应,这意味着单击下一个或上一个按钮会更改已被选择进行编辑的产品,如图 26-5 所示。

img/421542_4_En_26_Fig5_HTML.jpg

图 26-5。

响应路由变更

Tip

当激活的路由改变显示给用户的组件时,导航的效果是明显的。当仅仅是数据改变时,它可能不那么明显。为了帮助强调变化,Angular 可以应用动画来引起对导航效果的注意。详见第二十八章。

激活管线的样式链接

路由系统的一个常见用途是在它们选择的内容旁边显示多个导航元素。为了演示,清单 26-13 向应用添加了一个新的路由,该路由将允许使用包含类别过滤器的 URL 来定位表格组件。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    { path: "table/:category", component: TableComponent },
    { path: "table", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-13.Defining a Route in the app.routing.ts File in the src/app Folder

清单 26-14 更新了TableComponent类,以便它使用路由系统来获取活动路由的细节,并将category路由参数的值赋给一个可以在模板中访问的category属性。在getProducts方法中使用了category属性来过滤数据模型中的对象。

import { Component, Inject } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html"
})
export class TableComponent {
    category: string = null;

    constructor(public model: Model, activeRoute: ActivatedRoute) {
        activeRoute.params.subscribe(params => {
            this.category = params["category"] || null;
        })
    }

    getProduct(key: number): Product {
        return this.model.getProduct(key);
    }

    getProducts(): Product[] {
        return this.model.getProducts()
            .filter(p => this.category == null || p.category == this.category);
    }

    get categories(): string[] {
        return this.model.getProducts()
            .map(p => p.category)
            .filter((category, index, array) => array.indexOf(category) == index);
    }

    deleteProduct(key: number) {
        this.model.deleteProduct(key);
    }
}

Listing 26-14.Adding Category Filter Support in the table.component.ts File in the src/app/core Folder

还有一个新的categories属性,将在模板中用于生成过滤的类别集。最后一步是将 HTML 元素添加到允许用户应用过滤器的模板中,如清单 26-15 所示。

<div class="container-fluid">
    <div class="row">
        <div class="col-auto">
            <button class="btn btn-secondary btn-block"
                    routerLink="/" routerLinkActive="bg-primary">
                All
            </button>
            <button *ngFor="let category of categories"
                    class="btn btn-secondary btn-block px-3"
                    [routerLink]="['/table', category]"
                    routerLinkActive="bg-primary">
                {{category}}
            </button>
        </div>
        <div class="col">
            <table class="table table-sm table-bordered table-striped">
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
                </tr>
                <tr *ngFor="let item of getProducts()">
                    <td>{{item.id}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price | currency:"USD" }}</td>
                    <td class="text-center">
                        <button class="btn btn-danger btn-sm mr-1"
                                (click)="deleteProduct(item.id)">
                            Delete
                        </button>
                        <button class="btn btn-warning btn-sm"
                            [routerLink]="['/form', 'edit', item.id]">
                            Edit
                        </button>
                    </td>
                </tr>
            </table>
        </div>
    </div>
</div>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
    <button class="btn btn-danger" (click)="deleteProduct(-1)">
        Generate HTTP Error
    </button>
    <button class="btn btn-danger m-1" routerLink="/does/not/exist">
        Generate Routing Error
    </button>
</div>

Listing 26-15.Adding Filter Elements in the table.component.html File in the src/app/core Folder

该示例的重要部分是使用了routerLinkActive属性,该属性用于指定一个 CSS 类,当由routerLink属性指定的 URL 与活动路由匹配时,该元素将被分配给该 CSS 类。

清单指定了一个名为bg-primary的类,它改变了按钮的外观,使选中的类别更加明显。当与添加到清单 26-14 中的组件的功能相结合时,结果是一组按钮,允许用户查看单个类别中的产品,如图 26-6 所示。

img/421542_4_En_26_Fig6_HTML.jpg

图 26-6。

过滤产品

如果您单击足球按钮,应用将导航到/table/Soccer URL,并且表格将只显示足球类别中的那些产品。足球按钮也将被高亮显示,因为routerLinkActive属性意味着 Angular 将把button元素添加到 Bootstrap bg-primary类中。

修复全部按钮

导航按钮揭示了一个常见的问题,即 All 按钮总是被添加到active类中,即使用户已经过滤了表以显示特定的类别。

这是因为默认情况下,routerLinkActive属性在活动 URL 上执行部分匹配。在这个例子中,/ URL 将总是导致 All 按钮被激活,因为它在所有 URL 的开始。这个问题可以通过配置routerLinkActive指令来解决,如清单 26-16 所示。

...
<div class="col-auto">
    <button class="btn btn-secondary btn-block"
        routerLink="/table" routerLinkActive="bg-primary"
        [routerLinkActiveOptions]="{exact: true}">
        All
    </button>
    <button *ngFor="let category of categories"
            class="btn btn-secondary btn-block px-3"
            [routerLink]="['/table', category]"
            routerLinkActive="bg-primary">
        {{category}}
    </button>
</div>
...

Listing 26-16.Configuring the Directive in the table.component.html File in the src/app/core Folder

这个配置是通过绑定接受文字对象的routerLinkActiveOptions属性来执行的。exact属性是唯一可用的配置设置,用于控制匹配活动路由 URL。将该属性设置为true会将元素添加到由routerLinkActive属性指定的类中,仅当与活动路径的 URL 完全匹配时。有了这个改变,只有当所有的产品都显示时,“全部”按钮才会高亮显示,如图 26-7 所示。

img/421542_4_En_26_Fig7_HTML.jpg

图 26-7。

修复所有按钮问题

创建子路由

子路由允许组件通过在模板中嵌入router-outlet元素来响应 URL 的一部分,从而创建更复杂的内容安排。我将使用本章开始时创建的简单组件来演示子路由是如何工作的。这些组件将显示在产品表的上方,所显示的组件将在表 26-4 中显示的 URL 中指定。

表 26-4。

他们将选择的 URL 和组件

|

统一资源定位器

|

成分

| | --- | --- | | /table/products | 将显示ProductCountComponent。 | | /table/categories | 将显示CategoryCountComponent。 | | /table | 两个组件都不会显示。 |

清单 26-17 显示了应用路由配置的变化,以实现表中的路由策略。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    {
        path: "table",
        component: TableComponent,
        children: [
            { path: "products", component: ProductCountComponent },
            { path: "categories", component: CategoryCountComponent }
        ]
    },
    { path: "table/:category", component: TableComponent },
    { path: "table", component: TableComponent },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-17.Configuring Routes in the app.routing.ts File in the src/app Folder

子路由是使用children属性定义的,该属性被设置为一个路由数组,其定义方式与顶级路由相同。当 Angular 使用整个 URL 来匹配具有子路由的路由时,只有当浏览器导航到的 URL 包含既匹配顶级段又匹配由其中一个子路由指定的段时,才会有匹配。

Tip

请注意,我已经在路径为table/:category的路径之前添加了新的路径。Angular 尝试按照路径定义的顺序匹配路径。table/:category路径将匹配/table/products/table/categoriesURL,并引导表格组件过滤不存在类别的产品。通过首先放置更具体的路由,/table/products/table/categoriesURL 将在table/:category路径被考虑之前被匹配。

创建子路由出口

子路由选择的组件显示在父路由选择的组件模板中定义的router-outlet元素中。在本例中,这意味着子路由将指向表格组件模板中的一个元素,如清单 26-18 所示,其中还添加了将导航到新路由的元素。

<div class="container-fluid">
    <div class="row">
        <div class="col-auto">
            <button class="btn btn-secondary btn-block"
                routerLink="/table" routerLinkActive="bg-primary"
                [routerLinkActiveOptions]="{exact: true}">
                All
            </button>
            <button *ngFor="let category of categories"
                    class="btn btn-secondary btn-block px-3"
                    [routerLink]="['/table', category]"
                    routerLinkActive="bg-primary">
                {{category}}
            </button>
        </div>
        <div class="col">
            <button class="btn btn-info mx-1" routerLink="/table/products">
                Count Products
            </button>
            <button class="btn btn-primary mx-1" routerLink="/table/categories">
                Count Categories
            </button>
            <button class="btn btn-secondary mx-1" routerLink="/table">
                Count Neither
            </button>
            <div class="my-2">
                <router-outlet></router-outlet>
            </div>
            <table class="table table-sm table-bordered table-striped">
                <tr>
                    <th>ID</th><th>Name</th><th>Category</th><th>Price</th><th></th>
                </tr>
                <tr *ngFor="let item of getProducts()">
                    <td>{{item.id}}</td>
                    <td>{{item.name}}</td>
                    <td>{{item.category}}</td>
                    <td>{{item.price | currency:"USD" }}</td>
                    <td class="text-center">
                        <button class="btn btn-danger btn-sm mr-1"
                                (click)="deleteProduct(item.id)">
                            Delete
                        </button>
                        <button class="btn btn-warning btn-sm"
                            [routerLink]="['/form', 'edit', item.id]">
                            Edit
                        </button>
                    </td>
                </tr>
            </table>
        </div>
    </div>
</div>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
    <button class="btn btn-danger" (click)="deleteProduct(-1)">
        Generate HTTP Error
    </button>
    <button class="btn btn-danger m-1" routerLink="/does/not/exist">
        Generate Routing Error
    </button>
</div>

Listing 26-18.Adding an Outlet in the table.component.html File in the src/app/core Folder

button元素有routerLink属性,指定表 26-4 中列出的 URL,还有一个router-outlet元素,用于显示选中的组件,如图 26-8 所示,如果浏览器导航到/table URL,则没有组件。

img/421542_4_En_26_Fig8_HTML.jpg

图 26-8。

使用子路由

从子路由中访问参数

子路由可以使用顶级路由的所有可用功能,包括定义路由参数,甚至拥有自己的子路由。由于 Angular 将孩子与其父母隔离的方式,路由参数在子路由中值得特别注意。对于本节,我将添加对表 26-5 中描述的 URL 的支持。

表 26-5。

示例应用支持的新 URL

|

名字

|

描述

| | --- | --- | | /table/:category/products | 这条路由将过滤表格的内容并选择ProductCountComponent。 | | /table/:category/categories | 这条路由将过滤表格的内容并选择CategoryCountComponent。 |

清单 26-19 定义了支持表中所示 URL 的路由。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";

const childRoutes: Routes = [
    { path: "products", component: ProductCountComponent },
    { path: "categories", component: CategoryCountComponent },
    { path: "", component: ProductCountComponent }
];

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "does", redirectTo: "/form/create", pathMatch: "prefix" },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 26-19.Adding Routes in the app.routing.ts File in the src/app Folder

children属性的类型是一个Routes对象,当您需要在 URL 模式的不同部分应用同一组子路由时,这使得路由配置中的重复最小化变得容易。在清单中,我在名为childRoutesRoutes对象中定义了子路由,并在两个不同的顶级路由中将它用作children属性的值。

为了能够定位这些新路由,清单 26-20 改变了出现在表格上方的按钮的目标,以便它们相对于当前的 URL 进行导航。我已经删除了“既不计数”按钮,因为当空路径子路由与 URL 匹配时,将显示ProductCountComponent

...
<div class="col">
    <button class="btn btn-info mx-1" routerLink="products">
        Count Products
    </button>
    <button class="btn btn-primary mx-1" routerLink="categories">
        Count Categories
    </button>
    <button class="btn btn-secondary mx-1" routerLink="/table">
        Count Neither
    </button>
    <div class="my-2">
        <router-outlet></router-outlet>
    </div>

    <table class="table table-sm table-bordered table-striped">
...

Listing 26-20.Using Relative URLs in the table.component.html File in the src/app/core Folder

当 Angular 匹配路由时,它提供给通过ActivatedRoute对象选择的组件的信息被分离,这样每个组件只接收选择它的那部分路由的细节。

在清单 26-20 中添加的路由的情况下,这意味着ProductCountComponentCategoryCountComponent接收到一个ActivatedRoute对象,该对象仅描述了选择它们的子路由,带有单个线段/products/categories。同样,TableComponent组件接收一个ActivatedRoute对象,它不包含用于匹配子路由的线段。

幸运的是,ActivatedRoute类提供了一些属性,这些属性提供了对剩余路由的访问,允许父母和孩子访问剩余的路由信息,如表 26-6 中所述。

表 26-6。

子-父路由信息的 ActivatedRoute 属性

|

名字

|

描述

| | --- | --- | | pathFromRoot | 该属性返回一个由ActivatedRoute对象组成的数组,这些对象代表了用于匹配当前 URL 的所有路由。 | | parent | 该属性返回一个代表选择组件的路由的父路由的ActivatedRoute。 | | firstChild | 该属性返回一个ActivatedRoute,表示用于匹配当前 URL 的第一个子路由。 | | children | 该属性返回一个由ActivatedRoute对象组成的数组,这些对象表示用于匹配当前 URL 的所有子路由。 |

清单 26-21 展示了ProductCountComponent组件如何访问用于匹配当前 URL 的更广泛的路由集,以获取类别路由参数的值,并在针对单个类别过滤表格内容时调整其输出。

import {
    Component, KeyValueDiffer, KeyValueDiffers, ChangeDetectorRef
} from "@angular/core";
import { Model } from "../model/repository.model";
import { ActivatedRoute } from "@angular/router";

@Component({
    selector: "paProductCount",
    template: `<div class="bg-info p-2">There are {{count}} products</div>`
})
export class ProductCountComponent {
    private differ: KeyValueDiffer<any, any>;
    count: number = 0;
    private category: string;

    constructor(private model: Model,
            private keyValueDiffers: KeyValueDiffers,
            private changeDetector: ChangeDetectorRef,
            activeRoute: ActivatedRoute) {

        activeRoute.pathFromRoot.forEach(route => route.params.subscribe(params => {
            if (params["category"] != null) {
                this.category = params["category"];
                this.updateCount();
            }
        }))
    }

    ngOnInit() {
        this.differ = this.keyValueDiffers
            .find(this.model.getProducts())
            .create();
    }

    ngDoCheck() {
        if (this.differ.diff(this.model.getProducts()) != null) {
            this.updateCount();
        }
    }

    private updateCount() {
        this.count = this.model.getProducts()
            .filter(p => this.category == null || p.category == this.category)
            .length;
    }
}

Listing 26-21.Ancestor Routes in the productCount.component.ts File in the src/app/core Folder

pathFromRoot属性特别有用,因为它允许组件检查所有用于匹配 URL 的路由。Angular 最大限度地减少了处理导航所需的路由更新,这意味着如果只有父组件发生了变化,则由子路由选择的组件不会通过其ActivatedRoute对象接收到变化通知。正是因为这个原因,我订阅了由pathFromRoot属性返回的所有ActivatedRoute对象的更新,确保组件总是检测到category路由参数值的变化。

要查看结果,保存更改,单击 Watersports 按钮过滤表格内容,然后单击 Count Products 按钮,选择ProductCountComponent。组件报告的产品数量将与表格中的行数相对应,如图 26-9 所示。

img/421542_4_En_26_Fig9_HTML.jpg

图 26-9。

访问用于匹配 URL 的其他路由

摘要

在这一章中,我继续描述 Angular URL 路由系统提供的特性,超越了前一章描述的基本特性。我解释了如何创建通配符和重定向路由,如何创建相对于当前 URL 导航的路由,以及如何创建子路由来显示嵌套组件。在下一章,我将完成对 URL 路由系统的描述,重点放在最高级的特性上。

二十七、路由和导航:第三部分

在这一章中,我继续描述 Angular URL 路由系统,重点放在最高级的特性上。我解释了如何控制路由激活,如何动态加载特性模块,以及如何在一个模板中使用多个 outlet 元素。表 27-1 总结了本章内容。

表 27-1。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 延迟导航直到任务完成 | 使用路径解析器 | 1–7 | | 阻止路由激活 | 使用激活防护装置 | 8–14 | | 防止用户离开当前内容 | 使用去活保护装置 | 15–19 | | 将功能模块的加载推迟到需要时 | 创建动态加载的模块 | 20–25 | | 控制何时使用动态加载的模块 | 使用装载防护装置 | 26–28 | | 使用路由来管理多个路由器出口 | 在同一模板中使用命名插座 | 29–34 |

准备示例项目

对于这一章,我将继续使用在第二十二章中创建的 exampleApp 项目,并在随后的每一章中对其进行修改。为了准备本章,我简化了路由配置,如清单 27-1 所示。

Tip

你可以从 https://github.com/Apress/pro-angular-9 下载本章以及本书其他章节的示例项目。如果在运行示例时遇到问题,请参见第一章获取帮助。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";

const childRoutes: Routes = [
    { path: "products", component: ProductCountComponent },
    { path: "categories", component: CategoryCountComponent },
    { path: "", component: ProductCountComponent }
];

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-1.Simplifying the Routes in the app.routing.ts File in the src/app Folder

打开一个新的命令提示符,导航到exampleApp文件夹,运行以下命令启动提供 RESTful web 服务器的服务器:

npm run json

打开一个单独的命令提示符,导航到exampleApp文件夹,运行以下命令启动 Angular 开发工具:

ng serve

打开一个新的浏览器窗口并导航到http://localhost:4200以查看如图 27-1 所示的内容。

img/421542_4_En_27_Fig1_HTML.jpg

图 27-1。

运行示例应用

守卫路由

目前,用户可以在任何时间导航到应用中的任何位置。这并不总是一个好主意,要么是因为应用的某些部分可能并不总是准备好,要么是因为应用的某些部分在执行特定操作之前受到限制。为了控制导航的使用,角撑保护,它们被指定为路由配置的一部分,使用Routes类定义的属性,如表 27-2 所述。

表 27-2。

警卫的路由属性

|

名字

|

描述

| | --- | --- | | resolve | 此属性用于指定在某些操作(如从服务器加载数据)完成之前延迟路由激活的保护。 | | canActivate | 此属性用于指定将用于确定是否可以激活路由的防护。 | | canActivateChild | 此属性用于指定将用于确定是否可以激活子路由的防护。 | | canDeactivate | 此属性用于指定将用于确定是否可以停用路由的安全措施。 | | canLoad | 该属性用于保护动态加载功能模块的路由,如“动态加载功能模块”一节所述。 |

使用解析器延迟导航

保护路由的一个常见原因是确保应用在激活路由之前已经收到了它所需要的数据。示例应用从 RESTful web 服务异步加载数据,这意味着在浏览器被要求发送 HTTP 请求的时刻和收到响应并处理数据的时刻之间可能会有延迟。您可能没有注意到这个延迟,因为浏览器和 web 服务运行在同一台机器上。在已部署的应用中,更有可能出现延迟,这是由网络拥塞、高服务器负载或许多其他因素造成的。

为了模拟网络拥塞,清单 27-2 修改了 RESTful 数据源类,在从 web 服务收到响应后引入了一个延迟。

import { Injectable, Inject, InjectionToken } from "@angular/core";
import { HttpClient, HttpHeaders } from "@angular/common/http";
import { Observable, throwError } from "rxjs";
import { Product } from "./product.model";
import { catchError, delay } from "rxjs/operators";

export const REST_URL = new InjectionToken("rest_url");

@Injectable()
export class RestDataSource {
    constructor(private http: HttpClient,
        @Inject(REST_URL) private url: string) { }

    getData(): Observable<Product[]> {
        return this.sendRequest<Product[]>("GET", this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("POST", this.url, product);
    }

    updateProduct(product: Product): Observable<Product> {
        return this.sendRequest<Product>("PUT",
            `${this.url}/${product.id}`, product);
    }

    deleteProduct(id: number): Observable<Product> {
        return this.sendRequest<Product>("DELETE", `${this.url}/${id}`);
    }

    private sendRequest<T>(verb: string, url: string, body?: Product)
        : Observable<T> {

        let myHeaders = new HttpHeaders();
        myHeaders = myHeaders.set("Access-Key", "<secret>");
        myHeaders = myHeaders.set("Application-Names", ["exampleApp", "proAngular"]);

        return this.http.request<T>(verb, url, {
            body: body,
            headers: myHeaders
        })
        .pipe(delay(5000))
        .pipe(catchError((error: Response) =>
            throwError(`Network Error: ${error.statusText} (${error.status})`)));
    }
}

Listing 27-2.Adding a Delay in the rest.datasource.ts File in the src/app/model Folder

延迟是使用反应式扩展delay方法添加的,用于创建一个五秒的延迟,这个延迟足够长,可以创建一个明显的暂停,而不会因为每次重新加载应用而等待得太痛苦。要更改延迟,增加或减少delay方法的参数,它以毫秒表示。

延迟的结果是,当应用等待数据加载时,用户看到的是一个不完整且混乱的布局,如图 27-2 所示。

img/421542_4_En_27_Fig2_HTML.jpg

图 27-2。

等待数据

Note

该延迟适用于所有 HTTP 请求,这意味着如果您创建、编辑或删除产品,您所做的更改在五秒钟内不会反映在产品表中。

创建解析服务

一个解析器用于确保一个任务在一个路由被激活之前被执行。为了创建一个解析器,我在src/app/model文件夹中添加了一个名为model.resolver.ts的文件,并定义了清单 27-3 中所示的类。

import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { Observable } from "rxjs";
import { Model } from "./repository.model"
import { RestDataSource } from "./rest.datasource";
import { Product } from "./product.model";

@Injectable()
export class ModelResolver {

    constructor(
        private model: Model,
        private dataSource: RestDataSource) { }

    resolve(route: ActivatedRouteSnapshot,
            state: RouterStateSnapshot): Observable<Product[]> {

        return this.model.getProducts().length == 0
            ? this.dataSource.getData() : null;
    }
}

Listing 27-3.The Contents of the model.resolver.ts File in the src/app/model Folder

解析器是定义接受两个参数的resolve方法的类。第一个参数是一个ActivatedRouteSnapshot对象,它使用第二十五章中描述的属性描述了正在被导航到的路由。第二个参数是一个RouterStateSnapshot对象,它描述了通过一个名为url的属性的当前路径。这些参数可用于使解析器适应将要执行的导航,尽管清单中的解析器不需要这两个参数,它使用相同的行为,而不管导航到的路由和来自的路由。

Note

本章描述的所有防护都可以实现在@angular/router模块中定义的接口。例如,解析器可以实现一个名为Resolve的接口。这些接口是可选的,我在本章中没有用到它们。

resolve方法可以返回三种不同类型的结果,如表 27-3 所述。

表 27-3。

resolve 方法允许的结果类型

|

结果类型

|

描述

| | --- | --- | | Observable<any> | 当Observer发出一个事件时,浏览器将激活新的路由。 | | Promise<any> | 当Promise解析时,浏览器将激活新路由。 | | 还有其他结果吗 | 一旦该方法产生结果,浏览器将激活新路由。 |

ObservablePromise结果在处理异步操作时很有用,比如使用 HTTP 请求请求数据。Angular 会等到异步操作完成后再激活新路由。任何其他结果都被解释为同步操作的结果,Angular 将立即激活新路由。

清单 27-3 中的解析器使用其构造函数通过依赖注入接收ModelRestDataSource对象。当调用resolve方法时,它检查数据模型中对象的数量,以确定对 RESTful web 服务的 HTTP 请求是否已经完成。如果数据模型中没有对象,resolve方法从RestDataSource.getData方法返回Observable,当 HTTP 请求完成时,它将发出一个事件。Angular 将订阅Observable并延迟激活新路由,直到它发出一个事件。如果模型中有对象,则resolve方法返回null,由于这既不是Observable也不是Promise,Angular 将立即激活新路由。

Tip

结合异步和同步结果意味着解析器将延迟导航,直到 HTTP 请求完成并且数据模型被填充。这很重要,因为每次应用试图导航到应用了解析器的路径时,都会调用resolve方法。

注册解析程序服务

下一步是将解析器注册为其特性模块中的服务,如清单 27-4 所示。

import { NgModule } from "@angular/core";
import { Model } from "./repository.model";
import { HttpClientModule, HttpClientJsonpModule } from "@angular/common/http";
import { RestDataSource, REST_URL } from "./rest.datasource";
import { ModelResolver } from "./model.resolver";

@NgModule({
    imports: [HttpClientModule, HttpClientJsonpModule],
    providers: [Model, RestDataSource, ModelResolver,
        { provide: REST_URL, useValue: "http://localhost:3500/products" }]
})
export class ModelModule { }

Listing 27-4.Registering the Resolver in the model.module.ts File in the src/app/model Folder

应用解析器

使用resolve属性将解析器应用于路由,如清单 27-5 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";

const childRoutes: Routes = [
    {   path: "",
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    { path: "form/:mode/:id", component: FormComponent },
    { path: "form/:mode", component: FormComponent },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-5.Applying a Resolver in the app.routing.ts File in the src/app Folder

resolve属性接受一个 map 对象,其属性值是将应用于路径的解析器类。(属性名称无关紧要。)我想将解析器应用于显示产品表的所有视图,因此为了避免重复,我创建了一个具有resolve属性的路由,并将其用作现有子路由的父路由。

显示占位符内容

Angular 在激活它所应用到的任何路由之前使用解析器,这使得用户在模型被来自 RESTful web 服务的数据填充之前看不到产品表。可悲的是,这仅仅意味着当浏览器等待服务器响应时,用户看到的是一个空窗口。为了解决这个问题,清单 27-6 增强了解析器,使用消息服务告诉用户当数据被加载时发生了什么。

import { Injectable } from "@angular/core";
import { ActivatedRouteSnapshot, RouterStateSnapshot } from "@angular/router";
import { Observable } from "rxjs";
import { Model } from "./repository.model"
import { RestDataSource } from "./rest.datasource";
import { Product } from "./product.model";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";

@Injectable()
export class ModelResolver {

    constructor(
        private model: Model,
        private dataSource: RestDataSource,
        private messages: MessageService) { }

    resolve(route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): Observable<Product[]> {

        if (this.model.getProducts().length == 0) {
            this.messages.reportMessage(new Message("Loading data..."));
            return this.dataSource.getData();
        }
    }
}

Listing 27-6.Displaying a Message in the model.resolver.ts File in the src/app/model Folder

当接收到NavigationEnd事件时,显示来自服务的消息的组件清除其内容,这意味着当数据被加载时占位符将被移除,如图 27-3 所示。

img/421542_4_En_27_Fig3_HTML.jpg

图 27-3。

使用解析器确保数据已加载

使用解析器来防止 URL 输入问题

正如我在第二十五章中解释的,当开发 HTTP 服务器收到一个没有对应文件的 URL 请求时,它将返回index.html文件的内容。与自动浏览器重新加载功能相结合,很容易在项目中进行更改,并让浏览器重新加载一个 URL,从而使应用跳转到一个特定的 URL,而无需经过应用期望的导航步骤并设置所需的状态数据。

要查看问题示例,请单击产品表中的编辑按钮之一,然后重新加载浏览器页面。浏览器将请求一个类似于http://localhost:3500/form/edit/1的 URL,但是这并没有达到预期的效果,因为在收到来自 RESTful 服务器的 HTTP 响应之前,激活路由的组件试图从模型中检索一个对象。因此,表单是空的,如图 27-4 所示。

img/421542_4_En_27_Fig4_HTML.jpg

图 27-4。

重新加载任意 URL 的效果

为了避免这个问题,可以更广泛地应用解析器,以便它保护其他路由,如清单 27-7 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";

const childRoutes: Routes = [
    {
        path: "",
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-7.Applying the Resolver to Other Routes in the app.routing.ts File in the src/app Folder

ModelResolver类应用于以FormComponent为目标的路由可以防止图 27-4 中所示的问题。还有其他方法可以解决这个问题,包括我在第八章中为 SportsStore 应用使用的方法,该方法使用了本章“防止路由激活”一节中描述的路由保护功能。

用警卫阻止航行

解析器用于在应用执行一些先决工作(如加载数据)时延迟导航。Angular 提供的其他保护措施用于控制是否可以进行导航,当您希望提醒用户防止潜在的不必要的操作(如放弃数据编辑)或限制对应用部分的访问时,除非应用处于特定状态(如当用户已通过身份验证时),这可能很有用。

路由保护的许多用途都引入了与用户的额外交互,以获得执行操作的明确批准或获得额外的数据,如身份验证凭证。在本章中,我将通过扩展消息服务来处理这种交互,这样消息就可以要求用户输入。在清单 27-8 中,我在Message模型类中添加了一个可选的responses构造函数参数/属性,这将允许消息包含给用户的提示和当它们被选中时将被调用的回调。responses属性是一个 TypeScript 元组的数组,其中第一个值是响应的名称,它将被呈现给用户,第二个值是回调函数,它将被传递名称作为它的参数。

export class Message {

    constructor(public text: string,
        public error: boolean = false,
        public responses?: [string, (string) => void][]) { }
}

Listing 27-8.Adding Responses in the message.model.ts File in the src/app/messages Folder

实现该特性所需的唯一其他更改是向用户显示响应选项。清单 27-9 在每个response的消息文本下添加了button元素。点击按钮将调用回调函数。

<div *ngIf="lastMessage"
     class="bg-info text-white p-2 text-center"
     [class.bg-danger]="lastMessage.error">
    <h4>{{lastMessage.text}}</h4>
</div>
<div class="text-center my-2">
    <button *ngFor="let resp of lastMessage?.responses; let i = index"
            (click)="resp1"
            class="btn btn-primary m-2" [class.btn-secondary]="i > 0">
        {{resp[0]}}
    </button>
</div>

Listing 27-9.Presenting Responses in the message.component.html File in the src/app/core Folder

阻止路由激活

防护可用于防止路由被激活,有助于防止应用进入不需要的状态或警告用户执行操作的影响。为了演示,我将保护/form/create URL,以防止用户开始创建新产品的过程,除非用户同意应用的条款和条件。

路由激活的保护是定义了名为canActivate的方法的类,该方法接收与解析器相同的ActivatedRouteSnapshotRouterStateSnapshot参数。可以实现canActivate方法来返回三种不同的结果类型,如表 27-4 所述。

表 27-4。

canActivate 方法允许的结果类型

|

结果类型

|

描述

| | --- | --- | | boolean | 当执行同步检查以查看路由是否可以激活时,这种类型的结果非常有用。一个true结果将激活路由,而一个false结果将不会激活路由,实际上忽略了导航请求。 | | Observable<boolean> | 当执行异步检查以查看路由是否可以激活时,这种类型的结果非常有用。Angular 将等待,直到Observable发出一个值,该值将用于确定该路由是否被激活。使用这种结果时,通过调用complete方法终止Observable很重要;否则 Angular 只会一直等下去。 | | Promise<boolean> | 当执行异步检查以查看路由是否可以激活时,这种类型的结果非常有用。Angular 将等待直到Promise被解决,如果产生true则激活路由。如果Promise让出false,那么该路由将不会被激活,实际上忽略了导航请求。 |

首先,我在src/app文件夹中添加了一个名为terms.guard.ts的文件,并定义了清单 27-10 中所示的类。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { MessageService } from "./messages/message.service";
import { Message } from "./messages/message.model";

@Injectable()
export class TermsGuard {

    constructor(private messages: MessageService,
                private router: Router) { }

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
        Promise<boolean> | boolean {

        if (route.params["mode"] == "create") {

            return new Promise<boolean>((resolve) => {
                let responses: [string, () => void][]
                    = [["Yes", () => resolve(true)], ["No",  () => resolve(false)]];
                this.messages.reportMessage(
                    new Message("Do you accept the terms & conditions?",
                        false, responses));
            });
        } else {
            return true;
        }
    }
}

Listing 27-10.The Contents of the terms.guard.ts File in the src/app Folder

canActivate方法可以返回两种不同类型的结果。第一种类型是boolean,它允许警卫对不需要保护的路由立即做出响应,在这种情况下,它是任何缺少一个名为mode的参数,其值为create。如果路由匹配的 URL 不包含这个参数,canActivate方法返回true,告诉 Angular 激活路由。这一点很重要,因为编辑和创建要素都依赖于相同的路径,并且防护不应干扰编辑操作。

另一种类型的结果是一个Promise<boolean>,为了多样化,我用它代替了Observable<true>Promise使用对消息服务的修改来请求用户的响应,确认他们接受(未指定的)条款和条件。用户有两种可能的反应。如果用户单击 Yes 按钮,那么承诺将被解析并产生true,它告诉 Angular 激活路由,显示用于创建新产品的表单。如果用户点击 No 按钮,这个Promise将解析并产生false,这告诉 Angular 忽略导航请求。

清单 27-11 将TermsGuard注册为服务,这样它就可以在应用的路由配置中使用。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";
import { AppComponent } from './app.component';
import { routing } from "./app.routing";
import { TermsGuard } from "./terms.guard"

@NgModule({
    imports: [BrowserModule, ModelModule, CoreModule, MessageModule, routing],
    declarations: [AppComponent],
    providers: [TermsGuard],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 27-11.Registering the Guard as a Service in the app.module.ts File in the src/app Folder

最后,清单 27-12 将保护应用于路由配置。使用canActivate属性将激活保护应用于路由,该属性被分配给一组保护服务。所有守卫的canActivate方法必须返回true(或者返回一个Observable或者最终产生truePromise)Angular 才会激活路由。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";

const childRoutes: Routes = [
    {
        path: "",
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-12.Applying the Guard to a Route in the app.routing.ts File in the src/app Folder

创建并应用激活防护的效果是当用户点击创建新产品按钮时会得到提示,如图 27-5 所示。如果他们通过点击 Yes 按钮来响应,那么导航请求将被完成,Angular 将激活选择表单组件的路由,这将允许创建新产品。如果用户单击“否”按钮,导航请求将被取消。在这两种情况下,路由系统都会发出一个事件,向用户显示消息的组件会接收到该事件,从而清除其显示并确保用户不会看到过时的消息。

img/421542_4_En_27_Fig5_HTML.jpg

图 27-5。

保护路由激活

巩固子路由守卫

如果您有一组子路由,您可以使用子路由保护来防止它们被激活,这个类定义了一个名为canActivateChild的方法。该保护被应用于应用配置中的父路由,并且每当任何子路由将要被激活时,就调用canActivateChild方法。该方法接收与其他守卫相同的ActivatedRouteSnapshotRouterStateSnapshot对象,并可以返回表 27-4 中描述的结果类型集。

在这个例子中,通过在实现canActivateChild方法之前改变配置,可以更容易地处理这个防护,如清单 27-13 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver }
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-13.Guarding Child Routes in the app.routing.ts File in the src/app Folder

使用canActivateChild属性将子路由保护应用于路由,该属性设置为实现canActivateChild方法的服务类型数组。在 Angular 激活路由的任何子路由之前,将调用此方法。清单 27-14 将canActivateChild方法添加到上一节的守卫类中。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { MessageService } from "./messages/message.service";
import { Message } from "./messages/message.model";

@Injectable()
export class TermsGuard {

    constructor(private messages: MessageService,
        private router: Router) { }

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
        Promise<boolean> | boolean {

        if (route.params["mode"] == "create") {

            return new Promise<boolean>((resolve, reject) => {
                let responses: [string, (string) => void][] = [
                    ["Yes", () => resolve(true)],
                    ["No", () => resolve(false)]
                ];
                this.messages.reportMessage(
                    new Message("Do you accept the terms & conditions?",
                        false, responses));
            });
        } else {
            return true;
        }
    }

    canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot):
        Promise<boolean> | boolean {

        if (route.url.length > 0
            && route.url[route.url.length - 1].path == "categories") {

            return new Promise<boolean>((resolve, reject) => {
                let responses: [string, (string) => void][] = [
                    ["Yes", () => resolve(true)],
                    ["No ", () => resolve(false)]
                ];

                this.messages.reportMessage(
                    new Message("Do you want to see the categories component?",
                        false, responses));
            });
        } else {
            return true;
        }
    }
}

Listing 27-14.Implementing Child Route Guards in the terms.guard.ts File in the src/app Folder

守卫只保护categories子路由,对于任何其他路由将立即返回true。守卫提示用户使用消息服务,但是如果用户单击 No 按钮,则执行不同的操作。除了拒绝活动路由之外,守卫使用Router服务导航到不同的 URL,该服务作为构造函数参数接收。当用户被重定向到一个组件时,这是一种常见的身份验证模式,该组件将在尝试受限操作时请求安全凭据。在这种情况下,示例更简单,警卫导航到显示不同组件的同级路由。(您可以在第九章的 SportsStore 应用中看到使用路由卫士导航的示例。)

要查看守卫的效果,点击计数类别按钮,如图 27-6 所示。点击 Yes 按钮响应提示,将显示CategoryCountComponent,显示表格中的类别数。点击否将拒绝当前航路并导航到显示ProductCountComponent的航路。

img/421542_4_En_27_Fig6_HTML.jpg

图 27-6。

保护子路由

Note

仅当现用航路改变时,才应用防护。因此,例如,如果您在/table URL 处于活动状态时单击计数类别按钮,那么您将会看到提示,单击是将会更改活动路由。但是,如果您再次单击计数类别按钮,将不会发生任何事情,因为当目标路由和活动路由相同时,Angular 不会触发路由更改。

防止路由停用

当您开始使用路由时,您会倾向于关注路由被激活以响应导航和向用户呈现新内容的方式。但是同样重要的是 route 去激活,这发生在应用导航离开一条路由的时候。

停用保护最常见的用途是防止用户在有未保存的数据编辑时进行导航。在这一节中,我将创建一个防护,当用户在编辑产品时将要放弃未保存的更改时,它会向用户发出警告。为此,清单 27-15 更改了FormComponent类以简化守卫的工作。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { ActivatedRoute, Router } from "@angular/router";

@Component({
    selector: "paForm",
    templateUrl: "form.component.html",
    styleUrls: ["form.component.css"]
})
export class FormComponent {
    product: Product = new Product();
    originalProduct = new Product();

    constructor(public model: Model, activeRoute: ActivatedRoute,
        public router: Router) {

        activeRoute.params.subscribe(params => {
            this.editing = params["mode"] == "edit";
            let id = params["id"];
            if (id != null) {
                Object.assign(this.product, model.getProduct(id) || new Product());
                Object.assign(this.originalProduct, this.product);
            }
        })
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.originalProduct = this.product;
            this.router.navigateByUrl("/");
        }
    }

    //resetForm() {
    //    this.product = new Product();
    //}
}

Listing 27-15.Preparing for the Guard in the form.component.ts File in the src/app/core Folder

当组件开始编辑时,它创建一个从数据模型中获得的Product对象的副本,并将其分配给originalProduct属性。停用保护将使用该属性来查看是否有未保存的编辑。为了防止守卫中断保存操作,在导航请求之前,originalProduct属性被设置为submitForm方法中的编辑产品对象。

模板中需要相应的更改,这样取消按钮就不会调用表单的重置事件处理程序,如清单 27-16 所示。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
</div>

<div *ngIf="editing" class="p-2">
    <button class="btn btn-secondary m-1"
            [routerLink]="['/form', 'edit', model.getPreviousProductid(product.id)]">
        Previous
    </button>
    <button class="btn btn-secondary"
            [routerLink]="['/form', 'edit', model.getNextProductId(product.id)]">
        Next
    </button>
</div>

<form novalidate #form="ngForm" (ngSubmit)="submitForm(form)">

    <div class="form-group">
        <label>Name</label>
        <input class="form-control" name="name"
               [(ngModel)]="product.name" required />
    </div>

    <div class="form-group">
        <label>Category</label>
        <input class="form-control" name="category"
               [(ngModel)]="product.category" required />
    </div>

    <div class="form-group">
        <label>Price</label>
        <input class="form-control" name="price"
               [(ngModel)]="product.price"
               required pattern="^[0-9\.]+$" />
    </div>

    <button type="submit" class="btn btn-primary m-1"
            [class.btn-warning]="editing" [disabled]="form.invalid">
        {{editing ? "Save" : "Create"}}
    </button>
    <button type="button" class="btn btn-secondary m-1" routerLink="/">
            Cancel
    </button>
</form>

Listing 27-16.Disabling Form Reset in the form.component.html File in the src/app/core Folder

为了创建防护,我在src/app/core文件夹中添加了一个名为unsaved.guard.ts的文件,并定义了清单 27-17 中所示的类。

import { Injectable } from "@angular/core";
import {
    ActivatedRouteSnapshot, RouterStateSnapshot,
    Router
} from "@angular/router";
import { Observable, Subject } from "rxjs";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { FormComponent } from "./form.component";

@Injectable()
export class UnsavedGuard {

    constructor(private messages: MessageService,
                private router: Router) { }

    canDeactivate(component: FormComponent, route: ActivatedRouteSnapshot,
        state: RouterStateSnapshot): Observable<boolean> | boolean {

        if (component.editing) {
            if (["name", "category", "price"]
                .some(prop => component.product[prop]
                    != component.originalProduct[prop])) {
                let subject = new Subject<boolean>();

                let responses: [string, (string) => void][] = [
                    ["Yes", () => {
                        subject.next(true);
                        subject.complete();
                    }],
                    ["No", () => {
                        this.router.navigateByUrl(this.router.url);
                        subject.next(false);
                        subject.complete();
                    }]
                ];
                this.messages.reportMessage(new Message("Discard Changes?",
                    true, responses));
                return subject;
            }
        }
        return true;
    }
}

Listing 27-17.The Contents of the unsaved.guard.ts File in the src/app/core Folder

停用保护定义了一个名为canDeactivate的类,它接收三个参数:即将被停用的组件以及ActivatedRouteSnapshotRouteStateSnapshot对象。该保护检查组件中是否有未保存的编辑,如果有,则提示用户。为了多样化,这种保护使用一个Observable<true>,实现为一个Subject<true>而不是一个Promise<true>,根据用户选择的响应来告诉 Angular 它是否应该激活路由。

Tip

注意,在调用了next方法之后,我在Subject上调用了complete方法。Angular 将无限期地等待调用complete方法,实际上冻结了应用。

下一步是在包含它的模块中注册一个服务,如清单 27-18 所示。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";
import { ModelModule } from "../model/model.module";
import { TableComponent } from "./table.component";
import { FormComponent } from "./form.component";
import { Subject } from "rxjs";
import { StatePipe } from "./state.pipe";
import { MessageModule } from "../messages/message.module";
import { MessageService } from "../messages/message.service";
import { Message } from "../messages/message.model";
import { Model } from "../model/repository.model";
import { RouterModule } from "@angular/router";
import { ProductCountComponent } from "./productCount.component";
import { CategoryCountComponent } from "./categoryCount.component";
import { NotFoundComponent } from "./notFound.component";
import { UnsavedGuard } from "./unsaved.guard";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe,
        ProductCountComponent, CategoryCountComponent, NotFoundComponent],
    providers: [UnsavedGuard],
    exports: [ModelModule, TableComponent, FormComponent]
})
export class CoreModule { }

Listing 27-18.Registering the Guard as a Service in the core.module.ts File in the src/app/core Folder

最后,清单 27-19 将防护应用于应用的路由配置。使用canDeactivate属性将停用保护应用于路由,该属性设置为一组保护服务。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver },
        canDeactivate: [UnsavedGuard]
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-19.Applying the Guard in the app.routing.ts File in the src/app Folder

要查看保护效果,请单击表格中的一个编辑按钮。编辑其中一个文本字段中的数据;然后单击取消、下一个或上一个按钮。在允许 Angular 激活您选择的路由之前,警卫会提示您,如图 27-7 所示。

img/421542_4_En_27_Fig7_HTML.jpg

图 27-7。

保护路由去激活

动态加载功能模块

Angular 支持仅在需要时加载功能模块,称为动态加载惰性加载。这对于并非所有用户都需要的功能非常有用。在接下来的小节中,我将创建一个简单的功能模块,并演示如何配置应用,以便 Angular 仅在应用导航到特定 URL 时加载该模块。

Note

动态加载模块是一种权衡。对于大多数用户来说,该应用将更小,下载速度更快,从而改善他们的整体体验。但是需要动态加载特性的用户将不得不等待 Angular 获取模块及其依赖项。这种效果可能是不和谐的,因为用户不知道一些特性已经被加载,而另一些特性没有。当您创建动态加载的模块时,您正在平衡改善某些用户的体验和使其他用户的体验变差。考虑你的用户是如何归入这些群体的,注意不要降低你最有价值和最重要的客户的体验。

创建简单的特征模块

动态加载的模块必须只包含并非所有用户都需要的功能。我不能使用现有的模块,因为它们为应用提供了核心功能,这意味着我需要一个新的模块来完成本章的这一部分。我首先在src/app文件夹中创建一个名为ondemand的文件夹。为了给新模块一个组件,我在example/app/ondemand文件夹中添加了一个名为ondemand.component.ts的文件,并添加了清单 27-20 中所示的代码。

Caution

重要的是不要在应用的其他部分和动态加载的模块中的类之间创建依赖关系,这样 JavaScript 模块加载器就不会在需要模块之前加载它。

import { Component } from "@angular/core";

@Component({
    selector: "ondemand",
    templateUrl: "ondemand.component.html"
})
export class OndemandComponent { }

Listing 27-20.The Contents of the ondemand.component.ts File in the src/app/ondemand Folder

为了给组件提供模板,我添加了一个名为ondemand.component.html的文件,并添加了清单 27-21 中所示的标记。

<div class="bg-primary text-white p-2">This is the ondemand component</div>
<button class="btn btn-primary m-2" routerLink="/" >Back</button>

Listing 27-21.The ondemand.component.html File in the src/app/ondemand Folder

该模板包含一条消息,当组件被选中时,这条消息会很明显,并且包含一个button元素,当被单击时,这个元素会导航回应用的根 URL。

为了定义这个模块,我添加了一个名为ondemand.module.ts的文件,并添加了清单 27-22 中所示的代码。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";

@NgModule({
    imports: [CommonModule],
    declarations: [OndemandComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-22.The Contents of the ondemand.module.ts File in the src/app/ondemand Folder

该模块导入了CommonModule功能,该功能用于代替特定于浏览器的BrowserModule来访问按需加载的功能模块中的内置指令。

动态加载模块

设置动态加载模块有两个步骤。第一个是在特征模块内设置一个路由配置,提供允许 Angular 在模块加载时选择一个组件的规则。清单 27-23 向特征模块添加一条路由。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";
import { RouterModule } from "@angular/router";

let routing = RouterModule.forChild([
    { path: "", component: OndemandComponent }
]);

@NgModule({
    imports: [CommonModule, routing],
    declarations: [OndemandComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-23.Defining Routes in the ondemand.module.ts File in the src/app/ondemand Folder

动态加载模块中的路由使用与应用主要部分相同的属性来定义,并且可以使用所有相同的功能,包括子组件、保护和重定向。列表中定义的路由与空路径匹配,并选择OndemandComponent进行显示。

一个重要的区别是用于生成包含路由信息的模块的方法,如下所示:

...
let routing = RouterModule.forChild([
    { path: "", component: OndemandComponent }
]);
...

当我创建应用范围的路由配置时,我使用了RouterModule.forRoot方法。这是用于在应用的根模块中设置路由的方法。创建动态加载的模块时,必须使用RouterModule.forChild方法;该方法创建一个路由配置,当模块被加载时,该配置被合并到整个路由系统中。

创建动态加载模块的路由

设置动态加载模块的第二步是在应用的主要部分创建一条路由,为 Angular 提供模块的位置,如清单 27-24 所示。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "ondemand",
        loadChildren: () => import("./ondemand/ondemand.module")
                                .then(m => m.OndemandModule)
    },
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver },
        canDeactivate: [UnsavedGuard]
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-24.Creating an On-Demand Route in the app.routing.ts File in the src/app Folder

属性用于向 Angular 提供模块应该如何加载的细节。该属性被赋予一个调用import的函数,将路径传递给模块。结果是一个Promise,它的then方法用于在模块被导入后选择它。清单中的函数告诉 Angular 从ondemand/ondemand.module文件中加载OndemandModule类。

使用动态加载的模块

剩下的就是添加对 URL 导航的支持,这将激活随需应变模块的路由,如清单 27-25 所示,它为表格组件的模板添加了一个按钮。

<div class="container-fluid">
    <div class="row">
        <div class="col-auto">
            <button class="btn btn-secondary btn-block"
                routerLink="/table" routerLinkActive="bg-primary"
                [routerLinkActiveOptions]="{exact: true}">
                All
            </button>
            <button *ngFor="let category of categories"
                    class="btn btn-secondary btn-block px-3"
                    [routerLink]="['/table', category]"
                    routerLinkActive="bg-primary">
                {{category}}
            </button>
        </div>
        <div class="col">

                < !-- ...elements omitted for brevity... -->

        </div>
    </div>
</div>
<div class="p-2 text-center">
    <button class="btn btn-primary m-1" routerLink="/form/create">
        Create New Product
    </button>
    <button class="btn btn-danger" (click)="deleteProduct(-1)">
        Generate HTTP Error
    </button>
    <button class="btn btn-danger m-1" routerLink="/does/not/exist">
        Generate Routing Error
    </button>
    <button class="btn btn-danger" routerLink="/ondemand">
        Load Module
    </button>
</div>

Listing 27-25.Adding Navigation in the table.component.html File in the src/app/core Folder

不需要特别的措施来定位加载模块的路由,清单中的加载模块按钮使用标准的routerLink属性来导航到清单 27-24 中添加的路由所指定的 URL。

要查看动态模块加载是如何工作的,使用以下命令在exampleApp文件夹中重新启动 Angular 开发工具,这将重建模块,包括按需模块:

ng serve

现在使用浏览器的开发工具来查看应用启动时加载的文件列表。在您单击“加载模块”按钮之前,您不会看到对按需模块中任何文件的 HTTP 请求。点击按钮时,Angular 使用路由配置加载模块,检查其路由配置,并选择将向用户显示的组件,如图 27-8 所示。

img/421542_4_En_27_Fig8_HTML.jpg

图 27-8。

动态加载模块

保护动态模块

您可以防止动态加载模块,以确保只有当应用处于特定状态时,或者当用户明确同意等待 Angular 进行加载时,才加载模块(后一种选项通常只用于管理功能,在这种情况下,用户应该对应用的结构有所了解)。

模块的防护必须在应用的主体部分定义,所以我在src/app文件夹中添加了一个名为load.guard.ts的文件,并定义了清单 27-26 中所示的类。

import { Injectable } from "@angular/core";
import { Route, Router } from "@angular/router";
import { MessageService } from "./messages/message.service";
import { Message } from "./messages/message.model";

@Injectable()
export class LoadGuard {
    private loaded: boolean = false;

    constructor(private messages: MessageService,
                private router: Router) { }

    canLoad(route: Route): Promise<boolean> | boolean {

        return this.loaded || new Promise<boolean>((resolve, reject) => {
            let responses: [string, (string) => void] [] = [
                ["Yes", () => {
                    this.loaded = true;
                    resolve(true);
                }],
                ["No", () => {
                    this.router.navigateByUrl(this.router.url);
                    resolve(false);
                }]
            ];

            this.messages.reportMessage(
                new Message("Do you want to load the module?",
                    false, responses));
        });
    }
}

Listing 27-26.The Contents of the load.guard.ts File in the src/app Folder

动态加载守卫是实现名为canLoad的方法的类,当 Angular 需要激活它所应用的路由时调用该方法,并提供一个描述路由的Route对象。

只有当加载模块的 URL 第一次被激活时,才需要这个保护,所以它定义了一个loaded属性,当模块被加载时,该属性被设置为true,以便后续的请求被立即批准。否则,该保护遵循与前面示例相同的模式,并返回一个Promise,当用户单击消息服务显示的一个按钮时,该问题将被解决。清单 27-27 将防护注册为根模块中的服务。

import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { ModelModule } from "./model/model.module";
import { CoreModule } from "./core/core.module";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { MessageModule } from "./messages/message.module";
import { MessageComponent } from "./messages/message.component";
import { routing } from "./app.routing";
import { AppComponent } from "./app.component";
import { TermsGuard } from "./terms.guard"
import { LoadGuard } from "./load.guard";

@NgModule({
    imports: [BrowserModule, CoreModule, MessageModule, routing],
    declarations: [AppComponent],
    providers: [TermsGuard, LoadGuard],
    bootstrap: [AppComponent]
})
export class AppModule { }

Listing 27-27.Registering the Guard as a Service in the app.module.ts File in the src/app Folder

应用动态加载保护

使用canLoad属性将动态加载的保护应用于路由,该属性接受一组保护类型。清单 27-28 将清单 27-26 中定义的LoadGuard类应用于动态加载模块的路径。

import { Routes, RouterModule } from "@angular/router";
import { TableComponent } from "./core/table.component";
import { FormComponent } from "./core/form.component";
import { NotFoundComponent } from "./core/notFound.component";
import { ProductCountComponent } from "./core/productCount.component";
import { CategoryCountComponent } from "./core/categoryCount.component";
import { ModelResolver } from "./model/model.resolver";
import { TermsGuard } from "./terms.guard";
import { UnsavedGuard } from "./core/unsaved.guard";
import { LoadGuard } from "./load.guard";

const childRoutes: Routes = [
    {
        path: "",
        canActivateChild: [TermsGuard],
        children: [{ path: "products", component: ProductCountComponent },
                   { path: "categories", component: CategoryCountComponent },
                   { path: "", component: ProductCountComponent }],
        resolve: { model: ModelResolver }
    }
];

const routes: Routes = [
    {
        path: "ondemand",
        loadChildren: () => import("./ondemand/ondemand.module")
                                .then(m => m.OndemandModule),
        canLoad: [LoadGuard]
    },
    {
        path: "form/:mode/:id", component: FormComponent,
        resolve: { model: ModelResolver },
        canDeactivate: [UnsavedGuard]
    },
    {
        path: "form/:mode", component: FormComponent,
        resolve: { model: ModelResolver },
        canActivate: [TermsGuard]
    },
    { path: "table", component: TableComponent, children: childRoutes },
    { path: "table/:category", component: TableComponent, children: childRoutes },
    { path: "", redirectTo: "/table", pathMatch: "full" },
    { path: "**", component: NotFoundComponent }
]

export const routing = RouterModule.forRoot(routes);

Listing 27-28.Guarding the Route in the app.routing.ts File in the src/app Folder

结果是,在 Angular 第一次试图激活路径时,用户被提示确定是否要加载模块,如图 27-9 所示。

img/421542_4_En_27_Fig9_HTML.jpg

图 27-9。

保护动态负载

锁定指定的销售点

一个模板可以包含多个router-outlet元素,这允许一个 URL 选择多个组件显示给用户。

为了演示这个特性,我需要向ondemand模块添加两个新组件。我首先在src/app/ondemand文件夹中创建一个名为first.component.ts的文件,并用它来定义清单 27-29 中所示的组件。

import { Component } from "@angular/core";

@Component({
    selector: "first",
    template: `<div class="bg-primary text-white p-2">First Component</div>`
})
export class FirstComponent { }

Listing 27-29.The Contents of the first.component.ts File in the src/app/ondemand Folder

该组件使用一个内联模板来显示一条消息,其目的只是为了清楚地表明路由系统选择了哪个组件。接下来,我在src/app/ondemand文件夹中创建了一个名为second.component.ts的文件,并创建了清单 27-30 中所示的组件。

import { Component } from "@angular/core";

@Component({
    selector: "second",
    template: `<div class="bg-info text-white p-2">Second Component</div>`
})
export class SecondComponent { }

Listing 27-30.The Contents of the second.component.ts File in the src/app/ondemand Folder

该组件与清单 27-29 中的组件几乎相同,不同之处仅在于它通过其内联模板显示的消息。

创建额外的出口元素

当您在同一个模板中使用多个 outlet 元素时,Angular 需要一些方法来区分它们。这是使用name属性来完成的,它允许一个插座被唯一地标识,如清单 27-31 所示。

<div class="bg-primary text-white p-2">This is the ondemand component</div>
<div class="container-fluid">
    <div class="row">
        <div class="col-12 p-2">
            <router-outlet></router-outlet>
        </div>
    </div>
    <div class="row">
        <div class="col-6 p-2">
            <router-outlet name="left"></router-outlet>
        </div>
        <div class="col-6 p-2">
            <router-outlet name="right"></router-outlet>
        </div>
    </div>
</div>
<button class="btn btn-primary m-2" routerLink="/">Back</button>

Listing 27-31.Adding Outlets in the ondemand.component.html File in the src/app/ondemand Folder

新元素创造了三个新的出口。最多可以有一个router-outlet元素没有name元素,称为主出口。这是因为省略name属性与应用值为primary的属性具有相同的效果。到目前为止,本书中的所有路由示例都依赖于主出口向用户显示组件。

所有其他的router-outlet元素必须有一个具有唯一名称的name元素。我在清单中使用的名字是leftright,因为应用于包含商店的div元素的类使用 CSS 将这两个商店并排放置。

下一步是创建一个路由,它包括应该在每个 outlet 元素中显示哪个组件的细节,如清单 27-32 所示。如果 Angular 找不到匹配特定出口的路由,则该元素中不会显示任何内容。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";
import { RouterModule } from "@angular/router";
import { FirstComponent } from "./first.component";
import { SecondComponent } from "./second.component";

let routing = RouterModule.forChild([
    {
        path: "",
        component: OndemandComponent,
        children: [
            { path: "",
              children: [
                   { outlet: "primary", path: "", component: FirstComponent, },
                   { outlet: "left", path: "", component: SecondComponent, },
                   { outlet: "right", path: "", component: SecondComponent, },
              ]},
        ]
    },
]);

@NgModule({
    imports: [CommonModule, routing],
    declarations: [OndemandComponent, FirstComponent, SecondComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-32.Targeting Outlets in the ondemand.module.ts File in the src/app/ondemand Folder

outlet属性用于指定路由适用的出口元素。列表中的布线配置匹配所有三个插座的空路径,并为它们选择新创建的组件:主插座将显示FirstComponentleftright插座将显示SecondComponent,如图 27-10 所示。要亲自查看效果,请单击“加载模块”按钮,并在出现提示时单击“是”按钮。

img/421542_4_En_27_Fig10_HTML.jpg

图 27-10。

使用多个路由器插座

Tip

如果您省略了outlet属性,那么 Angular 会假设路由以主出口为目标。我倾向于在所有路由中包含outlet属性,以强调哪些路由匹配 outlet 元素。

当 Angular 激活路由时,它会查找每个插座的匹配项。所有三个新的出口都有与空路径匹配的路由,这使得 Angular 能够呈现图中所示的组件。

使用多个插座时导航

更改每个出口显示的组件意味着创建一组新的路由,然后导航到包含这些路由的 URL。清单 27-33 设置了一条与路径/ondemand/swap匹配的路由,该路由将切换三个插座显示的组件。

import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { OndemandComponent } from "./ondemand.component";
import { RouterModule } from "@angular/router";
import { FirstComponent } from "./first.component";
import { SecondComponent } from "./second.component";

let routing = RouterModule.forChild([
    {
        path: "",
        component: OndemandComponent,
        children: [
            {
                path: "",
                children: [
                    { outlet: "primary", path: "", component: FirstComponent, },
                    { outlet: "left", path: "", component: SecondComponent, },
                    { outlet: "right", path: "", component: SecondComponent, },
                ]
            },
            {
                path: "swap",
                children: [
                    { outlet: "primary", path: "", component: SecondComponent, },
                    { outlet: "left", path: "", component: FirstComponent, },
                    { outlet: "right", path: "", component: FirstComponent, },
                ]
            },
        ]
    },
]);

@NgModule({
    imports: [CommonModule, routing],
    declarations: [OndemandComponent, FirstComponent, SecondComponent],
    exports: [OndemandComponent]
})
export class OndemandModule { }

Listing 27-33.Setting Routes for Outlets in the ondemand.module.ts File in the src/app/ondemand Folder

清单 27-34 将button元素添加到组件的模板中,该模板将导航到清单 27-33 中的两组路由,交替显示给用户的组件集。

<div class="bg-primary text-white p-2">This is the ondemand component</div>
<div class="container-fluid">
    <div class="row">
        <div class="col-12 p-2">
            <router-outlet></router-outlet>
        </div>
    </div>
    <div class="row">
        <div class="col-6 p-2">
            <router-outlet name="left"></router-outlet>
        </div>
        <div class="col-6 p-2">
            <router-outlet name="right"></router-outlet>
        </div>
    </div>
</div>
<button class="btn btn-secondary m-2" routerLink="/ondemand">Normal</button>
<button class="btn btn-secondary m-2" routerLink="/ondemand/swap">Swap</button>
<button class="btn btn-primary m-2" routerLink="/">Back</button>

Listing 27-34.Navigating to Outlets in the ondemand.component.html File in the src/app/ondemand Folder

结果是,点击交换和正常按钮将导航到其子节点告诉 Angular 每个插座元件应显示哪些组件的路由,如图 27-11 所示。

img/421542_4_En_27_Fig11_HTML.jpg

图 27-11。

使用导航定位多个出口元素

摘要

在这一章中,我描述了 Angular URL 路由特性,并解释了如何保护路由以控制路由何时被激活,如何仅在需要时加载模块,以及如何使用多个 outlet 元素向用户显示组件。在下一章,我将向你展示如何将动画应用到 Angular 应用中。