个性化阅读
专注于IT技术分析

Angular 5教程:第一个Angular 5应用程序分步指南

Angular是由Google开发的AngularJS框架的新版本。它具有完整的重写和各种改进, 包括优化的构建和更快的编译时间。在这个Angular 5教程中, 我们将从头开始构建一个notes应用程序。如果你一直在等待学习Angular 5, 本教程适合你。

Angular 5教程:第一个Angular 5应用程序分步指南

该应用程序的最终源代码可以在这里找到。

该框架有两个主要版本:AngularJS(版本1)和Angular(版本2+)。从版本2开始, Angular不再是JavaScript框架, 因此它们之间存在巨大差异, 因此需要进行基本的名称更改。

我应该使用Angular吗?

这取决于。一些开发人员会告诉你, 最好使用React并构建自己的组件, 而无需太多其他代码。但这也可能是一个问题。 Angular是一个完全集成的框架, 使你可以快速开始处理项目, 而无需考虑选择哪个库以及如何处理日常问题。我认为Angular适用于前端, RoR适用于后端。

TypeScript

如果你不懂TypeScript, 请不要害怕。你的JavaScript知识足以快速学习TypeScript, 并且大多数现代编辑器在帮助方面都非常有效。如今, 最可取的选择是VSCode和任何JetBrains IntelliJ系列产品(例如Webstorm或我的RubyMine)。对我来说, 最好使用比vim更智能的编辑器, 因为TypeScript是强类型的, 它将使你对代码中的任何错误有更多的了解。还要提到的另一件事是, Angular CLI及其Webpack负责将TS编译为JS, 因此你不应该让IDE为你编译它。

角度CLI

Angular现在具有自己的CLI或命令行界面, 它将为你执行大多数例行操作。要开始使用Angular, 我们必须先安装它。它需要Node 6.9.0或更高版本以及NPM 3或更高版本。我们将不介绍它们在你的系统上的安装, 因为最好自行查找用于安装的最新文档。两者都安装之后, 我们将通过运行以下命令来安装Angular CLI:

npm install -g @angular/cli

安装成功后, 我们可以通过运行ng new命令生成一个新项目:

ng new getting-started-ng5
  create getting-started-ng5/README.md (1033 bytes)
  create getting-started-ng5/.angular-cli.json (1254 bytes)
  create getting-started-ng5/.editorconfig (245 bytes)
  create getting-started-ng5/.gitignore (516 bytes)
  create getting-started-ng5/src/assets/.gitkeep (0 bytes)
  create getting-started-ng5/src/environments/environment.prod.ts (51 bytes)
  create getting-started-ng5/src/environments/environment.ts (387 bytes)
  create getting-started-ng5/src/favicon.ico (5430 bytes)
  create getting-started-ng5/src/index.html (304 bytes)
  create getting-started-ng5/src/main.ts (370 bytes)
  create getting-started-ng5/src/polyfills.ts (2405 bytes)
  create getting-started-ng5/src/styles.css (80 bytes)
  create getting-started-ng5/src/test.ts (1085 bytes)
  create getting-started-ng5/src/tsconfig.app.json (211 bytes)
  create getting-started-ng5/src/tsconfig.spec.json (304 bytes)
  create getting-started-ng5/src/typings.d.ts (104 bytes)
  create getting-started-ng5/e2e/app.e2e-spec.ts (301 bytes)
  create getting-started-ng5/e2e/app.po.ts (208 bytes)
  create getting-started-ng5/e2e/tsconfig.e2e.json (235 bytes)
  create getting-started-ng5/karma.conf.js (923 bytes)
  create getting-started-ng5/package.json (1324 bytes)
  create getting-started-ng5/protractor.conf.js (722 bytes)
  create getting-started-ng5/tsconfig.json (363 bytes)
  create getting-started-ng5/tslint.json (3040 bytes)
  create getting-started-ng5/src/app/app.module.ts (316 bytes)
  create getting-started-ng5/src/app/app.component.css (0 bytes)
  create getting-started-ng5/src/app/app.component.html (1141 bytes)
  create getting-started-ng5/src/app/app.component.spec.ts (986 bytes)
  create getting-started-ng5/src/app/app.component.ts (207 bytes)
Installing packages for tooling via yarn.
yarn install v1.3.2
info No lockfile found.
[1/4] ????  Resolving packages...
[2/4] ????  Fetching packages...
[3/4] ????  Linking dependencies...
warning "@angular/cli > @schematics/[email protected]" has incorrect peer dependency "@angular-devkit/[email protected]".
warning "@angular/cli > @angular-devkit/schematics > @schematics/[email protected]" has incorrect peer dependency "@angular-devkit/[email protected]".
[4/4] ????  Building fresh packages...
success Saved lockfile.
✨  Done in 44.12s.
Installed packages for tooling via yarn.
Successfully initialized git.
Project 'getting-started-ng5' successfully created.

完成之后, 我们可以要求我们的新应用程序通过在其目录外运行ng serve来启动:

ng serve
** NG Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **
Date: 2017-12-13T17:48:30.322Z
Hash: d147075480d038711dea
Time: 7425ms
chunk {inline} inline.bundle.js (inline) 5.79 kB [entry] [rendered]
chunk {main} main.bundle.js (main) 20.8 kB [initial] [rendered]
chunk {polyfills} polyfills.bundle.js (polyfills) 554 kB [initial] [rendered]
chunk {styles} styles.bundle.js (styles) 34.1 kB [initial] [rendered]
chunk {vendor} vendor.bundle.js (vendor) 7.14 MB [initial] [rendered]

webpack: Compiled successfully.

如果我们将浏览器导航到该链接, 它将显示为此处的图片:

Angular App欢迎页面

那么, 这里实际发生了什么? Angular CLI运行webpack开发服务器, 该服务器在实时下载时在下一个可用端口上渲染我们的应用程序(以便你可以在同一台计算机上运行多个应用程序)。它还会监视项目源中的每个更改并重新编译所有更改, 然后要求浏览器重新加载打开的页面。因此, 通过使用Angular CLI, 我们已经在开发环境中工作, 而无需编写配置行或实际执行任何操作。但是我们才刚刚开始…

组件

我们正在运行空应用程序。我们来谈谈Angular中的应用程序组成。如果你具有AngularJS开发的背景知识, 那么你就会知道有些控制器, 指令和组件在某种程度上类似于指令, 但更简单, 可以让你升级到Angular2。对于那些没有那么精彩的选择经验的人他们之间, 并找出哪里去了, 不用担心。如今主要只是组件。该组件是Angular世界中最基本的构建块。让我们看看Angular CLI为我们生成的代码。

首先, 这里是index.html:

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

看起来就像你每天看到的那种标记。但是有一个特殊的标签, app-root。 Angular如何做到这一点?我们如何知道其中发生了什么?

让我们打开src / app目录, 查看其中的内容。你可以在此处查看ng new输出形式, 或在你选择的IDE中打开它。你将看到我们在下一个位置有app.component.ts(这可能取决于你的Angular版本是最新的):

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

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

@Component(…)在这里看起来像一个函数调用…这是什么?这是TypeScript装饰器, 我们稍后再讨论。现在, 让我们尝试了解它的功能, 并使用诸如selector之类的传递参数来生成我们的组件声明。它只是为我们做了很多样板工作, 并以工作形式返回组件声明。我们不必实施其他代码来支持装饰者的任何参数。全部由装饰员处理。因此, 通常, 我们将其称为工厂方法。

我们已经在index.html中看到了app-root。 Angular知道如何找到与我们的标签相对应的组件。显然, templateUrl和styleUrls定义Angular应该从哪里获取我们的标记和CSS。组件装饰器还有很多参数, 我们将在新应用中使用其中的一些参数, 但是如果你想获得完整的参考, 可以随时在这里查看。

让我们看一下该组件的标记:

<!--The content below is only a placeholder and can be replaced.-->
<div style="text-align:center">
  <h1>
    Welcome to {{ title }}!
  </h1>
  <img width="300" alt="Angular Logo" src="data:image/svg+xml;base64, PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==">
</div>
<h2>Here are some links to help you start: </h2>
<ul>
  <li>
    <h2><a target="_blank" rel="noopener" href="https://angular.io/tutorial">Tour of Heroes</a></h2>
  </li>
  <li>
    <h2><a target="_blank" rel="noopener" href="https://github.com/angular/angular-cli/wiki">CLI Documentation</a></h2>
  </li>
  <li>
    <h2><a target="_blank" rel="noopener" href="https://blog.angular.io/">Angular blog</a></h2>
  </li>
</ul>

因此, 除了将Angular徽标嵌入为SVG(非常简洁)之外, 这似乎也很典型。除了一件事(欢迎使用{{title}}!), 如果再次查看组件代码, 我们将看到title =’app’;。因此, 如果你已经有一些模板语言方面的实践经验或使用过AngularJS, 那么很明显这里发生了什么。如果你不知道, 这称为Angular Interpolation, 通过它从我们的组件中提取双花括号内的表达式(你可以将{{title}}视为{{this.title}}的简化形式)并显示在我们的标记上。

现在, 我们已经看到了自动生成的Angular应用程序的所有部分, 这些部分实际上发生在浏览器中显示的页面中。让我们回顾一下它的实际工作原理:Angular CLI运行Webpack, 它将Web应用程序编译为JavaScript包, 并将其注入到我们的index.html中。如果我们使用检查功能在浏览器中查看实际代码, 则会看到类似以下内容的内容:

通过Web检查器进行角度分析

每次更改代码时, Angular CLI都会重新编译, 并在需要时重新注入, 并要求我们的浏览器重新加载页面(如果打开的话)。 Angular的运行速度非常快, 因此在大多数情况下, 当你将窗口从IDE切换到浏览器时, 它已经为你重新加载了。

因此, 让我们开始朝着我们的目标迈进, 首先, 让我们将项目从CSS切换到Sass, 然后打开.angular-cli.json并编辑样式和styleExt属性:

"styles": [
  "styles.scss"
], [...]
"defaults": {
  "styleExt": "scss", "component": {}
}

我们还需要将Sass库添加到我们的项目中, 并将styles.css重命名为styles.scss。因此, 要添加Sass, 我正在使用yarn:

yarn add sass 
yarn add v1.3.2
[1/4] ????  Resolving packages...
[2/4] ????  Fetching packages...
[3/4] ????  Linking dependencies...
[...]
[4/4] ????  Building fresh packages...
success Saved lockfile.
success Saved 1 new dependency.
└─ [email protected]
✨  Done in 12.06s.
yarn add [email protected] --dev
✨  Done in 5.78s.

我也想在我们的项目上使用Twitter Bootstrap, 所以我也运行yarn add [email protected]并编辑我们的styles.scss以包括以下内容:

/* You can add global styles to this file, and also import other style files */
@import "../node_modules/bootstrap/scss/bootstrap";
 
body {
  padding-top: 5rem;
}

我们需要编辑index.html, 以通过将标记的meta更改为以下内容来使页面响应:

<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">

现在我们可以用以下代码替换app.component.html:

<!-- Fixed navbar -->
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
  <a class="navbar-brand" href="#">Angular Notes</a>
</nav>
<div class="container-fluid text-center pb-5">
  <div style="text-align:center">
    <h1>
      Welcome to {{title}}!
    </h1>
  </div>
</div>

现在, 如果打开浏览器, 则会看到以下内容:

欢迎使用该应用

这就是样板。让我们继续创建自己的组件。

我们的第一个组成部分

我们将在界面中将备注显示为卡片, 因此, 首先生成代表卡片本身的第一个组件。为此, 请运行以下命令来使用Angular CLI:

ng generate component Card
  create src/app/card/card.component.scss (0 bytes)
  create src/app/card/card.component.html (23 bytes)
  create src/app/card/card.component.spec.ts (614 bytes)
  create src/app/card/card.component.ts (262 bytes)
  update src/app/app.module.ts (390 bytes)

如果我们查看src / app / card / card.component.ts, 我们可以看到它们几乎与我们的AppComponent中的代码相同, 只有一点点不同:

[...]
@Component({
  selector: 'app-card', [...]
export class CardComponent implements OnInit {

  constructor() { }

  ngOnInit() {
  }
}

在这一点上, 我想提一下, 在我们的组件选择器前添加通用前缀(默认为app-)被认为是一种很好的做法。你可以通过在.angular-cli.json中编辑prefix属性来将其更改为首选项的前缀, 因此最好在首次使用ng generate之前进行此操作。

因此, 我们有一个用于组件的构造函数以及一个ngOnInit函数。如果你好奇我们为什么这样做, 可以在Angular文档中阅读。但是从基本的角度来看, 请考虑以下这些方法:在创建组件之后, 即在准备好要传递给它的数据并对其进行填充之前很久就调用了一个构造函数, 而ngOnInit仅在更改的第一个周期之后运行数据, 因此你可以访问组件输入。我们将很快讨论输入和组件通信, 但是现在, 让我们记住, 最好将构造函数用于常量, 例如实际上被硬编码到组件中的东西, 以及ngOnInit用于依赖于所有内容的东西。外部数据。

让我们填充CardComponent实现。首先, 让我们添加一些标记。标记的默认内容如下所示:

<p>
  card works!
</p>

让我们用代码替换它, 使其表现得像一张卡片:

<div class="card">
  <div class="card-block">
    <p class="card-text">Text</p>
  </div>
</div>

现在是显示卡组件的好时机, 但这引发了其他问题:谁将负责显示卡? AppComponent?但是AppComponent会先加载, 然后再加载, 因此我们必须考虑它的大小和体积。我们最好再创建一个组件, 以存储卡片列表并将其显示在我们的页面上。

正如我们描述组件的职责时一样, 很明显, 这应该是”卡片列表组件”。让我们请Angular CLI为我们生成它:

ng generate component CardList
  create src/app/card-list/card-list.component.scss (0 bytes)
  create src/app/card-list/card-list.component.html (28 bytes)
  create src/app/card-list/card-list.component.spec.ts (643 bytes)
  create src/app/card-list/card-list.component.ts (281 bytes)
  update src/app/app.module.ts (483 bytes)

在开始实施它之前, 让我们看一下在生成第一个组件之后被忽略的东西。 Angular CLI告诉我们它为我们更新了app.module.ts。我们从未研究过它, 因此请更正它:

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


import { AppComponent } from './app.component';
import { CardComponent } from './card/card.component';
import { CardListComponent } from './card-list/card-list.component';


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

显然, BrowserModule和NgModule是内部Angular模块。我们可以在文档中阅读有关它们的更多信息。在我们开始生成任何代码之前, AppComponent就在这里, 因此我们的新组件实际上在两个地方填充了该模块:首先, 从它们的定义文件中导入它们, 然后将它们包含在我们的NgModule装饰器的声明数组中。如果你是从头开始创建新组件, 而忘记将新模块添加到NgModule中, 但尝试将其添加到标记中, 则你的应用将无法处理JS控制台中的下一个错误:

Uncaught Error: Template parse errors:
'app-card-list' is not a known element:
1. If 'app-card-list' is an Angular component, then verify that it is part of this module.
2. If 'app-card-list' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message. ("

因此, 如果你的应用没有明显的原因无法正常运行, 请不要忘记检查控制台。

让我们填充卡片列表组件标记(src / app / card-list / card-list.component.html):

<div class="container-fluid text-center pb-5">
  <div class="row">
    <app-card class="col-4"></app-card>
    <app-card class="col-4"></app-card>
    <app-card class="col-4"></app-card>
  </div>
</div>

如果我们在浏览器中将其打开, 则会看到以下内容:

硬编码卡

目前, 我们从硬编码的标记中显示卡。通过将硬编码卡阵列移动到我们的应用程序中, 让我们的代码更接近实际情况:

export class AppComponent {
  public cards: Array<any> = [
    {text: 'Card 1'}, {text: 'Card 2'}, {text: 'Card 3'}, {text: 'Card 4'}, {text: 'Card 5'}, {text: 'Card 6'}, {text: 'Card 7'}, {text: 'Card 8'}, {text: 'Card 9'}, {text: 'Card 10'}, ];
}

我们有初始列表, 但仍然需要将其传递给组件并在此处进行渲染。为此, 我们需要创建我们的第一个输入。让我们将其添加到CardList组件中:

import {Component, Input, OnInit} from '@angular/core';
[...]
export class CardListComponent implements OnInit {
  @Input() cards: Array<any>;
[...]

我们从Angular代码中导入了Input, 并将其用作类型为Array of objects的类级可变卡的装饰器。理想情况下, 我们不应该使用任何类型, 而应该使用严格的类型, 以便我们可以定义接口卡之类的东西, 其中将包含接口卡的所有属性, 但是稍后我们将使它工作—目前, 我们正在使用任何只是为了进行快速而肮脏的实施。

现在, 我们在CardList中有我们的卡阵列。我们如何显示它而不是当前的标记?让我们看一下卡片列表组件中的新代码:

<app-card class="col-4" *ngFor="let card of cards"></app-card>

这对我们来说是新事物, 它的属性名称以星号开头。这是什么意思?这是命名Angular结构指令的默认约定。结构指令控制模板的结构。这里的星号实际上是”语法糖”, 你可以进一步阅读以了解其工作原理。但是对于你当前的示例, 足以理解将其添加到组件中时会发生什么。所以ngFor转发器指令, 它将对卡阵列中的每个元素重复我们的应用卡。如果我们查看浏览器, 那么接下来会看到以下内容:

空白页?这是什么样的Angular 5教程?

某些事情不正确;我们有一系列的卡片, 但页面空白。

我们在AppComponent级别定义了一组卡片, 但尚未将其传递给CardList输入。让我们编辑AppComponent模板来做到这一点。

<app-card-list [cards]="cards"></app-card-list>

这种语法(方括号中的属性)告诉Angular, 我们希望将组件变量卡单向绑定到Card List组件[cards]输入。一旦这样做, 我们得到以下信息:

卡片在页面上,但文字全错

当然, 我们要显示卡阵列的实际内容, 为此, 我们还需要将卡对象传递给卡组件。让我们扩展卡列表组件:

<app-card class="col-4" *ngFor="let card of cards" [card]="card"></app-card>

如果我们现在在浏览器中查看, 我们将在JS控制台中收到下一个错误:由于它不是’app-card’的已知属性, 因此无法绑定到’card’。.Angular告诉我们我们仍然需要在Card组件中定义输入。这样我们就可以进行编辑:

import {Component, Input, OnInit} from '@angular/core';
[...]
export class CardComponent implements OnInit {
  @Input() card:any;
[...]

让我们将卡片文本属性添加到卡片组件模板中:

[...]
<p class="card-text">{{ card.text }}</p>
[...]

让我们看看它现在如何工作:

文字正确的卡片

看起来不错, 但样式略有不足。通过在card.component.css中添加新样式来解决此问题:

.card {
    margin-top: 1.5rem;
}

现在看起来更好了:

样式更好的卡片

组件通讯

让我们添加一个新卡输入组件, 使我们可以添加注释:

ng g component NewCardInput
  create src/app/new-card-input/new-card-input.component.scss (0 bytes)
  create src/app/new-card-input/new-card-input.component.html (33 bytes)
  create src/app/new-card-input/new-card-input.component.spec.ts (672 bytes)
  create src/app/new-card-input/new-card-input.component.ts (300 bytes)
  update src/app/app.module.ts (593 bytes)

并将其添加到其模板旁边:

<div class="card">
  <div class="card-block">
    <input placeholder="Take a note..." class="form-control">
  </div>
</div>

接下来, 将其添加到组件装饰器中:

[...]
@Component({
  selector: 'app-new-card-input', [...]
  host: {'class': 'col-4'}
})
[...]

并将我们的新组件添加到AppComponent模板中:

[...]
<div class="container-fluid text-center pb-5">
  <div class="row justify-content-end">
    <app-new-card-input></app-new-card-input>
  </div>
</div>
<app-card-list [cards]="cards"></app-card-list>

让我们来看看浏览器。

添加输入字段

问题在于我们的新组件什么也没做。让我们开始吧, 让我们开始吧, 首先添加一个变量来保存我们的新卡:

[...]
export class NewCardInputComponent implements OnInit {
[...]
public newCard: any = {text: ''};
[...]

我们如何用输入填充它?如果你以前使用过AngularJS, 则可能会知道双向数据绑定的概念。或者, 你可能已经在所有这些精美的AngularJS演示中看到了它, 在其中输入了要输入的值, 并为我们更新了页面内容。

这是一个有趣的花絮:Angular中不再支持双向数据绑定。但这并不意味着我们无法获得该行为。我们已经看到并使用了[value] =” expression”, 它将表达式绑定到输入元素的value属性。但是我们还有(input)=” expression”, 这是一种将表达式绑定到输入元素的输入事件的声明方式。可以一起使用它们, 从而:

<input [value]="newCard.text" (input)="newCard.text = $event.target.value">

因此, 每当newCard.text值更改时, 它将被传递到组件输入。每次用户将数据输入到我们的输入中并且浏览器输出输入$ event时, 我们都会将newCard.text分配给输入值。

在我们实施它之前, 还有一件事:这个输入看起来有点多, 不是吗?实际上, Angular为我们提供了一些语法糖, 我们可以在这里使用它, 因此我从另一个角度开始来解释这种糖的工作原理。

<input placeholder="Take a note..." class="form-control" [(ngModel)]="newCard.text">

这种语法([])在box或ngModel中称为香蕉, 是Angular指令, 负责从事件等中获取价值。因此, 我们可以编写更简单的代码来获取我们的值, 并将其绑定到输入值和代码中的变量。

不幸的是, 在添加ngModel之后, 我们得到了错误, 因为它不是’input’的已知属性, 所以无法绑定到’ngModel’。我们需要将ngModel导入到AppModule中。但是从哪里来的?如果我们查看文档, 就会发现它在Angular Forms模块中。因此, 我们需要这样编辑AppModule:

[...]
import {FormsModule} from "@angular/forms";

@NgModule({
[...]
  imports: [
    BrowserModule, FormsModule
  ], [...]

处理本地事件

因此, 我们已填充了变量, 但仍需要将该值发送到AppComponent中的卡列表。为了将数据传递到组件Angular, 我们必须有输入。看来, 要在组件外部传递数据, 就可以得到输出, 并且可以使用与输入相同的方式使用它-我们从Angular代码中导入它, 并使用装饰器来定义它:

import {Component, EventEmitter, OnInit, Output} from '@angular/core';
[...]
export class NewCardInputComponent implements OnInit {
[...]
@Output() onCardAdd = new EventEmitter<string>();
[...]
}

但是, 不仅仅是输出。我们还定义了一个称为EventEmitter的东西, 因为组件输出应该是一个事件, 但是我们不应该像处理那些旧的JavaScript事件那样来考虑它。它们不是泡沫。你无需在每个事件监听器中都调用preventDefault。要从组件发送数据, 我们应该使用其有效负载。因此, 我们需要订阅事件-我们该如何做?让我们更改AppComponent模板:

<app-new-card-input (onCardAdd)="addCard($event)"></app-new-card-input>

正如我们在NewCardInput组件中提到的, 我们还将表达式绑定到事件onCardAdd。现在, 我们需要在AppComponent上实现addCard方法。

[...]
export class AppComponent {
[...]
addCard(cardText: string) {
  this.cards.push({text: cardText});
}

但是我们仍然没有从任何地方输出它。让我们尝试让它在用户按下Enter键时发生。我们需要在组件中监听DOM keypress事件, 并输出由此触发的Angular事件。为了侦听DOM事件, Angular为我们提供了HostListener装饰器。它是一个函数装饰器, 其名称为我们要监听的本地事件的名称, 而Angular希望响应此事件而调用该函数。让我们实施它并讨论其工作原理:

import {Component, EventEmitter, OnInit, Output, HostListener} from '@angular/core';
[...]
export class NewCardInputComponent implements OnInit {
[...]
@HostListener('document:keypress', ['$event'])
handleKeyboardEvent(event: KeyboardEvent) {
  if (event.code === "Enter" && this.newCard.text.length > 0) {
    this.addCard(this.newCard.text);
   }
}
[...]
addCard(text) {
  this.onCardAdd.emit(text);
  this.newCard.text = '';
}
[...]

因此, 如果发生document:keypress事件, 我们将检查按下的键是否为Enter, 并且newCard.text中包含某些内容。之后, 我们可以调用addCard方法, 在该方法中, 我们将Angular onCardAdd与卡片中的文本一起输出, 并将卡片中的文本重置为空字符串, 以便用户可以继续添加新卡片而无需编辑旧卡片的文本。

使用表格

在Angular中有两种使用表单的方法-一种是模板驱动的, 我们已经在使用其中最有价值的部分:ngModel用于双向绑定。但是Angular中的形式不仅与模型值有关, 而且与有效性有关。当前, 我们在HostListener函数中检查NewCardInput的有效性。让我们将其移到一个模板驱动的形式。为此, 我们可以更改组件模板:

<form novalidate #form="ngForm">
  <input placeholder="Take a note..." class="form-control" name="text" [(ngModel)]="newCard.text" required>
</form>

这是Angular的另一个语法糖。哈希#form是一个模板引用变量, 我们可以使用它从代码中访问表单。让我们使用它来确保我们实际上使用了必需的属性验证, 而不是手动检查值长度:

import {Component, EventEmitter, OnInit, Output, HostListener, ViewChild} from '@angular/core';
import {NgForm} from '@angular/forms';
[...]
export class NewCardInputComponent implements OnInit {
[...]
@ViewChild('form') public form: NgForm;
[...]
  @HostListener('document:keypress', ['$event'])
  handleKeyboardEvent(event: KeyboardEvent) {
    if (event.code === "Enter" && this.form.valid) {
[...]

这里还有一个新的装饰器:ViewChild。使用它, 我们可以访问用模板引用值标记的任何元素(在这种情况下为表单), 并且实际上将其声明为Component公共变量表单, 因此可以编写this.form.valid。

使用模板驱动的表单与使用简单HTML表单之前完全相同。如果我们需要更复杂的东西, 那么在Angular中, 这种情况有另一种形式:反应式。我们将介绍他们在转换表格后的反应。为此, 我们将一个新的导入添加到我们的AppModule中:

[...]
import {FormsModule, ReactiveFormsModule} from "@angular/forms";
[...]
imports: [
[...]
ReactiveFormsModule, ]
[...]

反应性表单是用代码而不是模板驱动的表单定义的, 因此让我们更改NewCardInput组件代码:

[...]
import {FormBuilder, FormGroup, Validators} from '@angular/forms';
[...]
export class NewCardInputComponent implements OnInit {
[...]
newCardForm: FormGroup;

constructor(fb: FormBuilder) {
  this.newCardForm = fb.group({
    'text': ['', Validators.compose([Validators.required, Validators.minLength(2)])], });
}
[...]
if (event.code === "Enter" && this.form.valid) {
   this.addCard(this.newCardForm.controls['text'].value);
[...]
addCard(text) {
  this.onCardAdd.emit(text);
  this.newCardForm.controls['text'].setValue('');
}

除了导入新模块之外, 这里还发生了一些新变化。首先, 我们在构造函数上对FormBuilder使用依赖项注入, 并以此构建表单。文本中有一个字段的名称, 一个空字符串是初始值, Validators.compose显然允许我们在单个字段上组合多个验证器。我们使用.value和.setValue(”)访问字段的值。

让我们来看一下这种使用表单的新方式的标记:

<form [formGroup]="newCardForm" novalidate>
  <input placeholder="Take a note..." class="form-control" name="text" formControlName="text">
</form>

我们使用FormGroupDirective来告诉Angular Angular需要为其定义查找哪些表单组。通过使用formControlName, 我们告诉Angular应使用反应式表单中的哪个字段。

就目前而言, 采用模板驱动形式的先前方法与采用反应形式的新方法之间的主要区别在于反应性方面的编码更多。如果我们不需要动态定义表单, 那真的值得吗?

绝对是。要了解它可能会有帮助, 让我们首先讨论为什么将此方法称为”反应性”。

首先, 向”新卡输入”组件构造函数添加其他代码:

import { takeWhile, debounceTime, filter } from 'rxjs/operators';
[...]
this.newCardForm.valueChanges.pipe(
    filter((value) => this.newCardForm.valid), debounceTime(500), takeWhile(() => this.alive)
).subscribe(data => {
   console.log(data);
});

打开浏览器和开发人员工具控制台, 观察当我们在输入中输入新值时会发生什么:

控制台会在每次更改时注销新值

RxJS

那这里到底发生了什么?我们看到RxJS发挥了作用。让我们讨论一下。我想你们至少至少了解诺言和构建异步代码。答应处理单个事件。例如, 我们要求浏览器进行POST, 它会向我们返回承诺。 RxJS与Observables一起操作, 后者可处理事件流。这样考虑:我们刚刚实现了在每次表单更改时都会调用的代码。如果我们以承诺方式处理用户更改, 则在我们需要重新订阅之前, 只会处理第一个用户更改。同时, Observable能够以几乎无限的”承诺”流处理每个事件。我们可以通过在执行过程中出现一些错误或取消订阅Observable来解决这一问题。

这是什么?我们在组件中订阅了Observable。它们在应用程序的不同部分中使用, 因此它们可能会被破坏(例如, 当我们在路由中将组件用作页面时(我们将在本指南的后面部分讨论路由))。但是, 虽然代替Observable的承诺只会运行一次, 并且在此之后将被释放, 但Observable的构建会一直持续到只要流正在更新且我们不会退订。因此, 我们的订阅需要取消订阅(如果我们不查找内存泄漏), 如下所示:

const subscription = observable.subscribe(value => console.log(value));
[...]
subscription.unsubscribe();

但是在我们的应用程序中, 我们有很多不同的订阅。我们是否需要完成所有样板代码?实际上, 我们可以作弊并使用takeWhile运算符。通过使用它, 我们确保只要this.alive变为false, 我们的流就将停止发出新值, 我们只需要在组件的onDestroy函数中设置该值即可。

使用后端

由于我们不在此处构建服务器端, 因此我们将Firebase用于我们的API。如果你确实有自己的API后端, 请在开发服务器中配置我们的后端。为此, 请在项目的根目录中创建proxy.conf.json并在其中添加以下内容:

{
  "/api": {
    "target": "http://localhost:3000", "secure": false
  }
}

对于从我们的应用程序到其主机(如果你记得, 它是Webpack开发服务器)的每个请求, / api路由服务器应将请求代理到http:// localhost:3000 / api。为此, 我们需要在我们的应用程序配置中添加其他内容;在package.json中, 我们需要为项目替换start命令:

[...]
"scripts": {
[...]
  "start": "ng serve --proxy-config proxy.conf.json", 

现在, 我们可以使用yarn start或npm start运行项目, 并获得适当的代理配置。我们如何使用Angular的API? Angular给了我们HttpClient。让我们为当前应用程序定义CardService:

import { Injectable } from '@angular/core';
import {HttpClient} from '@angular/common/http';

@Injectable()
export class CardService {

  constructor(private http: HttpClient) { }
 
  get() {
    return this.http.get(`/api/v1/cards.json`);
  }

  add(payload) {
    return this.http.post(`/api/v1/cards.json`, {text: trim(payload)});
  }

  remove(payload) {
    return this.http.delete(`/api/v1/cards/${payload.id}.json`);
  }

  update(payload) {
    return this.http.patch(`/api/v1/cards/${payload.id}.json`, payload);
  }
}

那么, Injectable在这里是什么意思?我们已经确定依赖注入可以帮助我们将组件与使用的服务一起注入。为了访问我们的新服务, 我们需要将其添加到AppModule的提供者列表中:

[...]
import { CardService } from './services/card.service';
[...]
@NgModule({
[...]
 providers: [CardService], 

现在, 我们可以将其注入到AppComponent中, 例如:

import { CardService } from './services/card.service';
[...]
  constructor(private cardService: CardService) {
    cardService.get().subscribe((cards: any) => this.cards = cards);
  }
Firebase

因此, 让我们现在配置Firebase, 在Firebase中创建一个演示项目, 然后点击将Firebase添加到你的应用按钮。然后, 我们将Firebase向我们显示的凭据复制到应用程序的Environment文件中, 该文件位于:src / environments /

export const environment = {
[...]
  firebase: {
    apiKey: "[...]", authDomain: "[...]", databaseURL: "[...]", projectId: "[...]", storageBucket: "[...]", messagingSenderId: "[...]"
  }
};

我们需要将其添加到environment.ts和environment.prod.ts中。只是为了让你对这里的环境文件有一些了解, 它们实际上是在编译阶段包含在项目中的, 并且是.prod。由–environment开关为ng服务或ng构建定义的部分。你可以在项目的所有部分中使用该文件中的值, 并将其包含在environment.ts中, 而Angular CLI负责提供相应的environment.your-environment.ts中的内容。

让我们添加Firebase支持库:

yarn add [email protected] angularfire2
yarn add v1.3.2
[1/4] ????  Resolving packages...
[2/4] ????  Fetching packages...
[3/4] ????  Linking dependencies...
[...]
success Saved lockfile.
success Saved 28 new dependencies.
[...]
✨  Done in 40.79s.

现在, 我们将CardService更改为支持Firebase:

import { Injectable } from '@angular/core';
import { AngularFireDatabase, AngularFireList, AngularFireObject } from 'angularfire2/database';
import { Observable } from 'rxjs/Observable';

import { Card } from '../models/card';

@Injectable()
export class CardService {

  private basePath = '/items';

  cardsRef: AngularFireList<Card>;
  cardRef:  AngularFireObject<Card>;

  constructor(private db: AngularFireDatabase) {
    this.cardsRef = db.list('/cards');
  }

  getCardsList(): Observable<Card[]> {
    return this.cardsRef.snapshotChanges().map((arr) => {
      return arr.map((snap) => Object.assign(snap.payload.val(), { $key: snap.key }) );
    });
  }

  getCard(key: string): Observable<Card | null> {
    const cardPath = `${this.basePath}/${key}`;
    const card = this.db.object(cardPath).valueChanges() as Observable<Card | null>;
    return card;
  }

  createCard(card: Card): void {
    this.cardsRef.push(card);
  }

  updateCard(key: string, value: any): void {
    this.cardsRef.update(key, value);
  }

  deleteCard(key: string): void {
    this.cardsRef.remove(key);
  }

  deleteAll(): void {
    this.cardsRef.remove();
  }

  // Default error handling for all actions
  private handleError(error: Error) {
    console.error(error);
  }
}

我们在这里看到有趣的东西, 在第一个导入卡的模型上。让我们看一下它的组成:

export class Card {
    $key: string;
    text: string;

    constructor(text: string) {
        this.text = text;
    }
}

因此, 我们使用类来构造数据, 除了文本, 我们还从Firebase添加key $。让我们更改AppComponent以使其与该服务一起使用:

[...]
import { AngularFireDatabase } from 'angularfire2/database';
import {Observable} from 'rxjs/Observable';
import { Card } from './models/card';
[...]
export class AppComponent {
public cards$: Observable<Card[]>;

addCard(cardText: string) {
  this.cardService.createCard(new Card(cardText));
}

constructor(private cardService: CardService) {
  this.cards$ = this.cardService.getCardsList();
}

什么是cards $?我们通过在变量上加$来标记可观察变量, 以确保我们按需对待它们。让我们将cards $添加到AppComponent模板中:

[...]
<app-card-list [cards]="cards$"></app-card-list>

作为回报, 我们在控制台中收到此错误:

CardListComponent.html:3 ERROR Error: Cannot find a differ supporting object '[object Object]' of type 'object'. NgFor only supports binding to Iterables such as Arrays.

为什么这样?我们从Firebase获得了可观察到的东西。但是, 我们在CardList组件中的* ngFor等待对象的数组, 而这种数组无法观察到。因此, 我们可以订阅该可观察的对象, 并将其分配给静态的纸牌数组, 但是有一个更好的选择:

<app-card-list [cards]="cards$ | async"></app-card-list>

异步管道实际上是Angular给我们的另一种语法糖, 它执行与我们讨论的相同的工作-订阅Observable并返回其当前值作为对表达式求值的结果。

反应角– Ngrx

让我们谈谈我们的应用程序状态, 这是指我们应用程序的所有属性, 这些属性定义了其当前行为和状态。状态是一个不变的数据结构, 至少是Ngrx为我们实现它的方式。 Ngrx是”受Redux启发的RxJS支持的Angular应用程序状态管理库”。

Ngrx的灵感来自Redux。 ” Redux是用于管理应用程序状态的模式。”因此, 它更像一组约定(对于那些曾经听说过Ruby on Rails中的配置约定的人, 你稍后会看到一些相似之处)使我们能够回答应用程序应如何决定需要显示的问题。一些接口元素(例如可折叠的侧边栏), 或者应该在它从服务器接收到会话状态后将其存储在其中的位置。

让我们看看如何实现这一目标。我们讨论了状态及其不变性, 这意味着创建状态后我们无法更改其任何属性。这使得几乎不可能将我们的应用程序状态存储在我们的州中。但并非完全如此-每个状态都是不可变的, 但是存储(这是我们访问状态的方式)实际上是状态的可观察状态。因此, 状态是存储值流中的单个值。为了更改应用程序的状态, 我们需要执行一些操作以采用当前状态, 然后将其替换为新状态。两者都是不可变的, 但是第二个是基于第一个的, 因此, 我们创建了一个新的State对象, 而不是在State上更改值。为此, 我们将Reducer用作纯函数, 这意味着对于任何给定的State和Action及其有效负载的reducer, 它将返回与使用相同参数的reducer函数的任何其他调用相同的状态。

操作由操作类型和可选的有效负载组成:

export interface Action {
  type: string;
  payload?: any;
}

对于我们的任务, 让我们来看看添加新卡的操作可能是:

store.dispatch({
  type: 'ADD', payload: 'Test Card'
});

让我们来看看一个减速器:

export const cardsReducer = (state = [], action) => {
  switch(action.type) {
    case 'ADD':
      return {...state, cards: [...cards, new Card(action.payload)]};
    default:
      return state;
  }
}

每个新的Action事件都将调用此函数。稍后我们将介绍Action调度。现在, 假设我们调度ADD_CARD操作, 它将进入该case语句。那里发生了什么事?我们将使用TypeScript扩展语法基于先前的状态返回新的状态, 因此在大多数情况下, 我们不必使用Object.assign之类的东西。我们永远都不应在这些case语句之外更改状态, 否则, 由于我们浪费时间寻找代码行为异常的原因, 这将使生活陷入困境。

让我们将Ngrx添加到我们的应用程序中。为此, 让我们接下来在控制台中运行:

yarn add @ngrx/core @ngrx/store ngrx-store-logger
yarn add v1.3.2
[1/4] ????  Resolving packages...
[2/4] ????  Fetching packages...
[3/4] ????  Linking dependencies...
[...]
[4/4] ????  Building fresh packages...
success Saved lockfile.
success Saved 2 new dependencies.
├─ @ngrx/[email protected]
└─ @ngrx/[email protected]
└─ [email protected]
✨  Done in 25.47s.

现在, 添加我们的操作定义(app / actions / cards.ts):

import { Action } from '@ngrx/store';

export const ADD = '[Cards] Add';

export const REMOVE = '[Cards] Remove';

export class Add implements Action {
    readonly type = ADD;

    constructor(public payload: any) {}
}

export class Remove implements Action {
    readonly type = REMOVE;

    constructor(public payload: any) {}
}

export type Actions
  = Add
| Remove;

以及我们的Reducer定义(app / reducers / cards.ts):

import * as cards from '../actions/cards';
import { Card } from '../models/card';

export interface State {
    cards: Array<Card>;
}

const initialState: State = {
    cards: []
}

export function reducer(state = initialState, action: cards.Actions): State {
    switch (action.type) {
      case cards.ADD:
        return {
            ...state, cards: [...state.cards, action.payload]
        };
      case cards.REMOVE:
        const index = state.cards.map((card) => card.$key).indexOf(action.payload);
        return {
            ...state, cards: [...state.cards.slice(0, index), ...state.cards.slice(index+1)]
        };
      default:
        return state;
    }
}

在这里, 我们可以看到如何使用点差和本地TypeScript函数(例如map)将元素从列表中删除。

让我们再进一步一步, 确保如果我们的应用程序状态包含多种类型的数据, 那么我们将针对每种类型从一个单独的隔离状态中进行组合。为此, 让我们通过(app / reducers / index.ts)使用模块分辨率:

import * as fromCards from './cards';
import {ActionReducer, ActionReducerMap, createFeatureSelector, createSelector, MetaReducer} from '@ngrx/store';
import {storeLogger} from 'ngrx-store-logger';
import {environment} from '../../environments/environment';

export interface State {
    cards: fromCards.State;
}

export const reducers: ActionReducerMap<State> = {
    cards: fromCards.reducer
}

export function logger(reducer: ActionReducer<State>): any {
    // default, no options
    return storeLogger()(reducer);
}

export const metaReducers: MetaReducer<State>[] = !environment.production
  ? [logger]
  : [];

/**
 * Cards Reducers
 */
export const getCardsState = createFeatureSelector<fromCards.State>('cards');
export const getCards = createSelector(
    getCardsState, state => state.cards
);  

我们还在开发环境中包括Ngrx记录器, 并为卡阵列创建选择器功能。让我们将其包含在我们的AppComponent中:

import { Component } from '@angular/core';
import { CardService } from './services/card.service';
import { Observable } from 'rxjs/Observable';
import { Card } from './models/card';
import * as fromRoot from './reducers';
import * as cards from './actions/cards';
import { Store } from '@ngrx/store';

@Component({
  selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css']
})
export class AppComponent {
  public cards$: Observable<Card[]>;

  addCard(card: Card) {
    this.store.dispatch(new cards.AddCard(card));
  }

  constructor(private store: Store<fromRoot.State>) {
    this.cards$ = this.store.select(fromRoot.getCards);
  }
}

现在, 我们看看如何使用商店调度动作。但是此代码仍然无法使用, 因为我们没有在我们的应用程序中包含我们的reducer(reducer和metaReducer)。让我们通过更改AppModule来做到这一点:

[...]
import { StoreModule } from '@ngrx/store';
import {reducers, metaReducers} from './reducers/index';
[...]
imports: [
[...]
  StoreModule.forRoot(reducers, { metaReducers }), [...]

现在可以正常工作了。有点儿。请记住, 我们碰巧将Firebase集成到了我们的应用程序中。现在由于高度可维护的Ngrx商店而丢失了。也就是说, 它不存储在任何地方。我们可以使用ngrx-store-localstorage之类的数据将数据存储在浏览器的localStore中, 但是如何使用API​​?也许我们可以将以前的API集成添加到我们的Reducer中?但是我们不能, 因为我们的Reducer函数应该是纯函数。因此, “结果评估不会引起任何语义上可观察到的副作用或输出, 例如可变对象的突变或输出到I / O设备”……我们该怎么办?答案实际上就是那个定义。 Ngrx对救援的副作用。

Ngrx效果

那有什么副作用呢?它的代码片段或多或少地与我们的reducer一样捕获我们的Action, 但是实际上并没有改变状态, 而是发送API请求, 并分派新的Action。与往常一样, 向你展示比告诉你更简单。让我们为新配置提供支持Firebase。为此, 我们安装效果模块:

yarn add @ngrx/effects
[...]
success Saved 1 new dependency.
└─ @ngrx/[email protected]
✨  Done in 11.28s.

现在, 我们将向卡操作添加新操作以加载支持(src / app / actions / cards.ts):

[...]
export const LOAD = '[Cards] Load';

export const LOAD_SUCCESS = '[Cards] Load Success';

export const SERVER_FAILURE = '[Cards] Server failure';
[...]
export class Load implements Action {
    readonly type = LOAD;
}

export class LoadSuccess implements Action {
    readonly type = LOAD_SUCCESS;

    constructor(public payload: any) {}
}

export class ServerFailure implements Action {
    readonly type = SERVER_FAILURE;

    constructor(public payload: any) {}
}
[...]
export type Actions
[...]
    | Load
  | LoadSuccess
  | ServerFailure

因此, 我们有三个新操作, 一个用于加载卡列表, 另一个用于处理成功和失败的响应。让我们实现我们的效果(src / app / effects / cards.ts):

import {Injectable} from '@angular/core';
import {Actions, Effect} from '@ngrx/effects';
import {CardService} from '../services/card.service';
import { of } from 'rxjs/observable/of';

import * as Cards from '../actions/cards';

import {exhaustMap, map, mergeMap, catchError} from 'rxjs/operators';

@Injectable()
export class CardsEffects {
    @Effect()
    loadCards$ = this.actions$
        .ofType(Cards.LOAD).pipe(
            mergeMap(action => {
                return this.cardService.getCardsList().pipe(
                map(res => new Cards.LoadSuccess(res)), catchError(error => of(new Cards.ServerFailure(error))))}
            )
        );

    @Effect({dispatch: false})
    serverFailure$ = this.actions$
        .ofType(Cards.SERVER_FAILURE).pipe(
        map((action: Cards.ServerFailure) => action.payload), exhaustMap(errors => {
            console.log('Server error happened:', errors);
            return of(null);
        }));        

    constructor(
        private actions$: Actions, private cardService: CardService) {}
}

因此, 我们有可注入的CardsEffects, 它们使用@Effect装饰器在Actions之上定义效果, 并使用ofType运算符仅过滤必要的操作。你可以使用ofType来创建将在多种操作类型上触发的效果。但是现在, 我们只需要三个动作中的两个即可。对于Load动作, 我们将根据getCardList方法调用的结果将每个动作转换为新的可观察到的动作。在成功的情况下, 可观察的对象将映射到具有我们请求结果的有效负载的新动作LoadSuccess, 在错误的情况下, 我们将返回单个ServerFailure动作(请注意此处的operator运算符-它将转换单个值或可观察值的数组)。

因此, 我们的效果器在做出依赖于外部系统(准确地说, 我们的Firebase)的内容后会调度新的动作。但是在同一代码中, 我们看到了另一个效果, 该效果使用装饰器参数dispatch来处理ServerFailure动作:false。这是什么意思?从其实现中可以看到, 它还将ServerFailure操作映射到其有效负载, 然后将该有效负载(我们的服务器错误)显示到console.log。显然, 在这种情况下, 我们不应更改状态内容, 因此我们不必分发任何内容。这就是我们无需任何空动作即可使其正常工作的方式。

现在, 我们已经介绍了三个操作中的两个, 让我们继续进行LoadSuccess。从目前为止我们所知道的, 我们正在从服务器下载卡的列表, 我们需要将它们合并到我们的州。因此, 我们需要将其添加到我们的reducer(src / app / reducers / cards.ts):

[...]
switch (action.type) {
[...]
case cards.LOAD_SUCCESS:
        return {
            ...state, cards: [...state.cards, ...action.payload]
        }  
[...]

和以前一样, 我们使用传播操作符在其中打开对象和卡阵列, 并将其与传播有效负载(在本例中为服务器卡)结合在一起。让我们将新的Load操作添加到AppComponent中:

[...]
export class AppComponent implements OnInit {
  public cards$: Observable<Card[]>;

  addCard(card: Card) {
    this.store.dispatch(new cards.AddCard(card));
  }

  constructor(private store: Store<fromRoot.State>) {
  }

  ngOnInit() {
    this.store.dispatch(new cards.Load());
    this.cards$ = this.store.select(fromRoot.getCards);
  }
}

那应该从Firebase加载我们的卡。让我们看一下浏览器:

卡不加载

某事不起作用。从日志中可以看出, 我们显然正在分派Action, 但是这里没有服务器请求。怎么了?我们忘记了将效果加载到AppModule中。让我们这样做:

[...]
import { EffectsModule } from '@ngrx/effects';
import { CardsEffects } from './effects/cards.effects';
[...]
imports: [
[...]
    EffectsModule.forRoot([CardsEffects]), 

现在, 回到浏览器…

啊,卡片现在是从火力场中加载的

现在可以了。这样便可以将效果集成到从服务器加载数据的过程中。但是我们仍然需要在创建卡片时将其发送回去。让我们也进行这项工作。为此, 我们来更改CardService createCard方法:

  createCard(card: Card): Card {
    const result = this.cardsRef.push(card);
    card.$key = result.key;
    return card;
  }

并为添加卡添加效果:

    @Effect()
    addCards$ = this.actions$
        .ofType(Cards.ADD).pipe(
            map((action: Cards.Add) => action.payload), exhaustMap(payload => {
              const card = this.cardService.createCard(payload);
                if (card.$key) {
                    return of(new Cards.LoadSuccess([card]));
                }
            })
        );

因此, 如果要创建卡, 它将从Firebase获取$ key, 然后将其合并到卡阵列中。我们还需要删除案例卡。ADD:从我们的减速器分支。让我们尝试一下:

卡片已创建,但文字不变

由于某些原因, 我们在卡添加操作中得到了重复的数据。让我们尝试找出原因。如果我们仔细查看控制台, 我们会看到两个LoadSuccess操作首先按预期的方式与我们的新卡一起分发, 而第二个操作与我们的两张卡一起分发。如果没有效果, 它将在我们的行动中被发送到哪里?

我们在卡片上的加载效果具有以下代码:

return this.cardService.getCardsList().pipe(
  map(res => new Cards.LoadSuccess(res)), 

并且我们的getCardsList是可观察的。因此, 当我们将新卡添加到卡集合中时, 将输出该卡。因此, 我们不需要自己添加该卡, 或者需要在该管道中使用take(1)运算符。只需一个值即可取消订阅。但是, 进行实时订阅似乎更为合理(大概, 我们将在系统中拥有多个用户), 所以让我们更改代码以处理订阅。

让我们添加一个非调度元素:

@Effect({dispatch: false})
addCards$ = this.actions$
  .ofType(Cards.ADD).pipe(
    map((action: Cards.Add) => action.payload), exhaustMap(payload => {
      this.cardService.createCard(payload);
      return of(null);
    })
  );

现在, 我们只需要更改reducer的LoadSuccess即可替换卡, 而无需组合它们:

case cards.LOAD_SUCCESS:
  return {
    ...state, cards: action.payload
  };

现在它可以正常工作了:

卡已正确添加

你现在可以以相同的方式实施删除操作。当我们从该订阅中获取数据时, 只需实现”删除”效果。但我会留给你。

路由和模块

让我们谈谈我们的应用程序组成。如果我们的应用程序需要About页面, 该怎么办?我们如何将其添加到当前代码库中?显然, 该页面应该是一个组件(与Angular中的其他任何东西一样)。让我们生成该组件。

ng g component about --inline-template --inline-style
[...]
  create src/app/about/about.component.ts (266 bytes)
  update src/app/app.module.ts (1503 bytes)

并添加下一个标记:

[...]
@Component({
  selector: 'app-about', template: `
<div class="jumbotron">
  <h1 class="display-3">Cards App</h1>
</div>
  `, [...]

现在, 我们有了”关于”页面。我们如何访问它?让我们向AppModule添加更多代码:

[...]
import { AboutComponent } from './about/about.component';
import { MainComponent } from './main/main.component';
import {Routes, RouterModule, Router} from "@angular/router";


const routes: Routes = [
  {path: '', redirectTo: 'cards', pathMatch: 'full'}, {path: 'cards', component: MainComponent}, {path: 'about', component: AboutComponent}, ]

@NgModule({
  declarations: [
[...]
    AboutComponent, MainComponent, ], imports: [
[...]   
    RouterModule.forRoot(routes, {useHash: true})

什么是MainComponent?目前, 只需使用与AboutComponent相同的方式生成它, 稍后我们将对其进行填充。至于路线结构, 它或多或少是不言而喻的。我们定义了两条路线:/ cards和/ about。并且我们确保空路径重定向/ cards。

现在, 将卡片处理代码移至MainComponent:

import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs/Observable';
import { Card } from '../models/card';
import * as fromRoot from '../reducers';
import * as cards from '../actions/cards';
import { Store } from '@ngrx/store';


@Component({
  selector: 'app-main', template: `
<div class="container-fluid text-center pb-5">
  <div class="row justify-content-end">
    <app-new-card-input (onCardAdd)="addCard($event)"></app-new-card-input>
  </div>
</div>
<app-card-list [cards]="cards$ | async"></app-card-list>
  `, styles: []
})
export class MainComponent implements OnInit {
  public cards$: Observable<Card[]>;

  addCard(card: Card) {
    this.store.dispatch(new cards.Add(card));
  }

  constructor(private store: Store<fromRoot.State>) {
  }

  ngOnInit() {
    this.store.dispatch(new cards.Load());
    this.cards$ = this.store.select(fromRoot.getCards);
  }
}

然后将其从AppComponent中删除:

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

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

  ngOnInit() {
  }
}

以及从标记:

<!-- Fixed navbar -->
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
  <a class="navbar-brand" href="#">Angular Notes</a>
  <ul class="navbar-nav mr-auto">
    <li class="nav-item" [routerLinkActive]="['active']">
      <a class="nav-link" [routerLink]="['cards']">Cards</a>
    </li>
    <li class="nav-item" [routerLinkActive]="['active']">
      <a class="nav-link" [routerLink]="['about']">About</a>
    </li>
  </ul>
</nav>
<router-outlet></router-outlet>

如你所见, 我们添加了更多内容。首先, 我们为RouterLinkActive添加了路由器指令, 该指令在路由处于活动状态时设置一个类, 并为routerLink替换我们的href。这是routerOutlet, 它告诉Router在当前页面上的何处显示其内容。因此, 结合这些内容, 我们现在在每个页面上都有菜单, 以及两个具有不同内容的页面:

根据命令将卡移除

有关更多详细信息, 请阅读路由器指南。

随着应用程序的增长, 我们可能会开始考虑优化。例如, 如果我们希望默认加载About组件, 并且仅在用户通过单击Cards链接隐式要求后加载其他组件, 该怎么办?为此, 我们可以使用模块的延迟加载。让我们首先生成CardsModule:

ng g module cards --flat
  create src/app/cards.module.ts (189 bytes)

通过使用flat标志, 我们告诉Angular不要为我们的模块创建单独的目录。让我们将所有与卡相关的内容转移到我们的新模块中:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { CardService } from './services/card.service';
import { CardComponent } from './card/card.component';
import { CardListComponent } from './card-list/card-list.component';
import { NewCardInputComponent } from './new-card-input/new-card-input.component';

import {FormsModule, ReactiveFormsModule} from "@angular/forms";

import { AngularFireModule } from 'angularfire2';
import { AngularFireDatabaseModule } from 'angularfire2/database';
import { AngularFireAuthModule } from 'angularfire2/auth';

import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { reducers } from './reducers';
import { CardsEffects } from './effects/cards.effects';

import { environment } from './../environments/environment';
import { MainComponent } from './main/main.component';

import {Routes, RouterModule, Router} from "@angular/router";

const routes: Routes = [
  {path: '', redirectTo: 'cards', pathMatch: 'full'}, {path: 'cards', component: MainComponent}, ]

@NgModule({
  imports: [
    CommonModule, FormsModule, ReactiveFormsModule, StoreModule.forFeature('cards', reducers), EffectsModule.forFeature([CardsEffects]), RouterModule.forChild(routes), AngularFireModule.initializeApp(environment.firebase), AngularFireDatabaseModule, AngularFireAuthModule, ], providers: [CardService], declarations: [
    CardComponent, CardListComponent, NewCardInputComponent, MainComponent
  ]
})
export class CardsModule { }

以前, 我们在导入中看到很多forRoot调用, 但是在这里, 我们需要很多forFeature或forChild。这就是我们告诉组件我们正在扩展配置的方式, 而不是从头开始创建配置。

让我们看看AppModule中仍然有什么:

[...]
import { reducers, metaReducers } from './reducers/root';

const routes: Routes = [
  {path: '', redirectTo: 'about', pathMatch: 'full'}, {path: 'about', component: AboutComponent}, { path: 'cards', loadChildren: './cards.module#CardsModule'}
]

@NgModule({
  declarations: [
    AppComponent, AboutComponent, ], imports: [
    BrowserModule, RouterModule.forRoot(routes, {useHash: true}), StoreModule.forRoot(reducers, { metaReducers }), EffectsModule.forRoot([]), ], bootstrap: [AppComponent]
})
export class AppModule { }

在这里, 我们仍然定义EffectsModule.forRoot, 否则它将无法在我们已加载的模块中运行(因为在延迟加载中将无处添加)。我们还在此处看到了路由器loadChildren的新语法, 该语法告诉我们的路由器在请求卡片路由时延迟加载位于./cards.module文件中的CardsModule。此外, 我们还包括了新的./reducers/root.ts文件中的meta reducer, 让我们看一下:

import {ActionReducer, ActionReducerMap, createFeatureSelector, createSelector, MetaReducer} from '@ngrx/store';
import {storeLogger} from 'ngrx-store-logger';
import {environment} from '../../environments/environment';

export interface State {
}

export const reducers: ActionReducerMap<State> = {
}

export function logger(reducer: ActionReducer<State>): any {
    // default, no options
    return storeLogger()(reducer);
}

export const metaReducers: MetaReducer<State>[] = !environment.production
  ? [logger]
  : [];

从根本上讲, 我们目前没有任何状态, 但是我们仍然需要定义空状态, 以便我们可以在延迟加载的过程中扩展它。这也意味着我们的卡状态必须在其他地方定义, 对于这个示例, 我们在src / app / reducers / index.ts中定义它:

import * as fromCards from './cards';
import {ActionReducer, ActionReducerMap, createFeatureSelector, createSelector, MetaReducer} from '@ngrx/store';
import {storeLogger} from 'ngrx-store-logger';
import {environment} from '../../environments/environment';
import * as fromRoot from './root';

export interface CardsState {
    cards: fromCards.State;
}

export interface State extends fromRoot.State {
  cards: CardsState;
}

export const reducers = {
    cards: fromCards.reducer
}

/**
 * Cards Reducers
 */
export const getCardsState = createFeatureSelector<CardsState>('cards');
export const getCards = createSelector(
    getCardsState, state => state.cards.cards
);  

因此, 我们通过卡密钥扩展了根状态。这样一来, 我们就可以在最后进行密钥嵌套复制(作为模块和称为卡的数组)。

如果我们现在打开我们的应用程序并查看开发者控制台的”网络”标签, 我们将看到仅在单击/ cards链接后, cards.module.chunk.js才被加载。

准备生产

因此, 让我们构建用于生产的应用程序。为此, 我们运行build命令:

ng build --aot -prod
 65% building modules 465/466 modules 1 active ...g/getting-started-ng5/src/styles.scssNode#moveTo was deprecated. Use Container#append.
Date: 2018-01-09T22:14:59.803Z
Hash: d11fb9d870229fa05b2d
Time: 43464ms
chunk {0} 0.657b0d0ea895bd46a047.chunk.js () 427 kB  [rendered]
chunk {1} polyfills.fca27ddf9647d9c26040.bundle.js (polyfills) 60.9 kB [initial] [rendered]
chunk {2} main.5e577f3b7b05660215d6.bundle.js (main) 279 kB [initial] [rendered]
chunk {3} styles.e5d5ef7041b9b072ef05.bundle.css (styles) 136 kB [initial] [rendered]
chunk {4} inline.1d85c373f8734db7f8d6.bundle.js (inline) 1.47 kB [entry] [rendered]

那这是怎么回事?我们正在将应用程序构建为可以从任何Web服务器提供服务的静态资产(如果要从ng build子目录提供服务, 请选择–base-href)。通过使用-prod, 我们告诉AngularCLI我们需要生产版本。 –aot告诉我们, 我们希望提前进行编译。在大多数情况下, 我们更喜欢这样做, 因为它使我们可以获得更小的捆绑包和更快的代码。另外, 请记住, AoT对代码质量的要求过于严格, 因此可能会产生你之前从未见过的错误。尽早运行构建, 以便更轻松地进行修复。

I18n

构建我们的应用程序的另一个原因是Angular如何处理i18n, 或者简单地说, 是国际化。 Angular不会在运行时进行处理, 而是在编译时进行处理。让我们为应用配置它。为此, 我们将i18n属性添加到AboutComponent中。

<div class="jumbotron">
  <h1 class="display-3" i18n>Cards App</h1>
</div>

通过使用它, 我们告诉Angular编译器需要翻译标签的内容。它不是Angular指令, 在编译过程中会被编译器删除, 并由给定语言的翻译代替。因此, 我们标记了我们的第一个翻译后的消息, 但是接下来呢?我们该如何实际翻译呢?为此, Angular向我们提供了ng xi18n命令:

ng xi18n
cat src/messages.xlf
<?xml version="1.0" encoding="UTF-8" ?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
  <file source-language="en" datatype="plaintext" original="ng2.template">
    <body>
      <trans-unit id="80dcbb43f590ee82c132b8c725df2b7b433dc10e" datatype="html">
        <source>Cards App</source>
        <context-group purpose="location">
          <context context-type="sourcefile">app/about/about.component.ts</context>
          <context context-type="linenumber">3</context>
        </context-group>
      </trans-unit>
    </body>
  </file>
</xliff>

因此, 我们有一个翻译文件, 将消息映射到源代码中它们的实际位置。现在, 我们可以将文件提供给短语。或者, 我们可以手动添加翻译。为此, 让我们在src中创建一个新文件messages.ru.xlf:

<?xml version="1.0" encoding="UTF-8"?>
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
  <file original="ng2.template" datatype="plaintext" source-language="en" target-language="ru">
    <body>
      <trans-unit id="80dcbb43f590ee82c132b8c725df2b7b433dc10e">
        <source xml:lang="en">Cards App</source>
        <target xml:lang="ru">Картотека</target>
      </trans-unit>
  </body>
  </file>
</xliff>

现在, 我们可以通过运行以下命令来服务我们的应用程序(例如, 俄语)ng serve –aot –locale = ru –i18n-file = src / messages.ru.xlf。让我们看看它是否有效:

在俄语应用中提供

现在, 让我们的构建脚本自动化, 以便我们可以在每个生产构建中以两种语言构建我们的应用程序构建, 并调用其相应的目录en或ru。为此, 我们将build-i18n命令添加到package.json的脚本部分:

 "build-i18n": "for lang in en ru; do yarn run ng build --output-path=dist/$lang --aot -prod --bh /$lang/ --i18n-file=src/messages.$lang.xlf --i18n-format=xlf --locale=$lang --missing-translation=warning; done"

码头工人

现在, 我们打包应用以供生产使用, 并为此使用Docker。让我们从Dockerfile开始:

#### STAGE 1: Build ###
## We label our stage as 'builder'
FROM node:8.6-alpine as builder

ENV APP_PATH /app
MAINTAINER Sergey Moiseev <[email protected]>

COPY package.json .
COPY yarn.lock .

### Storing node modules on a separate layer will prevent unnecessary npm installs at each build
RUN yarn install --production && yarn global add gulp && mkdir $APP_PATH && cp -R ./node_modules .$APP_PATH

WORKDIR $APP_PATH

COPY . .

### Build the angular app in production mode and store the artifacts in dist folder
RUN yarn remove node-sass && yarn add node-sass && yarn run build-i18n && yarn run gulp compress

#### STAGE 2: Setup ###
FROM nginx:1.13.3-alpine

ENV APP_PATH /app
MAINTAINER Sergey Moiseev <[email protected]>

### Copy our default nginx config
RUN rm -rf /etc/nginx/conf.d/*
COPY nginx/default.conf /etc/nginx/conf.d/

### Remove default nginx website
RUN rm -rf /usr/share/nginx/html/*

EXPOSE 80

### From 'builder' stage copy over the artifacts in dist folder to default nginx public folder
COPY --from=builder $APP_PATH/dist/ /usr/share/nginx/html/

CMD ["nginx", "-g", "daemon off;"]

因此, 我们使用基于节点的映像为应用程序使用多阶段构建, 然后使用基于Nginx的映像构建服务器软件包。我们还使用Gulp压缩了工件, 因为Angular CLI不再为我们这样做。我觉得很奇怪, 但是好的, 让我们添加Gulp和压缩脚本。

yarn add [email protected] [email protected] --dev
[...]
success Saved 2 new dependencies.
├─ [email protected]
└─ [email protected]
✨  Done in 10.48s.

让我们在应用程序根目录中添加gulpfile.js:

const gulp = require('gulp');
const zip = require('gulp-gzip');

gulp.task('compress', function() {
    for (var lang in ['en', 'ru']) {
        gulp.src([`./dist/${lang}/*.js`, `./dist/${lang}/*.css`])
        .pipe(zip())
        .pipe(gulp.dest(`./dist/${lang}/`));
    }
});

现在我们只需要我们的Nginx配置来构建我们的容器。让我们将其添加到nginx / default.conf中:

server {
  listen 80;

  sendfile on;

  default_type application/octet-stream;

  client_max_body_size  16m;

  gzip on;
  gzip_disable "msie6";

  gzip_vary on;
  gzip_proxied any;
  gzip_comp_level 6;
  gzip_buffers 16 8k;
  gzip_http_version 1.0; # This allow us to gzip on nginx2nginx upstream.
  gzip_min_length 256;
  gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/vnd.ms-fontobject application/x-font-ttf font/opentype image/svg+xml image/x-icon;

  root /usr/share/nginx/html;

  location ~* \.(js|css)$ {
    gzip_static on;
    expires max;
    add_header Cache-Control public;
  }

  location ~ ^/(en|ru)/ {
    try_files $uri $uri/ /index.html =404;
  }

  location = / {
     return 301 /en/;
   }
}

因此, 我们从目录en或ru提供构建应用程序, 默认情况下, 我们从根URL重定向到/ en /。

现在, 我们可以使用docker build -t app来构建应用程序。命令:

docker build -t app .
Sending build context to Docker daemon    347MB
Step 1/17 : FROM node:8.6-alpine as builder
 ---> b7e15c83cdaf
Step 2/17 : ENV APP_PATH /app
[...]
Removing intermediate container 1ef1d5b8d86b
Successfully built db57c0948f1e
Successfully tagged app:latest

然后我们可以通过运行docker run -it -p 80:80 app在本地计算机上使用Docker为它提供服务。并且有效:

有用! Angular 5教程现已完成

注意URL中的/ en /。

摘要

祝贺你完成本教程。你现在可以加入其他Angular开发人员的行列。你刚刚创建了第一个Angular应用, 将Firebase用作后端, 并通过Nginx在Docker容器中提供了它。

与任何新框架一样, 要擅长于此, 唯一的方法就是继续练习。希望你开始了解Angular的功能。当你准备好继续学习时, Angular文档是一个很好的参考资料, 其中包含有关高级技术的整个章节。

如果你想进行更高级的操作, 请尝试由srcminier Igor Geshoki编写的Angular 4表单的使用:嵌套和输入验证。

赞(0)
未经允许不得转载:srcmini » Angular 5教程:第一个Angular 5应用程序分步指南

评论 抢沙发

评论前必须登录!