Angular9-高级教程-四-

50 阅读1小时+

Angular9 高级教程(四)

原文:Pro Angular 9

协议:CC BY-NC-SA 4.0

十、SportsStore:先进的功能和部署

在这一章中,我通过添加允许 SportsStore 应用离线工作的渐进式功能来准备部署该应用,并向您展示如何准备和部署该应用到 Docker 容器中,该容器可以在大多数托管平台上使用。

准备示例应用

本章不需要准备,继续使用第九章中的 SportsStore 项目。

Tip

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

添加渐进式功能

渐进式 web 应用 (PWA)的行为更像本机应用,这意味着它可以在没有网络连接的情况下继续工作,其代码和内容被缓存,因此可以立即启动,并且可以使用通知等功能。渐进式 web 应用特性并不是 Angular 特有的,但是在接下来的章节中,我将渐进式特性添加到 SportsStore 应用中,向您展示这是如何实现的。

Tip

开发和测试 PWA 的过程可能很费力,因为只有在为生产而构建应用时才能完成,这意味着不能使用自动构建工具。

安装 PWA 包

Angular 团队提供了一个 NPM 包,可用于将 PWA 特性引入 Angular 项目。运行SportsStore文件夹中清单 10-1 所示的命令,下载并安装 PWA 包。

Tip

注意这个命令是ng add,而不是我在其他地方用来添加包的npm install命令。ng add命令专门用于安装软件包,如@angular/pwa,这些软件包被设计用于增强或重新配置 Angular 项目。

ng add @angular/pwa

Listing 10-1.Installing a Package

缓存数据 URL

@angular/pwa包配置应用,以便缓存 HTML、JavaScript 和 CSS 文件,这将允许应用在没有网络可用时启动。我还希望缓存产品目录,以便应用有数据呈现给用户。在清单 10-2 中,我在ngsw-config.json文件中添加了一个新的部分,用于为 Angular 应用配置 PWA 特性,并由@angular/pwa包添加到项目中。

{
  "index": "/index.html",
  "assetGroups": [{
    "name": "app",
    "installMode": "prefetch",
    "resources": {
      "files": [
        "/favicon.ico",
        "/index.html",
        "/*.css",
        "/*.js"
      ]
    }
  }, {
    "name": "assets",
    "installMode": "lazy",
    "updateMode": "prefetch",
    "resources": {
      "files": [
        "/assets/**",
        "/font/*"
      ]
    }
  }],
  "dataGroups": [
    {
        "name": "api-product",
        "urls": ["/api/products"],
        "cacheConfig" : {
            "maxSize": 100,
            "maxAge": "5d"
        }
    }],
    "navigationUrls": [
      "/**"
    ]
}

Listing 10-2.Caching the Data URLs in the ngsw-config.json File in the SportsStore Folder

当新版本可用时,运行应用所需的 PWA 代码和内容将被缓存和更新,确保使用配置文件的assetGroups部分中的配置,在更新可用时应用一致的更新。

使用配置文件的dataGroups部分缓存应用的数据,这允许使用自己的缓存设置管理数据。在这个清单中,我配置了缓存,使其包含来自 100 个请求的数据,并且这些数据在五天内有效。最后的配置部分是navigationUrls,它指定了将被定向到index.html文件的 URL 的范围。在这个例子中,我使用了通配符来匹配所有的 URL。

Note

我只是触及了您可以在 PWA 中使用的缓存功能的表面。有很多选择,包括尝试连接到网络,然后在没有连接的情况下返回到缓存数据。详见 https://angular.io/guide/service-worker-intro

响应连接变化

SportsStore 应用不是渐进式功能的理想候选,因为下单需要连接。当应用在没有连接的情况下运行时,为了避免用户混淆,我将禁用结帐过程。用于添加渐进式功能的 API 提供有关连接状态的信息,并在应用脱机和联机时发送事件。为了向应用提供其连接性的细节,我在src/app/model文件夹中添加了一个名为connection.service.ts的文件,并使用它来定义清单 10-3 中所示的服务。

import { Injectable } from "@angular/core";
import { Observable, Subject } from "rxjs";

@Injectable()
export class ConnectionService {
    private connEvents: Subject<boolean>;

    constructor() {
        this.connEvents = new Subject<boolean>();
        window.addEventListener("online",
            (e) => this.handleConnectionChange(e));
        window.addEventListener("offline",
            (e) => this.handleConnectionChange(e));
    }

    private handleConnectionChange(event) {
        this.connEvents.next(this.connected);
    }

    get connected() : boolean {
        return window.navigator.onLine;
    }

    get Changes(): Observable<boolean> {
        return this.connEvents;
    }
}

Listing 10-3.The Contents of the connection.service.ts File in the src/app/model Folder

该服务为应用的其余部分预设连接状态,通过浏览器的navigator.onLine属性获取状态,并响应onlineoffline事件,这些事件在连接状态改变时触发,并通过浏览器提供的addEventListener方法访问。在清单 10-4 中,我将新服务添加到数据模型的模块中。

import { NgModule } from "@angular/core";
import { ProductRepository } from "./product.repository";
import { StaticDataSource } from "./static.datasource";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { OrderRepository } from "./order.repository";
import { RestDataSource } from "./rest.datasource";
import { HttpClientModule } from "@angular/common/http";
import { AuthService } from "./auth.service";
import { ConnectionService } from "./connection.service";

@NgModule({
  imports: [HttpClientModule],
  providers: [ProductRepository, Cart, Order, OrderRepository,
    { provide: StaticDataSource, useClass: RestDataSource },
    RestDataSource, AuthService, ConnectionService]
})
export class ModelModule { }

Listing 10-4.Adding a Service in the model.module.ts File in the src/app/model Folder

为了防止用户在没有连接的情况下结账,我更新了 cart detail 组件,以便它在其构造函数中接收连接服务,如清单 10-5 所示。

import { Component } from "@angular/core";
import { Cart } from "../model/cart.model";
import { ConnectionService } from "../model/connection.service";

@Component({
    templateUrl: "cartDetail.component.html"
})
export class CartDetailComponent {
    public connected: boolean = true;

    constructor(public cart: Cart, private connection: ConnectionService) {
        this.connected = this.connection.connected;
        connection.Changes.subscribe((state) => this.connected = state);
    }
}

Listing 10-5.Receiving a Service in the cartDetail.component.ts File in the src/app/store Folder

该组件定义了一个connected属性,该属性由服务设置,然后在收到更改时更新。为了完成这个特性,我修改了 checkout 按钮,使其在没有连接时被禁用,如清单 10-6 所示。

...
<div class="row">
  <div class="col">
  <div class="text-center">
    <button class="btn btn-primary m-1" routerLink="/store">
        Continue Shopping
    </button>
    <button class="btn btn-secondary m-1" routerLink="/checkout"
            [disabled]="cart.lines.length == 0 || !connected">
      {{  connected ?  'Checkout' : 'Offline' }}
    </button>
  </div>
</div>
...

Listing 10-6.Reflecting Connectivity in the cartDetail.component.html File in the src/app/store Folder

为部署准备应用

在接下来的小节中,我准备了 SportsStore 应用,以便可以对其进行部署。

创建数据文件

当我在第八章中创建 RESTful web 服务时,我为json-server包提供了一个 JavaScript 文件,它在服务器每次启动时执行,并确保总是使用相同的数据。这对生产没有帮助,所以我在SportsStore文件夹中添加了一个名为serverdata.json的文件,其内容如清单 10-7 所示。当json-server包被配置为使用 JSON 文件时,应用所做的任何更改都将被持久化。

{
    "products": [
        { "id": 1, "name": "Kayak", "category": "Watersports",
            "description": "A boat for one person", "price": 275 },
        { "id": 2, "name": "Lifejacket", "category": "Watersports",
            "description": "Protective and fashionable", "price": 48.95 },
        { "id": 3, "name": "Soccer Ball", "category": "Soccer",
            "description": "FIFA-approved size and weight", "price": 19.50 },
        { "id": 4, "name": "Corner Flags", "category": "Soccer",
            "description": "Give your playing field a professional touch",
            "price": 34.95 },
        { "id": 5, "name": "Stadium", "category": "Soccer",
            "description": "Flat-packed 35,000-seat stadium", "price": 79500 },
        { "id": 6, "name": "Thinking Cap", "category": "Chess",
            "description": "Improve brain efficiency by 75%", "price": 16 },
        { "id": 7, "name": "Unsteady Chair", "category": "Chess",
            "description": "Secretly give your opponent a disadvantage",
            "price": 29.95 },
        { "id": 8, "name": "Human Chess Board", "category": "Chess",
            "description": "A fun game for the family", "price": 75 },
        { "id": 9, "name": "Bling Bling King", "category": "Chess",
            "description": "Gold-plated, diamond-studded King", "price": 1200 }
    ],
    "orders": []
}

Listing 10-7.The Contents of the serverdata.json File in the SportsStore Folder

创建服务器

部署应用时,我将使用单个 HTTP 端口来处理对应用及其数据的请求,而不是我在开发中使用的两个端口。使用单独的端口在开发中更简单,因为这意味着我可以使用 Angular development HTTP 服务器,而不必集成 RESTful web 服务。Angular 没有为部署提供 HTTP 服务器,因为我必须提供一个,所以我将对它进行配置,使它能够处理两种类型的请求,并包括对 HTTP 和 HTTPS 连接的支持,如侧栏中所述。

Using Secure Connections for Progressive Web Applications

当您向应用添加渐进式功能时,您必须部署它,以便可以通过安全的 HTTP 连接访问它。如果你不这样做,渐进的功能将无法工作,因为底层技术——称为服务工作器——将不会被浏览器允许通过常规的 HTTP 连接。

您可以使用 localhost 测试渐进的特性,正如我稍后演示的那样,但是在部署应用时需要 SSL/TLS 证书。如果您没有证书,那么一个好的起点是 https://letsencrypt.org ,您可以在那里免费获得一个证书,尽管您应该注意,您还需要拥有您打算部署以生成证书的域或主机名。出于本书的目的,我将 SportsStore 应用及其渐进式功能部署到了 sportsstore.adam-freeman.com ,这是一个我用于开发测试和接收电子邮件的域。这不是一个提供公共 HTTP 服务的域,您将无法通过该域访问 SportsStore 应用。

运行SportsStore文件夹中清单 10-8 中所示的命令,安装创建 HTTP/HTTPS 服务器所需的包。

npm install --save-dev express@4.17.1
npm install --save-dev connect-history-api-fallback@1.6.0
npm install --save-dev https@1.0.0

Listing 10-8.Installing Additional Packages

我向 SportsStore 添加了一个名为server.js的文件,其内容如清单 10-9 所示,它使用新添加的包来创建一个 HTTP 和 HTTPS 服务器,该服务器包含将提供 RESTful web 服务的json-server功能。(json-server软件包是专门为集成到其他应用中而设计的。)

const express = require("express");
const https = require("https");
const fs = require("fs");
const history = require("connect-history-api-fallback");
const jsonServer = require("json-server");
const bodyParser = require('body-parser');
const auth = require("./authMiddleware");
const router = jsonServer.router("serverdata.json");

const enableHttps = false;

const ssloptions = {}

if (enableHttps) {
    ssloptions.cert =  fs.readFileSync("./ssl/sportsstore.crt");
    ssloptions.key = fs.readFileSync("./ssl/sportsstore.pem");
}

const app = express();

app.use(bodyParser.json());
app.use(auth);
app.use("/api", router);
app.use(history());
app.use("/", express.static("./dist/SportsStore"));

app.listen(80,
    () => console.log("HTTP Server running on port 80"));

if (enableHttps) {
    https.createServer(ssloptions, app).listen(443,
        () => console.log("HTTPS Server running on port 443"));
} else {
    console.log("HTTPS disabled")
}

Listing 10-9.The Contents of the server.js File in the SportsStore Folder

服务器可以从ssl文件夹中的文件读取 SSL/TLS 证书的详细信息,这是您应该放置证书文件的位置。如果您没有证书,那么您可以通过将enableHttps的值设置为false来禁用 HTTPS。您仍然可以使用本地服务器测试应用,但是您将无法在部署中使用渐进式功能。

更改 Repository 类中的 Web 服务 URL

既然 RESTful 数据和应用的 JavaScript 和 HTML 内容将由同一个服务器交付,我需要更改应用用来获取数据的 URL,如清单 10-10 所示。

import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Observable } from "rxjs";
import { Product } from "./product.model";
import { Cart } from "./cart.model";
import { Order } from "./order.model";
import { map } from "rxjs/operators";
import { HttpHeaders } from '@angular/common/http';

const PROTOCOL = "http";
const PORT = 3500;

@Injectable()
export class RestDataSource {
    baseUrl: string;
    auth_token: string;

    constructor(private http: HttpClient) {
        //this.baseUrl = `${PROTOCOL}://${location.hostname}:${PORT}/`;
        this.baseUrl = "/api/"
    }

    // ...methods omitted for brevity...
}

Listing 10-10.Changing the URL in the rest.datasource.ts File in the src/app/model Folder

构建和测试应用

要构建用于生产的应用,运行清单SportsStore文件夹中的 10-11 所示的命令。

ng build --prod

Listing 10-11.Building the Application for Production

该命令构建了应用的优化版本,而没有支持开发工具的附加功能。构建过程的输出放在dist/SportsStore文件夹中。除了 JavaScript 文件之外,还有一个从SportsStore/src文件夹中复制的index.html文件,修改后可以使用新构建的文件。

Note

Angular 提供了对服务器端渲染的支持,其中应用运行在服务器中,而不是浏览器中。这种技术可以改善应用启动时间的感知,并可以改善搜索引擎的索引。这是一个应该谨慎使用的功能,因为它有严重的局限性,会破坏用户体验。由于这些原因,我没有在本书中讨论服务器端渲染。您可以在 https://angular.io/guide/universal 了解更多关于此功能的信息。

构建过程可能需要几分钟才能完成。一旦构建就绪,运行清单 10-12 中的命令来启动 HTTP 服务器。如果您没有将服务器配置为使用有效的 SSL/TLS 证书,那么您应该在server.js文件中更改enableHttps常量的值,然后运行清单 10-12 中的命令。

node server.js

Listing 10-12.Starting the Production HTTP Server

一旦服务器启动,打开一个新的浏览器窗口并导航到http://localhost,您将看到如图 10-1 所示的熟悉内容。

img/421542_4_En_10_Fig1_HTML.jpg

图 10-1。

测试应用

测试渐进式功能

打开 F12 开发工具,导航到网络选项卡,点击在线右侧的箭头,选择离线,如图 10-2 所示。这模拟了一个没有连接的设备,但由于 SportsStore 是一个渐进式 web 应用,它已经被浏览器缓存了,连同它的数据。

img/421542_4_En_10_Fig2_HTML.jpg

图 10-2。

离线

一旦应用离线,单击浏览器的重新加载按钮,应用将从浏览器的缓存中加载。如果您单击“添加到购物车”按钮,您将看到“结帐”按钮被禁用,如图 10-3 所示。取消选中离线复选框,按钮的文本会改变,这样用户就可以下订单了。

img/421542_4_En_10_Fig3_HTML.jpg

图 10-3。

反映应用中的连接状态

容器化 SportsStore 应用

为了完成本章,我将为 SportsStore 应用创建一个容器,以便将其部署到生产中。在撰写本文时,Docker 是创建容器最流行的方式,它是 Linux 的精简版,功能仅够运行应用。大多数云平台或托管引擎都支持 Docker,其工具运行在最流行的操作系统上。

安装 Docker

第一步是在你的开发机器上下载并安装 Docker 工具,可以从 www.docker.com/products 获得。有适用于 macOS、Windows 和 Linux 的版本,也有一些适用于 Amazon 和 Microsoft 云平台的专门版本。Docker 桌面的免费社区版对于本章来说已经足够了。

准备应用

第一步是为 NPM 创建一个配置文件,该文件将用于下载应用在容器中使用所需的附加包。我在SportsStore文件夹中创建了一个名为deploy-package.json的文件,内容如清单 10-13 所示。

{
  "dependencies": {
      "@fortawesome/fontawesome-free": "5.12.1",
      "bootstrap": "4.4.1"
  },

  "devDependencies": {
    "json-server": "0.16.0",
    "jsonwebtoken": "8.5.1",
    "express": "4.17.1",
    "https": "1.0.0",
    "connect-history-api-fallback": "1.6.0"
  },

  "scripts": {
    "start":  "node server.js"
  }
}

Listing 10-13.The Contents of the deploy-package.json File in the SportsStore Folder

dependencies部分省略了 Angular 和所有其他运行时包,这些包是在项目创建时添加到package.json文件中的,因为构建过程将应用所需的所有 JavaScript 代码合并到了dist/SportsStore文件夹中的文件中。devDependencies部分包括生产 HTTP/HTTPS 服务器所需的工具。

设置了deploy-package.json文件的scripts部分,这样npm start命令将启动生产服务器,该服务器将提供对应用及其数据的访问。

创建 Docker 容器

为了定义容器,我在SportsStore文件夹中添加了一个名为Dockerfile(没有扩展名)的文件,并添加了清单 10-14 中所示的内容。

FROM node:12.15.0

RUN mkdir -p /usr/src/sportsstore

COPY dist/SportsStore /usr/src/sportsstore/dist/SportsStore
COPY ssl /usr/src/sportsstore/ssl

COPY authMiddleware.js /usr/src/sportsstore/
COPY serverdata.json /usr/src/sportsstore/
COPY server.js /usr/src/sportsstore/server.js
COPY deploy-package.json /usr/src/sportsstore/package.json

WORKDIR /usr/src/sportsstore

RUN npm install

EXPOSE 80

CMD ["node", "server.js"]

Listing 10-14.The Contents of the Dockerfile File in the SportsStore Folder

Dockerfile的内容使用用Node.js配置的基本映像,并复制运行应用所需的文件,包括包含应用的包文件和将用于安装在部署中运行应用所需的包的package.json文件。

为了加快容器化过程,我在SportsStore文件夹中创建了一个名为.dockerignore的文件,其内容如清单 10-15 所示。这告诉 Docker 忽略node_modules文件夹,这在容器中是不需要的,并且需要很长的处理时间。

node_modules

Listing 10-15.The Contents of the .dockerignore File in the SportsStore Folder

SportsStore文件夹中运行清单 10-16 中所示的命令,创建一个包含 SportsStore 应用以及它所需的所有工具和软件包的映像。

Tip

SportsStore 项目必须包含一个ssl目录,即使您尚未安装证书。这是因为当在Dockerfile中使用COPY命令时,无法检查文件是否存在。

docker build . -t sportsstore  -f  Dockerfile

Listing 10-16.Building the Docker Image

图像是容器的模板。当 Docker 处理 Docker 文件中的指令时,将下载并安装 NPM 包,并将配置和代码文件复制到映像中。

运行应用

一旦创建了映像,使用清单 10-17 中所示的命令创建并启动一个新的容器。

Tip

确保在启动 Docker 容器之前停止清单 10-12 中启动的测试服务器,因为两者都使用相同的端口来监听请求。

docker run -p 80:80 -p 443:443 sportsstore

Listing 10-17.Starting the Docker Container

您可以通过在浏览器中打开http://localhost来测试应用,这将显示运行在容器中的 web 服务器提供的响应,如图 10-4 所示。

img/421542_4_En_10_Fig4_HTML.jpg

图 10-4。

运行容器化 SportsStore 应用

要停止容器,运行清单 10-18 中所示的命令。

docker ps

Listing 10-18.Listing the Containers

您将看到一个正在运行的容器列表,如下所示(为简洁起见,我省略了一些字段):

CONTAINER ID        IMAGE               COMMAND                 CREATED
ecc84f7245d6        sportsstore         "docker-entrypoint.s…"  33 seconds ago

使用容器 ID 列中的值,运行清单 10-19 中所示的命令。

docker stop ecc84f7245d6

Listing 10-19.Stopping the Container

该应用已准备好部署到任何支持 Docker 的平台上,尽管只有在为应用部署到的域配置了 SSL/TLS 证书的情况下,渐进式功能才会起作用。

摘要

本章完成了 SportsStore 应用,展示了如何为部署准备 Angular 应用,以及将 Angular 应用放入 Docker 之类的容器有多容易。这部分书到此结束。在第二部分中,我开始深入研究细节,并向您展示我用来创建 SportsStore 应用的特性是如何深入工作的。

十一、了解 Angular 项目和工具

在这一章中,我解释了一个 Angular 项目的结构和用于开发的工具。在本章结束时,您将了解项目的各个部分是如何组合在一起的,并有一个应用后续章节中描述的更高级功能的基础。

Tip

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

创建新的 Angular 项目

您在第二章中安装的angular-cli包包含了创建一个新的 Angular 项目所需的所有功能,该项目包含一些占位符内容以启动开发,它还包含一组紧密集成的工具,用于构建、测试和准备 Angular 应用以进行部署。

要创建一个新的 Angular 项目,打开一个命令提示符,导航到一个方便的位置,并运行清单 11-1 中所示的命令。

ng new example --routing false --style css --skip-git --skip-tests

Listing 11-1.Creating a Project

ng new命令创建新项目,参数是项目名称,在本例中是exampleng new命令有一组参数,这些参数决定了所创建项目的形状;表 11-1 描述了最有用的。

表 11-1。

有用的 ng 新选项

|

争吵

|

描述

| | --- | --- | | --collection | 此选项指定 schematics 集合。Schematics 是用于创建项目和条目的模板,但不在本书中使用。 | | --directory | 此选项用于指定项目目录的名称。它默认为项目名称。 | | --dry-run | 此选项用于模拟项目创建过程,而不执行它。 | | --force | 如果为真,此选项将覆盖任何现有文件。 | | --inline-style | 此选项指定将使用组件中定义的样式而不是单独的 CSS 文件中定义的样式来配置项目。 | | --inline-template | 此选项指定将使用组件中定义的模板而不是单独的 HTML 文件中定义的模板来配置项目。 | | --minimal | 该选项创建一个项目,而不添加对测试框架的支持。 | | --package-manager | 该选项用于指定软件包管理器,该管理器将用于下载和安装 Angular 所需的软件包。如果省略,将使用 NPM。其他选项有yarnpnpmcnpm。默认的包管理器适用于大多数项目。 | | --prefix | 该选项将前缀应用于所有组件选择器,如“了解 Angular 应用如何工作”一节所述。 | | --routing | 此选项用于在项目中创建路由模块。我会在第 25–27 章中详细解释路由功能的工作原理。 | | --skip-git | 使用该选项可以防止在项目中创建 Git 存储库。如果创建项目时没有使用该选项,则必须安装 Git 工具。 | | --skip-install | 此选项阻止下载和安装 Angular 应用和项目开发工具所需的包的初始操作。 | | --skip-tests | 此选项阻止添加测试工具的初始配置。 | | --style | 此选项指定如何处理样式表。在本书中,我使用了css选项,但是也支持流行的 CSS 预处理程序,比如 SCSS、萨斯等等。 |

ng new命令执行的项目初始化过程可能需要一些时间来完成,因为项目需要大量的包,既要运行 Angular 应用,也要运行我在本章中描述的开发和测试工具。

了解项目结构

使用您喜欢的代码编辑器打开example文件夹,您将看到如图 11-1 所示的文件和文件夹结构。该图显示了 Visual Studio Code 表示项目的方式;其他编辑可能会以不同的方式呈现项目内容。

img/421542_4_En_11_Fig1_HTML.jpg

图 11-1。

新 Angular 项目的内容

表 11-2 描述了通过ng new命令添加到新项目中的文件和文件夹,这些文件和文件夹为大多数 Angular 开发提供了起点。

表 11-2。

新 Angular 项目中的文件和文件夹

|

名字

|

描述

| | --- | --- | | e2e | 该文件夹包含用于端到端测试的文件,该文件被设置为使用量角器包。我在本书中不描述端到端测试,因为它需要额外的基础设施,但您可以在 www.protractortest.org 了解更多信息。 | | node_modules | 该文件夹包含应用和 Angular 开发工具所需的 NPM 软件包,如“了解软件包文件夹”一节所述。 | | src | 该文件夹包含应用的源代码、资源和配置文件,如“了解源代码文件夹”一节中所述。 | | .editorconfig | 该文件包含配置文本编辑器的设置。并非所有的编辑器都响应此文件,但它可能会覆盖您定义的首选项。您可以在 http://editorconfig.org 了解更多关于可以在该文件中设置的编辑器设置。 | | .gitignore | 该文件包含使用 Git 时从版本控制中排除的文件和文件夹的列表。 | | angular.json | 该文件包含 Angular 开发工具的配置。 | | package.json | 该文件包含应用和开发工具所需的 NPM 包的详细信息,并定义了运行开发工具的命令,如“了解包文件夹”一节中所述。 | | package-lock.json | 该文件包含安装在node_modules文件夹中的所有软件包的版本信息,如“了解软件包文件夹”一节所述。 | | README.md | 这是一个自述文件,包含开发工具的命令列表,这些命令在“使用开发工具”一节中进行了描述。 | | tsconfig.json | 该文件包含 TypeScript 编译器的配置设置。在大多数 Angular 项目中,您不需要更改编译器配置。 | | tstlint.json | 该文件包含 TypeScript linter 的设置,如“使用 linter”一节中所述。 |

您不会在每个项目中都需要所有这些文件,您可以删除不需要的文件。例如,我倾向于删除README.md.editorconfig.gitignore文件,因为我已经熟悉了工具命令,我不喜欢覆盖我的编辑器设置,并且我不使用 Git 进行版本控制。

了解源代码文件夹

src文件夹包含应用的文件,包括源代码和静态资产,比如图像。这个文件夹是大多数开发活动的焦点,图 11-2 显示了使用ng new命令创建的src文件夹的内容。

img/421542_4_En_11_Fig2_HTML.jpg

图 11-2。

src 文件夹的内容

app文件夹是您为应用添加定制代码和内容的地方,随着您添加特性,它的结构会变得更加复杂。其他文件支持开发过程,如表 11-3 所述。

表 11-3。

src 文件夹中的文件和文件夹

|

名字

|

描述

| | --- | --- | | app | 此文件夹包含应用的源代码和内容。该文件夹的内容是本书“了解 Angular 应用如何工作”一节和其他章节的主题。 | | assets | 该文件夹用于应用所需的静态资源,如图像。 | | environments | 该文件夹包含定义不同环境设置的配置文件。默认情况下,唯一的配置设置是production标志,当应用为部署而构建时,它被设置为true,如“了解应用引导”一节中所述。 | | favicon.ico | 该文件包含一个图标,浏览器将在应用的选项卡中显示该图标。默认图像是 Angular 徽标。 | | index.html | 这是在开发过程中发送到浏览器的 HTML 文件,如“理解 HTML 文档”一节所述。 | | main.ts | 该文件包含执行时启动应用的 TypeScript 语句,如“了解应用引导”一节中所述。 | | polyfills.ts | 该文件用于在项目中包含多填充,以提供对某些浏览器(尤其是 Internet Explorer)中原本不可用的功能的支持。 | | styles.css | 该文件用于定义应用于整个应用的 CSS 样式。 | | tests.ts | 这是 Karma 测试包的配置文件,我在第二十九章中描述过。 |

了解包文件夹

JavaScript 应用开发的世界依赖于一个丰富的包生态系统,其中一些包包含 Angular 框架,该框架将通过开发过程中在后台使用的小包发送到浏览器。一个 Angular 项目需要很多包;例如,本章开头创建的示例项目需要 850 多个包。

许多这样的包只有几行代码,但是它们之间有一个复杂的依赖层次,太大了以至于不能手工管理,所以使用了包管理器。包管理器得到了项目所需包的初始列表。然后检查这些包中的每一个包的依赖关系,这个过程一直继续,直到创建了完整的一组包。所有需要的包都被下载并安装在node_modules文件夹中。

使用dependenciesdevDependencies属性在package.json文件中定义初始的一组包。dependencies属性用于列出应用运行所需的包。下面是来自示例应用中的package.json文件的dependencies包,尽管您可能会在您的项目中看到不同的版本号:

...
"dependencies": {
  "@angular/animations": "~9.0.0",
  "@angular/common": "~9.0.0",
  "@angular/compiler": "~9.0.0",
  "@angular/core": "~9.0.0",
  "@angular/forms": "~9.0.0",
  "@angular/platform-browser": "~9.0.0",
  "@angular/platform-browser-dynamic": "~9.0.0",
  "@angular/router": "~9.0.0",
  "bootstrap": "⁴.4.1",
  "rxjs": "~6.5.4",
  "tslib": "¹.10.0",
  "zone.js": "~0.10.2"
},
...

大多数包提供 Angular 功能,少数支持包在后台使用。对于每个包,package.json文件包括可接受版本号的详细信息,使用表 11-4 中描述的格式。

表 11-4。

软件包版本编号系统

|

格式

|

描述

| | --- | --- | | 9.0.0 | 直接表示版本号将只接受具有精确匹配版本号的包,例如 9.0.0。 | | * | 使用星号表示接受要安装的任何版本的软件包。 | | >9.0.0 >=9.0.0 | 在版本号前面加上>或> =接受任何大于或等于给定版本的软件包版本。 | | <9.0.0 <=9.0.0 | 在版本号前加上 | | ~9.0.0 | 在版本号前加一个波浪号(~字符)接受要安装的版本,即使修补程序级别号(三个版本号中的最后一个)不匹配。例如,指定~9.0.0意味着您将接受版本 9.0.1 或 9.0.2(将包含版本 9.0.0 的补丁),但不接受版本 9.1.0(将是一个新的次要版本)。 | | ⁹.0.0 | 在版本号前加一个插入符号(^字符)将接受版本,即使次要版本号(三个版本号中的第二个)或补丁号不匹配。例如,指定⁹.0.0 意味着您将接受版本 9.1.0 和 9.2.0,但不接受版本 10.0.0。 |

package.json文件的dependencies部分指定的版本号将接受较小的更新和补丁。当涉及到文件的devDependencies部分时,版本灵活性更加重要,该部分包含了开发所需的包的列表,但这些包不是最终应用的一部分。在示例应用的package.json文件的devDependencies部分列出了 19 个包,每个包都有自己的可接受版本范围。

...
"devDependencies": {
  "@angular-devkit/build-angular": "~0.900.1",
  "@angular/cli": "~9.0.1",
  "@angular/compiler-cli": "~9.0.0",
  "@angular/language-service": "~9.0.0",
  "@types/node": "¹².11.1",
  "@types/jasmine": "~3.5.0",
  "@types/jasminewd2": "~2.0.3",
  "codelyzer": "⁵.1.2",
  "jasmine-core": "~3.5.0",
  "jasmine-spec-reporter": "~4.2.1",
  "karma": "~4.3.0",
  "karma-chrome-launcher": "~3.1.0",
  "karma-coverage-istanbul-reporter": "~2.1.0",
  "karma-jasmine": "~2.0.1",
  "karma-jasmine-html-reporter": "¹.4.2",
  "protractor": "~5.4.3",
  "ts-node": "~8.3.0",
  "tslint": "~5.18.0",
  "typescript": "~3.7.5"
}
...

同样,您可能会看到不同的细节,但关键的一点是,包之间的依赖关系的管理太复杂,无法手动完成,而是委托给包管理器。使用最广泛的包管理器是 NPM,它安装在 Node.js 旁边,是本书第二章准备工作的一部分。

当你创建一个项目时,开发所需的所有包都被自动下载并安装到node_modules文件夹中,但是表 11-5 列出了一些你可能会发现在开发过程中有用的命令。所有这些命令都应该在项目文件夹中运行,这个文件夹包含了package.json文件。

表 11-5。

有用的 NPM 命令

|

命令

|

描述

| | --- | --- | | npm install | 该命令执行在package.json文件中指定的包的本地安装。 | | npm install package@version | 该命令执行包的特定版本的本地安装,并更新package.json文件以将包添加到dependencies部分。 | | npm install package@version --save-dev | 该命令执行包的特定版本的本地安装,并更新package.json文件以将包添加到devDependencies部分。 | | npm install --global package@version | 此命令执行特定版本软件包的全局安装。 | | npm list | 该命令列出了所有本地包及其依赖项。 | | npm run <script name> | 该命令执行在package.json文件中定义的脚本之一,如下所述。 | | npx package@version | 这个命令下载并执行一个包。 |

Understanding Global And Local Packages

NPM 可以安装特定于单个项目的包(称为本地安装,或者可以从任何地方访问(称为全局安装)。很少有软件包需要全局安装,但有一个例外,那就是本书第二章中安装的@angular/cli软件包。@angular-cli包需要全局安装,因为它用于创建新项目。项目所需的单个包被本地安装到node_modules文件夹中。

表 11-5 中描述的最后两个命令很奇怪,但是包管理器传统上包括对运行在package.json文件的scripts部分中定义的命令的支持。在 Angular 项目中,该特性用于提供对工具的访问,这些工具在开发过程中使用,并为应用的部署做准备。下面是示例项目中package.json文件的scripts部分:

...
"scripts": {
    "ng": "ng",
    "start": "ng serve",
    "build": "ng build",
    "test": "ng test",
    "lint": "ng lint",
    "e2e": "ng e2e"
},
...

表 11-6 总结了这些命令,我将在本章后面的章节或本书这一部分后面的章节中演示它们的用法。

表 11-6。

package.json 文件的脚本部分中的命令

|

名字

|

描述

| | --- | --- | | ng | 该命令运行ng命令,该命令提供对 Angular 开发工具的访问。 | | start | 该命令启动开发工具,相当于ng serve命令。 | | build | 该命令执行生产构建过程。 | | test | 该命令启动单元测试工具,这些工具在第二十九章中有描述,它相当于ng test命令。 | | lint | 这个命令启动 TypeScript linter,如“使用 linter”一节所述,它相当于ng lint命令。 | | e2e | 该命令启动端到端测试工具,相当于ng e2e命令。 |

这些命令通过使用npm run后跟您需要的命令名来运行,并且必须在包含package.json文件的文件夹中完成。因此,如果您想在示例项目中运行lint命令,导航到example文件夹并键入npm run lint。使用命令ng lint可以得到相同的结果。

npx命令对于用一个命令下载和执行一个包很有用,我在本章后面的“运行生产构建”一节中会用到它。不是所有的包都被设置为使用npx,这是一个最近的特性。

使用开发工具

使用ng new命令创建的项目包括一套完整的开发工具,用于监控应用的文件,并在检测到变更时构建项目。运行example文件夹中清单 11-2 所示的命令,启动开发工具。

ng serve

Listing 11-2.Starting the Development Tools

命令启动构建过程,该过程在命令提示符下生成如下消息:

...
10% building modules 4/7 modules 3 active
...

在该过程结束时,您将看到已经创建的包的摘要,如下所示:

...
chunk {main} main.js, main.js.map (main) 9.29 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 140 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 6.15 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 9.75 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 2.67 MB [initial] [rendered]
Hash: 93087e33d02b97e55e3c - Time: 5540ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
: Compiled successfully.
...

了解开发 HTTP 服务器

为了简化开发过程,该项目包含了一个与构建过程紧密集成的 HTTP 服务器。在初始构建过程之后,HTTP 服务器被启动,并显示一条消息,告诉您正在使用哪个端口来侦听请求,如下所示:

...
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
...

默认端口是 4200,但是如果您已经在使用端口 4200,您可能会看到不同的消息。打开一个新的浏览器窗口,请求http://localhost:4200,你会看到ng new命令添加到项目中的占位符内容,如图 11-3 所示。

img/421542_4_En_11_Fig3_HTML.jpg

图 11-3。

使用 HTTP 开发服务器

ng serve命令构建应用并启动 HTTP 服务器,这样您就可以看到应用了。

了解构建过程

当您运行ng serve时,项目被构建,以便它可以被浏览器使用。这个过程需要三个重要的工具:TypeScript 编译器、Angular 编译器和一个名为 webpack 的包。

Angular 应用是使用包含表达式的 TypeScript 文件和 HTML 模板创建的,这两种文件浏览器都无法理解。TypeScript 编译器负责将 TypeScript 文件编译成 JavaScript,Angular 编译器负责将模板转换成 JavaScript 语句,这些语句在模板文件中创建 HTML 元素并计算它们包含的表达式。

构建过程通过 webpack 管理,web pack 是一个模块捆绑器,这意味着它从编译器获取 JavaScript 输出,并将其合并到一个可以发送到浏览器的模块中。这个过程被称为捆绑,这是对一个重要功能的平淡描述,它是开发 Angular 应用时您将依赖的关键工具之一,尽管您不会直接处理它,因为它是由 Angular 开发工具为您管理的。

当您运行清单 11-2 中的命令时,您会在 webpack 处理应用时看到一系列消息。Webpack 从main.ts文件中的代码开始,这是应用的入口点,并遵循它包含的import语句来发现它的依赖项,对每个有依赖项的文件重复这个过程。Webpack 通过import语句工作,编译每个声明了依赖关系的 TypeScript 和模板文件,为整个应用生成 JavaScript 代码。

Note

本节描述开发构建过程。有关准备应用进行部署的过程的详细信息,请参见“了解生产构建过程”一节。

编译过程的输出被组合成一个文件,称为。在捆绑过程中,webpack 会报告其在模块中的工作过程,并找到需要包含在捆绑包中的模块,如下所示:

...
10% building modules 4/7 modules 3 active
...

在该过程结束时,您将看到已经创建的包的摘要,如下所示:

...
chunk {main} main.js, main.js.map (main) 9.29 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 140 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 6.15 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 9.75 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 2.67 MB [initial] [rendered]
Hash: 93087e33d02b97e55e3c - Time: 5540ms
** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
: Compiled successfully.
...

初始构建过程可能需要一段时间才能完成,因为生产了五个包,如表 11-7 中所述。(每个文件都有对应的.map文件,用来让浏览器中的 JavaScript 调试更容易。)

表 11-7。

由成 Angular 构建过程产生的束

|

名字

|

描述

| | --- | --- | | main.js | 该文件包含从src/app文件夹产生的编译输出。 | | polyfills.js | 此文件包含目标浏览器不支持的应用使用的功能所需的 JavaScript 聚合填充。 | | runtime.js | 该文件包含加载其他模块的代码。 | | styles.js | 该文件包含添加应用全局 CSS 样式表的 JavaScript 代码。 | | vendor.js | 该文件包含应用所依赖的第三方包,包括 Angular 包。 |

了解应用捆绑包

仅当第一次运行ng serve命令时,才执行完整的构建过程。此后,如果组成包的文件发生变化,包将被重新构建。您可以通过用清单 11-3 中所示的元素替换app.component.html文件的内容来看到这一点。

<div class="bg-primary text-center text-white p-2">
    Hello, World
</div>

Listing 11-3.Replacing the Contents of the app.component.html File in the src/app Folder

当您保存更改时,只有main.js包将被重新构建,您将在命令提示符下看到如下消息:

4 unchanged chunks
chunk {main} main.js, main.js.map (main) 9.39 kB [initial] [rendered]
Time: 140ms
: Compiled successfully.

有选择地编译文件和准备包可以确保开发过程中的变化效果可以很快看到。图 11-4 显示了清单 11-3 变更的效果。

img/421542_4_En_11_Fig4_HTML.jpg

图 11-4。

更改 main.js 包中使用的文件

Understanding Hot Reloading

在开发过程中,Angular 开发工具在main.js包中增加了对一个叫做热重装的特性的支持。这个特性意味着您可以自动看到清单 11-3 中的变化效果。添加到捆绑包中的 JavaScript 代码打开了一个返回到 Angular development HTTP 服务器的连接。当一个变更触发一个构建时,服务器通过 HTTP 连接发送一个信号,这将导致浏览器自动重新加载应用。

了解 Polyfills 包

默认情况下,Angular 构建过程以最新版本的浏览器为目标,如果您需要为旧浏览器提供支持,这可能会是一个问题(旧浏览器经常出现在企业应用中)。polyfills.js包用于为没有本地支持的旧版本提供 JavaScript 特性的实现。polyfills.js文件的内容由polyfills.ts文件决定,该文件可以在src文件夹中找到。默认情况下,仅启用一个聚合填充,这将启用 Zone.js 库,Angular 使用该库在浏览器中执行更改检测。通过将import语句添加到polyfills.ts文件,您可以将自己的聚合填充添加到包中。

Tip

您还可以通过编辑browserslist文件来修改编译器生成的 JavaScript 和 CSS,该文件通过ng new命令添加到项目文件夹中。该文件的主要用途是启用对 Internet Explorer 的支持,默认情况下是禁用的。

了解样式包

styles.js包用于将 CSS 样式表添加到应用中。包文件包含使用浏览器 API 定义样式的 JavaScript 代码,以及应用所需的 CSS 样式表的内容。(使用 JavaScript 来分发 CSS 文件似乎有悖常理,但它工作得很好,并且具有使应用自包含的优势,因此它可以作为一系列 JavaScript 文件进行部署,而不依赖于在部署 web 服务器上设置的附加资产。)

使用angular.json文件的样式部分将 CSS 样式表添加到应用中。运行清单 11-4 中所示的命令,将引导 CSS 包添加到项目中。

npm install bootstrap@4.4.1

Listing 11-4.Installing the Bootstrap CSS Package

主 CSS 样式表是node_modules/bootstrap/dist/css/bootstrap.min.css文件。要将样式表合并到应用中,将文件名添加到angular.json文件的styles部分,如清单 11-5 所示。

...
"architect": {
  "build": {
    "builder": "@angular-devkit/build-angular:browser",
    "options": {
      "outputPath": "dist/example",
      "index": "src/index.html",
      "main": "src/main.ts",
      "polyfills": "src/polyfills.ts",
      "tsConfig": "tsconfig.app.json",
      "aot": true,
      "assets": [
        "src/favicon.ico",
        "src/assets"
      ],
      "styles": [
        "src/styles.css",
        "node_modules/bootstrap/dist/css/bootstrap.min.css"
      ],
      "scripts": []
    },
...

Listing 11-5.Adding a Stylesheet in the angular.json File in the example Folder

angular.json文件中有两个styles部分,您必须确保将文件名添加到最靠近文件顶部的部分。开发工具没有检测到对angular.json文件的更改,所以通过键入Control+C来停止它们,并运行example文件夹中清单 11-6 中所示的命令来再次启动它们。

ng serve

Listing 11-6.Starting the Angular Development Tools

在初始启动期间,将创建一个新的styles.js包。如果浏览器没有重新连接到开发 HTTP 服务器,重新加载浏览器窗口,你会看到新样式的效果,如图 11-5 所示。(这些样式是由我添加到清单 11-3 中的div元素的类应用的。)

img/421542_4_En_11_Fig5_HTML.jpg

图 11-5。

添加样式表

最初的包只包含了src文件夹中的styles.css文件,默认情况下这个文件夹是空的。现在这个包包含了引导样式表,这个包要大得多,如构建消息所示。

...
chunk {styles} styles.js, styles.js.map (styles) 1.17 MB [initial] [rendered]
...

对于某些风格来说,这似乎是一个很大的文件,但是正如我在“理解产品构建过程”一节中解释的那样,只有在开发过程中才会有这么大。

使用棉绒

linter 是一种检查源代码的工具,以确保它符合一组编码约定和规则。用ng new命令创建的项目包括一个名为 TSLint 的 TypeScript linter,它支持的规则在 https://palantir.github.io/tslint 中描述,涵盖了从可能导致意外结果的常见错误到样式问题的所有内容。

您可以在tslint.json文件中启用和禁用林挺规则,许多规则都有配置选项,可以微调它们检测到的问题。为了演示 linter 是如何工作的,我对一个 TypeScript 文件做了两处修改,如清单 11-7 所示。

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

debugger;

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'example'
}

Listing 11-7.Making Changes in the app.component.ts File in the src/app Folder

我添加了一个debugger语句,并删除了语句末尾的分号,该语句为AppComponent类中的title属性设置值。这两个更改都违反了默认的TSLint规则,您可以通过打开一个新的命令提示符,导航到example项目文件夹,并使用清单 11-8 中所示的命令运行 linter 来查看结果。

ng lint

Listing 11-8.Running the TypeScript Linter

linter 检查项目中的 TypeScript 文件,并报告它遇到的任何问题。清单 11-7 中的更改导致以下消息:

...
ERROR: C:/Users/adam/Documents/Books/Pro Angular 9/Source Code/Current/example/src/app/app.component.ts:3:1 - Use of debugger statements is forbidden
ERROR: C:/Users/adam/Documents/Books/Pro Angular 9/Source Code/Current/example/src/app/app.component.ts:11:20 - Missing semicolon
ERROR: C:/Users/adam/Documents/Books/Pro Angular 9/Source Code/Current/example/src/app/app.module.ts:16:27 - file should end with a newline
Lint errors found in the listed files.
...

这些消息强调了我所做的更改,但是也报告了app.module.ts文件没有以换行符结尾,这是另一个默认规则。

林挺没有集成到常规的构建过程中,并且是手动执行的。林挺最常见的用途是在提交对版本控制系统的更改之前检查潜在的问题,尽管一些项目团队通过将它集成到其他过程中来更广泛地使用林挺工具。

您可能会发现有个别语句会导致 linter 报告错误,但您无法更改。您可以在代码中添加一个注释,告诉 linter 忽略下一行,而不是完全禁用该规则,如下所示:

...
// tslint:disable-next-line
...

如果您有一个充满问题的文件,但您无法进行更改(通常是因为应用的其他部分应用了约束),那么您可以通过在页面顶部添加以下注释来禁用整个文件的林挺:

...
/* tslint:disable */
...

这些注释允许您忽略那些不符合规则但不能更改的代码,同时仍然林挺项目的其余部分。

为了解决 linter 警告,我注释掉了调试器语句并恢复了app.component.ts文件中的分号,如清单 11-9 所示。我还在app.module.ts文件的末尾添加了一个换行符。

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

//debugger;

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'example';
}

Listing 11-9.Addressing Linting Warnings in the app.component.ts File in the src/app Folder

The Joy and Misery of Linting

Linters 可能是一个强大的工具,特别是在一个技术和经验水平参差不齐的开发团队中。Linters 可以检测导致意外行为或长期维护问题的常见问题和细微错误。一个很好的例子是 JavaScript 操作符=====之间的区别,当执行了错误类型的比较时,linter 会发出警告。我喜欢这种林挺,我喜欢在完成一个主要的应用特性之后,或者在将代码提交到版本控制之前,通过林挺过程运行我的代码。

但是棉绒也可能成为分裂和冲突的工具。除了检测编码错误之外,linters 还可以用于强制执行关于缩进、括号放置、分号和空格的使用以及许多其他样式问题的规则。大多数开发人员都有自己的风格偏好——我当然也有:我喜欢缩进四个空格,我喜欢左括号在同一行,以及它们所涉及的表达式。我知道一些程序员有不同的偏好,正如我知道那些人是完全错误的,总有一天会明白过来并开始正确地格式化他们的代码。

Linters 允许对格式有强烈看法的人强加给别人,通常打着“固执己见”的旗号,这可能会变得“令人讨厌”逻辑是,开发人员浪费时间争论不同的编码风格,每个人被迫以相同的方式编写会更好,这通常是观点强烈的人更喜欢的方式,并且忽略了这样一个事实,即开发人员只会争论其他事情,因为争论很有趣。

我尤其不喜欢林挺的格式,我认为这是分裂和不必要的。我经常在读者无法获得书籍示例时帮助他们(如果你需要帮助,我的电子邮件地址是adam@adam-freeman.com),我每周都会看到各种各样的编码风格。但我没有强迫读者按照我的方式编码,而是让我的代码编辑器将代码重新格式化为我喜欢的格式,这是每个有能力的编辑器都提供的功能。

我的建议是少用林挺,把注意力放在会引起真正问题的问题上。将格式化决策留给个人,当您需要阅读由具有不同偏好的团队成员编写的代码时,依靠代码编辑器进行重新格式化。

了解 Angular 应用的工作原理

当您第一次开始使用 Angular 时,它看起来像魔术一样,并且很容易因为害怕破坏某些东西而对项目文件进行修改。尽管 Angular 应用中有许多文件,但它们都有特定的用途,它们一起工作来完成一件远非神奇的事情:向用户显示 HTML 内容。在本节中,我将解释示例 Angular 应用是如何工作的,以及每个部分是如何实现最终结果的。

如果您在上一节中停止了 Angular 开发工具来运行 linter,运行在example文件夹中的清单 11-10 中显示的命令来再次启动它们。

ng serve

Listing 11-10.Starting the Angular Development Tools

一旦初始构建完成,使用浏览器请求http://localhost:4200,您将看到如图 11-6 所示的内容。

img/421542_4_En_11_Fig6_HTML.jpg

图 11-6。

运行示例应用

在接下来的小节中,我将解释如何将项目中的文件组合起来,以产生如图所示的响应。

理解 HTML 文档

运行应用的起点是位于src文件夹中的index.html文件。当浏览器向开发 HTTP 服务器发送请求时,它会收到这个文件,其中包含以下元素:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</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>
  <app-root></app-root>
</body>
</html>

body只包含一个app-root元素,其用途很快就会清楚。index.html文件的内容在被发送到浏览器时被修改,以包含 JavaScript 文件的script元素,如下所示:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</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>
  <app-root></app-root>
  <script src="runtime.js" type="module"></script>
  <script src="polyfills.js" type="module"></script>
  <script src="styles.js" type="module"></script>
  <script src="vendor.js" type="module"></script>
  <script src="main.js" type="module"></script></body>
</html>

Tip

您可能不熟悉 HTML 文件中用于script元素的type属性的module值。我在“理解差动加载”一节中解释了使用它的原因。

了解应用引导

浏览器按照它们的script元素出现的顺序执行 JavaScript 文件,从runtime.js文件开始,它包含处理其他 JavaScript 文件内容的代码。

接下来是polyfills.js文件,它包含实现浏览器不支持的特性的代码,然后是styles.js文件,它包含应用需要的 CSS 样式。vendor.js文件包含应用需要的第三方代码,包括 Angular 框架。该文件在开发过程中可能很大,因为它包含所有的 Angular 特征,即使应用不需要它们。优化过程用于为部署准备应用,如本章后面所述。

最后一个文件是main.js包,其中包含定制的应用代码。这个包的名称取自应用的入口点,即src文件夹中的main.ts文件。一旦处理完其他包文件,就会执行main.ts文件中的语句来初始化 Angular 并运行应用。下面是由ng new命令创建的main.ts文件的内容:

import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));

import语句声明了对其他 JavaScript 模块的依赖,提供了对 Angular 特性的访问(对@angular模块的依赖,包含在vendor.js文件中)和应用中的定制代码(AppModule依赖)。最后一个导入是针对环境设置的,它用于为开发、测试和生产平台创建不同的配置设置,如以下代码:

...
if (environment.production) {
  enableProdMode();
}
...

Angular 有一个生产模式,该模式禁用一些在开发过程中执行的有用检查,这些检查将在后面的章节中介绍。启用生产模式意味着提高性能,也意味着检查结果不会在浏览器的 JavaScript 控制台中报告,用户可以在那里看到它们。通过调用从@angular/core模块导入的enableProdMode函数启用生产模式。

为确定是否应启用生产模式,执行检查以查看environment.production是否为true。该检查对应于src/environments文件夹中的environment.prod.ts文件的内容,它设置该值,并在构建应用以准备部署时应用。结果是,如果应用是为生产而构建的,那么生产模式将被启用,但在其余时间被禁用。

文件中剩下的语句负责启动应用。

...
platformBrowserDynamic().bootstrapModule(AppModule).catch(err => console.error(err));
...

platformBrowserDynamic函数初始化用于网络浏览器的 Angular 平台,从@angular/platform-browser-dynamic模块导入。Angular 被设计成可以在一系列不同的环境中运行,调用platformBrowserDynamic函数是在浏览器中启动应用的第一步。

下一步是调用bootstrapModule方法,该方法接受应用的角根模块,默认为AppModuleAppModulesrc/app文件夹中的app.module.ts文件导入,在下一节描述。bootstrapModule方法为 Angular 提供了进入应用的入口点,并代表了@angular模块提供的功能与项目中的定制代码和内容之间的桥梁。该语句的最后一部分使用catch关键字通过将引导错误写入浏览器的 JavaScript 控制台来处理这些错误。

了解根部 Angular 模块

术语模块在 Angular 应用中有双重作用,指的是 JavaScript 模块和 Angular 模块。JavaScript 模块用于跟踪应用中的依赖关系,并确保浏览器只接收它需要的代码。Angular 模块用于配置 Angular 应用的一部分。

每个应用都有一个 Angular 模块,负责向 Angular 描述应用。对于用ng new命令创建的应用,根模块被称为AppModule,它被定义在src/app文件夹的app.module.ts文件中,包含以下代码:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

AppModule类没有定义任何成员,但是它通过其@NgModule装饰器的配置属性为 Angular 提供了基本信息。我将在后面的章节中描述用于配置 Angular 模块的不同属性,但是现在感兴趣的是bootstrap属性,它告诉 Angular 它应该加载一个名为AppComponent的组件,作为应用启动过程的一部分。组件是 Angular 应用中的主要构件,名为AppComponent的组件提供的内容将显示给用户。

理解 Angular 分量

由根 Angular 模块选择的名为AppComponent的组件定义在src/app文件夹的app.component.ts文件中。下面是app.component.ts文件的内容,我在本章前面编辑了它来演示林挺:

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

//debugger;

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'example';
}

@Component装饰器的属性配置了它的行为。属性告诉 Angular 这个组件将被用来替换一个名为app-root的 HTML 元素。templateUrlstyleUrls属性告诉 Angular,组件想要呈现给用户的 HTML 内容可以在一个名为app.component.html的文件中找到,应用于 HTML 内容的 CSS 样式在一个名为app.component.css的文件中定义(尽管 CSS 文件在新项目中是空的)。

下面是app.component.html文件的内容,我在本章的前面编辑了它来演示热重载和 CSS 样式的使用:

<div class="bg-primary text-center text-white p-2">
  Hello, World
</div>

该文件包含常规的 HTML 元素,但是,正如您将了解到的,Angular 特性是通过使用定制的 HTML 元素或通过向常规 HTML 元素添加属性来应用的。

了解内容显示

当应用启动时,Angular 处理index.html文件,定位与根组件的selector属性匹配的元素,并用根组件的templateUrlstyleUrls属性指定的文件内容替换它。这是使用浏览器为 JavaScript 应用提供的域对象模型(DOM) API 完成的,只有在浏览器窗口中右键单击并从弹出菜单中选择 Inspect,才能看到更改,产生以下结果:

<html lang="en"><head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <style>/* You can add global styles to this file, and also import other
         style files */ sourceMappingURL=data:application/json;base64,
         eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbInNyYyyBmaWxlLCBhbmQgYWxzbyBpb
         Igc3R5bGUgZmlsZXMgKi9cbiJdfQ== */
  </style>
  <style>
     /*!
      * Bootstrap v4.4.1 (https://getbootstrap.com/)
      * Copyright 2011-2019 The Bootstrap Authors
      ...
  </style>
  <style>/*#sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uI
           jozLCJzb3VyY2VzIjpbXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IiIsImZpb
           cmMvYXBwL2FwcC5jb21wb25lbnQuY3NzIn0= */
  </style>
</head>
<body>
    <app-root _nghost-utj-c11="" ng-version="9.0.0">
        <div _ngcontent-utj-c11="" class="bg-primary text-center text-white p-2">
            Hello, World
        </div>
    </app-root>
    <script src="runtime.js" type="module"></script>
    <script src="polyfills.js" type="module"></script>
    <script src="styles.js" type="module"></script>
    <script src="vendor.js" type="module"></script>
    <script src="main.js" type="module"></script>
</body>
</html>

app-root元素包含组件模板中的div元素,属性在初始化过程中由 Angular 添加。

style元素表示app文件夹中的styles.css文件、src/app文件夹中的app.component.css文件以及使用清单 11-5 中的angular.json文件添加的引导 CSS 样式表的内容。styles.cssapp.component.css文件是由ng new命令创建的空文件,但是我已经删除了大部分引导 CSS 样式,因为太多了,无法在此列出。动态生成的div元素与清单 11-3 中指定的class属性的组合产生了如图 11-7 所示的结果。

img/421542_4_En_11_Fig7_HTML.jpg

图 11-7。

显示组件的内容

了解生产构建流程

在开发过程中,重点是快速编译,以便结果可以尽快显示在浏览器中,从而实现良好的迭代开发过程。在开发过程中,编译器和捆绑器不应用任何优化,这就是捆绑文件如此之大的原因。大小并不重要,因为浏览器与服务器运行在同一台机器上,并且会立即加载。

在部署应用之前,它是使用优化过程构建的。要运行这种类型的构建,运行示例文件夹中清单 11-11 中所示的命令。

ng build --prod

Listing 11-11.Performing the Production Build

这个命令执行生产编译过程,它生成的包更小,只包含应用所需的代码。您可以在编译器生成的消息中看到包的详细信息。

...
Generating ES5 bundles for differential loading...
ES5 bundle generation complete.

chunk {2} polyfills-es2015.ca64e4516afbb1b890d5.js (polyfills) 35.6 kB [initial] [rendered]
chunk {3} polyfills-es5.277e2e1d6fb2daf91a5c.js (polyfills-es5) 127 kB [initial] [rendered]
chunk {1} main-es2015.c38e85d415502ff8dc25.js (main) 102 kB [initial] [rendered]
chunk {1} main-es5.c38e85d415502ff8dc25.js (main) 122 kB [initial] [rendered]
chunk {0} runtime-es2015.0811dcefd377500b5b1a.js (runtime) 1.45 kB [entry] [rendered]
chunk {0} runtime-es5.0811dcefd377500b5b1a.js (runtime) 1.45 kB [entry] [rendered]
chunk {4} styles.18138bb15891daf44583.css (styles) 154 kB [initial] [rendered]
...

热重装等特性不会添加到包中,并且不再生产大的vendor.js包。相反,main.js包包含应用和它所依赖的第三方代码部分。对于大多数应用,这意味着生产main.js文件只包含应用使用的 Angular 特征,而vendor.js文件包含所有 Angular 包。

了解提前编译

开发构建过程将 decoratorss 留在输出中,decorator 描述了 Angular 应用的构建块。然后这些被浏览器中的 Angular 运行时转换成 API 调用,这就是所谓的实时 (JIT)编译。生产构建过程启用了一个名为的提前 (AOT)编译特性,该特性可以转换装饰器,这样就不必在每次应用运行时都这样做。

结合其他构建优化,结果是一个加载和启动速度更快的 Angular 应用。缺点是额外的编译需要时间,如果您在开发期间启用优化构建,这可能会令人沮丧。

了解差异负载

生产构建过程为每个包生成两个 JavaScript 文件,以支持一个名为差异加载的特性。支持现代语言特性的浏览器可以使用这个包的较小版本。不支持最新 JavaScript 特性的旧浏览器使用大版本的捆绑包,它表达了相同的特性,但用更详细的代码表达,并添加了一些内容来解决缺少的语言特性。Angular 并没有向所有浏览器提供冗长的代码,而是生成了两组包文件,这样,main-es2015.c38e85d415502ff8dc25.js文件包含了现代浏览器将使用的简洁版本的代码,而main-es5.c38e85d415502ff8dc25.js文件包含了旧浏览器可以运行的更冗长的代码。(文件名的c38e85d415502ff8dc25部分是一个校验和,确保新的包版本不会被缓存和意外使用,称为缓存破坏。)

生产包文件是在dist/example文件夹中创建的,同时还有一个生产版本的index.html文件,其中包含用于差异加载的script元素,如下所示:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</title>
  <base href="/">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
<link rel="stylesheet" href="styles.18138bb15891daf44583.css"></head>
<body>
  <app-root></app-root>
  <script src="runtime-es2015.0811dcefd377500b5b1a.js" type="module"></script>
  <script src="runtime-es5.0811dcefd377500b5b1a.js" nomodule defer></script>
  <script src="polyfills-es5.277e2e1d6fb2daf91a5c.js" nomodule defer></script>
  <script src="polyfills-es2015.ca64e4516afbb1b890d5.js" type="module"></script>
  <script src="main-es2015.c38e85d415502ff8dc25.js" type="module"></script>
  <script src="main-es5.c38e85d415502ff8dc25.js" nomodule defer></script>
</body>
</html>

现代浏览器的包文件有一个设置为moduletype属性。

...
<script src="runtime-es2015.0811dcefd377500b5b1a.js" type="module"></script>
...

老版本的浏览器不理解这个值,不会处理这些script元素。相反,他们将使用其他元素,这些元素具有nomoduledefer属性。

...
<script src="runtime-es5.0811dcefd377500b5b1a.js" nomodule defer></script>
...

这些元素将被现代浏览器忽略,这意味着每个类别的浏览器都获得正确的文件集。

Tip

请注意,在产品构建过程中,CSS 样式被放在带有文件扩展名.css的标准样式表中,而不是放在开发过程中生成的 JavaScript 文件中。

表 11-8 显示了在开发过程中,在现代浏览器的生产中,以及在旧浏览器的生产中,包文件的总大小。

表 11-8。

总束尺寸

|

目标

|

总尺寸

| | --- | --- | | 发展 | 2.8MB | | 生产(旧浏览器) | 404KB | | 生产(现代浏览器) | 293KB |

您可以看到生产构建过程产生了更小的包,现代浏览器接收的文件比遗留浏览器更小,即使对于一个简单的示例项目也是如此。然而,优化过程需要时间,这就是为什么在开发过程中不使用它。

运行生产版本

为了测试生产版本,运行example文件夹中清单 11-12 所示的命令。

npx http-server@0.12.1 dist/example --port 5000

Listing 11-12.Running the Production Build

这个命令将下载并执行 0.12.1 版的http-server包,它提供了一个简单的、自包含的 HTTP 服务器。该命令告诉http-server包提供dist/example文件夹的内容,并监听端口 5000 上的请求。打开一个新的 web 浏览器并请求http://localhost:5000,您将看到示例应用的生产版本,如图 11-8 所示(尽管,除非您检查浏览器发送的获取包文件的 HTTP 请求,否则您不会看到与前面图中所示的开发版本有任何不同)。

img/421542_4_En_11_Fig8_HTML.jpg

图 11-8。

运行生产版本

一旦测试了生产版本,使用Control+C停止 HTTP 服务器。

在 Angular 项目中开始开发

您已经看到了 Angular 应用的初始构件是如何组合在一起的,以及引导过程是如何将内容显示给用户的。在这一节中,我将向项目添加一个简单的数据模型,这是大多数开发人员的典型起点,并向组件添加一些特性,而不仅仅是本章前面添加的静态内容。

创建数据模型

在应用的所有构建块中,数据模型是 Angular 规范最少的一个。在应用的其他地方,Angular 要求应用特定的装饰器或使用 API 的部分,但对模型的唯一要求是它提供对应用所需数据的访问;如何做到这一点的细节以及数据看起来像什么是留给开发人员的。

这可能感觉有点奇怪,并且很难知道如何开始,但是,在它的核心,模型可以分为三个部分。

  • 描述模型中数据的类

  • 加载和保存数据的数据源,通常保存到服务器

  • 允许操作模型中的数据的存储库

在接下来的部分中,我创建了一个简单的模型,它提供了我在接下来的章节中描述 Angular 特征所需的功能。

创建描述性模型类

顾名思义,描述类描述了应用中的数据。在一个真实的项目中,通常会有很多类来完整地描述应用所操作的数据。从本章开始,我将创建一个简单的类作为数据模型的基础。我在src/app文件夹中添加了一个名为product.model.ts的文件,代码如清单 11-13 所示。

Understanding Angular Schematics

Schematics 是智能模板,可用于创建从简单文件到完整项目的所有内容。例如,ng new命令使用原理图特征创建一个项目。使用ng new命令创建的项目还包括默认的 schematics,这些 schematics 充当向 Angular 项目添加通常需要的项目的模板,例如组件和 TypeScript 类。

schematics 功能很聪明,而且 schematics 很有用,但前提是您了解 Angular 的工作原理。在本书中,示例所需的所有文件都是显式创建的,因此您可以看到每个文件是如何使用的,并理解每个文件在 Angular 中扮演的角色。如果您想了解更多关于 schematics 的信息,那么运行ng generate命令,这将显示可用的 schematics 列表。一旦你理解了 Angular 是如何工作的,你会发现 schematics 很方便,但是在此之前,我建议你手动添加文件到你的项目中。

文件名遵循 Angular 描述性命名惯例。名称的productmodel部分告诉您这是数据模型中与产品相关的部分,而.ts扩展名表示一个 TypeScript 文件。您不必遵循这种约定,但是 Angular 项目通常包含许多文件,并且隐晦的名称使得在源代码中导航很困难。

export class Product {

    constructor(public id?: number,
        public name?: string,
        public category?: string,
        public price?: number) { }
}

Listing 11-13.The Contents of the product.model.ts File in the src/app Folder

Product类定义了产品标识符、产品名称、类别和价格的属性。属性被定义为可选的构造函数参数,如果你使用 HTML 表单创建对象,这是一个有用的方法,我将在第十四章中演示。

创建数据源

数据源为应用提供数据。最常见的数据源使用 HTTP 从 web 服务请求数据,我在第二十四章中描述了这一点。对于这一章,我需要一些更简单的东西,我可以在每次应用启动时重置为已知状态,以确保您从示例中获得预期的结果。我用清单 11-14 中所示的代码在src/app文件夹中添加了一个名为datasource.model.ts的文件。

import { Product } from "./product.model";

export class SimpleDataSource {
    private data: Product[];

    constructor() {
        this.data = new Array<Product>(
            new Product(1, "Kayak", "Watersports", 275),
            new Product(2, "Lifejacket", "Watersports", 48.95),
            new Product(3, "Soccer Ball", "Soccer", 19.50),
            new Product(4, "Corner Flags", "Soccer", 34.95),
            new Product(5, "Thinking Cap", "Chess", 16));
    }

    getData(): Product[] {
        return this.data;
    }
}

Listing 11-14.The Contents of the datasource.model.ts File in the src/app Folder

该类中的数据是硬连线的,这意味着当浏览器重新加载时,应用中所做的任何更改都将丢失。这在真实的应用中毫无用处,但是对于书本上的例子来说非常理想。

创建模型库

完成简单模型的最后一步是定义一个存储库,该存储库将提供对来自数据源的数据的访问,并允许在应用中对其进行操作。我在src/app文件夹中添加了一个名为repository.model.ts的文件,并用它来定义清单 11-15 中所示的类。

import { Product } from "./product.model";
import { SimpleDataSource } from "./datasource.model";

export class Model {
    private dataSource: SimpleDataSource;
    private products: Product[];
    private locator = (p: Product, id: number) => p.id == id;

    constructor() {
        this.dataSource = new SimpleDataSource();
        this.products = new Array<Product>();
        this.dataSource.getData().forEach(p => this.products.push(p));
    }

    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 11-15.The Contents of the repository.model.ts File in the src/app Folder

Model类定义了一个构造函数,它从数据源类获取初始数据,并通过一组方法提供对它的访问。这些方法是存储库定义的典型方法,如表 11-9 所述。

表 11-9。

Web 窗体代码块的类型

|

名字

|

描述

| | --- | --- | | getProducts | 该方法返回一个包含模型中所有Product对象的数组。 | | getProduct | 这个方法根据 ID 返回一个单独的Product对象。 | | saveProduct | 该方法更新一个现有的Product对象或向模型添加一个新的对象。 | | deleteProduct | 该方法根据 ID 从模型中删除一个Product对象。 |

存储库的实现可能看起来很奇怪,因为数据对象存储在标准的 JavaScript 数组中,但是由Model类定义的方法呈现数据,就好像它是由id属性索引的Product对象的集合。在为模型数据编写存储库时,有两个主要考虑事项。首先,它应该尽可能高效地呈现将要显示的数据。对于示例应用,这意味着以一种可以迭代的形式呈现模型中的所有数据,比如数组。这很重要,因为迭代会经常发生,正如我在后面的章节中解释的那样。Model类的其他操作效率较低,但会较少使用。

第二个要考虑的是能够呈现 Angular 要处理的不变数据。我在第十三章中解释了为什么这很重要,但是就实现存储库而言,这意味着getProducts方法在被多次调用时应该返回相同的对象,除非其他方法之一或应用的另一部分对getProducts方法提供的数据进行了更改。如果一个方法每次返回一个不同的对象,即使它们是包含相同对象的不同数组,Angular 也会报告一个错误。考虑到这两点意味着实现存储库的最佳方式是将数据存储在一个数组中,并接受低效率。

创建组件和模板

模板包含组件想要呈现给用户的 HTML 内容。模板的范围可以从单个 HTML 元素到复杂的内容块。

为了创建一个模板,我在src/app文件夹中添加了一个名为template.html的文件,并添加了清单 11-16 中所示的 HTML 元素。

<div class="bg-info text-white m-2 p-2">
    There are {{model.getProducts().length}} products in the model
</div>

Listing 11-16.The Contents of the template.html File in the src/app Folder

这个模板的大部分是标准 HTML,但是双括号字符之间的部分(div元素中的{{}})是数据绑定的一个例子。当显示模板时,Angular 将处理其内容,发现绑定,并评估它包含的表达式,以产生将由数据绑定显示的内容。

支持模板所需的逻辑和数据由其组件提供,该组件是一个应用了@Component装饰器的 TypeScript 类。为了给清单 11-16 中的模板提供一个组件,我在src/app文件夹中添加了一个名为component.ts的文件,并定义了清单 11-17 中所示的类。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();
}

Listing 11-17.The Contents of the component.ts File in the src/app Folder

@Component装饰器配置组件。属性指定了指令将被应用到的 HTML 元素,即app。在@Component指令中的templateUrl属性指定了将被用作app元素内容的内容,对于这个例子,这个属性指定了template.html文件。

组件类,在这个例子中是ProductComponent,负责为模板提供绑定所需的数据和逻辑。ProductComponent类定义了一个名为model的属性,它提供了对Model对象的访问。

我用于组件选择器的app元素与ng new命令在创建项目时使用的元素不同,它应该出现在index.html文件中。在清单 11-18 中,我修改了index.html文件以引入一个app元素来匹配清单 11-17 中的组件选择器。

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Example</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>
  <app></app>
</body>
</html>

Listing 11-18.Changing the Custom Element in the index.html File in the app Folder

这不是你在实际项目中需要做的事情,但它进一步证明了 Angular 应用以简单和可预测的方式组合在一起,你可以改变你需要或想要的任何部分。

配置根部 Angular 模块

我在上一节中创建的组件不会成为应用的一部分,直到我将它注册到根 Angular 模块。在清单 11-19 中,我使用了import关键字来导入组件,并且使用了@NgModule配置属性来注册组件。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

//import { AppComponent } from './app.component';
import { ProductComponent } from "./component";

@NgModule({
  declarations: [ProductComponent],
  imports: [BrowserModule],
  providers: [],
  bootstrap: [ProductComponent]
})
export class AppModule { }

Listing 11-19.Registering a Component in the app.module.ts File in the src/app Folder

我在import语句中使用了名称ProductComponent,并将该名称添加到了declarations数组中,该数组配置了应用中的一组组件和其他特性。我还更改了bootstrap属性的值,以便新组件是应用启动时使用的组件。

运行example文件夹中清单 11-20 所示的命令,启动 Angular 开发工具。

ng serve

Listing 11-20.Starting the Angular Development Tools

一旦初始构建过程完成,使用 web 浏览器请求http://localhost:4200,这将产生如图 11-9 所示的响应。

img/421542_4_En_11_Fig9_HTML.jpg

图 11-9。

新组件和模板的效果

执行了标准的 Angular 引导序列,但是使用了我在上一节中创建的定制组件和模板,而不是创建项目时设置的组件和模板。

摘要

在这一章中,我创建了一个 Angular 项目,并用它来介绍它包含的工具,并解释一个简单的 Angular 应用是如何工作的。在下一章,我将从数据绑定开始深入研究细节。

十二、使用数据绑定

前一章中的示例应用包含一个显示给用户的简单模板,该模板包含一个数据绑定,该数据绑定显示数据模型中有多少对象。在这一章中,我将描述 Angular 提供的基本数据绑定,并演示如何使用它们来生成动态内容。在后面的章节中,我将描述更高级的数据绑定,并解释如何用自定义特性扩展 Angular 绑定系统。表 12-1 将数据绑定放在上下文中。

表 12-1。

将数据绑定放在上下文中

|

问题

|

回答

| | --- | --- | | 它们是什么? | 数据绑定是嵌入到模板中的表达式,用于在 HTML 文档中生成动态内容。 | | 它们为什么有用? | 数据绑定提供了 HTML 文档和模板文件中的 HTML 元素与应用中的数据和代码之间的链接。 | | 它们是如何使用的? | 数据绑定作为 HTML 元素的属性或字符串中的特殊字符序列来应用。 | | 有什么陷阱或限制吗? | 数据绑定包含简单的 JavaScript 表达式,计算这些表达式可以生成内容。主要的缺陷是在绑定中包含太多的逻辑,因为这样的逻辑不能在应用的其他地方正确地测试或使用。数据绑定表达式应该尽可能简单,并依赖组件(和其他有 Angular 的特性,如管道)来提供复杂的应用逻辑。 | | 有其他选择吗? | 不。数据绑定是 Angular 开发的重要组成部分。 |

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

表 12-2。

章节总结

|

问题

|

解决办法

|

列表

| | --- | --- | --- | | 在 HTML 文档中动态显示数据 | 定义数据绑定 | 1–4 | | 配置 HTML 元素 | 使用标准属性或特性绑定 | 5, 8 | | 设置元素的内容 | 使用字符串插值绑定 | 6, 7 | | 配置元素被分配到的类 | 使用类绑定 | 9–13 | | 配置应用于元素的各个样式 | 使用样式绑定 | 14–17 | | 手动触发数据模型更新 | 使用浏览器的 JavaScript 控制台 | 18, 19 |

为本章做准备

对于这一章,我继续使用第十一章中的示例项目。为了准备本章,我向组件类添加了一个方法,如清单 12-1 所示。

Tip

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

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(): string {
        return this.model.getProducts().length == 5 ? "bg-success" : "bg-warning";
    }
}

Listing 12-1.Adding a Method in the component.ts File in the src/app Folder

example文件夹中运行以下命令,启动 Angular 开发工具:

ng serve

打开一个新的浏览器并导航到http://localhost:4200以查看将要显示的内容,如图 12-1 所示。

img/421542_4_En_12_Fig1_HTML.jpg

图 12-1。

运行示例应用

了解单向数据绑定

单向数据绑定用于为用户生成内容,是 Angular 模板中使用的基本特征。术语单向指的是数据向一个方向流动的事实,这意味着数据从组件数据绑定流动,以便可以在模板中显示。

Tip

还有其他类型的 Angular 数据绑定,我将在后面的章节中介绍。事件绑定从另一个方向流动,从模板中的元素到应用的其余部分,它们允许用户交互。双向绑定允许数据双向流动,最常用于表单中。其他绑定详见 13 和 14 章节。

为了开始单向数据绑定,我替换了模板的内容,如清单 12-2 所示。

<div [ngClass]="getClasses()" >
    Hello, World.
</div>

Listing 12-2.The Contents of the template.html File in the src/app Folder

当您保存对模板的更改时,开发工具将重新构建应用并触发浏览器重新加载,显示如图 12-2 所示的输出。

img/421542_4_En_12_Fig2_HTML.jpg

图 12-2。

使用单向数据绑定

这是一个简单的例子,但它展示了数据绑定的基本结构,如图 12-3 所示。

img/421542_4_En_12_Fig3_HTML.jpg

图 12-3。

数据绑定的剖析

数据绑定有以下四个部分:

  • 主机元素是 HTML 元素,绑定将通过改变它的外观、内容或行为来影响它。

  • 方括号告诉 Angular 这是一个单向数据绑定。当 Angular 在数据绑定中看到方括号时,它将计算表达式,并将结果传递给绑定的目标*,以便修改主机元素。*

  • 目标指定绑定将做什么。有两种不同类型的目标:一个指令或者一个属性绑定

  • 表达式是 JavaScript 的一个片段,使用模板的组件来提供上下文,这意味着组件的属性和方法可以包含在表达式中,就像示例绑定中的getClasses方法一样。

查看清单 12-2 中的绑定,您可以看到主机元素是一个div元素,这意味着这是绑定想要修改的元素。表达式调用组件的getClasses方法,该方法在本章开始时定义。该方法根据数据模型中对象的数量返回一个包含引导 CSS 类的字符串。

...
getClasses(): string {
    return this.model.getProducts().length == 5 ? "bg-success" : "bg-warning";
}
...

如果数据模型中有五个对象,那么方法返回bg-success,这是一个应用绿色背景的引导类。否则,该方法返回bg-warning,这是一个应用琥珀色背景的引导类。

数据绑定的目标是一个指令,它是一个专门为支持数据绑定而编写的类。Angular 附带了一些有用的内置指令,您可以创建自己的指令来提供定制功能。内置指令的名称以ng开头,这告诉您ngClass目标是内置指令之一。目标通常给出指令做什么的指示,顾名思义,ngClass指令将在一个或多个类中添加或移除主机元素,当表达式被求值时,这些类的名称被返回。

综上所述,数据绑定将根据数据模型中项的数量将div元素添加到bg-successbg-warning类中。

由于应用启动时模型中有五个对象(因为初始数据被硬编码到第十一章中创建的SimpleDataSource类中),getClasses方法返回bg-success并产生如图 12-3 所示的结果,给div元素添加绿色背景。

了解绑定目标

当 Angular 处理数据绑定的目标时,它首先检查它是否匹配一个指令。大多数应用将依赖 Angular 提供的内置指令和提供应用特定功能的自定义指令的混合。您通常可以判断指令何时是数据绑定的目标,因为名称将是独特的,并给出了该指令的一些用途。内置指令可以通过前缀ng来识别。清单 12-2 中的绑定提示您,目标是一个内置指令,与宿主元素的类成员资格相关。为了快速参考,表 12-3 描述了基本的内置 Angular 指令以及它们在本书中的描述位置。(在后面的章节中还描述了其他指令,但这些是最简单的,也是您最常使用的。)

表 12-3。

基本的内置 Angular 指令

|

名字

|

描述

| | --- | --- | | ngClass | 该指令用于将宿主元素分配给类,如“设置类和样式”一节中所述。 | | ngStyle | 该指令用于设置单个样式,如“设置类和样式”一节所述。 | | ngIf | 如第十三章所述,该指令用于在表达式的值为true时在 HTML 文档中插入内容。 | | ngFor | 该指令为数据源中的每一项在 HTML 文档中插入相同的内容,如第十三章所述。 | | ngSwitchngSwitchCasengSwitchDefault | 这些指令用于根据表达式的值选择插入 HTML 文档的内容块,如第十三章所述。 | | ngTemplateOutlet | 该指令用于重复内容块,如第十三章所述。 |

了解属性绑定

如果绑定目标与指令不对应,那么 Angular 会检查目标是否可以用来创建属性绑定。有五种不同类型的属性绑定,在表 12-4 中列出,以及详细描述它们的细节。

表 12-4。

Angular 属性绑定

|

名字

|

描述

| | --- | --- | | [property] | 这是标准属性绑定,用于在表示文档对象模型(DOM)中的主机元素的 JavaScript 对象上设置属性,如“使用标准属性和属性绑定”一节中所述。 | | [attr.name] | 这是属性绑定,用于设置没有 DOM 属性的宿主 HTML 元素的属性值,如“使用属性绑定”一节所述。 | | [class.name] | 这是特殊的类属性绑定,用于配置宿主元素的类成员资格,如“使用类绑定”一节所述。 | | [style.name] | 这是特殊的样式属性绑定,用于配置宿主元素的样式设置,如“使用样式绑定”一节所述。 |

理解表达

数据绑定中的表达式是 JavaScript 代码的一个片段,对其求值以提供目标值。表达式可以访问组件定义的属性和方法,这就是清单 12-2 中的绑定如何能够调用getClasses方法来为ngClass指令提供主机元素应该添加到的类的名称。

表达式不限于调用方法或从组件读取属性;他们还可以执行大多数标准的 JavaScript 操作。作为一个例子,清单 12-3 显示了一个表达式,它有一个与getClasses方法的结果连接的字符串值。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()" >
    Hello, World.
</div>

Listing 12-3.Performing an Operation in the template.html File in the src/app Folder

表达式用双引号括起来,这意味着字符串文字必须用单引号定义。JavaScript 连接操作符是+字符,表达式的结果将是两个字符串的组合,如下所示:

text-white m-2 p-2 bg-success

其效果是,ngClass指令将主机元素添加到四个类中,text-whitem-2p-2,Bootstrap 使用这些类来设置文本颜色,并在元素内容周围添加边距和填充;和bg-success,它设置背景颜色。图 12-4 显示了这两类的组合。

img/421542_4_En_12_Fig4_HTML.jpg

图 12-4。

在 JavaScript 表达式中组合类

编写表达式时很容易忘乎所以,在模板中包含复杂的逻辑。这可能会导致问题,因为表达式不会被 TypeScript 编译器检查,也不容易进行单元测试,这意味着在部署应用之前,错误很可能不会被发现。为了避免这个问题,表达式应该尽可能简单,并且理想情况下,只用于从组件中检索数据并将其格式化以供显示。所有复杂的检索和处理逻辑都应该在组件或模型中定义,在那里可以对其进行编译和测试。

理解括号

方括号([]字符)告诉 Angular 这是一个单向数据绑定,其中有一个表达式应该被求值。如果您省略了括号并且目标是一个指令,Angular 仍然会处理绑定,但是不会计算表达式,并且引号字符之间的内容将作为文字值传递给指令。清单 12-4 用一个没有方括号的绑定向模板添加了一个元素。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Hello, World.
</div>
<div ngClass="'text-white m-2 p-2 ' + getClasses()">
  Hello, World.
</div>

Listing 12-4.Omitting the Brackets in a Data Binding in the template.html File in the src/app Folder

如果您在浏览器的 DOM 查看器中检查 HTML 元素(通过在浏览器窗口中右键单击并从弹出菜单中选择 Inspect 或 Inspect Element),您将看到它的class属性已被设置为文字字符串,如下所示:

class="'text-white m-2 p-2 ' + getClasses()"

浏览器将尝试处理主机元素被分配到的类,但是元素的外观不会像预期的那样,因为类与 Bootstrap 使用的名称不对应。这是一个常见的错误,所以首先要检查一个绑定是否没有达到您预期的效果。

方括号并不是 Angular 在数据绑定中使用的唯一方括号。为了快速参考,表 12-5 提供了一套完整的括号,每个括号的含义,以及它们的详细描述。

表 12-5。

尖括号

|

名字

|

描述

| | --- | --- | | [target]="expr" | 方括号表示单向数据绑定,数据从表达式流向目标。这种绑定的不同形式是本章的主题。 | | {{expression}} | 这是字符串插值绑定,在“使用字符串插值绑定”一节中有所描述。 | | (target) ="expr" | 圆括号表示单向绑定,数据从目标流向表达式指定的目的地。这是用于处理事件的绑定,如第十四章所述。 | | [(target)] ="expr" | 这种括号的组合——被称为盒中香蕉——表示一种双向绑定,其中数据在由表达式指定的目标和目的地之间双向流动,如第十四章中所述。 |

了解主体元素

宿主元素是数据绑定中最简单的部分。数据绑定可以应用于模板中的任何 HTML 元素,一个元素可以有多个绑定,每个绑定可以管理元素外观或行为的不同方面。在后面的示例中,您将看到具有多个绑定的元素。

使用标准属性和特性绑定

如果绑定的目标与指令不匹配,Angular 将尝试应用属性绑定。接下来的部分描述了最常见的属性绑定:标准属性绑定和属性绑定。

使用标准属性绑定

浏览器使用文档对象模型来表示 HTML 文档。HTML 文档中的每个元素,包括 host 元素,都使用 DOM 中的 JavaScript 对象来表示。像所有 JavaScript 对象一样,用于表示 HTML 元素的对象也有属性。这些属性用于管理元素的状态,例如,value属性用于设置input元素的内容。当浏览器解析 HTML 文档时,它会遇到每个新的 HTML 元素,在 DOM 中创建一个对象来表示它,并使用元素的属性来设置对象属性的初始值。

标准属性绑定允许您使用表达式的结果为表示宿主元素的对象设置属性值。例如,将一个绑定的目标设置为value将会设置一个input元素的内容,如清单 12-5 所示。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Hello, World.
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>

Listing 12-5.Using the Standard Property Binding in the template.html File in the src/app Folder

本例中的新绑定指定value属性应该绑定到一个表达式的结果,该表达式调用数据模型上的一个方法,通过指定一个键从存储库中检索数据对象。有可能没有带有那个键的数据对象,在这种情况下,存储库方法将返回null

为了防止将null用于主机元素的 value 属性,绑定使用空条件操作符(?字符)来安全地导航该方法返回的结果,如下所示:

...
<input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
...

如果来自getProduct方法的结果不是null,那么表达式将读取name属性的值并将其用作结果。但是如果方法的结果是null,那么name属性将不会被读取,空合并操作符(||字符)将会把结果设置为None

Getting to Know the HTML Element Properties

使用属性绑定可能需要做一些工作来确定需要设置哪个属性,因为 HTML 规范中存在不一致的地方。大多数属性的名称与设置其初始值的属性的名称相匹配,例如,如果您习惯于在一个input元素上设置value属性,那么您可以通过设置value属性达到相同的效果。但是有些属性名和属性名不匹配,有些属性根本不是由属性配置的。

Mozilla Foundation 为所有用于在 DOM 中表示 HTML 元素的对象提供了一个有用的参考。对于每个元素,Mozilla 提供了可用属性的摘要以及每个属性的用途。从HTMLElement ( developer.mozilla.org/en-US/docs/Web/API/HTMLElement)开始,它提供了所有元素共有的功能。然后,您可以分支到特定元素的对象中,比如用于表示input元素的HTMLInputElement

当您保存对模板的修改时,浏览器会重新加载并显示一个input元素,其内容是模型库中关键字为1的数据对象的name属性,如图 12-5 所示。

img/421542_4_En_12_Fig5_HTML.jpg

图 12-5。

使用标准属性绑定

使用字符串插值绑定

Angular 提供了一个特殊版本的标准属性绑定,称为字符串插值绑定,用于在宿主元素的文本内容中包含表达式结果。要理解这种特殊绑定为什么有用,考虑一下如何使用标准属性绑定来设置元素的内容会有所帮助。属性用于设置 HTML 元素的内容,这意味着元素的内容可以使用数据绑定来设置,如清单 12-6 所示。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()"
          [textContent]="'Name: ' + (model.getProduct(1)?.name || 'None')">
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>

Listing 12-6.Setting an Element’s Content in the template.html File in the src/app Folder

新绑定中的表达式将一个文字字符串与一个方法调用的结果连接起来,以设置div元素的内容。

这个例子中的表达式很难写,需要特别注意引号、空格和括号,以确保输出中显示预期的结果。对于更复杂的绑定来说,这个问题变得更糟,因为在静态内容块中散布着多个动态值。

字符串插值绑定通过允许在元素内容中定义表达式片段简化了这个过程,如清单 12-7 所示。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Name: {{ model.getProduct(1)?.name || 'None' }}
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>

Listing 12-7.Using the String Interpolation Binding in the template.html File in the src/app Folder

字符串插值绑定使用成对的花括号({{}})来表示。一个元素可以包含多个字符串插值绑定。

Angular 将 HTML 元素的内容与方括号的内容结合起来,为textContent属性创建一个绑定。结果和清单 12-6 一样,如图 12-6 所示,但是编写绑定的过程更简单,更不容易出错。

img/421542_4_En_12_Fig6_HTML.jpg

图 12-6。

使用字符串插值绑定

使用属性绑定

HTML 和 DOM 规范中有一些奇怪的地方,这意味着并非所有的 HTML 元素属性在 DOM API 中都有等价的属性。对于这些情况,Angular 提供了属性绑定,用于设置主机元素的属性,而不是设置在 DOM 中表示它的 JavaScript 对象的值。

最常用的没有相应属性的属性是colspan,它用于设置一个td元素在一个表中所占的列数。清单 12-8 展示了如何使用属性绑定来根据数据模型中对象的数量设置colspan元素。

<div [ngClass]="'text-white m-2 p-2 ' + getClasses()">
  Name: {{model.getProduct(1)?.name || 'None'}}
</div>
<div class="form-group m-2">
  <label>Name:</label>
  <input class="form-control" [value]="model.getProduct(1)?.name || 'None'" />
</div>
<table class="table table-sm table-bordered table-striped mt-2">
    <tr>
        <th>1</th><th>2</th><th>3</th><th>4</th><th>5</th>
    </tr>
    <tr>
        <td [attr.colspan]="model.getProducts().length">
            {{model.getProduct(1)?.name || 'None'}}
        </td>
    </tr>
</table>

Listing 12-8.Using an Attribute Binding in the template.html File in the src/app Folder

属性绑定是通过定义一个目标来应用的,该目标在属性的名称前加上前缀attr.(术语attr,后跟一个句点)。在清单中,我使用了属性绑定来设置表中一个td元素上的colspan元素的值,如下所示:

...
<td [attr.colspan]="model.getProducts().length">
...

Angular 将计算表达式并将colspan属性的值设置为结果。由于数据模型是从五个数据对象开始的,结果是colspan属性创建了一个跨越五列的表格单元,如图 12-7 所示。

img/421542_4_En_12_Fig7_HTML.jpg

图 12-7。

使用属性绑定

设置类别和样式

Angular 在属性绑定中为将宿主元素分配给类以及配置单个样式属性提供了特殊支持。我将在接下来的章节中描述这些绑定,以及提供密切相关特性的ngClassngStyle指令的细节。

使用类绑定

有三种不同的方法可以使用数据绑定来管理元素的类成员资格:标准属性绑定、特殊类绑定和ngClass指令。表 12-6 中描述了这三种方法,每种方法的工作方式略有不同,在不同的情况下都很有用,如下文所述。

表 12-6。

Angular 类绑定

|

例子

|

描述

| | --- | --- | | <div [class]="expr"></div> | 此绑定计算表达式,并使用结果替换任何现有的类成员身份。 | | <div [class.myClass]="expr"></div> | 这个绑定对表达式求值,并使用结果来设置元素的成员资格myClass。 | | <div [ngClass]="map"></div> | 该绑定使用 map 对象中的数据设置多个类的类成员资格。 |

用标准绑定设置元素的所有类

标准属性绑定可用于在一个步骤中设置元素的所有类,这在组件中有一个方法或属性以单个字符串返回元素所属的所有类(名称用空格分隔)时非常有用。清单 12-9 显示了组件中getClasses方法的修改,它基于Product对象的price属性返回不同的类名字符串。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }
}

Listing 12-9.Providing All Classes in a Single String in the component.ts File in the src/app Folder

来自getClasses方法的结果将包括p-2类,它为所有的Product对象在主机元素的内容周围添加填充。如果price属性的值小于 50,结果中将包含bg-info类,如果值大于等于 50,将包含bg-warning类(这些类设置不同的背景颜色)。

Tip

您必须确保类名由空格分隔。

清单 12-10 显示了模板中使用的标准属性绑定,使用组件的getClasses方法设置主机元素的class属性。

<div class="text-white m-2">
  <div [class]="getClasses(1)">
    The first product is {{model.getProduct(1).name}}.
  </div>
  <div [class]="getClasses(2)">
    The second product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-10.Setting Class Memberships in the template.html File in the src/app Folder

当使用标准属性绑定来设置class属性时,表达式的结果将替换元素所属的任何以前的类,这意味着只有当绑定表达式返回所有需要的类时,才能使用它,如本例所示,产生如图 12-8 所示的结果。

img/421542_4_En_12_Fig8_HTML.jpg

图 12-8。

设置类成员资格

使用特殊的类绑定设置单个类

特殊的类绑定提供了比标准属性绑定更细粒度的控制,并允许使用表达式管理单个类的成员资格。如果您希望在元素的现有类成员基础上构建,而不是完全替换它们,这将非常有用。清单 12-11 展示了特殊类绑定的使用。

<div class="text-white m-2">
  <div [class]="getClasses(1)">
    The first product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2"
       [class.bg-success]="model.getProduct(2).price < 50"
       [class.bg-info]="model.getProduct(2).price >= 50">
    The second product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-11.Using the Special Class Binding in the template.html File in the src/app Folder

这个特殊的类绑定是用一个 target 指定的,target 组合了术语class,后跟一个句点,再跟一个被管理成员的类名。在清单中,有两个特殊的类绑定,它们管理bg-successbg-info类的成员。

如果表达式的结果是真值,那么特殊的类绑定会将主机元素添加到指定的类中(如“理解真值和假值”侧栏中所述)。在这种情况下,如果price属性小于 50,则主机元素将是bg-success类的成员,如果价格属性大于等于 50,则主机元素将是bg-info类的成员。

这些绑定相互独立,不会干扰元素所属的任何现有类,例如p-2类,Bootstrap 使用它在元素内容周围添加填充。

Understanding Truthy and Falsy

JavaScript 有一个奇怪的特性,表达式的结果可能是真的,也可能是假的,这为粗心的人提供了一个陷阱。以下结果总是假的:

  • false ( boolean)值

  • 0 ( number)值

  • 空字符串("")

  • null

  • undefined

  • NaN(特殊数值)

所有其他值都是真实的,这可能会令人困惑。例如,"false"(内容为单词false的字符串)为 truthy。避免混淆的最好方法是只使用评估为booleantruefalse的表达式。

使用 nclass 指令设置类

ngClass指令是标准和特殊属性绑定的一种更灵活的替代方式,根据表达式返回的数据类型表现不同,如表 12-7 所述。

表 12-7。

ngClass 指令支持的表达式结果类型

|

名字

|

描述

| | --- | --- | | String | 主机元素被添加到由字符串指定的类中。多个类由空格分隔。 | | Array | 数组中的每个对象都是宿主元素将被添加到的类的名称。 | | Object | 对象上的每个属性都是一个或多个类的名称,用空格分隔。如果属性值为 true,则宿主元素将被添加到类中。 |

字符串和数组特性很有用,但是使用对象(称为映射)来创建复杂的类成员关系策略的能力使得ngClass指令特别有用。清单 12-12 显示了返回地图对象的组件方法的添加。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }

    getClassMap(key: number): Object {
        let product = this.model.getProduct(key);
        return {
            "text-center bg-danger": product.name == "Kayak",
            "bg-info": product.price < 50
        };
    }
}

Listing 12-12.Returning a Class Map Object in the component.ts File in the src/app Folder

getClassMap方法返回一个对象,该对象的属性值是一个或多个类名,其值基于Product对象的属性值,该对象的键被指定为方法参数。例如,当密钥为 1 时,该方法返回此对象:

...
{
  "text-center bg-danger":true,
  "bg-info":false
}
...

第一个属性将主机元素分配给text-center类(Bootstrap 使用它来水平居中文本)和bg-danger类(它设置元素的背景颜色)。第二个属性的值为false,这意味着主机元素不会被添加到bg-info类中。指定一个不会导致元素被添加到类中的属性可能看起来很奇怪,但是,您很快就会看到,表达式的值会自动更新以反映应用中的变化,并且能够定义一个以这种方式指定成员资格的 map 对象可能会很有用。

清单 12-13 显示了getClassMap和它返回的映射对象,这些对象被用作针对ngClass指令的数据绑定的表达式。

<div class="text-white m-2">
  <div class="p-2" [ngClass]="getClassMap(1)">
    The first product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2" [ngClass]="getClassMap(2)">
    The second product is {{model.getProduct(2).name}}.
  </div>
  <div class="p-2" [ngClass]="{'bg-success': model.getProduct(3).price < 50,
                                'bg-info': model.getProduct(3).price >= 50}">
        The third product is {{model.getProduct(3).name}}
  </div>
</div>

Listing 12-13.Using the ngClass Directive in the template.html File in the src/app Folder

前两个div元素有使用getClassMap方法的绑定。第三个div元素显示了另一种方法,即在模板中定义地图。对于这个元素,bg-infobg-warning类的成员关系与Product对象的价格属性的值相关联,如图 12-9 所示。应该小心使用这种技术,因为表达式包含不容易测试的 JavaScript 逻辑。

img/421542_4_En_12_Fig9_HTML.jpg

图 12-9。

使用 nclass 指令

使用样式绑定

有三种不同的方法可以使用数据绑定来设置主机元素的样式属性:标准属性绑定、特殊样式绑定和ngStyle指令。表 12-8 中描述了这三种方法,并在以下章节中进行了演示。

表 12-8。

有 Angular 的样式绑定

|

例子

|

描述

| | --- | --- | | <div [style.myStyle]="expr"></div> | 这是标准的属性绑定,用于将单个样式属性设置为表达式的结果。 | | <div [style.myStyle.units]="expr"></div> | 这是特殊的样式绑定,它允许将样式值的单位指定为目标的一部分。 | | <div [ngStyle]="map"></div> | 此绑定使用地图对象中的数据设置多个样式属性。 |

设置单一样式属性

标准属性绑定和特殊样式绑定用于设置单个样式属性的值。这些绑定之间的区别在于,标准属性绑定必须包含样式所需的单元,而特殊绑定允许将单元包含在绑定目标中。为了演示不同之处,清单 12-14 向组件添加了两个新属性。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }

    getClassMap(key: number): Object {
        let product = this.model.getProduct(key);
        return {
            "text-center bg-danger": product.name == "Kayak",
            "bg-info": product.price < 50
        };
    }

    fontSizeWithUnits: string = "30px";
    fontSizeWithoutUnits: string= "30";
}

Listing 12-14.Adding Properties in the component.ts File in the src/app Folder

fontSizeWithUnits属性返回一个值,该值包括一个数量和表示该数量的单位:30 像素。属性只返回数量,没有任何单位信息。清单 12-15 展示了如何将这些属性用于标准和特殊绑定。

Caution

不要试图使用标准属性绑定来针对style属性设置多个样式值。由表示 DOM 中主机元素的 JavaScript 对象的style属性返回的对象是只读的。有些浏览器会忽略这一点,允许进行更改,但结果是不可预测的,也是不可靠的。如果你想设置多个样式属性,那么为每个属性创建一个绑定或者使用ngStyle指令。

<div class="text-white m-2">
  <div class="p-2 bg-warning">
    The <span [style.fontSize]="fontSizeWithUnits">first</span>
    product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2 bg-info">
    The <span [style.fontSize.px]="fontSizeWithoutUnits">second</span>
    product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-15.Using Style Bindings in the template.html File in the src/app Folder

绑定的目标是style.fontSize,它设置用于主机元素内容的字体大小。这个绑定的表达式使用了fontSizeWithUnits属性,其值包括设置字体大小所需的单位px像素。

特殊绑定的目标是style.fontSize.px,它告诉 Angular 表达式的值指定了像素数。这允许绑定使用组件的fontSizeWithoutUnits属性,它不包括单元。

Tip

您可以使用 JavaScript 属性名称格式([style.fontSize])或 CSS 属性名称格式([style.font-size])来指定样式属性。

两个绑定的结果是一样的,都是将span元素的字体大小设置为 30 像素,产生如图 12-10 所示的结果。

img/421542_4_En_12_Fig10_HTML.jpg

图 12-10。

设置单个样式属性

使用 ngStyle 指令设置样式

ngStyle指令允许使用一个地图对象设置多个样式属性,类似于ngClass指令的工作方式。清单 12-16 显示了添加一个组件方法,返回一个包含样式设置的地图。

import { Component } from "@angular/core";
import { Model } from "./repository.model";

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    getClasses(key: number): string {
        let product = this.model.getProduct(key);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }

    getStyles(key: number) {
        let product = this.model.getProduct(key);
        return {
            fontSize: "30px",
            "margin.px": 100,
            color: product.price > 50 ? "red" : "green"
        };
    }
}

Listing 12-16.Creating a Style Map Object in the component.ts File in the src/app Folder

getStyle方法返回的 map 对象表明ngStyle指令能够支持可用于属性绑定的两种格式,包括值中的单位或属性名。下面是当 key 参数的值为 1 时,getStyles方法产生的 map 对象:

...
{
  "fontSize":"30px",
  "margin.px":100,
  "color":"red"
}
...

清单 12-17 显示了模板中使用ngStyle指令的数据绑定,其表达式调用getStyles方法。

<div class="text-white m-2">
  <div class="p-2 bg-warning">
    The <span [ngStyle]="getStyles(1)">first</span>
    product is {{model.getProduct(1).name}}.
  </div>
  <div class="p-2 bg-info">
    The <span [ngStyle]="getStyles(2)">second</span>
    product is {{model.getProduct(2).name}}
  </div>
</div>

Listing 12-17.Using the ngStyle Directive in the template.html File in the src/app Folder

结果是每个span元素接收一组定制的样式,基于传递给getStyles方法的参数,如图 12-11 所示。

img/421542_4_En_12_Fig11_HTML.jpg

图 12-11。

使用 ngStyle 指令

更新应用中的数据

当您开始使用 Angular 时,似乎需要花费很多精力来处理数据绑定,记住在不同的情况下需要哪种绑定。你可能想知道这是否值得努力。

绑定值得理解,因为当它们所依赖的数据改变时,它们的表达式会被重新计算。例如,如果您使用字符串插值绑定来显示属性值,则当属性值更改时,绑定将自动更新。

为了提供一个演示,我将跳到前面,向您展示如何手动控制更新过程。这不是正常 Angular 开发中需要的技术,但它提供了一个为什么绑定如此重要的坚实的演示。清单 12-18 显示了对支持演示的组件的一些更改。

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

@Component({
    selector: "app",
    templateUrl: "template.html"
})
export class ProductComponent {
    model: Model = new Model();

    constructor(ref: ApplicationRef) {
        (<any>window).appRef = ref;
        (<any>window).model = this.model;
    }

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

    getClassesByPosition(position: number): string {
        let product = this.getProductByPosition(position);
        return "p-2 " + (product.price < 50 ? "bg-info" : "bg-warning");
    }
}

Listing 12-18.Preparing the Component in the component.ts File in the src/app Folder

我已经从@angular/core模块中导入了ApplicationRef类型。当 Angular 执行引导过程时,它创建一个ApplicationRef对象来表示应用。清单 12-18 使用 Angular 依赖注入特性,向接收ApplicationRef对象作为参数的组件添加一个构造函数,我在第十九章中对此进行了描述。现在不讨论细节,像这样声明一个构造函数参数告诉 Angular,当一个新的实例被创建时,组件想要接收ApplicationRef对象。

在构造函数中,有两个语句使演示成为可能,但如果在实际项目中使用,会破坏使用 TypeScript 和 Angular 的许多好处。

...
(<any>window).appRef = ref;
(<any>window).model = this.model;
...

这些语句在全局名称空间中定义变量,并将ApplicationRefModel对象分配给它们。保持全局名称空间尽可能清晰是一个好习惯,但是公开这些对象允许通过浏览器的 JavaScript 控制台操作它们,这对本例很重要。

添加到构造函数中的其他方法允许根据位置从存储库中检索Product对象,而不是根据它的键,并根据price属性的值生成不同的类别映射。

清单 12-19 显示了对模板的相应更改,它使用ngClass指令来设置类成员资格,并使用字符串插值绑定来显示 Product.name 属性的值。

<div class="text-white m-2">
  <div [ngClass]="getClassesByPosition(0)">
    The first product is {{getProductByPosition(0).name}}.
  </div>
  <div [ngClass]="getClassesByPosition(1)">
    The second product is {{getProductByPosition(1).name}}
  </div>
</div>

Listing 12-19.Preparing for Changes in the template.html File in the src/app Folder

保存对组件和模板的更改。浏览器重新加载页面后,在浏览器的 JavaScript 控制台中输入以下语句,然后按 Return 键:

model.products.shift()

该语句对模型中的Product对象的数组调用shift方法,从数组中移除第一个项目并返回它。您还看不到任何变化,因为 Angular 不知道模型已经被修改。要让 Angular 检查更改,请在浏览器的 JavaScript 控制台中输入以下语句,然后按 Return 键:

appRef.tvick()

tick方法启动 Angular 变化检测过程,其中 Angular 查看应用中的数据和数据绑定中的表达式,并处理任何变化。模板中的数据绑定使用特定的数组索引来显示数据,现在已经从模型中移除了一个对象,绑定将被更新以显示新的值,如图 12-12 所示。

img/421542_4_En_12_Fig12_HTML.jpg

图 12-12。

手动更新应用模型

值得花点时间来思考一下变更检测过程运行时发生了什么。Angular 重新评估了模板中绑定的表达式,并更新了它们的值。反过来,ngClass指令和字符串插值绑定通过改变它们的类成员和显示新内容来重新配置它们的宿主元素。

发生这种情况是因为 Angular 数据绑定是活动的,这意味着在初始内容显示给用户之后,表达式、目标和主机元素之间的关系继续存在,并动态地反映应用状态的变化。我承认,当您不必使用 JavaScript 控制台进行更改时,这种效果更令人印象深刻。我会在第十四章的中解释 Angular 如何允许用户使用事件和表单来触发变化。

摘要

在本章中,我描述了 Angular 数据绑定的结构,并向您展示了如何使用它们来创建应用中的数据和显示给用户的 HTML 元素之间的关系。我介绍了属性绑定,并描述了如何使用两个内置指令— ngClassngStyle。在下一章,我将解释更多的内置指令是如何工作的。