Angular9-高级教程-十-

80 阅读49分钟

Angular9 高级教程(十)

原文:Pro Angular 9

协议:CC BY-NC-SA 4.0

二十四、发出 HTTP 请求

从第十一章开始的所有例子都依赖于硬连线到应用中的静态数据。在这一章中,我将演示如何使用异步 HTTP 请求,通常称为 Ajax 请求,与 web 服务进行交互,将真实数据输入到应用中。表 24-1 将 HTTP 请求放在上下文中。

表 24-1。

将异步 HTTP 请求放在上下文中

|

问题

|

回答

| | --- | --- | | 它们是什么? | 异步 HTTP 请求是由浏览器代表应用发送的 HTTP 请求。术语异步是指在浏览器等待服务器响应的同时,应用继续运行。 | | 它们为什么有用? | 异步 HTTP 请求允许 Angular 应用与 web 服务进行交互,以便将持久数据加载到应用中,并将更改发送到服务器并保存。 | | 它们是如何使用的? | 使用HttpClient类发出请求,该类通过依赖注入作为服务交付。这个类为浏览器的XMLHttpRequest特性提供了一个 Angular 友好的包装器。 | | 有什么陷阱或限制吗? | 使用 Angular HTTP 特性需要使用反应式扩展Observable对象,如第二十三章所述。 | | 还有其他选择吗? | 如果您愿意,您可以直接使用浏览器的XMLHttpRequest对象,并且一些应用——那些不需要处理持久数据的应用——可以在根本不发出 HTTP 请求的情况下编写。 |

表 24-2 总结了本章内容。

表 24-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 在 Angular 应用中发送 HTTP 请求 | 使用Http服务 | 1–8 | | 执行休息操作 | 使用 HTTP 方法和 URL 来指定操作和该操作的目标 | 9–11 | | 提出跨来源请求 | 使用HttpClient服务自动支持 CORS(也支持 JSONP 请求) | 12–13 | | 在请求中包含标头 | 在Request对象中设置headers属性 | 14–15 | | 响应 HTTP 错误 | 创建错误处理程序类 | 16–19 |

准备示例项目

本章使用在第二十二章中创建的 exampleApp 项目。对于这一章,我依赖一个用 JSON 数据响应 HTTP 请求的服务器。运行exampleApp文件夹中清单 24-1 所示的命令,将json-server包添加到项目中。

Tip

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

npm install json-server@0.16.0

Listing 24-1.Adding a Package to the Project

我在package.json文件的scripts部分添加了一个条目来运行json-server包,如清单 24-2 所示。

...
"scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e",
    "json": "json-server --p 3500 restData.js"
},
...

Listing 24-2.Adding a Script Entry in the package.json File in the exampleApp Folder

配置模型特征模块

@angular/common/http JavaScript 模块包含一个名为HttpClientModule的 Angular 模块,在创建 HTTP 请求之前,必须将它导入到应用的根模块或某个特性模块中。在清单 24-3 中,我将模块导入到模型模块中,这是示例应用中的自然位置,因为我将使用 HTTP 请求用数据填充模型。

import { NgModule } from "@angular/core";
import { StaticDataSource } from "./static.datasource";
import { Model } from "./repository.model";
import { HttpClientModule } from "@angular/common/http";

@NgModule({
  imports: [HttpClientModule],
  providers: [Model, StaticDataSource]
})
export class ModelModule { }

Listing 24-3.Importing a Module in the model.module.ts File in the src/app/model Folder

创建数据文件

为了给json-server包提供一些数据,我在exampleApp文件夹中添加了一个名为restData.js的文件,并添加了清单 24-4 中所示的代码。

module.exports = function () {
    var data = {
        products: [
            { id: 1, name: "Kayak", category: "Watersports", price: 275 },
            { id: 2, name: "Lifejacket", category: "Watersports", price: 48.95 },
            { id: 3, name: "Soccer Ball", category: "Soccer", price: 19.50 },
            { id: 4, name: "Corner Flags", category: "Soccer", price: 34.95 },
            { id: 5, name: "Stadium", category: "Soccer", price: 79500 },
            { id: 6, name: "Thinking Cap", category: "Chess", price: 16 },
            { id: 7, name: "Unsteady Chair", category: "Chess", price: 29.95 },
            { id: 8, name: "Human Chess Board", category: "Chess", price: 75 },
            { id: 9, name: "Bling Bling King", category: "Chess", price: 1200 }
        ]
    }
    return data
}

Listing 24-4.The Contents of the restData.js File in the exampleApp Folder

json-server包可以处理 JSON 或 JavaScript 文件。如果您使用一个 JSON 文件,那么它的内容将被修改以反映客户的变更请求。我选择了 JavaScript 选项,它允许以编程方式生成数据,并且意味着重新启动该过程将返回到原始数据。

更新表单组件

在第二十三章中,我配置了管理 HTML 表单的组件来忽略由表格组件生成的事件,直到第一次点击创建新产品按钮。为了避免混淆结果,清单 24-5 禁用了应用于ObservableskipWhiledistinctUntilChanged方法。

...
constructor(private model: Model,
    @Inject(SHARED_STATE) public stateEvents: Observable<SharedState>) {
        stateEvents
        // .pipe(skipWhile(state => state.mode == MODES.EDIT))
        // .pipe(distinctUntilChanged((firstState, secondState) =>
        //     firstState.mode == secondState.mode
        //         && firstState.id == secondState.id))
        .subscribe(update => {
            this.product = new Product();
            if (update.id != undefined) {
                Object.assign(this.product, this.model.getProduct(update.id));
            }
            this.editing = update.mode == MODES.EDIT;
        });
}
...

Listing 24-5.Disabling Event Skipping in the form.component.ts File in the src/app/core Folder

运行示例项目

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

npm run json

这个命令将启动json-server,它将监听端口 3500 上的 HTTP 请求。打开新的浏览器窗口并导航至http://localhost:3500/products/2。服务器将使用以下数据进行响应:

{ "id": 2, "name": "Lifejacket", "category": "Watersports", "price": 48.95 }

保持json-server运行,并通过在exampleApp文件夹中运行以下命令,使用单独的命令提示符启动 Angular 开发工具:

ng serve

使用浏览器导航至http://localhost:4200以查看图 24-1 中所示的内容。

img/421542_4_En_24_Fig1_HTML.jpg

图 24-1。

运行示例应用

理解 RESTful Web 服务

向应用交付数据的最常见方法是使用表述性状态转移模式(称为 REST)来创建数据 web 服务。REST 没有详细的规范,这导致很多不同的方法都打着 RESTful 的旗号。然而,在 web 应用开发中有一些有用的统一思想。

RESTful web 服务的核心前提是包含 HTTP 的特性,以便请求方法——也称为动词——指定服务器要执行的操作,请求 URL 指定操作将应用到的一个或多个数据对象。

例如,在示例应用中,下面是一个可能指向特定产品的 URL:

http://localhost:3500/products/2

URL 的第一段—products—用于指示将被操作的对象集合,并允许单个服务器提供多个服务,每个服务都有自己的数据。第二个片段——2——在products集合中选择一个单独的对象。在本例中,id属性的值唯一地标识了一个对象,并将在 URL 中使用,在本例中,指定了Lifejacket对象。

用于发出请求的 HTTP 动词或方法告诉 RESTful 服务器应该对指定的对象执行什么操作。在上一节中测试 RESTful 服务器时,浏览器发送了一个 HTTP GET 请求,服务器将其解释为检索指定对象并将其发送给客户机的指令。正是由于这个原因,浏览器显示了一个表示Lifejacket对象的 JSON。

表 24-3 显示了 HTTP 方法和 URL 的最常见组合,并解释了当发送到 RESTful 服务器时它们各自的作用。

表 24-3。

RESTful Web 服务中常见的 HTTP 动词及其作用

|

动词

|

统一资源定位器

|

描述

| | --- | --- | --- | | GET | /products | 这种组合检索products集合中的所有对象。 | | GET | /products/2 | 这个组合从products集合中检索出id2的对象。 | | POST | /products | 该组合用于向products集合添加一个新对象。请求体包含新对象的 JSON 表示。 | | PUT | /products/2 | 该组合用于替换products集合中id2的对象。请求体包含替换对象的 JSON 表示。 | | PATCH | /products/2 | 这个组合用于更新集合products中对象属性的子集,集合id2。请求体包含要更新的属性和新值的 JSON 表示。 | | DELETE | /products/2 | 该组合用于从products集合中删除id2的产品。 |

需要谨慎,因为一些 RESTful web 服务的工作方式可能存在相当大的差异,这是由用于创建它们的框架和开发团队的偏好的差异造成的。确认 web 服务如何使用动词以及在 URL 和请求正文中需要什么来执行操作是很重要的。

一些常见的变体包括不接受任何包含id值的请求主体的 web 服务(以确保它们是由服务器的数据存储唯一生成的),或者不支持所有动词的任何 web 服务(通常忽略PATCH请求,只接受使用PUT动词的更新)。

替换静态数据源

从 HTTP 请求开始的最佳方式是将示例应用中的静态数据源替换为从 RESTful web 服务中检索数据的数据源。这将为描述 Angular 如何支持 HTTP 请求以及如何将它们集成到应用中提供基础。

创建新的数据源服务

为了创建一个新的数据源,我在src/app/model文件夹中添加了一个名为rest.datasource.ts的文件,并添加了清单 24-6 中所示的语句。

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

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.http.get<Product[]>(this.url);
  }
}

Listing 24-6.The Contents of the rest.datasource.ts File in the src/app/model Folder

这是一个看起来简单的类,但是有一些重要的工作特性,我将在接下来的章节中描述。

设置 HTTP 请求

Angular 提供了通过HttpClient类进行异步 HTTP 请求的能力,该类在@angular/common/http JavaScript 模块中定义,并作为服务在HttpClientModule功能模块中提供。数据源使用其构造函数声明了对HttpClient类的依赖,如下所示:

...
constructor(private http: HttpClient, @Inject(REST_URL) private url: string) { }
...

使用另一个构造函数参数,这样请求发送到的 URL 就不必硬连接到数据源中。当我配置特性模块时,我将使用REST_URL不透明令牌创建一个提供者。通过构造函数接收的HttpClient对象用于在数据源的getData方法中发出 HTTP GET 请求,如下所示:

...
getData(): Observable<Product[]> {
    return this.http.get<Product[]>(this.url);
}
...

HttpClient类定义了一组发出 HTTP 请求的方法,每个方法使用不同的 HTTP 动词,如表 24-4 中所述。

表 24-4。

HttpClient 方法

|

名字

|

描述

| | --- | --- | | get(url) | 此方法向指定的 URL 发送 GET 请求。 | | post(url, body) | 此方法使用指定的对象作为主体发送 POST 请求。 | | put(url, body) | 此方法使用指定的对象作为主体发送 PUT 请求。 | | patch(url, body) | 此方法使用指定的对象作为主体发送修补请求。 | | delete(url) | 此方法向指定的 URL 发送删除请求。 | | head(url) | 这个方法发送一个 HEAD 请求,它与 GET 请求具有相同的效果,只是服务器只返回头部,而不返回请求体。 | | options(url) | 此方法向指定的 URL 发送选项请求。 | | request(method, url, options) | 该方法可用于发送带有任何动词的请求,如“整合 HTTP 请求”一节所述。 |

Tip

表 24-4 中的方法接受一个可选的配置对象,如“配置请求头”一节所示。

处理响应

表 24-4 中描述的方法接受一个类型参数,HttpClient类用它来解析从服务器收到的响应。RESTful web 服务器返回 JSON 数据,这已经成为 web 服务使用的事实上的标准,HttpClient对象会自动将响应转换成一个Observable,当它完成时会产生一个类型参数的实例。这意味着,如果您调用get方法,例如,使用一个Product[]类型参数,那么来自get方法的响应将是一个Observable<Product[]>,它代表 HTTP 请求的最终响应。

...
getData(): Observable<Product[]> {
  return this.http.get<Product[]>(this.url);
}
...

Caution

表 24-4 中的方法准备了一个 HTTP 请求,但是直到Observer对象的subscribe方法被调用,它才被发送到服务器。但是要小心,因为每次调用subscribe方法都会发送一次请求,这就很容易在无意中多次发送相同的请求。

配置数据源

下一步是为新数据源配置一个提供者,并创建一个基于值的提供者,用一个请求将被发送到的 URL 来配置它。清单 24-7 显示了对model.module.ts文件的修改。

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

@NgModule({
  imports: [HttpClientModule],
  providers: [Model, RestDataSource,
    { provide: REST_URL, useValue: `http://${location.hostname}:3500/products` }]
})
export class ModelModule { }

Listing 24-7.Configuring the Data Source in the model.module.ts File in the src/app/model Folder

这两个新的提供者将RestDataSource类作为服务启用,并使用REST_URL不透明令牌来配置 web 服务的 URL。我移除了StaticDataSource类的提供者,不再需要它了。

使用 REST 数据源

最后一步是更新 repository 类,以便它声明对新数据源的依赖,并使用它来获取应用数据,如清单 24-8 所示。

import { Injectable } from "@angular/core";
import { Product } from "./product.model";
//import { StaticDataSource } from "./static.datasource";
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.products = new Array<Product>();
        //this.dataSource.getData().forEach(p => this.products.push(p));
        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));
    }

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

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

    private generateID(): number {
        let candidate = 100;
        while (this.getProduct(candidate) != null) {
            candidate++;
        }
        return candidate;
    }
}

Listing 24-8.Using the New Data Source in the repository.model.ts File in the src/app/model Folder

构造函数依赖关系已经改变,因此存储库在创建时将接收到一个RestDataSource对象。在构造函数中,调用数据源的getData方法,使用subscribe方法接收从服务器返回的数据对象并对其进行处理。

当您保存更改时,浏览器将重新加载应用,并将使用新的数据源。一个异步 HTTP 请求将被发送到 RESTful web 服务,它将返回如图 24-2 所示的更大的一组数据对象。

img/421542_4_En_24_Fig2_HTML.jpg

图 24-2。

获取应用数据

保存和删除数据

数据源能够从服务器获取数据,但是它还需要以另一种方式发送数据,持久化用户对模型中的对象所做的更改,并存储所创建的新对象。清单 24-9 使用 Angular HttpClient类向数据源类添加方法来发送保存或更新对象的 HTTP 请求。

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

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.http.get<Product[]>(this.url);
    }

    saveProduct(product: Product): Observable<Product> {
        return this.http.post<Product>(this.url, product);
    }

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

    deleteProduct(id: number): Observable<Product> {
        return this.http.delete<Product>(`${this.url}/${id}`);
    }
}

Listing 24-9.Sending Data in the rest.datasource.ts File in the src/app/model Folder

saveProductupdateProductdeleteProduct方法遵循相同的模式:它们调用其中一个HttpClient类方法并返回一个Observable<Product>作为结果。

当保存一个新对象时,对象的 ID 由服务器生成,因此它是唯一的,客户端不会无意中对不同的对象使用相同的 ID。在这种情况下,使用 POST 方法,请求被发送到/products URL。当更新或删除一个现有对象时,ID 是已知的,一个 PUT 请求被发送到一个包含该 ID 的 URL。因此,例如,更新 ID 为 2 的对象的请求被发送到/products/2 URL。类似地,要删除该对象,需要向同一个 URL 发送一个删除请求。

这些方法的共同点是服务器是权威的数据存储,来自服务器的响应包含服务器保存的对象的正式版本。正是这个对象作为这些方法的结果返回,通过Observable<Product>提供。

清单 24-10 显示了 repository 类中的相应变化,这些变化利用了新的数据源特性。

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));
    }

    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 24-10.Using the Data Source Features in the repository.model.ts File in the src/app/model Folder

这些更改使用数据源将更新发送到服务器,并使用结果更新本地存储的数据,以便应用的其余部分显示这些数据。要测试这些更改,请单击 Kayak 产品的 Edit 按钮,并将其名称更改为 Green Kayak。点击保存按钮,浏览器会向服务器发送一个 HTTP PUT 请求,服务器会返回一个修改后的对象,该对象被添加到存储库的products数组中,并显示在表格中,如图 24-3 所示。

img/421542_4_En_24_Fig3_HTML.jpg

图 24-3。

向服务器发送上传请求

您可以通过使用浏览器请求http://localhost:3500/products/1来检查服务器是否已经存储了更改,这将产生对象的以下表示:

{
  "id": 1,
  "name": "Green Kayak",
  "category": "Watersports",
  "price": 275
}

整合 HTTP 请求

数据源类中的每个方法都复制了相同的基本模式,即使用特定于动词的HttpClient方法发送 HTTP 请求。这意味着对 HTTP 请求方式的任何更改都必须在四个不同的地方重复,以确保使用 GET、POST、PUT 和 DELETE 动词的请求都得到正确的更新和一致的执行。

HttpClient类定义了request方法,该方法允许将 HTTP 动词指定为参数。清单 24-11 使用request方法合并数据源类中的 HTTP 请求。

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

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> {
        return this.http.request<T>(verb, url, {
            body: body
        });
    }
}

Listing 24-11.Consolidating HTTP Requests in the rest.datasource.ts File in the src/app/model Folder

request方法接受 HTTP 动词、请求的 URL 和用于配置请求的可选对象。配置对象用于使用body属性设置请求主体,而HttpClient将自动负责编码主体对象并在请求中包含它的序列化表示。

表 24-5 描述了最有用的属性,可以指定这些属性来配置使用request方法发出的 HTTP 请求。

表 24-5。

有用的请求方法配置对象属性

|

名字

|

描述

| | --- | --- | | headers | 该属性返回一个允许指定请求头的HttpHeaders对象,如“配置请求头”一节所述。 | | body | 此属性用于设置请求正文。发送请求时,分配给该属性的对象将被序列化为 JSON。 | | withCredentials | 当true时,该属性用于在进行跨站点请求时包含身份验证 cookies。作为跨源资源共享(CORS)规范的一部分,此设置必须仅用于响应中包含Access-Control-Allow-Credentials标头的服务器。有关详细信息,请参见“提出跨来源请求”一节。 | | responseType | 此属性用于指定服务器预期的响应类型。默认值为json,表示 JSON 数据格式。 |

提出跨来源请求

默认情况下,浏览器执行一个安全策略,允许 JavaScript 代码只在与包含它们的文档相同的内发出异步 HTTP 请求。此策略旨在降低跨站点脚本(CSS)攻击的风险,这种攻击会诱使浏览器执行恶意代码。这种攻击的细节超出了本书的范围,但是可以在 http://en.wikipedia.org/wiki/Cross-site_scripting 找到的文章很好地介绍了这个主题。

对于 Angular 开发人员来说,同源策略在使用 web 服务时可能是个问题,因为它们通常位于包含应用 JavaScript 代码的源之外。如果两个 URL 具有相同的协议、主机和端口,则它们被视为来源相同,如果不是这样,则被视为来源不同。包含示例应用的 JavaScript 代码的 HTML 文件的 URL 是http://localhost:3000/index.html。表 24-6 总结了与应用的 URL 相比,相似的 URL 具有相同或不同的来源。

表 24-6。

URL 及其来源

|

统一资源定位器

|

原产地比较

| | --- | --- | | http://localhost:3000/otherfile.html | 相同的起源 | | http://localhost:3000/app/main.js | 相同的起源 | | https://localhost:3000/index.html | 出身不同;协议不同 | | http://localhost:3500/products | 出身不同;端口不同 | | http://angular.io/index.html | 出身不同;主机不同 |

如上表所示,RESTful web 服务的 URLhttp://localhost:3500/products有不同的来源,因为它使用了与主应用不同的端口。

使用 Angular HttpClient类发出的 HTTP 请求将自动使用跨源资源共享向不同的源发送请求。使用 CORS,浏览器在异步 HTTP 请求中包含标头,向服务器提供 JavaScript 代码的来源。来自服务器的响应包括告诉浏览器它是否愿意接受请求的头。CORS 的详细情况不在本书讨论范围内,但在 https://en.wikipedia.org/wiki/Cross-origin_resource_sharing 有题目介绍,在 www.w3.org/TR/cors 有 CORS 规格。

对于 Angular 开发人员来说,只要接收异步 HTTP 请求的服务器支持该规范,CORS 就是自动处理的事情。为示例提供 RESTful web 服务的json-server包支持 CORS,并将接受来自任何来源的请求,这就是示例一直工作的原因。如果您希望看到 CORS 的实际应用,请使用浏览器的 F12 开发人员工具来观察在您编辑或创建产品时发出的网络请求。您可能会看到使用OPTIONS动词发出的请求,称为预检请求,浏览器使用它来检查是否允许向服务器发出 POST 或 PUT 请求。这个请求和随后向服务器发送数据的请求将包含一个Origin头,而响应将包含一个或多个Access-Control-Allow头,服务器通过这些头确定它愿意从客户端接受什么。

所有这些都是自动发生的,唯一的配置选项是表 24-5 中描述的withCredentials属性。当该属性为true时,浏览器将包含认证 cookies,来自源的头将包含在对服务器的请求中。

使用 JSONP 请求

只有当 HTTP 请求发送到的服务器支持 CORS 时,它才可用。对于没有实现 CORS 的服务器,Angular 还提供了对 JSONP 的支持,这允许更有限形式的跨源请求。

JSONP 通过向文档对象模型添加一个script元素来工作,该元素在其src属性中指定跨源服务器。浏览器向服务器发送一个 GET 请求,服务器返回 JavaScript 代码,当执行该代码时,向应用提供它需要的数据。JSONP 本质上是一种绕过浏览器同源安全策略的黑客行为。JSONP 只能用于发出 GET 请求,它比 CORS 存在更大的安全风险。因此,JSONP 应该只在 CORS 不可用时使用。

JSONP 的 Angular 支持在一个名为HttpClientJsonpModule的特性模块中定义,这个特性模块在@angular/common/http JavaScript 模块中定义。为了启用 JSONP,清单 24-12 将HttpClientJsonpModule添加到模型模块的导入集中。

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

@NgModule({
    imports: [HttpClientModule, HttpClientJsonpModule],
    providers: [Model, RestDataSource,
        { provide: REST_URL, useValue: `http://${location.hostname}:3500/products` }]
})
export class ModelModule { }

Listing 24-12.Enabling JSONP in the model.module.ts File in the src/app/model Folder

Angular 通过HttpClient服务提供对 JSONP 的支持,该服务负责管理 JSONP HTTP 请求和处理响应,否则这将是一个冗长且容易出错的过程。清单 24-13 展示了使用 JSONP 为应用请求初始数据的数据源。

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

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.http.jsonp<Product[]>(this.url, "callback");
    }

    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> {
        return this.http.request<T>(verb, url, {
            body: body
        });
    }
}

Listing 24-13.Making a JSONP Request in the rest.datasource.ts File in the src/app/model Folder

JSONP 只能用于 get 请求,这些请求是使用HttpClient.jsonp方法发送的。当您调用这个方法时,您必须提供请求的 URL 和回调参数的名称,回调参数必须设置为callback,如下所示:

...
return this.http.jsonp<Product[]>(this.url, "callback");
...

当 Angular 发出 HTTP 请求时,它用动态生成的函数的名称创建一个 URL。如果您查看浏览器发出的网络请求,您会看到初始请求被发送到如下 URL:

http://localhost:3500/products?callback=ng_jsonp_callback_0

服务器 JavaScript 函数匹配 URL 中使用的名称,并将从请求中接收的数据传递给它。JSONP 是一种进行跨来源请求的更有限的方式,并且与 CORS 不同,它绕过了浏览器的安全策略,但在必要时它可以是一种有用的后备措施。

配置请求标头

如果您使用的是商业 RESTful web 服务,那么您通常需要设置一个请求头来提供一个 API 键,这样服务器就可以将请求与您的应用相关联,以便进行访问控制和计费。您可以通过配置传递给request方法的配置对象来设置这种头——或者任何其他头,如清单 24-14 所示。(这个清单还返回到对所有请求使用request方法,而不是 JSONP。)

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

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> {
        return this.http.request<T>(verb, url, {
            body: body,
            headers: new HttpHeaders({
                "Access-Key": "<secret>",
                "Application-Name": "exampleApp"
            })
        });
    }
}

Listing 24-14.Setting a Request Header in the rest.datasource.ts File in the src/app/model Folder

headers属性被设置为一个HttpHeaders对象,该对象可以使用一个映射对象来创建,该映射对象的属性对应于标题名和应该用于标题名的值。如果您使用浏览器的 F12 开发人员工具来检查异步 HTTP 请求,您将会看到清单中指定的两个头与浏览器创建的标准头一起被发送到服务器,如下所示:

...
Accept:*/*
Accept-Encoding:gzip, deflate, sdch, br
Accept-Language:en-US,en;q=0.8
access-key:<secret>
application-name:exampleApp
Connection:keep-alive
...

如果您对请求头有更复杂的需求,那么您可以使用由HttpHeaders类定义的方法,如表 24-7 中所述。

表 24-7。

HttpHeaders 方法

|

名字

|

描述

| | --- | --- | | keys() | 返回集合中的所有标题名 | | get(name) | 返回指定标题的第一个值 | | getAll(name) | 返回指定标题的所有值 | | has(name) | 如果集合包含指定的标题,则返回true | | set(header, value) | 返回一个新的HttpHeaders对象,用一个值替换指定标题的所有现有值 | | set(header, values) | 返回一个新的HttpHeaders对象,用一个值数组替换指定标题的所有现有值 | | append(name, value) | 向指定标头的值列表中追加一个值 | | delete(name) | 从集合中移除指定的标头 |

HTTP 头可以有多个值,这就是为什么有些方法会为头追加值或替换集合中的所有值。清单 24-15 创建一个空的HttpHeaders对象,并用有多个值的头填充它。

...
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
    });
}
...

Listing 24-15.Setting Multiple Header Values in the rest.datasource.ts File in the src/app/model Folder

当浏览器向服务器发送请求时,它们将包括以下标题:

...
Accept:*/*
Accept-Encoding:gzip, deflate, sdch, br
Accept-Language:en-US,en;q=0.8
access-key:<secret>
application-names:exampleApp,proAngular
Connection:keep-alive
...

处理错误

目前,应用中没有错误处理,这意味着如果 HTTP 请求出现问题,Angular 不知道该怎么办。为了更容易生成错误,我在 product 表中添加了一个按钮,该按钮将导致一个 HTTP 请求来删除一个在服务器上不存在的对象,如清单 24-16 所示。

<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" (click)="editProduct(item.id)">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 24-16.Adding an Error Button in the table.component.html File in the src/app/core Folder

button元素调用组件的deleteProduct方法,参数为-1。组件将要求存储库删除这个对象,这将导致一个 HTTP 删除请求被发送到不存在的/products/-1。如果您打开浏览器的 JavaScript 控制台并单击 new 按钮,您将看到来自服务器的响应,如下所示:

DELETE http://localhost:3500/products/-1 404 (Not Found)

改善这种情况意味着在出现错误时检测到这种错误并通知用户,用户通常不会查看 JavaScript 控制台。一个真正的应用也可能通过记录错误来响应错误,这样以后就可以对它们进行分析,但是为了简单起见,我只显示一条错误消息。

生成用户就绪消息

处理错误的第一步是将 HTTP 异常转换成可以向用户显示的内容。默认的错误消息是写入 JavaScript 控制台的消息,包含太多信息,无法向用户显示。用户不需要知道请求被发送到的 URL 仅仅知道发生了什么样的问题就足够了。

转换错误消息的最佳方式是使用catchErrorthrowError方法。使用catchError方法和pipe方法来接收发生在Observable序列中的任何错误,使用throwError方法来创建一个新的Observable来包含错误。清单 24-17 将两种方法都应用于数据源。

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 } 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(catchError((error: Response) =>
             throwError(`Network Error: ${error.statusText} (${error.status})`)));
    }

}

Listing 24-17.Transforming Errors in the rest.datasource.ts File in the src/app/model Folder

当出现错误时,传递给catchError方法的函数被调用,并接收描述结果的Response对象。throwError函数创建一个新的仅包含一个错误对象的可观察对象,在本例中,该对象用于生成一条错误消息,其中包含 HTTP 状态代码和响应中的状态文本。

如果保存更改,然后再次单击 Generate HTTP Error 按钮,错误消息仍会被写入浏览器的 JavaScript 控制台,但会更改为由catchError / throwError方法生成的格式。

EXCEPTION: Network Error: Not Found (404)

处理错误

错误已经被转换,但没有被处理,这就是为什么它们仍然在浏览器的 JavaScript 控制台中被报告为异常。有两种方法可以处理这些错误。第一个是为由HttpClient对象创建的Observable对象的subscribe方法提供一个错误处理函数。这是定位错误并为存储库提供重试操作或尝试以其他方式恢复的机会的有用方法。

第二种方法是替换内置的 Angular 错误处理功能,该功能响应应用中任何未处理的错误,并在默认情况下将它们写入控制台。正是这个特性写出了前面几节中显示的消息。

对于示例应用,我想用一个使用消息服务的错误处理程序来覆盖默认的错误处理程序。我在src/app/messages文件夹中创建了一个名为errorHandler.ts的文件,并用它来定义清单 24-18 中所示的类。

import { ErrorHandler, Injectable, NgZone } from "@angular/core";
import { MessageService } from "./message.service";
import { Message } from "./message.model";

@Injectable()
export class MessageErrorHandler implements ErrorHandler {

    constructor(private messageService: MessageService, private ngZone: NgZone) {
    }

    handleError(error) {
        let msg = error instanceof Error ? error.message : error.toString();
        this.ngZone.run(() => this.messageService
            .reportMessage(new Message(msg, true)), 0);
    }
}

Listing 24-18.The Contents of the errorHandler.ts File in the src/app/messages Folder

@angular/core模块中定义了ErrorHandler类,它通过一个handleError方法来响应错误。清单中显示的类用一个使用MessageService报告错误的实现替换了这个方法的默认实现。

构造函数接收一个ngZone对象,它是异步操作 Angular 支持的一部分,也是变化检测特性的一个重要部分。在这个清单中,ngZone对象的run方法用于报告一个错误消息,以便操作触发变更检测过程并向用户显示错误。

为了替换默认的ErrorHandler,我在消息模块中使用了一个类提供者,如清单 24-19 所示。

import { NgModule, ErrorHandler } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { MessageComponent } from "./message.component";
import { MessageService } from "./message.service";
import { MessageErrorHandler } from "./errorHandler";

@NgModule({
    imports: [BrowserModule],
    declarations: [MessageComponent],
    exports: [MessageComponent],
    providers: [MessageService,
        { provide: ErrorHandler, useClass: MessageErrorHandler }]
})
export class MessageModule { }

Listing 24-19.Configuring an Error Handler in the message.module.ts File in the src/app/messages Folder

错误处理功能使用MessageService向用户报告错误消息。一旦保存了这些更改,点击生成 HTTP 错误按钮会产生一个用户可以看到的错误,如图 24-4 所示。

img/421542_4_En_24_Fig4_HTML.jpg

图 24-4。

处理 HTTP 错误

摘要

在这一章中,我解释了如何在 Angular 应用中进行异步 HTTP 请求。我介绍了 RESTful web 服务和 Angular HttpClient类提供的可用于与它们交互的方法。我解释了浏览器如何限制对不同来源的请求,以及 Angular 如何支持 CORS 和 JSONP 在应用来源之外发出请求。在下一章,我将介绍 URL 路由特性,它允许导航复杂的应用。

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

Angular 路由功能允许应用通过响应浏览器 URL 的更改来更改显示给用户的组件和模板。这允许创建复杂的应用,用最少的代码以开放和灵活的方式修改它们所呈现的内容。为了支持这一特性,可以使用数据绑定和服务来更改浏览器的 URL,从而允许用户在应用中导航。

随着项目复杂性的增加,路由变得非常有用,因为它允许应用的结构与组件和指令分开定义,这意味着可以在路由配置中对结构进行更改,而不必应用于单个组件。

在这一章中,我演示了基本的路由系统是如何工作的,并将其应用到示例应用中。在第 26 和 27 章中,我解释了更高级的路由特性。表 25-1 将路由放在上下文中。

表 25-1。

将路由和导航放在上下文中

|

问题

|

回答

| | --- | --- | | 这是什么? | 路由使用浏览器的 URL 来管理向用户显示的内容。 | | 为什么有用? | 路由允许应用的结构与应用中的组件和模板分开。对应用结构的更改是在路由配置中进行的,而不是在单个组件和指令中进行的。 | | 如何使用? | 路由配置被定义为一组片段,用于匹配浏览器的 URL 并选择一个组件,该组件的模板显示为一个名为router-outlet的 HTML 元素的内容。 | | 有什么陷阱或限制吗? | 路由配置可能变得难以管理,尤其是当 URL 模式是在特定的基础上逐渐定义的时候。 | | 还有其他选择吗? | 您不必使用路由功能。您可以通过创建一个组件来获得类似的结果,该组件的视图使用ngIfngSwitch指令选择向用户显示的内容,尽管随着应用的规模和复杂性的增加,这种方法比使用路由更加困难。 |

表 25-2 总结了本章内容。

表 25-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 使用 URL 导航选择向用户显示的内容 | 使用 URL 路由 | 1–7 | | 使用 HTML 元素导航 | 应用routerLink属性 | 8–10 | | 响应路由变更 | 使用路由服务接收通知 | Eleven | | 在 URL 中包含信息 | 使用路由参数 | 12–18 | | 使用代码导航 | 使用Router服务 | Nineteen | | 接收路由活动的通知 | 处理路由事件 | 20–21 |

准备示例项目

本章使用在第二十二章中创建的 exampleApp 项目。为了准备本章的专题,需要做一些修改。该应用被配置为在两个位置显示从表组件发送到产品组件的状态更改事件:通过消息服务和在表单组件的模板中。不再需要这些消息,清单 25-1 从组件的模板中移除了事件显示。

Tip

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

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

Listing 25-1.Removing the Event Display in the form.component.html File in the src/app/core Folder

清单 25-2 禁用了将状态改变事件推入消息服务的代码。

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 { SharedState, SHARED_STATE } from "./sharedState.model";
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 { MODES } from "./sharedState.model";

@NgModule({
  imports: [BrowserModule, FormsModule, ModelModule, MessageModule],
  declarations: [TableComponent, FormComponent, StatePipe],
  exports: [ModelModule, TableComponent, FormComponent],
  providers: [{
    provide: SHARED_STATE,
    deps: [MessageService, Model],
    useFactory: (messageService, model) => {
      return new Subject<SharedState>();
      //let subject = new Subject<SharedState>();
      //subject.subscribe(m => messageService.reportMessage(
      //  new Message(MODES[m.mode] + (m.id != undefined
      //    ? ` ${model.getProduct(m.id).name}` : "")))
      //);
      //return subject;
    }
  }]
})
export class CoreModule { }

Listing 25-2.Disabling State Change Events 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以查看如图 25-1 所示的内容。

img/421542_4_En_25_Fig1_HTML.jpg

图 25-1。

运行示例应用

路由入门

目前,应用中的所有内容始终对用户可见。对于示例应用,这意味着表格和表单总是可见的,并且由用户来跟踪他们正在使用应用的哪一部分来完成手头的任务。

这对于一个简单的应用来说很好,但是对于一个复杂的项目来说就变得难以管理了,这个项目可能有很多功能区域,如果一次全部显示出来的话,这些功能区域会变得令人不知所措。

URL 路由使用 web 应用的一个自然且众所周知的方面:URL,向应用添加结构。在这一节中,我将通过将 URL 路由应用到示例应用来介绍它,这样表格或表单都是可见的,活动组件是根据用户的操作选择的。这将为解释路由如何工作提供一个良好的基础,并为更高级的功能奠定基础。

创建路由配置

应用路由的第一步是定义路由,这是 URL 和将显示给用户的组件之间的映射。路由配置通常在名为app.routing.ts的文件中定义,该文件在src/app文件夹中定义。我创建了这个文件,并添加了清单 25-3 中所示的语句。

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

const routes: Routes = [
    { path: "form/edit", component: FormComponent },
    { path: "form/create", component: FormComponent },
    { path: "", component: TableComponent }]

export const routing = RouterModule.forRoot(routes);

Listing 25-3.The Contents of the app.routing.ts File in the src/app Folder

Routes类定义了一个路由集合,每个路由告诉 Angular 如何处理一个特定的 URL。这个例子使用了最基本的属性,其中,path指定了 URL,component属性指定了将向用户显示的组件。

path属性是相对于应用的其余部分指定的,这意味着清单 25-3 中的配置设置了表 25-3 中所示的路由。

表 25-3。

示例中创建的路由

|

统一资源定位器

|

显示的组件

| | --- | --- | | http://localhost:4200/form/edit | FormComponent | | http://localhost:4200/form/create | FormComponent | | http://localhost:4200/ | TableComponent |

使用RouterModule.forRoot方法将路由打包成一个模块。forRoot方法产生一个包含路由服务的模块。还有一种不包含服务的forChild方法,这将在第二十六章中演示,在那里我将解释如何为特性模块创建路径。

虽然在定义路由时最常用的是pathcomponent属性,但是还有一系列附加属性可用于定义具有高级功能的路由。这些属性在表 25-4 中有描述,以及描述它们的详细位置。

表 25-4。

用于定义路由的路由属性

|

名字

|

描述

| | --- | --- | | path | 此属性指定路由的路径。 | | component | 该属性指定当活动 URL 与path匹配时将被选择的组件。 | | pathMatch | 这个属性告诉 Angular 如何将当前 URL 匹配到path属性。有两个允许的值:full,它要求path值完全匹配 URL,和prefix,它允许path值匹配 URL,即使 URL 包含不属于path值的附加段。使用redirectTo属性时,该属性是必需的,如第二十六章所示。 | | redirectTo | 此属性用于创建一个路由,该路由在激活时将浏览器重定向到不同的 URL。详见第二十六章。 | | children | 该属性用于指定子路由,该子路由在活动组件模板中包含的嵌套router-outlet元素中显示附加组件,如第二十六章所示。 | | outlet | 该属性用于支持多个出口元素,如第二十七章所述。 | | resolve | 该属性用于定义在激活路由之前必须完成的工作,如第二十七章所述。 | | canActivate | 如第二十七章所述,该属性用于控制何时激活一条路由。 | | canActivateChild | 如第二十七章所述,该属性用于控制何时可以激活子路由。 | | canDeactivate | 如第二十七章所述,该属性用于控制何时可以停用一条路由,以便激活一条新路由。 | | loadChildren | 该属性用于配置仅在需要时加载的模块,如第二十七章所述。 | | canLoad | 此属性用于控制何时可以加载按需模块。 |

Understanding Route Ordering

定义路由的顺序非常重要。Angular 依次将浏览器导航到的 URL 与每条路由的 path 属性进行比较,直到找到匹配项。这意味着应该首先定义最具体的路由,随后的路由的具体性逐渐降低。这对于清单 25-3 中的路由来说没什么大不了的,但是当使用路由参数(在本章的“使用路由参数”一节中描述)或者添加子路由(在第二十六章中描述)时就变得很重要了。

如果您发现您的路由配置没有导致您期望的行为,那么定义路由的顺序是首先要检查的。

创建路由组件

使用路由时,根组件专用于管理应用不同部分之间的导航。这是创建时由ng new命令添加到项目中的app.component.ts文件的典型用途,在清单 25-4 中,我已经为这种用途更新了它的内容。

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

@Component({
    selector: "app",
    templateUrl: "./app.component.html"
})
export class AppComponent { }

Listing 25-4.Replacing the Contents of the app.component.ts File in the src/app Folder

该组件是其模板的载体,该模板是src/app文件夹中的app.component.html文件。在清单 25-5 中,我已经替换了默认内容。

<paMessages></paMessages>
<router-outlet></router-outlet>

Listing 25-5.Replacing the Contents of the app.component.html File in the src/app File

元素显示应用中的任何消息和错误。出于路由的目的,重要的是router-outlet元素——称为出口——因为它告诉 Angular 这是路由配置匹配的组件应该显示的位置。

更新根模块

下一步是更新根模块,以便新的根组件用于引导应用,如清单 25-6 所示,它还导入包含路由配置的模块。

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";

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

Listing 25-6.Enabling Routing in the app.module.ts File in the src/app Folder

完成配置

最后一步是更新index.html文件,如清单 25-7 所示。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>ExampleApp</title>
  <base href="/">

  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>
<body class="m-2">
  <app></app>
</body>
</html>

Listing 25-7.Configuring Routing in the index.html File in the src Folder

app元素应用新的根组件,其模板包含router-outlet元素。当你保存更改并且浏览器重新加载应用时,你将只看到产品表,如图 25-2 所示。应用的默认 URL 对应于显示产品表的路由。

img/421542_4_En_25_Fig2_HTML.jpg

图 25-2。

使用路由向用户显示组件

Tip

对于本例,您可能需要停止 Angular 开发工具,并使用ng serve命令再次启动它们。

添加导航链接

基本的路由配置已经就绪,但是无法在应用中导航:当您单击 Create New Product 或 Edit 按钮时,什么都不会发生。

下一步是将链接添加到应用中,这将改变浏览器的 URL,并在这样做时,触发路由更改,从而向用户显示不同的组件。清单 25-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" (click)="editProduct(item.id)"
                    routerLink="/form/edit">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

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

routerLink属性应用来自执行导航改变的路由包的指令。该指令可以应用于任何元素,尽管它通常应用于button和锚(a)元素。应用于编辑按钮的routerLink指令的表达式告诉 Angular 瞄准/form/edit路由。

...
<button class="btn btn-warning btn-sm" (click)="editProduct(item.id)"
        routerLink="/form/edit">
    Edit
</button>
...

应用于 Create New Product 按钮的相同指令告诉 Angular 以/create路由为目标。

...
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
...

添加到表组件模板的路由链接将允许用户导航到表单。清单 25-9 中所示的表单组件模板的添加将允许用户使用取消按钮再次返回。

<div class="bg-primary text-white p-2" [class.bg-warning]="editing">
    <h5>{{editing  ? "Edit" : "Create"}} Product</h5>
    <!-- Last Event: {{ stateEvents | async | formatState }} -->
</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 25-9.Adding a Navigation Link in the form.component.html File in the src/app/core Folder

分配给routerLink属性的值以显示产品表的路由为目标。清单 25-10 更新了包含模板的特征模块,以便它导入RouterModule,这是包含选择routerLink属性的指令的 Angular 模块。

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 { SharedState, SHARED_STATE } from "./sharedState.model";
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 { MODES } from "./sharedState.model";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe],
    exports: [ModelModule, TableComponent, FormComponent],
    providers: [{
        provide: SHARED_STATE,
        deps: [MessageService, Model],
        useFactory: (messageService, model) => {
            return new Subject<SharedState>();
        }
    }]
})
export class CoreModule { }

Listing 25-10.Enabling the Routing Directive in the core.module.ts File in the src/app/core Folder

了解路由的影响

重启 Angular 开发工具,您将能够使用编辑、创建新产品和取消按钮在应用中导航,如图 25-3 所示。

img/421542_4_En_25_Fig3_HTML.jpg

图 25-3。

使用路由在应用中导航

并非应用中的所有功能都可以工作,但是这是探索将路由添加到应用中的效果的好时机。输入应用的根 URL(http://localhost:4200),然后单击“创建新产品”按钮。当您单击按钮时,Angular routing 系统将浏览器显示的 URL 更改为:

http://localhost:4200/form/create

如果您在转换过程中观察开发 HTTP 服务器的输出,您会注意到服务器没有接收到新内容的请求。这种改变完全是在 Angular 应用中完成的,不会产生任何新的 HTTP 请求。

新的 URL 由 Angular 路由系统处理,该系统能够将新的 URL 与来自app.routing.ts文件的该路由进行匹配。

...
{ path: "form/create", component: FormComponent },
...

当路由系统将 URL 与路由匹配时,它会考虑到index.html文件中的base元素。base元素配置有/href值,当 URL 为/form/create时,该值与路由中的path相结合以进行匹配。

component属性告诉 Angular 路由系统应该向用户显示FormComponent。创建了一个FormComponent类的新实例,它的模板内容被用作根组件模板中router-outlet元素的内容。

如果您单击表单下方的 Cancel 按钮,则会重复该过程,但这一次,浏览器会返回到应用的根 URL,该 URL 与路径组件为空字符串的路由相匹配。

{ path: "", component: TableComponent }

这条路由告诉 Angular 向用户显示TableComponent。创建了一个TableComponent类的新实例,它的模板被用作router-outlet元素的内容,向用户显示模型数据。

这就是路由的本质:浏览器的 URL 发生变化,导致路由系统查询其配置,以确定应该向用户显示哪个组件。有很多选项和特性是可用的,但这是路由的核心目的,如果你记住这一点,你就不会犯太大的错误。

The Perils of Changing the URL Manually

routerLink指令使用 JavaScript API 设置 URL,告诉浏览器这是相对于当前文档的更改,而不是需要向服务器发出 HTTP 请求的更改。

如果您在浏览器窗口中输入一个与路由系统匹配的 URL,您将看到一个看起来像预期的变化,但实际上完全是另外一回事的效果。在浏览器中手动输入以下 URL 时,请注意开发 HTTP 服务器的输出:

http://localhost:4200/form/create

浏览器不是在 Angular 应用中处理更改,而是向服务器发送 HTTP 请求,服务器重新加载应用。一旦应用被加载,路由系统检查浏览器的 URL,匹配配置中的一个路由,然后显示FormComponent

这样做的原因是,对于与磁盘上的文件不对应的 URL,开发 HTTP 服务器将返回index.html文件的内容。例如,请求以下 URL:

http://localhost:4200/this/does/not/exist

浏览器将显示一个错误,因为请求已经向浏览器提供了index.html文件的内容,浏览器已经使用该文件加载并启动示例 Angular 应用。当路由系统检查 URL 时,它找不到匹配的路由并产生一个错误。

有两点需要注意。首先,当您测试应用的路由配置时,您应该检查浏览器发出的 HTTP 请求,因为您有时会因为错误的原因看到正确的结果。在速度快的机器上,你可能甚至没有意识到应用已经被浏览器重新加载和重启了。

其次,您必须记住,必须使用routerLink指令(或路由器模块提供的类似功能之一)来更改 URL,而不是使用浏览器的 URL 栏手动更改。

最后,由于用户不知道编程和手动 URL 更改之间的区别,你的路由配置应该能够处理不对应于路由的 URL,如第二十六章所述。

完成路由实施

将路由添加到应用中是一个好的开始,但是应用的许多功能都不起作用。例如,单击编辑按钮会显示表单,但它不会被填充,也不会显示表示编辑的颜色提示。在接下来的小节中,我使用路由系统提供的特性来完成应用的连接,这样一切都可以按预期工作。

处理组件中的工艺路由变更

表单组件工作不正常,因为它没有收到用户单击按钮编辑产品的通知。出现这个问题是因为路由系统仅在需要时才创建组件类的新实例,这意味着仅在单击编辑按钮后才创建FormComponent对象。如果单击表单下的 Cancel 按钮,然后再次单击该表中的 Edit 按钮,将会创建第二个FormComponent实例。

这导致了 product 组件和 table 组件通过一个反应式扩展Subject进行通信的时间问题。一个Subject只将事件传递给在subscribe方法被调用后到达的订阅者。路由的引入意味着FormComponent对象是在描述编辑操作的事件被发送后创建的。

这个问题可以通过用一个BehaviorSubject替换Subject来解决,当订阅者调用 subscribe 方法时,它会将最近的事件发送给订阅者。但是更好的方法——特别是因为这是关于路由系统的一章——是使用 URL 在组件之间进行协作。

Angular 提供了一种服务,组件可以接收该服务以获得当前路由的详细信息。服务和它提供访问的类型之间的关系初看起来可能很复杂,但是当您看到示例是如何展开的以及路由的一些不同使用方式时,它就有意义了。

组件声明依赖关系的类称为ActivatedRoute。在本节中,它定义了一个重要的属性,如表 25-5 所述。还有一些其他的属性,将在本章的后面描述,但是你可以暂时忽略它们。

表 25-5。

ActivatedRoute 属性

|

名字

|

描述

| | --- | --- | | snapshot | 该属性返回一个描述当前路由的ActivatedRouteSnapshot对象。 |

snapshot属性返回ActivatedRouteSnapshot类的一个实例,该实例使用表 25-6 中描述的属性,提供有关导致当前组件显示给用户的路径的信息。

表 25-6。

基本 ActivatedRouteSnapshot 属性

|

名字

|

描述

| | --- | --- | | url | 该属性返回一个由UrlSegment对象组成的数组,每个对象描述了 URL 中与当前路径匹配的一段。 | | params | 这个属性返回一个Params对象,它描述了 URL 参数,通过名称进行索引。 | | queryParams | 这个属性返回一个Params对象,它描述了 URL 查询参数,按名称进行索引。 | | fragment | 该属性返回一个包含 URL 片段的string。 |

对于这个例子来说,url属性是最重要的,因为它允许组件检查当前 URL 的段,并从中提取执行操作所需的信息。url属性返回一组UrlSegment对象,这些对象提供了表 25-7 中描述的属性。

表 25-7。

URLSegment 属性

|

名字

|

描述

| | --- | --- | | path | 此属性返回包含段值的字符串。 | | parameters | 该属性返回参数的索引集合,如“使用路由参数”一节中所述。 |

为了确定用户激活了什么路由,表单组件可以声明对ActivatedRoute的依赖,然后使用它接收到的对象来检查 URL 的段,如清单 25-11 所示。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
//import { filter, map, distinctUntilChanged, skipWhile } from "rxjs/operators";
import { ActivatedRoute } from "@angular/router";

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

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        this.editing = activeRoute.snapshot.url[1].path == "edit";
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

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

Listing 25-11.Inspecting the Active Route in the form.component.ts File in the src/app/core Folder

该组件不再使用反应式扩展来接收事件。相反,它检查活动路径的 URL 的第二段,以设置editing属性的值,该值决定它是否应该显示其创建或编辑模式。如果你点击表格中的编辑按钮,你将会看到正确的颜色显示,如图 25-4 所示。

img/421542_4_En_25_Fig4_HTML.jpg

图 25-4。

在元件中使用活动管线

使用路由参数

当我为应用设置路由配置时,我定义了两条针对表单组件的路由,如下所示:

...
{ path: "form/edit", component: FormComponent },
{ path: "form/create", component: FormComponent },
...

当 Angular 试图将一条路由与一个 URL 相匹配时,它会依次查看每一段,并检查它是否与正在导航的 URL 相匹配。这两个 URL 都由静态段组成,这意味着在 Angular 激活路由之前,它们必须与导航的 URL 完全匹配。

Angular 路由可以更加灵活,包括路由参数,它允许段的任何值与导航 URL 中的相应段相匹配。这意味着以具有相似 URL 的相同组件为目标的路由可以合并成一个单一的路由,如清单 25-12 所示。

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

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

export const routing = RouterModule.forRoot(routes);

Listing 25-12.Consolidating Routes in the app.routing.ts File in the src/app Folder

修改后的 URL 的第二段定义了一个路由参数,由冒号(:字符)后跟一个名称表示。在这种情况下,路由参数称为mode。该路由将匹配任何包含两段的 URL,其中第一段是form,如表 25-8 中所总结的。第二段的内容将被分配给一个名为mode的参数。

表 25-8。

与路由参数匹配的 URL

|

统一资源定位器

|

结果

| | --- | --- | | http://localhost:4200/form | 不匹配-线段太少 | | http://localhost:4200/form/create | 匹配,将create分配给mode参数 | | http://localhost:4200/form/london | 匹配,将london分配给mode参数 | | http://localhost:4200/product/edit | 不匹配—第一段不是form | | http://localhost:4200/form/edit/1 | 不匹配-分段太多 |

使用路由参数使得以编程方式处理路由变得更加简单,因为参数的值可以使用其名称来获得,如清单 25-13 所示。

import { Component, Inject } from "@angular/core";
import { NgForm } from "@angular/forms";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
import { Observable } from "rxjs";
//import { filter, map, distinctUntilChanged, skipWhile } from "rxjs/operators";
import { ActivatedRoute } from "@angular/router";

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

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        this.editing = activeRoute.snapshot.params["mode"] == "edit";
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

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

Listing 25-13.Reading a Route Parameter in the form.component.ts File in the src/app/core Folder

组件不需要知道 URL 的结构来获取它需要的信息。相反,它可以使用由ActivatedRouteSnapshot类提供的params属性来获取参数值的集合,按名称进行索引。该组件获取mode参数的值,并使用它来设置editing属性。

使用多个路由参数

为了告诉表单组件当用户单击编辑按钮时选择了哪个产品,我需要使用第二个路由参数。由于 Angular 根据 URL 所包含的段的数量来匹配 URL,这意味着我需要再次分割以表单组件为目标的路由,如清单 25-14 所示。这种合并然后扩展路由的循环是大多数开发项目的典型特征,因为您增加了路由 URL 中包含的信息量,从而为应用添加了功能。

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

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

export const routing = RouterModule.forRoot(routes);

Listing 25-14.Adding a Route in the app.routing.ts File in the src/app Folder

新路径将匹配任何包含三段的 URL,其中第一段是form。为了创建以这条路由为目标的 URL,我需要对模板中的routerLink表达式使用不同的方法,因为我需要为产品表中的每个编辑按钮动态生成第三段,如清单 25-15 所示。

<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" (click)="editProduct(item.id)"
                    [routerLink]="['/form', 'edit', item.id]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 25-15.Generating Dynamic URLs in the table.component.html File in the src/app/core Folder

routerLink属性现在用方括号括起来,告诉 Angular 它应该将属性值视为数据绑定表达式。表达式被设置为一个数组,每个元素包含一个段的值。前两段是文字字符串,将被包含在目标 URL 中,无需修改。第三段将被求值,以包含由ngIf指令处理的当前Product对象的id属性值,就像模板中的其他表达式一样。routerLink指令将组合各个片段来创建一个 URL,如/form/edit/2

清单 25-16 显示了表单组件如何获得新的路由参数值,并使用它来选择要编辑的产品。

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 } from "@angular/router";

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

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        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.product = new Product();
            form.reset();
        }
    }

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

Listing 25-16.Using the New Route Parameter in the form.component.ts File in the src/app/core Folder

当用户点击编辑按钮时,被激活的路由 URL 告诉表单组件需要一个编辑操作,并指定要修改的产品,从而允许表单被正确填充,如图 25-5 所示。

img/421542_4_En_25_Fig5_HTML.jpg

图 25-5。

使用 URL 段提供信息

Tip

注意,我检查以确认我已经能够从清单 25-16 中的数据模型中检索到一个Product对象,如果不是这样,就创建一个新对象。这一点很重要,因为模型中的数据是异步获取的,如果用户直接请求 URL,在显示表单组件时可能还没有到达。这也可能是开发中的一个问题,应用中代码的更改会触发重新编译,然后重新加载您在进行更改之前导航到的任何 URL。结果是一个错误,因为 Angular 试图直接导航到一个您预期在填充数据模型之前不需要的路径。在第二十七章中,我解释了如何阻止路由被激活,直到一个特定的条件成立,比如数据到达。

使用可选的路由参数

可选的 route 参数允许 URL 包含为应用的其余部分提供提示或指导的信息,但这对于应用的工作来说不是必需的。

这种类型的路由参数使用 URL 矩阵符号表示,这不是 URL 规范的一部分,但浏览器仍然支持。以下是一个具有可选路由参数的 URL 示例:

http://localhost:4200/form/edit/2;name=Lifejacket;price=48.95

可选的路由参数由分号(;字符)分隔,这个 URL 包括名为nameprice的可选参数。

作为如何使用可选参数的演示,清单 25-17 显示了添加一个可选的路由参数,该参数包括要编辑的对象作为 URL 的一部分。这些信息并不重要,因为表单组件可以从模型中获取数据,但是通过路由 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" (click)="editProduct(item.id)"
                    [routerLink]="['/form', 'edit', item.id,
                    {name: item.name, category: item.category, price: item.price}]">
                Edit
            </button>
        </td>
    </tr>
</table>
<button class="btn btn-primary m-1" (click)="createProduct()"
        routerLink="/form/create">
    Create New Product
</button>
<button class="btn btn-danger" (click)="deleteProduct(-1)">
    Generate HTTP Error
</button>

Listing 25-17.An Optional Route Parameter in the table.component.html File in the src/app/core Folder

可选值表示为文字对象,其中属性名标识可选参数。在这个例子中,有namecategoryprice属性,它们的值使用由ngIf指令处理的对象来设置。可选参数将生成如下所示的 URL:

http://localhost:4200/form/edit/5;name=Stadium;category=Soccer;price=79500

清单 25-18 显示了表单组件如何检查可选参数是否存在。如果它们已经包含在 URL 中,那么参数值被用来避免对数据模型的请求。

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 } from "@angular/router";

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

    constructor(private model: Model, activeRoute: ActivatedRoute) {
        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        let id = activeRoute.snapshot.params["id"];
        if (id != null) {
            let name = activeRoute.snapshot.params["name"];
            let category = activeRoute.snapshot.params["category"];
            let price = activeRoute.snapshot.params["price"];

            if (name != null && category != null && price != null) {
                this.product.id = id;
                this.product.name = name;
                this.product.category = category;
                this.product.price = Number.parseFloat(price);
            } else {
                Object.assign(this.product, model.getProduct(id) || new Product());
            }
        }
    }

    editing: boolean = false;

    submitForm(form: NgForm) {
        if (form.valid) {
            this.model.saveProduct(this.product);
            this.product = new Product();
            form.reset();
        }
    }

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

Listing 25-18.Receiving Optional Parameters in the form.component.ts File in the src/app/core Folder

可选路由参数的访问方式与必需参数相同,组件负责检查它们是否存在,如果它们不是 URL 的一部分,则继续处理。在这种情况下,如果 URL 不包含它所寻找的可选参数,组件就可以返回查询数据模型。

在代码中导航

使用routerLink属性可以很容易地在模板中设置导航,但是应用通常需要在组件或指令中代表用户启动导航。

为了让指令和组件等构建模块访问路由系统,Angular 提供了Router类,该类通过依赖注入作为服务提供,其最有用的方法和属性在表 25-9 中描述。

表 25-9。

选定的路由器方法和属性

|

名字

|

描述

| | --- | --- | | navigated | 如果至少有一个导航事件,这个boolean属性返回true,否则返回false。 | | url | 此属性返回活动 URL。 | | isActive(url, exact) | 如果指定的 URL 是由活动路由定义的 URL,则该方法返回trueexact参数指定了指定 URL 中的所有段是否都必须与当前 URL 匹配,以便该方法返回true。 | | events | 这个属性返回一个Observable<Event>,它可以用来监控导航的变化。有关详细信息,请参见“接收导航事件”部分。 | | navigateByUrl(url, extras) | 此方法导航到指定的 URL。该方法的结果是一个Promise,当导航成功时用true解决,当导航失败时用false解决,当有错误时被拒绝。 | | navigate(commands, extras) | 此方法使用线段数组导航。extras对象可用于指定 URL 的改变是否与当前路由相关。该方法的结果是一个Promise,当导航成功时用true解决,当导航失败时用false解决,当有错误时被拒绝。 |

navigatenavigateByUrl方法使得在构建块(比如组件)内部执行导航变得容易。清单 25-19 展示了在创建或更新产品后,使用表单组件中的Router将应用重定向回表格。

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(private model: Model, activeRoute: ActivatedRoute,
            private router: Router) {

        this.editing = activeRoute.snapshot.params["mode"] == "edit";
        let id = activeRoute.snapshot.params["id"];
        if (id != null) {
            let name = activeRoute.snapshot.params["name"];
            let category = activeRoute.snapshot.params["category"];
            let price = activeRoute.snapshot.params["price"];

            if (name != null && category != null && price != null) {
                this.product.id = id;
                this.product.name = name;
                this.product.category = category;
                this.product.price = Number.parseFloat(price);
            } else {
                Object.assign(this.product, model.getProduct(id) || new Product());
            }
        }
    }

    editing: boolean = false;

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

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

Listing 25-19.Navigating Programmatically in the form.component.ts File in the src/app/core Folder

组件接收Router对象作为构造函数参数,并在submitForm方法中使用它导航回应用的根 URL。在submitForm方法中被注释掉的两条语句不再需要,因为一旦表单组件不再显示,路由系统就会销毁它,这意味着不需要重置表单的状态。

结果是单击表单中的保存或创建按钮将导致应用显示产品表,如图 25-6 所示。

img/421542_4_En_25_Fig6_HTML.jpg

图 25-6。

以编程方式导航

接收导航事件

在许多应用中,有些组件或指令不直接参与应用的导航,但仍然需要知道导航何时发生。示例应用在消息组件中包含一个示例,它向用户显示通知和错误。该组件总是显示最新的消息,即使该信息已经过时并且对用户没有帮助。要查看问题,请单击“生成 HTTP 错误”按钮,然后单击“创建新产品”按钮或其中一个编辑按钮。即使您在应用中导航到其他位置,错误消息仍会显示出来。

Router类定义的events属性返回一个Observable<Event>,它发出一系列Event对象来描述路由系统的变化。通过观察器提供五种类型的事件,如表 25-10 所述。

表 25-10。

路由器事件观察器提供的事件类型

|

名字

|

描述

| | --- | --- | | NavigationStart | 导航过程开始时发送此事件。 | | RoutesRecognized | 当路由系统将 URL 与路由匹配时,会发送此事件。 | | NavigationEnd | 导航过程成功完成时会发送此事件。 | | NavigationError | 当导航过程产生错误时,会发送此事件。 | | NavigationCancel | 导航过程取消时会发送此事件。 |

所有事件类都定义了一个id属性和一个url属性,前者返回每次导航增加的数字,后者返回目标 URL。RoutesRecognizedNavigationEnd事件还定义了一个urlAfterRedirects属性,该属性返回已经导航到的 URL。

为了解决消息传递系统的问题,清单 25-20 订阅由Router.events属性提供的Observer,并在收到NavigationEndNavigationCancel事件时清除显示给用户的消息。

import { Component } from "@angular/core";
import { MessageService } from "./message.service";
import { Message } from "./message.model";
import { Observable } from "rxjs";
import { Router, NavigationEnd, NavigationCancel } from "@angular/router";
import { filter } from "rxjs/operators";

@Component({
    selector: "paMessages",
    templateUrl: "message.component.html",
})
export class MessageComponent {
    lastMessage: Message;

    constructor(messageService: MessageService, router: Router) {
        messageService.messages.subscribe(m => this.lastMessage = m);
        router.events
            .pipe(filter(e => e instanceof NavigationEnd
                || e instanceof NavigationCancel))
            .subscribe(e => { this.lastMessage = null; });
    }
}

Listing 25-20.Responding to Events in the message.component.ts File in the src/app/messages Folder

filter方法用于从Observer中选择一种类型的事件,subscribe方法更新lastMessage属性,这将清除组件显示的消息。清单 25-21 将路由功能导入消息模块。(因为根模块已经导入了路由特性,所以这并不是应用工作所必需的,但是让每个模块导入它所需要的所有特性是一个很好的实践。)

import { NgModule, ErrorHandler } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { MessageComponent } from "./message.component";
import { MessageService } from "./message.service";
import { MessageErrorHandler } from "./errorHandler";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, RouterModule],
    declarations: [MessageComponent],
    exports: [MessageComponent],
    providers: [MessageService,
        { provide: ErrorHandler, useClass: MessageErrorHandler }]
})
export class MessageModule { }

Listing 25-21.Importing the Routing Module in the message.module.ts File in the src/app/messages Folder

这些变化的结果是,直到下一次导航事件,消息才会显示给用户,如图 25-7 所示。

img/421542_4_En_25_Fig7_HTML.jpg

图 25-7。

响应导航事件

移除事件绑定和支持代码

使用路由系统的一个好处是,它可以简化应用,用导航更改替换事件绑定和它们调用的方法。完成路由实现的最后一项更改是删除以前用于组件间协调的机制的最后痕迹。清单 25-22 注释掉了表格组件模板中的事件绑定,这些事件绑定用于在用户单击 Create New Product 或 Edit 按钮时做出响应。(删除按钮的事件绑定仍然是必需的。)

<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,
                    {name: item.name, category: item.category, price: item.price}]">
                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 25-22.Removing Event Bindings in the table.component.html File in the src/app/core Folder

清单 25-23 显示了组件中相应的变化,这些变化删除了事件绑定调用的方法,并删除了对服务的依赖,该服务用于通知何时应该编辑或创建产品。

import { Component, Inject } from "@angular/core";
import { Product } from "../model/product.model";
import { Model } from "../model/repository.model";
//import { MODES, SharedState, SHARED_STATE } from "./sharedState.model";
//import { Observer } from "rxjs";

@Component({
    selector: "paTable",
    templateUrl: "table.component.html"
})
export class TableComponent {

    constructor(private model: Model,
        /*@Inject(SHARED_STATE) private observer: Observer<SharedState>*/) { }

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

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

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

    //editProduct(key: number) {
    //    this.observer.next(new SharedState(MODES.EDIT, key));
    //}

    //createProduct() {
    //    this.observer.next(new SharedState(MODES.CREATE));
    //}
}

Listing 25-23.Removing Event Handling Code in the table.component.ts File in the src/app/core Folder

不再需要组件用于协调的服务,清单 25-24 从核心模块中禁用它。

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 { SharedState, SHARED_STATE } from "./sharedState.model";
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 { MODES } from "./sharedState.model";
import { RouterModule } from "@angular/router";

@NgModule({
    imports: [BrowserModule, FormsModule, ModelModule, MessageModule, RouterModule],
    declarations: [TableComponent, FormComponent, StatePipe],
    exports: [ModelModule, TableComponent, FormComponent],
    //providers: [{
    //    provide: SHARED_STATE,
    //    deps: [MessageService, Model],
    //    useFactory: (messageService, model) => {
    //        return new Subject<SharedState>();
    //    }
    //}]
})
export class CoreModule { }

Listing 25-24.Removing the Shared State Service in the core.module.ts File in the src/app/core Folder

结果是表格和表单组件之间的协调完全通过路由系统来处理,路由系统现在负责显示组件并管理组件之间的导航。

摘要

在本章中,我介绍了 Angular 路由特性,并演示了如何在应用中导航到一个 URL 来选择显示给用户的内容。我向您展示了如何在模板中创建导航链接,如何在组件或指令中执行导航,以及如何以编程方式响应导航更改。在下一章,我将继续描述角路由系统。