Web笔记 ·

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

前言

上周由于收到是开工第一天,就拖到这周了,最终没能发布这篇。现在抓紧时间弥补上。

原文An Angular 5 Tutorial: Step by Step Guide to Your First Angular 5 App

作者:SERGEY MOISEEV -

正文

现在我们可以app.component.html用这个替换:Angular是由Google开发的AngularJS框架的新版本。它带有一个完整的重写,以及各种改进,包括优化构建和更快的编译时间。在这个Angular 5教程中,我们将从头开始构建一个笔记应用程序。如果您一直在等待学习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为你编译它。

Angular现在拥有自己的CLI,或者command line interface,它将为您执行大部分日常操作。要开始使用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/angular@0.1.10" has incorrect peer dependency "@angular-devkit/schematics@0.0.40".
warning "@angular/cli > @angular-devkit/schematics > @schematics/schematics@0.0.10" has incorrect peer dependency "@angular-devkit/schematics@0.0.40".
[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 5教程:一步一步指导实现你的第一个Angular 5应用程序

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

组件

我们有我们的空应用程序运行。我们来谈谈Angular中的应用程序组合。如果您在AngularJS中有一些背景知识,那么您知道存在控制器,指令和组件,这些控制器,指令和组件在某种程度上类似于指令但更简单,允许您升级到Angular 2.对于那些没有那种精彩体验的用户他们并找出什么地方,不要担心。它现在主要是组件。该组件是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来生成我们的组件声明。这只是为我们做了很多样板工作,并以工作形式回馈我们的组件声明。我们不必实现额外的代码来支持任何装饰器的参数。这一切都由装饰者处理。所以,通常我们称之为工厂方法。

我们已经看到app-root了我们的index.html。以下是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 Logo嵌入SVG之外,这很好,这也是典型的日常标记。除了一件事情(Welcome to {{ title }}!)之外,如果我们再看看我们的组件代码,我们会看到title = 'app';。所以,如果你已经在模板语言中做过一些练习,或者已经和AngularJS一起工作,那么这里发生的事情是非常明显的。如果你不知道,这就是所谓的Angular Interpolation,通过这个Angular Interpolation,我们的组件中拉出了双曲花括号中的表达式(你可以把它想象{{ title }}成简化的形式{{ this.title }})并显示在我们的标记上。

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

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

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

所以,让我们开始走向我们的目标,首先,让我们将项目从CSS切换到Sass,然后打开我们的.angular-cli.json编辑stylesstyleExt属性:

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

我们还需要将Sass库添加到我们的项目并重命名styles.cssstyles.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.
└─ sass@1.0.0-beta.4
✨  Done in 12.06s.
yarn add node-sass@4.7.2 --dev
✨  Done in 5.78s.

我也想在我们的项目中使用Twitter Bootstrap,所以我也运行yarn add bootstrap@v4.0.0-beta.2并编辑我们的项目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 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 5教程:一步一步指导实现你的第一个Angular 5应用程序

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

我们的第一个组件(component)

我们将在我们的界面中将卡片显示为卡片,所以让我们开始生成我们的第一个组件,代表卡片本身。为此,我们通过运行以下命令来使用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-。您可以通过编辑prefix属性将其更改为首选项的前缀.angular-cli.json,因此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 { }

显然,BrowserModuleNgModule是内部角模块。我们可以在文档中阅读更多有关它们的内容。在我们开始生成任何代码之前,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>

如果我们在浏览器中打开它,我们会看到类似这样的内容:

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

目前,我们从硬编码标记中显示我们的卡片。让我们通过将硬编码数组移动到我们的应用程序中,让我们的代码更接近真实案例场景:

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>;
[...]

我们Input从Angular代码导入,并将其用作类型为Array的任何类型对象的类级变量卡的装饰器。理想情况下,我们不应该使用any,但应该使用严格的打字方式,以便我们可以定义类似于界面卡的东西,它将包含我们卡的所有属性,但稍后我们会开始工作 - 现在,我们any只使用得到一个快速和肮脏的实施正在进行中。

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

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

这对我们来说是一个新东西,一个从星号开始的属性名称。这是什么意思?这是命名Angular 结构指令的默认约定。结构指令控制着我们模板的结构。这里的星号实际上是“语法糖”,你可以进一步阅读以理解它是如何工作的。但是对于您当前的示例,了解将其添加到组件时会发生什么情况就足够了。因此,ngFor一个repeater指令,它会重复我们的应用程序卡中的每个元素的卡片。如果我们看一下浏览器,我们接下来会看到:

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

有些事情是不对的; 我们有我们的卡片阵列,但我们正在获得一个空白页面。

我们在AppComponent级别上定义了我们的卡片数组,但是我们没有将它传递给CardList输入。让我们编辑我们的AppComponent模板来做到这一点。

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

此语法(方括号中的属性)告诉Angular,我们希望我们的组件变量单向绑定cards到我们的卡片列表组件[cards]输入。只要我们这样做,我们得到这个:

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

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

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

如果我们现在查看浏览器,我们会在JS控制台中看到下一个错误:Can't bind to 'card' since it isn't a known property of 'app-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>
[...]

现在看看它是如何工作的:

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

看起来很好,但造型有点不合适。让我们通过添加一个新的样式card.component.css来解决这个问题:

.card {
    margin-top: 1.5rem;
}

现在看起来好多了:
一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

组件通信

让我们添加一个新的卡片输入组件,它将允许我们添加注释:

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>

我们来看看浏览器。

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

问题是我们的新组件没有做任何事情。让我们开始工作 - 让我们开始添加一个变量来保存我们的新卡:

[...]
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值发生变化时,它都会传递给我们的组件输入。每次用户向我们的输入和浏览器输出中输入数据时input $event,我们都会将其分配newCard.text给输入值。

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

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

这个语法,在一个盒子里([])叫做banana,或者ngModel是一个Angular指令,它负责从事件和所有那些中获取价值。因此,我们只需编写简单的代码即可获取我们的价值,并将其绑定到代码中的输入值和变量。

不幸的是,在我们添加之后ngModel,我们正在收到错误Can't bind to 'ngModel' since it isn't a known property of 'input'.。我们需要导入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>

onCardAdd就像我们在NewCardInput组件中提到的那样,我们也对事件有一个表达式。现在我们需要addCard在我们的方法上实现这个方法AppComponent

[...]
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方法,在该方法中,我们onCardAdd从我们的卡片中输出Angular 文本,并将卡片文本重置为空字符串,以便用户可以继续添加新卡片而无需编辑旧卡片的文本。

使用表单

在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。使用它,我们可以访问由模板引用值标记的任何元素 - 在这种情况下,我们的表单,我们实际上声明它是我们的组件公共变量形式,所以我们可以写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);
});

打开浏览器和开发者工具控制台,并观察在我们的输入中输入新值时会发生什么:一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

RxJS

那么这里究竟发生了什么?我们正在看到RxJS的行动。我们来讨论一下。我猜你们至少都知道一些关于承诺和构建异步代码的内容。承诺处理单一事件。POST例如,我们要求浏览器创建一个承诺。RxJS使用Observable来处理事件流。想想像这样:我们刚刚实现了在我们的表单发生变化时调用的代码。如果我们用承诺处理用户更改,则只有第一个用户更改会在我们需要重新订阅之前处理。同时,Observable能够处理每一个事件,实际上有着无数的“承诺”。我们可以通过在这个过程中得到一些错误或者通过退订Observable来解决这个问题。

这里是什么takeWhile?我们在我们的组件中订阅我们的观察器。它们被用在我们应用程序的不同部分,所以它们可能会一路销毁 - 例如,当我们在路由中使用组件作为页面时(我们将在本指南后面讨论路由)。但是,尽管代替Observable的承诺将只运行一次并在此之后处理,但只要流正在更新并且我们不取消订阅,Observable就会持续运行。所以我们的订阅需要取消订阅(如果我们不查找内存泄漏),如下所示:

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

但在我们的应用程序中,我们有很多不同的订阅。我们是否需要完成所有这些样板代码?其实,我们可以欺骗并使用takeWhile运算符。通过使用它,我们确保我们的数据流一旦发生this.alive错误就会停止发布新值,并且我们只需在该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);
  }

一个Angular 5教程:一步一步指导实现你的第一个Angular 5应用程序

现在我们来配置Firebase,在Firebase中创建一个演示项目并点击Add Firebase to your app按钮。然后,我们将Firebase显示给我们的凭据复制到我们应用的环境文件中,在此处:src/environments/

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

我们需要将它添加到environment.tsenvironment.prod.ts。只是为了让您对这些环境文件在这里有所了解,它们实际上包含在编译阶段的项目中,并且.prod.该部分由--environment交换机为ng serveor 定义ng build。您可以在项目的所有部分使用该文件中的值,并environment.ts在Angular CLI负责提供来自相应项目的内容时将其包含在内environment.your-environment.ts

让我们添加我们的Firebase支持库:

yarn add firebase@4.8.0 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;
    }
}

因此,我们正在使用类来构建数据,除了我们的文本外,我们还key$从Firebase中添加。让我们更改我们的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获得观察结果。但是,我们*ngFor在CardList组件中等待对象数组,不能观察这些数组。所以我们可以订阅这个可观察对象并将它分配给一个静态的卡片数组,但是有一个更好的选择:

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

异步管道实际上是Angular为我们提供的另一种语法糖,与我们讨论过的同样的事情 - 订阅Observable并通过评估我们的表达式返回其当前值。

反应角 - Ngrx

让我们来谈谈我们的应用程序状态,我的意思是我们的应用程序的所有属性,它们的字面定义其当前的行为和状态。State是一个单一的,不可变的数据结构 - 至少Ngrx为我们实现它的方式。Ngrx是由Redux提供灵感的“RxJS支持Angular应用程序的状态管理库”。

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

让我们看看这是如何实现的。我们讨论了State它的不变性,这意味着我们在创建它之后不能改变它的任何属性。这使得我们的应用程序状态存储在我们的系统中几乎不可能State。但并非完全 - 每一个国家都是不变的,但是Store,这是我们访问的方式State,实际上是一个国家的可观察。因此,State价值流中的一个值是单一的Store。为了改变应用程序的状态,我们需要制作一些Action将会采用我们当前版本State并将其替换为新版本的版本。两者都是不可变的,但第二个是基于第一个的,所以State我们创建一个新的State对象,而不是在我们的变异值上。为此,我们使用Reducers纯函数,这意味着对于任何给定的StateAction它的payloadreducer,它将返回与使用相同参数的reducer函数的任何其他调用相同的状态。

Actions 由动作类型和可选的有效载荷组成:

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

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

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

我们来看一个reducer:

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行动,它会进入该案例陈述。那里发生了什么?我们回到我们的新的State基于我们前面State使用打字稿传播语法,所以我们并没有使用像Object.assign在大多数情况下。除了这些案例陈述之外,我们绝不应该改变我们的状态,否则当我们浪费时间寻找我们的代码行为不可预测的原因时,它会使生活变得悲惨。

让我们将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/core@1.2.0
└─ @ngrx/store@4.1.1
└─ ngrx-store-logger@0.2.0
✨  Done in 25.47s.

现在,添加我们的Action定义(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效应

那么什么是副作用?它的代码片段Actions或多或少地与我们的缩减器相同,但它不是在我们的状态中改变某些内容,而是实际发送API请求,并根据结果派发新的代码Actions。与往常一样,展示你比告诉你更简单。让我们让我们的新配置支持Firebase。为此,我们安装effects模块:

yarn add @ngrx/effects
[...]
success Saved 1 new dependency.
└─ @ngrx/effects@4.1.1
✨  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方法调用结果的新可观察对象。在成功的情况下,observable将被映射到一个新的动作,LoadSuccess并带有请求结果的有效载荷,并且在出错的情况下,我们将返回一个单独的ServerFailure动作(介意of那里的操作符 - 它将单个值或数组的可观察价值)。

所以我们的效果在做出取决于外部系统的东西(我们的Firebase,准确地说)后派发新的动作。但是在相同的代码中,我们看到了另一个效果,它ServerFailure使用装饰器参数处理动作dispatch: false是什么意思?正如我们从它的实现中可以看到的那样,它也将我们的ServerFailure动作映射到它的有效负载,然后显示这个有效负载(我们的服务器错误)console.log。显然,在这种情况下,我们不应该改变国家的内容,所以我们不需要发送任何东西。这就是我们如何使它工作而不需要任何空的行为。

所以,现在我们已经介绍了我们的三个动作中的两个,让我们继续前进LoadSuccess。从目前我们所知道的,我们正在从服务器上下载一张卡片列表,我们需要将它们合并到我们的服务器中State。所以我们需要将它添加到我们的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]),

现在,回到浏览器...

啊,这些卡现在从firebase加载

现在它正在工作。这就是你如何将效果集成到从服务器加载数据的过程。但是我们仍然需要将其发回到我们的卡片创建中。让我们来做这件事吧。为此,我们来更改我们的CardServicecreateCard方法:

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

所以,如果要创建该卡,它将$key从Firebase 获得,我们会将其合并到我们的卡阵列中。我们还需要case cards.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);
    })
  );

现在我们只需要更换减速器LoadSuccess来替换卡,而不是将它们组合起来:

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

现在它的工作原理是:

卡已正确添加

remove action现在可以用同样的方法。当我们从订阅中获取数据时,您只需要实现该Remove效果。但我会把它留给你。

路由和模块

我们来谈谈我们的应用程序组合。如果我们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添加了路由器指令,该指令在我们的路由处于活动状态时设置一个类,以及为我们替换的routerLinkhref。这里是routerOutlet,它告诉Router在当前页面上显示其内容的位置。所以,结合这些,我们现在在每个页面上都有菜单,以及具有不同内容的两个页面:

卡上的命令被删除

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

随着我们的应用程序的增长,我们可能会开始考虑优化。例如,如果我们想要将关于组件加载为默认组件,并且只在用户通过单击卡片链接隐式请求后才加载附加组件,该怎么办。为此,我们可以使用延迟加载模块。让我们开始生成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,当我们询问 路由时,路由器会告诉路由器CardsModule./cards.module文件中的延迟加载cards。我们在新./reducers/root.ts文件中包含元减速器- 让我们来看看它:

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只有在我们点击/cards链接后才会加载。

准备生产

因此,让我们构建我们的应用程序供生产使用 为此,我们运行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对您的代码质量过于严格,因此它可能会产生您以前从未见过的错误。更早地运行构建,因此更容易修复。

国际化

构建我们的应用程序的另一个原因是Angular如何处理国际化,或者以简单的语言来讲,国际化。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>

所以我们有一个翻译文件将消息映射到源代码中的实际位置。现在,我们可以将文件提供给PhraseApp。或者,我们可以手动添加我们的翻译。为此,我们在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命令添加到scripts我们的部分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 <sergey.moiseev@toptal.com>

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 <sergey.moiseev@toptal.com>

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

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

yarn add gulp@3.9.1 gulp-zip@4.1.0 --dev
[...]
success Saved 2 new dependencies.
├─ gulp-zip@4.1.0
└─ gulp@3.9.1
✨  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/;
   }
}

所以,我们从目录中为我们构建的应用程序enru并且默认情况下,我们是从根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来提供它docker run -it -p 80:80 app。它正在工作:

有用! 这个Angular 5教程现在已经完成

注意/en/URL中的内容。

综述

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

就像任何新的框架一样,要做到这一点,唯一的方法就是继续练习。希望你已经了解了Angular的强大。当您准备好继续时,Angular文档是一个很好的资源,并且附带了关于高级技术的整个部分。

如果您想采取更先进的措施,请尝试使用Angular 4 Forms:由Toptaler Igor Geshoki进行嵌套和输入验证

理解基础知识

我们为什么要使用Angular?

我们用它来开发丰富的接口客户端应用程序,如单页应用程序和移动应用程序。Angular的主要优势在于获得一个完全集成的Web框架,该框架为构建组件,路由和使用远程API提供了自己的框内解决方案。

参与评论