Angular-设计模式-全-

Angular 设计模式(全)

原文:zh.annas-archive.org/md5/7218DB9929A7962C59313A052F4806F8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Angular 是谷歌推出的用于构建 Web 应用程序的框架。与 AngularJS 相比,这是一个全新的产品。

AngularJS 以性能问题而闻名,并且并不一定很容易上手。只要你了解框架的具体细节和潜在瓶颈,一切都可能顺利进行。此外,AngularJS 通常被视为一个大工具箱,里面有很多工具,让你可以以多种不同的方式构建应用程序,最终导致同一逻辑的各种实现取决于开发人员。

Angular 在性能方面带来了巨大的改进,同时也是一个更简单、更直接的框架。Angular 简单地让你用更少的代码做更多的事情。

谷歌从 Angular 开发的开始就宣布,该框架将是一个全新的产品,不兼容 AngularJS,尽管他们可能会尝试提供一些工具来简化过渡。通常情况下,从头开始重写应用程序可能是迁移的最佳解决方案。在这种情况下,开发人员需要学习 Angular 框架的关键部分,以启动应用程序和开发它的最佳实践,以及调试和基准应用程序的现有工具。

通过对最有价值的设计模式进行全面的介绍,并清晰地指导如何在 Angular 中有效地使用它们,本书为你提供了学习 Angular 和将其用于满足当今 Web 开发所需的稳定性和质量的最佳途径之一。

我们将带领读者走进 Angular 在现实世界中的设计之旅,结合案例研究、设计模式和要遵循的反模式。

在本书结束时,你将了解 Angular 的各种特性,并能够在工作中应用广为人知的、经过行业验证的设计模式。

本书的受众

本书适用于希望增进对 Angular 的理解并将其应用于实际应用程序开发的新手 Angular 开发人员。

本书涵盖的内容

第一章《TypeScript 最佳实践》描述了 TypeScript 语言的一些最佳实践。虽然 Angular 与其他编程语言兼容,但在本书中我们使用 TypeScript。TypeScript 功能强大且表达力强,但也有一些需要避免的“坑”。

第二章,Angular 引导,允许我们使用最佳可用工具来创建、构建和部署我们的应用程序。

第三章,经典模式,在 Angular 的上下文中重新审视了一些众所周知的面向对象模式。

第四章,导航模式,侧重于不同的导航 Angular 应用程序的方式。

第五章,稳定性模式,介绍了可以用来确保实际 Angular 应用程序稳定性的不同稳定性模式。

第六章,性能模式,基于谷歌对 Angular 进行的巨大性能改进,并描述了适用于改进应用程序性能的模式。

第七章,操作模式,侧重于在使用众所周知的设计模式实现功能并使用一些性能和稳定性模式后,使我们的应用程序准备好进行操作。

为了充分利用本书

为了充分利用本书,读者需要了解 Angular、Typescript 和面向对象编程。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com上登录或注册。

  2. 选择“支持”选项卡。

  3. 点击“代码下载和勘误”。

  4. 在搜索框中输入书名,并按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩软件解压缩文件夹:

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Angular-Design-Patterns。我们还有来自丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/AngularDesignPatterns_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“APIService,显示了@Injectable()注释,使其可以注入。”

代码块设置如下:

interface Animal{ 
   eat():void; 
   sleep():void; 
} 

当我们希望引起您对代码块的特定部分的注意时,相关行或项目以粗体设置:

 ReferenceError: window is not defined

任何命令行输入或输出都是这样写的:

$ curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash -
$ sudo apt-get install -y Node.js

粗体:表示一个新术语、一个重要词或屏幕上看到的词。例如,菜单或对话框中的单词会以这样的方式出现在文本中。这是一个例子:“Model根据控制器发送的命令存储应用程序所需的数据。”

警告或重要说明是这样出现的。提示和技巧是这样出现的。

第一章:TypeScript 最佳实践

我一直讨厌 JavaScript。当然我会用它,但只是在必要的时候。我清楚地记得我的第一次实习面试,那时我还是法国计算机工程学校 eXia.Cesi 的大一新生。我只知道 C 和一些 Java,被要求帮助一个主要使用自制 Ajax 库的内部网络。那纯粹是疯狂,有点让我暂时远离了计算机工程的 Web 方面。我对以下内容一无所知。

var r = new XMLHttpRequest();  
r.open("POST", "webservice", true); 
r.onreadystatechange = function () { 
   if (r.readyState != 4 || r.status != 200) return;  
   console.log(r.responseText); 
}; 
r.send("a=1&b=2&c=3"); 

一个本地的 Ajax 调用。多丑陋啊?

当然,使用 jQuery 模块和一些关注点分离,它是可以使用的,但仍然不像我想要的那样舒适。你可以在下面的截图中看到关注点是分离的,但并不那么容易:

使用 PHP5 和 Codeigniter 的已弃用的 toolwatch.io 版本

然后,我学习了一些 RoR(基于 Ruby 的面向对象的 Web 应用程序框架:rubyonrails.org/)和 Hack(Facebook 的一种带类型的 PHP:hacklang.org/)。这太棒了;我拥有了我一直想要的一切:类型安全、工具和性能。第一个,类型安全,相当容易理解:

<?hh 
class MyClass { 
  public function alpha(): int { 
    return 1; 
  } 

  public function beta(): string { 
    return 'hi test'; 
  } 
} 

function f(MyClass $my_inst): string { 
  // Fix me! return $my_inst->alpha(); 
} 

另外,有了类型,你可以拥有很棒的工具,比如强大的自动完成和建议:

Sublime Text 在 toolwatch.io 移动应用程序(Ionic2 [5] + Angular 2)上的自动完成

Angular 可以与 CoffeeScript、TypeScript 和 JavaScript 一起使用。在本书中,我们将专注于 TypeScript,这是 Google 推荐的语言。TypeScript 是 JavaScript 的一种带类型的超集;这意味着,使用 TypeScript,你可以做任何你以前在 JavaScript 中做的事情,还有更多!举几个优点:用户定义的类型、继承、接口和可见性。最好的部分是,TypeScript 被转译成 JavaScript,所以任何现代浏览器都可以运行它。

事实上,通过使用 polyfill,甚至我们那个老旧的 IE6 几乎可以执行最终的输出。我们将在下一章回到这个问题。转译与编译不同(例如,从 C 到可执行文件或从.java.class),因为它只是将 TypeScript 转换成 JavaScript。

在本章中,我们将学习 TypeScript 的最佳实践。对于了解 JavaScript 和面向对象语言的任何人来说,TypeScript 语言的语法都非常容易掌握。如果您对面向对象编程一无所知,我建议您将这本书放在一边,花几分钟时间查看这个快速的 Udacity 课程:www.udacity.com/wiki/classes

总结一下涉及的主题:

  • TypeScript 语法

  • TypeScript 最佳实践

  • TypeScript 的缺点

环境设置

对于环境设置,我将涵盖所有三个主要平台:Debian 风格的 Linux,macOS 和 Windows。我们将要使用的所有工具都是跨平台的。因此,随意选择您最喜欢的那个;以后您将能够做任何事情。

接下来,我们将安装Node.jsnpm和 TypeScript。

Linux 的 Node.js 和 npm

$ curl -sL https://deb.nodesource.com/setup_6.x | sudo -E bash -
$ sudo apt-get install -y Node.js

这个命令会将一个脚本直接下载到您的bash中,它将获取您需要的每一个资源并安装它。在大多数情况下,它会正常工作并安装Node.js + npm

现在,这个脚本有一个缺陷;如果您有不再可用的 Debian 存储库,它将失败。您可以利用这个机会清理您的 Debian 存储库,或者稍微编辑一下脚本。

$ curl https://deb.nodesource.com/setup_6.x > node.sh 
$ sudo chmod +x node.sh 
$ vim node.sh //Comment out all apt-get update 
//Save the file $ sudo apt-get update 
$ ./node.sh 
$ sudo apt-get update 
$ sudo apt-get install -y Node.js 

然后,前往Node.js.org/en/download/,下载并安装最新的.pkg.msi(分别用于 Linux 或 Windows)。

TypeScript

现在,您应该可以在终端中访问nodenpm。您可以使用以下命令测试它们:

$ node -v 
V8.9.0 

$ npm -v 
5.5.1  

请注意,这些命令的输出(例如 v6.2.1 和 3.9.3)可能会有所不同,当您阅读这些内容时,您的环境中的 node 和 npm 的最新版本可能会有所不同。但是,如果您至少有这些版本,您将在本书的其余部分中表现良好:

    $ npm install -g TypeScript

-g参数代表全局。在 Linux 系统中,根据您的发行版,您可能需要sudo权限来安装全局包。

与 node 和 npm 非常相似,我们可以使用以下命令测试安装是否顺利进行:

    $ tsc -v
    Version 2.6.1

目前我们拥有的是 TypeScript 转译器。您可以这样使用:

    tsc --out myTranspiledFile.js myTypeScriptFile.ts

这个命令将转译myTypeScriptFile.ts的内容并创建myTranspiledFile.js。然后,您可以在控制台中使用 node 执行生成的js文件。

    node myTranspiledFile.js

为了加快我们的开发过程,我们将安装ts-node。这个 node 包将 TypeScript 文件转译成 JavaScript,并解决这些文件之间的依赖关系:

    $ npm install -g ts-node
    $ ts-node -v
    3.3.0

创建一个名为hello.ts的文件,并添加以下内容:

console.log('Hello World'); 

现在,我们可以使用我们的新包:

    $ ts-node hello.ts 
    Hello World

快速概述

在这一部分,我将简要介绍 TypeScript。这个介绍并不是详尽无遗的,因为我会在遇到特定概念时进行解释。但是,这里有一些基础知识。

TypeScript 是我提到的 JavaScript 的一个有类型的超集。虽然 TypeScript 是有类型的,但它只提供了四种基本类型供您直接使用。这四种类型分别是StringnumberBooleanany。这些类型可以使用:运算符,对变量或函数参数进行类型标记,比如var name: string,或者返回add(a:number, b:number):number类型的函数。此外,void可以用于函数,指定它们不返回任何内容。在面向对象的一面,string、number 和 boolean 是 any 的特例。Any可以用于任何类型。它是 Java 对象的 TypeScript 等价物。

如果您需要更多的类型,那么您将不得不自己创建!幸运的是,这非常简单。这是一个包含一个属性的用户类的声明:

class Person{
name:String;
}

您可以使用这里显示的简单命令创建一个新的Person实例:

var p:Person = new Person();
p.name = "Mathieu"

在这里,我创建了一个p变量,它在静态(例如左侧)和动态(例如右侧)方面都代表一个人。然后,我将Mathieu添加到name属性中。属性默认是公共的,但您可以使用publicprivateprotected关键字来定义它们的可见性。它们会像您在任何面向对象的编程语言中所期望的那样工作。

TypeScript 以非常简单的方式支持接口、继承和多态。这里有一个由两个类和一个接口组成的简单层次结构。接口People定义了将被任何People实现继承的字符串。然后,Employee实现了People并添加了两个属性:managertitle。最后,Manager类定义了一个Employee数组,如下面的代码块所示:

interface People{ 
   name:string; 
} 

class Employee implements People{ 
   manager:Manager; 
   title:string; 
} 

class Manager extends Employee{ 
   team:Employee[]; 
} 

函数可以被具有相同签名的函数覆盖,并且super关键字可以用来引用父类的实现,如下面的代码片段所示:

Interface People { 

   name: string; 
   presentSelf():void; 
} 

class Employee implements People { 

   name: string; 
   manager: Manager; 
   title: string; 

   presentSelf():void{ 

         console.log( 

               "I am", this.name,  
               ". My job is title and my boss is",  
               this.manager.name 

         ); 
   } 
} 

class Manager extends Employee { 

   team: Employee[]; 

   presentSelf(): void { 
         super.presentSelf(); 

         console.log("I also manage", this.team.toString()); 
   } 
} 

在我们继续讨论最佳实践之前,您需要了解有关 TypeScript 的最后一件事是letvar之间的区别。在 TypeScript 中,您可以使用这两个关键字来声明变量。

现在,TypeScript 中变量的特殊之处在于它允许您使用 var 和 let 关键字为变量选择函数作用域和块作用域。Var 将为您的变量提供函数作用域,而 let 将产生一个块作用域的变量。函数作用域意味着变量对整个函数可见和可访问。大多数编程语言都有变量的块作用域(如 C#,Java 和 C ++)。一些语言也提供了与 TypeScript 相同的可能性,例如 Swift 2。更具体地说,以下代码段的输出将是456

var foo = 123; 
if (true) { 
    var foo = 456; 
} 
console.log(foo); // 456

相反,如果您使用 let,输出将是123,因为第二个foo变量只存在于if块中:

let foo = 123; 
if (true) { 
    let foo = 456; 
} 
console.log(foo); // 123 

最佳实践

在本节中,我们将介绍 TypeScript 的最佳实践,包括编码约定、使用技巧、以及要避免的功能和陷阱。

命名

Angular 和 definitely typed 团队提倡的命名约定非常简单:

  • 类:CamelCase

  • 接口:CamelCase。此外,您应该尽量避免在接口名称前加大写 I。

  • 变量:lowerCamelCase。私有变量可以在前面加上_

  • 函数:lowerCamelCase。此外,如果一个方法不返回任何内容,您应该指定该方法返回void以提高可读性。

接口重新定义

TypeScript 允许程序员多次使用相同的名称重新定义接口。然后,所述接口的任何实现都继承了所有接口的定义。官方原因是允许用户增强 JavaScript 接口,而无需在整个代码中更改对象的类型。虽然我理解这种功能的意图,但我预见到在使用过程中会遇到太多麻烦。让我们来看一个微软网站上的示例功能:

interface ICustomerMerge 
{ 
   MiddleName: string; 
} 
interface ICustomerMerge 
{ 
   Id: number; 
} 
class CustomerMerge implements ICustomerMerge 
{ 
   id: number; 
   MiddleName: string; 
} 

撇开命名约定不被遵守的事实,我们得到了ICustomerMerge接口的两个不同的定义。第一个定义了一个字符串,第二个定义了一个数字。自动地,CustomerMerge有这些成员。现在,想象一下你有十二个文件依赖,你实现了一个接口,你不明白为什么你必须实现这样那样的函数。嗯,某个地方的某个人决定重新定义一个接口并一下子破坏了你所有的代码。

获取器和设置器

在 TypeScript 中,您可以使用?运算符指定可选参数。虽然这个特性很好,我将在接下来的章节中不加节制地使用它,但它也会带来以下的丑陋:

class User{ 
   private name:string; 
   public  getSetName(name?:string):any{ 
         if(name !== undefined){ 
               this.name = name; 
         }else{ 
               return this.name 
         } 
   } 
} 

在这里,我们测试可选的名称参数是否通过!== undefined传递。如果getSetName函数接收到了某些东西,它将作为 setter,否则作为 getter。函数在作为 setter 时不返回任何内容是被允许的。

为了清晰和可读性,坚持受 ActionScript 启发的 getter 和 setter:

class User{
private name:_string = "Mathieu";
get name():String{
return this._name;
}
set name(name:String){
this._name = name;
}
}

然后,您可以这样使用它们:

var user:User = new User():
if(user.name === "Mathieu") { //getter
 user.name = "Paul" //setter
}

构造函数

TypeScript 构造函数提供了一个非常不寻常但节省时间的特性。事实上,它们允许我们直接声明一个类成员。因此,不需要这么冗长的代码:

class User{ 

   id:number; 
   email:string; 
   name:string; 
   lastname:string; 
   country:string; 
   registerDate:string; 
   key:string; 

   constructor(id: number,email: string,name: string, 
         lastname: string,country: string,registerDate:  
         string,key: string){ 

         this.id = id; 
         this.email = email; 
         this.name = name; 
         this.lastname = lastname; 
         this.country = country; 
         this.registerDate = registerDate; 
         this.key = key; 
   } 
} 

你可以有:

class User{ 
   constructor(private id: number,private email: string,private name: string, 

         private lastname: string,private country: string, private            registerDate: string,private key: string){} 
} 

前面的代码实现了相同的功能,并且将被转译为相同的 JavaScript。唯一的区别是它以一种不会降低代码清晰度或可读性的方式节省了您的时间。

类型保护

在 TypeScript 中,类型保护为给定值定义了一系列类型。如果您的变量可以被赋予一个特定的值或一组特定的值,那么考虑使用类型保护而不是枚举器。它将实现相同的功能,同时更加简洁。这里有一个关于People人的虚构例子,他有一个性别属性,只能是MALEFEMALE

class People{
gender: "male" | "female";
}

现在,考虑以下内容:

class People{
gender:Gender;
}
enum Gender{
MALE, FEMALE
}

枚举器

与类型保护相反,如果您的类有一个变量可以从有限的值列表中同时取多个值,那么考虑使用基于位的枚举器。这里有一个来自basarat.gitbooks.io/的绝佳例子:

class Animal{ 
   flags:AnimalFlags = AnimalFlags.None 
} 

enum AnimalFlags { 
    None           = 0, 
    HasClaws       = 1 << 0, 
    CanFly         = 1 << 1, 
} 

function printAnimalAbilities(animal) { 
    var animalFlags = animal.flags; 
    if (animalFlags & AnimalFlags.HasClaws) { 
        console.log('animal has claws'); 
    } 
    if (animalFlags & AnimalFlags.CanFly) { 
        console.log('animal can fly'); 
    } 
    if (animalFlags == AnimalFlags.None) { 
        console.log('nothing'); 
    } 
} 

var animal = { flags: AnimalFlags.None }; 
printAnimalAbilities(animal); // nothing 
animal.flags |= AnimalFlags.HasClaws; 
printAnimalAbilities(animal); // animal has claws 
animal.flags &= ~AnimalFlags.HasClaws; 
printAnimalAbilities(animal); // nothing 
animal.flags |= AnimalFlags.HasClaws | AnimalFlags.CanFly; 
printAnimalAbilities(animal); // animal has claws, animal can fly 

我们使用<<移位运算符在AnimalFlags中定义了不同的值,然后使用|=来组合标志,使用&=~来移除标志,使用|来组合标志。

陷阱

在本节中,我们将讨论我在编写 Angular 2 应用程序时遇到的两个 TypeScript 陷阱。

类型转换和 JSON

如果您计划构建不仅仅是一个 Angular 2 的游乐场,显然您会对性能、稳定性和操作的模式感兴趣,那么您很可能会使用 API 来为您的应用程序提供数据。很可能,这个 API 将使用 JSON 与您通信。

假设我们有一个User类,有两个私有变量:lastName:stringfirstName:string。此外,这个简单的类提供了hello方法,打印出Hi I amthis.firstNamethis.lastName

class User{
 constructor(private lastName:string,         private firstName:string){
 }

 hello(){
 console.log("Hi I am", this.firstName,         this.lastName);
 }
}

现在,考虑到我们通过 JSON API 接收用户。很可能,它看起来像[{"lastName":"Nayrolles","firstName":"Mathieu"}...]。通过以下代码片段,我们可以创建一个User

let userFromJSONAPI: User = JSON.parse('[*{"lastName":"Nayrolles","firstName":"Mathieu"}]'*)[0];

到目前为止,TypeScript 编译器没有抱怨,并且执行顺利。这是因为parse方法返回any(即 Java 对象的 TypeScript 等价物)。毫无疑问,我们可以将any转换为User。然而,接下来的userFromJSONAPI.hello();将产生:

    json.ts:19
    userFromJSONAPI.hello();
     ^
    TypeError: userFromUJSONAPI.hello is not a function
     at Object.<anonymous> (json.ts:19:18)
     at Module._compile (module.js:541:32)
     at Object.loader (/usr/lib/node_modules/ts-node/src/ts-node.ts:225:14)
     at Module.load (module.js:458:32)
     at tryModuleLoad (module.js:417:12)
     at Function.Module._load (module.js:409:3)
     at Function.Module.runMain (module.js:575:10)
     at Object.<anonymous> (/usr/lib/node_modules/ts-node/src/bin/ts-node.ts:110:12)
     at Module._compile (module.js:541:32)
     at Object.Module._extensions..js (module.js:550:10)

为什么?嗯,赋值的左侧被定义为User,当我们将其转译为 JavaScript 时,它将被擦除。进行类型安全的 TypeScript 方式是:

let validUser = JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]') 
.map((json: any):User => { 
return new User(json.lastName, json.firstName); 
})[0]; 

有趣的是,typeof函数也无法帮助您。在这两种情况下,它都会显示Object而不是User,因为User的概念在 JavaScript 中根本不存在。

当参数列表变得越来越多时,这种类型的 fetch/map/new 可能会变得非常乏味。您可以使用工厂模式,我们将在第三章中看到,经典模式,或者创建一个实例加载器,比如:

class InstanceLoader { 
    static getInstance<T>(context: Object, name: string, rawJson:any): T { 
        var instance:T = Object.create(context[name].prototype); 
        for(var attr in instance){ 
         instance[attr] = rawJson[attr]; 
         console.log(attr); 
        } 
        return <T>instance; 
    } 
} 
InstanceLoader.getInstance<User>(this, 'User', JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]')[0]) 

InstanceLoader只能在 HTML 页面内使用,因为它依赖于window变量。如果您尝试使用ts-node执行它,您将收到以下错误:

    ReferenceError: window is not defined

继承和多态

假设我们有一个简单的继承层次结构如下。我们有一个定义了eat():voidsleep(): void方法的接口Animal

interface Animal{ eat():void; sleep():void; }

然后,我们有一个实现了Animal接口的Mammal类。这个类还添加了一个构造函数,并利用了我们之前看到的私有name: type符号。对于eat():voidsleep(): void方法,这个类打印出"Like a mammal"

class Mammal implements Animal{ 

   constructor(private name:string){ 
         console.log(this.name, "is alive"); 
   } 

   eat(){ 
         console.log("Like a mammal"); 
   } 

   sleep(){ 
         console.log("Like a mammal"); 
   } 
} 

我们还有一个Dog类,它扩展了Mammal并重写了eat(): void,所以它打印出"Like a Dog"

class Dog extends Mammal{ 
   eat(){ 
         console.log("Like a dog") 
   } 
} 

最后,我们有一个期望Animal作为参数并调用eat()方法的函数。

let mammal: Mammal = new Mammal("Mammal"); 
let dolly: Dog = new Dog("Dolly"); 
let prisca: Mammal = new Dog("Prisca");  
let abobination: Dog = new Mammal("abomination"); //-> Wait. WHAT ?! function makeThemEat (animal:Animal):void{ 
   animal.eat(); 
}

输出如下:

    ts-node class-inheritance-polymorhism.ts

    Mammal is alive 
    Dolly is alive
 Prisca is alive
 abomination is alive
 Like a mammal
 Like a dog
 Like a dog
 Like a mammal

现在,我们的最后一个创建,let abomination: Dog = new Mammal("abomination"); 应该不可能,根据面向对象的原则。事实上,赋值语句的左侧比右侧更具体,这是 TypeScript 编译器不应该允许的。如果我们查看生成的 JavaScript,我们可以看到发生了什么。类型消失了,被函数替换。然后,变量的类型在创建时被推断:

var __extends = (this && this.__extends) || function (d, b) { 
    for (var p in b) if (b.hasOwnProperty(p)) d[p] = b[p]; 
    function __() { this.constructor = d; } 
    d.prototype = b === null ? Object.create(b) : (__.prototype = b.prototype, new __()); 
}; 
var Mammal = (function () { 
    function Mammal() { 
    } 
    Mammal.prototype.eat = function () { 
        console.log("Like a mammal"); 
    }; 
    Mammal.prototype.sleep = function () { 
        console.log("Like a mammal"); 
    }; 
    return Mammal; 
}()); 
var Dog = (function (_super) { 
    __extends(Dog, _super); 
    function Dog() { 
        _super.apply(this, arguments); 
    } 
    Dog.prototype.eat = function () { 
        console.log("Like a dog"); 
    }; 
    return Dog; 
}(Mammal)); 
function makeThemEat(animal) { 
    animal.eat(); 
} 
var mammal = new Mammal(); 
var dog = new Dog(); 
var labrador = new Mammal(); 
makeThemEat(mammal); 
makeThemEat(dog); 
makeThemEat(labrador); 

当有疑问时,查看转译后的 JavaScript 总是一个好主意。您将看到执行时发生了什么,也许会发现其他陷阱!另外,TypeScript 转译器在这里被愚弄了,因为从 JavaScript 的角度来看,MammalDog并没有不同;它们具有相同的属性和函数。如果我们在Dog类中添加一个属性(比如private race:string),它将不再转译。这意味着覆盖方法并不足以被识别为类型;它们必须在语义上有所不同。

这个例子有点牵强,我同意这种 TypeScript 的特殊性不会每天都困扰你。然而,如果我们在使用一些有严格层次结构的有界泛型,那么你就必须了解这一点。事实上,以下例子不幸地有效:

function makeThemEat<T extends Dog>(dog:T):void{ 
   dog.eat(); 
} 

makeThemEat<Mammal>(abomination); 

总结

在这一章中,我们完成了 TypeScript 的设置,并审查了大部分的最佳实践,包括代码规范、我们应该和不应该使用的功能,以及需要避免的常见陷阱。

在下一章中,我们将专注于 Angular 以及如何使用全新的 Angular CLI 入门。

第二章:Angular 引导

在第一章之后,Typescript 最佳实践,我们可以深入了解 Angular 本身。Angular 的一个重点是大幅提高 Angular 应用程序的性能和加载时间,与 AngularJS 相比。性能改进是非常显著的。根据 Angular 团队和各种基准测试,Angular 2 比 Angular 1 快 5 到 8 倍。

为了实现这种改进,谷歌工程师并没有在 AngularJS 的基础上进行开发;相反,他们从头开始创建了 Angular。因此,如果你已经使用 Angular 1 一段时间,这并不会在开发 Angular 应用程序时给你带来很大的优势。

在这一章中,我们将做以下事情:

  • 我将首先介绍 Angular 背后的主要架构概念。

  • 然后,我们将使用新引入的 Angular CLI 工具引导一个 Angular 应用程序,这将消除大部分入门的痛苦。网上有数百种 Angular 样板,选择一个可能会耗费大量时间。你可以在 GitHub 上找到各种风格的样板,带有测试、带有库、用于移动设备、带有构建和部署脚本等等。

尽管社区的多样性和热情是一件好事,但这意味着没有两个 Angular 项目看起来一样。事实上,这两个项目很可能是用不同的样板创建的,或者根本没有使用样板。为了解决这个问题,Angular 团队现在提出了 angular CLI。Angular CLI 是一个命令行 node 包,允许开发人员基于官方样板创建新的应用程序。这个工具还提供了一些有用的功能,比如创建 Angular 应用程序的不同构建模块,构建、测试和压缩你的应用程序。它甚至支持用一个简短的命令将你的应用程序部署到 GitHub 页面上。

这仍然是一个新工具,它有许多缺点和未完善的行为。

架构概述

在这一部分,我将介绍 Angular 应用程序的主要构建模块:服务、组件、模板和指令。我们还将学习依赖注入、装饰器和区域解决了哪些问题。

现在,如果你从(虚拟)书架上拿起这本书,你很可能有一些关于 Angular 的经验,并希望通过良好的实践和设计模式来改进你的应用程序。因此,你应该对 Angular 构建块的一般架构有一些了解。

然而,一个快速而务实的提醒不会有太大的伤害,我们可以确信我们有一个坚实的架构基础来构建我们的模式。

以下是主要的 Angular 2 构建块如何相互交互的概述:

Angular 2 应用程序的高级架构

接下来,我将通过创建一个操作 Floyd 数组的应用程序来介绍每个 Angular 2 构建块的示例。以下是一个基于字母的 Floyd 数组的示例:

 a 
 b c 
 d e f 
 g h i j 

我同意你不太可能在不久的将来构建处理 Floyd 数组的应用程序。然而,当学习新语言或框架时,Floyd 数组是一个很好的编程练习,因为它涉及用户输入、显示结果、循环和字符串操作。

组件

组件是我们 Angular 应用程序的视图,它们控制屏幕上的内容、时间和方式。它们采用一个简单的类的形式,定义了视图所需的逻辑。以下是一个简单组件的示例:

export class FloydComponent implements OnInit { 

 private floydString:string = ""; 
 private static startOfAlphabet = 97; 

 constructor() { } 

 ngOnInit() { 
 } 

 onClick(rows:number){ 

 let currentLetter = FloydComponent.startOfAlphabet; 
 for (let i = 0; i < rows; i++) { 
 for (let j = 0; j < i; j++) { 
 this.floydString += String.fromCharCode(currentLetter) + " "; 
 currentLetter++; 
 } 
 this.floydString += "\n\r"; 
 } 
 } 
}

请注意,组件类有一个后缀:Component。我将在下一章讨论原因。

这个名为FloydComponent的组件有两个私有成员:floydStringstartOfAlphabetfloydString将包含表示第 n 个 Floyd 三角形的字符串,而startOfAlphabet则不断标记 ASCII 表中字母的位置。

FloydComponent还定义了一个构造函数,当用户请求我们组件管理的屏幕补丁时将被调用。目前,构造函数是空的。

最后,接受一个名为rows的数字参数的onClick方法将生成一个由rows行组成的 Floyd 三角形。总之,我们有一个管理展示 Floyd 三角形行为的视图的类。是的?嗯,视图部分有点缺失!我的用于客户端渲染的 HTML 在哪里?

在 Angular 中,我们的组件将控制的 HTML 部分被称为模板,我们可以使用元数据将模板链接到组件上:

import { Component } from '@angular/core'; 
@Component({ 
 selector: 'floyd', 
 template: 
 `<p> 
 <input #checkbox type="checkbox" value="even">Even?<br> 
 <input #rows type="text" name="rows"> 
 <button (click)="onClick(rows.value, checkbox.checked)">CLICK</button> 
 </p> 
 <pre> 

 {{floydString}} 
 </pre> 
 ` 
}) 
export class FloydComponent { 

那么,这一切到底是怎么回事?如果我们回顾一下FloydComponent的原始定义,没有任何地方指定FloydComponent是一个组件。我们没有像FloydComponent扩展/实现组件的任何东西,所以它只是一个普通的 typescript 类,什么都不是。更令人惊讶的是,根本没有 Angular 的引用;这个FloydComponent完全可以是 Angular 框架之外的一个 typescript 类。

元数据使用装饰器模式装饰FloydComponent类,因此 Angular 知道如何解释和处理FloydComponent类。

在任何面向对象的语言中,通过继承静态地扩展对象的责任是很容易的,但是在运行时动态地这样做是完全不同的。装饰器模式的目的是在对象上动态地添加额外的责任。

我们将在《第三章》《经典模式》中实现我们自己的装饰器。

注解本身是@Component,并使用一些参数使我们的类成为 Angular 组件。

注意import { Component } from '@angular/core';导入了@angular/core库中的Component模块。

第一个参数是一个selector,描述了我们的FloydComponent应该绑定到视图的哪个部分。在下面的 HTML 片段中,我们有<floyd></floyd>选择器标记,FloydComponent将绑定到它。第二个参数是模板字符串。模板字符串定义了在运行时将添加到 DOM 中的内容,位于<floyd>标记内部:

 <p> 
 <input #rows type="text" name="rows"> 
 <button (click)="onClick(rows.value)">CLICK</button> 
 </p> 
 <pre> 
 {{floydString}} 
 </pre>

反引号`允许我们在 JavaScript 中定义多行字符串。

首先,我们有<input>标记,看起来几乎像纯 HTML。唯一的特殊之处在于标记中的#rows属性。这个属性用于将标记引用为名为rows的变量。因此,我们可以在下面的标记中访问它的值:<button (click)="onClick(rows.value)">CLICK</button>。在这里,我们在模板和组件之间进行了事件绑定。当按钮被点击时,组件的onClick方法将被调用,并且输入的值将被传递给该方法。

在代码的下方,我们有{{floydString}},这是从组件到模板的属性绑定。在这种情况下,我们将floydString组件属性绑定到模板上。换句话说,我们在 DOM 中显示floydString组件属性的内容。

我必须使用预先标记,以便\n\r在输出中得到保留。

总之,组件将其属性绑定到模板,而模板将其事件绑定到组件。运行此应用程序时可以期待以下截图:

Angular 2 中的 Floyd 数组在你这边不起作用吗?想要在 GitHub 上 fork 这段代码吗?你现在可以在bit.ly/angular2-patterns-chap2看到整个应用程序。

服务

到目前为止,我们已经审查了 Angular 2 的四个构建块中的两个。剩下的两个是服务和指令。接下来我们将审查服务。服务是具有独特目的的类,它们应尽可能地具有内聚性,以便为应用程序的其他部分提供狭窄而明确定义的服务。从设计的角度来看,对于我们的 Floyd 三角形应用程序来说,将FloydComponent.onClick方法的内容放在一个服务中可能会更好。实际上,floydString字符串的计算不应该出现在管理视图的组件中。

组件应该只负责用户体验——将属性绑定到模板——其他所有事情都应该委托给服务。我们可以创建一个三角形服务,负责鼓掌创建像 Floyd 三角形这样的奇怪三角形。我们还可以让这个服务负责生成 Floyd 三角形,输出看起来像一棵树:

 a 
 b c 
 d e f 
 g h i j 

而不是:

 a 
 b c 
 d e f 
 g h i j 

这样的服务看起来会像下面这样:

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

@Injectable() 
export class TriangleService { 

 private static startOfAlphabet = 97; 

 constructor() {} 

 /** 
 * Computes a Floyd Triangle of letter.
 * Here's an example for rows = 5 
 * 
 * a 
 * b c 
 * d e f 
 * g h i j 
 * 
 * Adapted from http://www.programmingsimplified.com/c-program-print-floyd-triangle 
 * 
 * @param  {number} rows 
 * @return {string}
 */ 
 public floydTriangle(rows:number):string{ 

 let currentLetter = TriangleService.startOfAlphabet; 
 let resultString = ""; 

 for (let i = 0; i < rows; i++) { 
 for (let j = 0; j < i; j++) { 
 resultString += String.fromCharCode(currentLetter) + " "; 
 currentLetter++; 
 } 
 resultString += "\n\r"; 
 } 

 return resultString; 
 } 

 /** 
 * Computes a Even Floyd Triangle of letter. 
 * Here's an example for rows = 7 
 *       a 
 *      b c 
 *     d e f 
 *    g h i j 
 *   k l m n o 
 *  p q r s t u 
 * v w x y z { | 
 * 
 * @param  {number} rows 
 * @return {string} 
 */ 
 public evenFloydTriangle(rows:number):string{ 

 let currentLetter = TriangleService.startOfAlphabet; 
 let resultString = ""; 

 for (let i = 0; i < rows; i++) { 

 for (let j = 0; j <= (rows-i-2); j++) { 
 resultString += " "; 
 } 

 for (let j = 0; j <= i; j++) { 
 resultString += String.fromCharCode(currentLetter) + " "; 
 currentLetter++; 
 } 

 resultString+="\n\r"; 
 } 

 return resultString; 
 } 
 } 

TriangleService是一个简单的类,提供两种方法:floydTriangleevenFloydTriangleevenFloydTriangle有一个额外的 for 循环,用于在三角形的不同行添加前导空格。业务应用现在位于一个专用的服务上,我们可以在FloydComponent上使用它。在FloydComponent中使用我们的服务的正确方法是通过依赖注入。依赖注入是一个过程,通过该过程,请求类会动态地获得所请求类的一个完整形式的实例。将这个相当技术性的定义应用到我们的上下文中,FloydComponent在实例化时将获得TriangleService的一个实例。

要在 Angular 中使用依赖注入,我们需要为TriangleService定义一个提供者。我们可以在应用程序级别这样做:

import { TriangleService } from './app/triangle.service' 

bootstrap(FloydComponent, [TriangleService]); 

或者,我们可以在组件注解中定义提供者,以在组件级别进行此操作:

import { Component, OnInit, ViewEncapsulation } from '@angular/core'; 
import { TriangleService } from '../triangle.service' 

@Component({ 
 selector: 'floyd', 
 template:   `<p> 
 <input #checkbox type="checkbox" value="even">Even?<br> 
 <input #rows type="text" name="rows"> 
 <button (click)="onClick(rows.value, checkbox.checked)">CLICK</button> 
 </p> 
 <pre> 

 {{floydString}} 
 </pre> 
 `, 
 styleUrls: ['./floyd.component.css'], 
 providers: [TriangleService], 
 encapsulation: ViewEncapsulation.None 
}) 
export class FloydComponent implements OnInit { 

如果在应用程序级别创建提供者,那么TriangleService的相同实例将提供给任何请求它的人。然而,在组件级别,每次实例化该组件时都会创建一个新的TriangleService实例并提供给该组件。这两种情况都是有道理的。这取决于你的组件和你的服务在做什么。例如,我们将在第七章中实现的日志服务没有自己的状态,并且被应用程序的每个模块使用。因此,我们可以使用基于应用程序的提供者。反例是来自第五章的Circuit breaker模式,稳定性模式,它具有内部状态,因此是组件级别的。

最后一步是修改我们的FloydComponent构造函数,使其看起来像这样:

 constructor(private triangleService:TriangleService) { 
 }

在这里,我们为我们的FloydComponent定义了一个名为triangleService的私有成员,它将被用作注入的依赖项的占位符。

此外,我们在模板中添加一个复选框,用于确定我们是要一个偶数还是一个普通的 Floyd 数组:

<input #rows type="text" name="rows"> 
 <button (click)="onClick(rows.value, checkbox.checked)">CLICK</button> 

我们还可以修改onClick方法以使用我们的TriangleService。最终组件看起来像这样:


import { Component, OnInit, ViewEncapsulation } from '@angular/core'; 
import { TriangleService } from '../triangle.service' 

@Component({ 
 selector: 'floyd', 
 template:   `<p> 
 <input #checkbox type="checkbox" value="even">Even?<br> 
 <input #rows type="text" name="rows"> 
 <button (click)="onClick(rows.value, checkbox.checked)">CLICK</button> 
 </p> 
 <pre> 
 {{floydString}} 
 </pre> 
 `, 
 styleUrls: ['./floyd.component.css'], 
 providers: [TriangleService], 
 encapsulation: ViewEncapsulation.None 
}) 
export class FloydComponent implements OnInit { 

 private floydString:string = ""; 
 private static startOfAlphabet = 97; 

 constructor(private triangleService:TriangleService) { } 

 ngOnInit() { 
 } 

 onClick(rows:number, checked:boolean){ 

 if(checked){ 
 this.floydString = this.triangleService.evenFloydTriangle(rows); 
 }else{ 
 this.floydString = this.triangleService.floydTriangle(rows); 
 } 
 } 
} 

应用程序的当前状态可以在这里看到:bit.ly/angular2-patterns-chap2-part2

指令

结束我们快速的架构概述,我们将创建一个指令来增强我们相当单调的预标记。指令与模板以及它们的父组件进行属性和事件绑定交互。我们将创建一个指令,为我们的预标记添加样式。样式包括 1 像素边框,并将背景颜色更改为红色或黄色,分别用于偶数或奇数的 Floyd 数组。

首先,我们需要一种方法来询问用户希望使用哪种类型的数组。让我们在FloydComponent的模板中添加另一个输入,并修改onClick方法,使其接受第二个参数:

import { Component } from '@angular/core'; 
import { TriangleService } from '../triangle.service'; 
@Component({ 
 selector: 'floyd', 
 template: 
 `<p> 
 <input #checkbox type="checkbox" value="even">Even?<br> 
 <input #rows type="text" name="rows"> 
 <button (click)="onClick(rows.value, checkbox.checked)">CLICK</button> 
 </p> 
 <pre> 

 {{floydString}} 
 </pre> 
 `, 
 providers:   [TriangleService] 
}) 
export class FloydComponent { 

 private floydString:string = ""; 
 private color:"yellow" | "red"; 

 constructor(private triangleService:TriangleService) { 

 } 

 onClick(rows:number, even:boolean){ 

 if(even){ 
 this.floydString = this.triangleService.evenFloydTriangle(rows); 
 }else{ 
 this.floydString = this.triangleService.floydTriangle(rows); 
 } 

 } 

} 

然后,我们可以创建指令。它将如下所示:

import { Directive, Input, ElementRef, HostListener } from '@angular/core'; 

@Directive({ 
 selector: '[AngularPre]' 
}) 
export class AngularPre { 

 @Input() 
 highlightColor:string; 

 constructor(private el: ElementRef) { 
 el.nativeElement.style.border = "1px solid black"; 
 el.nativeElement.style.backgroundColor = this.highlightColor; 
 } 

 @HostListener('mouseenter') onMouseEnter() { 
 this.highlight(this.highlightColor); 
 } 

 @HostListener('mouseleave') onMouseLeave() { 
 this.highlight(null); 
 } 

 private highlight(color: string) { 
 this.el.nativeElement.style.backgroundColor = color; 
 } 

}

这里发生了很多事情。首先,我们有带有选择器的指令注释。选择器将用于表示给定的 HTML 标记取决于指令。在我们的例子中,我选择将指令命名为AngularPre,并为选择器使用相同的名称。它们可以不同;这取决于你。但是,选择器和类具有相同的名称是有意义的,这样你就知道当你的指令出现问题时应该打开哪个文件。

然后,我们有非常有趣的@Input()注释highlightColor:string;成员。在这里,我们指定highlightColor字符串的值实际上绑定到父组件的变量。换句话说,父组件将不得不指定它希望预标记突出显示的颜色。在构造函数中,指令通过注入接收了一个ElementRef对象。这个ElementRef代表了您的指令作用的 DOM。最后,我们在mouseentermouseleave上定义了两个HostListener,它们将分别开始和停止预标记的突出显示。

要使用这个指令,我们必须在FloydComponent模板的预标记中插入其选择器,如下所示:

<pre AngularPre [highlightColor]="color"> 
 {{floydString}} 
</pre> 

在这里,我们指定我们希望我们的预标记受到AngularPre选择器的影响,并将调用指令的highlightColor变量与FloydComponent的颜色变量绑定。这是带有颜色变量和onClick方法的FloydComponent,所以它改变颜色变量的值:

export class FloydComponent { 

 private floydString:string = ""; 
 private color:"yellow" | "red"; 

 constructor(private triangleService:TriangleService) { 

 } 

 onClick(rows:number, even:boolean){ 

 if(even){ 
 this.floydString = this.triangleService.evenFloydTriangle(rows); 
 this.color = "red"; 
 }else{ 
 this.floydString = this.triangleService.floydTriangle(rows); 
 this.color = "yellow"; 
 } 

 } 

} 
onClick modifies the color variable 

这是应用程序使用奇数数组的样子:

奇数 Floyd 数组结果

这是偶数数组的样子:

甚至弗洛伊德数组结果该应用程序可在此处下载:bit.ly/angular2-patterns-chap2-part3

管道

我想在这里解释的最后两个构建块是管道和路由。管道很棒。它们允许我们创建一个专门的类,将任何输入转换为所需的输出。在 Angular 中,管道遵循 Unix 管道编程范式,其中信息可以从一个进程传递到另一个进程。我们可以在基于弗洛伊德三角形的应用程序中创建一个管道,该管道将在每次遇到换行序列(如\n\r)时将任何给定的弗洛伊德字符串转换为包含段落244,&para;)的 ASCII 字符:

import { Pipe, PipeTransform } from '@angular/core'; 

@Pipe({ 
 name: 'paragraph' 
}) 
export class ParagraphPipe implements PipeTransform { 

 transform(value: string): string { 

 return value.replace( 
 new RegExp("\n\r", 'g'), 
 "¶ \n\r" 
 ); 
 } 

} 

管道使用@Pipe注解进行装饰,非常类似于组件和指令。现在,与管道相比,与组件和指令相比的区别在于,除了装饰注解之外,我们还必须实现 Angular 框架提供的一个接口。这个接口被命名为PipeTransform,并定义了每个实现它的类必须具有的单个方法:

transform(value: any, args?:any): any 

该方法的实际签名由任何类型组成,因为管道可以用于一切,不仅仅是字符串。在我们的情况下,我们想要操作一个字符串输入并获得一个字符串输出。我们可以在不违反接口合同的情况下,细化transform方法的签名,如下所示:

transform(value: string): string 

在这里,我们只期望一个字符串参数并产生一个字符串输出。该方法的主体包含一个全局正则表达式,匹配所有的\n\r序列并添加

要在FloydComponent中使用ParagraphPipe,我们必须修改模板如下:

 `<p> 

 <input #checkbox type="checkbox" value="even">Even?<br> 

 <input #rows type="text" name="rows"> 

 <button (click)="onClick(rows.value, checkbox.checked)">CLICK</button> 

 </p> 

 <pre AngularPre [highlightColor]="color"> 

 {{floydString | paragraph}} 

 </pre> 

floydString通过|运算符传递给ParagraphPipe。这是它的样子:

将 floydString 管道化以获得段落标记

段落管道硬编码段落符号让我有点烦。如果我想要根据每次使用来更改它怎么办?嗯,Angular 正在处理许多额外的管道参数。我们可以修改transform方法如下:

 transform(value: string, paragrapheSymbol:string): string { 

 return value.replace( 

 new RegExp("\n\r", 'g'), 

 paragrapheSymbol + "\n\r" 

 ); 

 } 

此外,我们可以这样调用管道:

{{floydString | paragraph: "¶"}} 

在这里,transform方法的第一个参数将是floydString,而第二个参数将是段落符号。

如果我们考虑一下,我们目前正在为 Typescript 实现replaceAll函数,除了目标(\n\r是硬编码的)。让我们创建一个名为replaceAll的管道,它将目标替换和替换作为参数。唯一的问题是PipeTransform接口定义了一个带有两个参数的 transform 方法,第二个参数是可选的。在这里,我们需要三个参数:要转换的字符串,要在字符串中替换的目标,以及目标的替换。如果你尝试使用三个参数来定义一个 transform 方法,那么你将违反PipeTransform的约定,你的 Typescript 将不再编译。为了克服这个小问题,我们可以定义一个名为replace的内联类型,它将包含两个成员,fromto,它们都是字符串:

transform(value: string, replace: {from:string, to:string}): string 
To call it inside the FloydComponent we can do the following: 

{{floydString | replaceAll: {from:'\\n\\r', to:'¶ \\n\\r'} }}

在这里,我们使用\\n\\r作为字符串模式,因为我们还没有构建RegExp。因此,\需要转义\n\r

这是replaceAll管道的代码:

import { Pipe, PipeTransform } from '@angular/core'; 

@Pipe({ 
 name: 'replaceAll' 
}) 
export class ReplaceAllPipe implements PipeTransform { 

 transform(value: string, replace: {from:string, to:string}): string { 

 return value.replace( 
 new RegExp(replace.from, 'g'), 
 replace.to 
 ); 

 } 

} 

不错,对吧?我们已经填补了 JavaScript 的一个缺点,即replaceAll功能,以一种模块化和高效的方式。这个replaceAll管道将在你的应用程序中随处可用:

@Component({ 
 selector: 'floyd', 
 template:   `<p> 
 <input #checkbox type="checkbox" value="even">Even?<br> 
 <input #rows type="text" name="rows"> 
 <button (click)="onClick(rows.value, checkbox.checked)">CLICK</button> 
 </p> 
 <pre AngularPre [highlightColor]="color"> 
 {{floydString | replaceAll: {from:'\\n\\r', to:'¶ \\n\\r'} }} 
 </pre> 
 `, 
 styleUrls: ['./floyd.component.css'], 
 providers: [TriangleService], 
 encapsulation: ViewEncapsulation.None 
}) 
export class FloydComponent implements OnInit {

关于管道的最后一件事是,你可以像在 Unix 控制台中一样组合它们。例如,我们完全可以做以下事情,其中段落管道首先添加到所有行的末尾。然后,replaceAll管道介入并替换所有的管道:

{{floydString | paragraph:'¶' | replaceAll: {from:'¶', to:'¶ piped'} }} 

应用程序的当前状态可以在这里下载:bit.ly/angular2-patterns-chap2-part5

路由

路由使得在 Angular 视图之间进行导航成为可能。在这个教程中,我们将了解它们,并在一个小应用程序的框架内看到它们的运作。

Angular CLI

Angular CLI是一个非常简单但非常有用的 node 包,它采用命令行工具的形式。这个工具的目的是消除大部分与 Angular 2 开始的痛苦。基于框架的任何应用程序的问题是如何为你的代码引导事物,以便与框架的特性和库进行顺畅的通信。

这个工具是由 Angular 团队直接提供的,它为即将启动的应用程序提供了可用的蓝图。实际上,通过使用一个简单的命令,我们可以生成一个完整的 Angular 样板,可以进行转译、本地运行、测试,甚至部署到 GitHub 页面。

安装

安装 Angular CLI 非常简单,因为它是一个 node 包。无论您使用什么操作系统,以下命令都可以工作:

npm install -g angular-cli 

如果您使用的是基于 Unix 的系统,全局安装可能需要 sudo

创建一个新的应用程序

一旦安装了 Angular CLI,我们就可以通过使用 ng new 命令来生成一个新的 Angular 应用程序。

ng new MyApp 

这个命令将为您的应用程序创建一个空的样板,并获取每个所需的节点模块。

请注意,根据您的互联网连接,这个命令可能需要一段时间才能完成。实际上,需要获取的节点包很多,这进一步证明了这样一个工具的必要性。

在新创建的文件夹的根目录,您可以找到以下文件和文件夹:

  • Angular-cli-build.js:用于构建应用程序的配置文件。

  • config:测试环境的配置文件夹。

  • Node_modules:所需的不同的节点模块。当我写下这些文字时,Angular CLI 的当前版本已经在 node-modules 目录中有 60,886 个文件和文件夹。

  • Public:包含应用程序公共部分。

  • tslint.json:您的 linter 的配置。我们将在下一章中对其进行配置。

  • typings.json:Typings 依赖。

  • angular-cli.json:应用程序的一些配置。

  • e2e:e2e 配置。

  • package.json:应用程序的依赖项。

  • src: 您的源代码。

  • typings:所需的 typings。

毫无疑问,我们将花费最多时间的文件夹是 src 文件夹,因为它包含了 TypeScript 源代码。创建后,它里面包含以下内容:

src 

├── app 

│   ├── environment.ts 

│   ├── index.ts 

│   ├── my-app.component.css 

│   ├── my-app.component.html 

│   ├── my-app.component.spec.ts 

│   ├── my-app.component.ts 

│   └── shared 

│       └── index.ts 

├── favicon.ico 

├── index.html 

├── main.ts 

├── system-config.ts 

├── tsconfig.json 

└── typings.d.ts 

正如您所看到的,这里有一个 app 文件夹,里面已经包含了一个名为 my-app 的组件,还有一个共享文件夹,可以用来在不同的应用程序之间共享资源。然后,我们有包含以下内容的 index.html

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

在这个 index.html 中,插入了 <app-root></app-root> 标记,并将所需的文件加载到脚本中。

另一个重要的地方是 main.ts 文件,它包含了应用程序的引导行:

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

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

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

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

在这里,MyAppAppComponent 组件被导入并用作我们应用程序的顶层或根组件。这是将首先实例化的组件。

生成

目前,我们的应用程序并不是特别令人兴奋;它只会在h1标记中显示my-app works!

如果我们想要向这个样板添加组件、指令、服务和管道,我们必须使用generate命令。以下是一个生成名为Floyd的新组件的示例:

ng generate component Floyd 

作为回应,Angular CLI 创建了一个名为Floyd的新文件夹和我们组件所需的文件:

src/app 

├── environment.ts 

├── Floyd 

│   ├── floyd.component.css 

│   ├── floyd.component.html 

│   ├── floyd.component.spec.ts 

│   ├── floyd.component.ts 

│   └── index.ts 

├── index.ts 

├── my-app.component.css 

├── my-app.component.html 

├── my-app.component.spec.ts 

├── my-app.component.ts 

└── shared 

 └── index.ts 

我们可以使用指令、服务或管道来执行相同的操作,而不是组件。

Angular CLI 中的每个关键字都可以通过仅使用单词的第一个字母来缩写。因此,生成另一个名为Pascal的组件将会是ng g c Pascal

服务

我们的应用程序中有许多组件、服务、指令和管道,我们已经准备好看到结果了。幸运的是,Angular CLI 可以构建您的应用程序,并使用命令ng serve启动 Web 服务器。

然后,您可以在localhost:4200上查看您的应用程序。

您的文件正在被 Angular CLI 监视。每当您对文件进行更改时,Angular CLI 将重新编译它并刷新您的浏览器。

部署

准备让您的应用程序上线了吗?ng build就是您要找的。这个命令将创建一个dist目录,您可以将其推送到任何能够提供 HTML 页面的服务器上。甚至可以放在 GitHub 页面上,这不会花费您一分钱。

总结

在本章中,我们已经完成了对 Angular 构建模块的概述,并看到它们是如何相互交互的。我们还创建了一个相对简单的应用程序来操作 Floyd 数组。最后,我们学会了如何使用 Angular CLI 来使用命令行创建新应用程序、组件、服务、指令和管道。

在下一章中,我们将专注于 Angular 的最佳实践。我们将以实际的方式了解谷歌工程师推荐的“做”和“不做”。

第三章:经典模式

TypeScript 是一种面向对象的编程语言,因此我们可以利用几十年关于面向对象架构的知识。在本章中,我们将探索一些最有用的面向对象设计模式,并学习如何在 Angular 中应用它们。

Angular 本身就是一个面向对象的框架,它强制你以某种方式进行大部分开发。例如,你需要有组件、服务、管道等。强制这些构建块对你有助于构建良好的架构,就像 Zend 框架对 PHP 或 Ruby on Rails 对 Ruby 所做的那样。当然,框架的存在是为了让你的生活更轻松,加快开发时间。

虽然 Angular 的设计方式远远超出了平均水平,但我们总是可以做得更好。我并不是说我在本章中提出的是最终设计,或者你将能够用它来解决从面包店网页到火星一号任务的仪表板的任何问题——不幸的是,这样的设计并不存在——但它肯定会丰富你的工具库。

在本章中,我们将看到以下经典模式:

  • 组件

  • 单例

  • 观察者

组件

在这本书的前三章中,我们看到了大量的 Angular 组件。Angular Component是 Angular 应用程序的主要构建块之一,例如servicespipes等。作为提醒,TypeScript 类使用以下注解成为 Angular 组件:

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

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

在这里,AppComponent类通过selectortemplateUrlstyleUrls Angular 组件的行为得到了增强。

单例模式

用于前端应用程序的另一个方便的模式是单例模式。单例模式确保你的程序中只存在一个给定对象的实例。此外,它提供了对对象的全局访问点。

实际上看起来是这样的:

export class MySingleton{ 

    //The constructor is private so we  
    //can't do `let singleton:MySingleton = new MySingleton();` 
    private static instance:MySingleton = null; 

    private constructor(){ 

    } 

    public static getInstance():MySingleton{ 
        if(MySingleton.instance == null){ 
            MySingleton.instance = new MySingleton(); 
        }
        return MySingleton.instance; 
    } 
} 
 let singleton:MySingleton = MySingleton.getInstance();

我们有一个具有private static instance:MySingleton属性的类。然后,我们有一个私有构造函数,使以下操作失败:

let singleton:MySingleton = new MySingleton(); 

请注意,它失败是因为你的 TypeScript 转译器对可见性提出了抱怨。然而,如果你将MySingleton类转译为 JavaScript 并将其导入到另一个 TypeScript 项目中,你将能够使用new运算符,因为转译后的 TypeScript 没有任何可见性。

这种相当简单的单例模式实现的问题在于并发。确实,如果两个进程同时调用getInstance():MySingleton,那么程序中将会有两个MySingleton的实例。为了确保这种情况不会发生,我们可以使用一种称为早期实例化的技术:

export

 class MySingleton
 {
   private static instance : MySingleton = new MySingleton();

 private constructor()
  {

  }

 }

singleton: MySingleton = MySingleton.getInstance();

虽然你可以在 TypeScript 中实现你的单例,但你也可以利用 Angular 创建单例的方式:服务!确实,在 Angular 中,服务只被实例化一次,并且被注入到任何需要它的组件中。下面是一个通过本书之前看到的NgModule进行服务和注入的示例:


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

@Injectable() 
export class ApiService { 

  private static increment:number = 0; 

  public constructor(){ 
    ApiService.increment++; 
  } 

  public toString() :string { 
    return "Current instance: " + ApiService.increment; 
  } 

} 

 // ./app.component.ts

 import { Component } from '@angular/core'; 
import { ApiService } from './api.service'; 

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

  public constructor(api:ApiService){ 
    console.log(api); 
  } 
} 

 // ./other/other.component.ts

 import { Component, OnInit } from '@angular/core'; 
import { ApiService } from './../api.service'; 

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

  public constructor(api:ApiService){ 
    console.log(api); 
  } 

  ngOnInit() { 
  } 

} 

 //app.module.ts

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

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

import { ApiService } from './api.service'; 

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

} 

在上述代码中,我们有以下内容:

  • APIService显示了@Injectable()注解,使其可以被注入。此外,APIService有一个increment:number属性,每次创建新实例时都会递增。由于increment:number是静态的,它将准确告诉我们程序中有多少个实例。最后,APIService有一个toString:string方法,返回当前实例编号。

  • AppComponent是一个经典组件,它接收了APIService的注入。

  • OtherComponent是另一个经典组件,它接收了APIService的注入。

  • /app.module.ts包含了NgModule。在NgModule中,这里显示的大部分声明已经在本书中讨论过。新颖之处来自于providers: [APIService]部分。在这里,我们为APIService本身声明了一个提供者。由于APIService并没有做什么太疯狂的事情,它本身就足够了,并且可以通过引用类来提供。而更复杂的服务,例如它们自己需要注入的服务,需要定制的提供者。

现在,如果我们导航到这两个组件,结果将如下:

Current instance: 1
Current instance: 1

这证明只创建了一个实例,并且相同的实例已被注入到两个组件中。因此,我们有一个单例。然而,这个单例虽然方便,但并不是真正安全的。你为什么这样问?嗯,APIService也可以在组件级别提供,就像这样:

import { Component } from '@angular/core'; 
import { ApiService } from './api.service'; 

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

  public constructor(api:ApiService){ 
    console.log(api); 
  } 
} 
 // ./other.component.ts

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

  public constructor(api:ApiService){ 
    console.log(api); 
  } 

  ngOnInit() { 
  } 

} 

在这种情况下,将创建两个单独的实例,导致以下输出:

Current instance: 1
Current instance: 2

因此,使用 Angular 服务,你无法强制使用单例模式,与其普通的 TypeScript 对应相反。此外,普通的 TypeScript 比 Angular 服务快上一个数量级,因为我们完全跳过了注入过程。确切的数字严重依赖于你的机器的 CPU/RAM。

在单例的情况下,唯一剩下的问题是何时使用它或哪种实现效果最好。单例只强制在程序中给定类的一个实例。因此,它非常适合与后端的任何通信或任何硬件访问。例如,在与后端的通信的情况下,可能希望只有一个APIService处理 API 密钥、API 限制和整个板块的csrf令牌,而无需确保我们在所有组件、模型等中传递相同的服务实例。在硬件访问的情况下,您可能希望确保您只打开一个与用户的网络摄像头或麦克风的连接,以便在完成后可以正确释放它们。

在性能方面,以下是每种实现的结果,以毫秒为单位。我运行了每个版本 100 次,排除了异常值(最好和最差的 5%),并对剩下的 90 次调用进行了平均,如下表所示:

单例懒加载 单例早期加载 服务注入
196 毫秒 183 毫秒 186 毫秒

我运行的代码如下:

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

 import {MySingleton} from './singleton';
 import { SingletonService } from './singleton.service';

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

   constructor(private singleton:SingletonService){
     singleton.doStuff();
   }
   //OR
   constructor(){
     MySingleton.getInstance().doStuff();
   }
 }

对于服务注入的实验,我不得不在app.module.ts中添加以下行:providers: [SingletonService]

令我惊讶的是,两种方法的结果相差不大。早期实例化的单例实现仅比更实用的服务注入好 2%。懒加载的单例排名第三,用时 196 毫秒(比早期实例化的单例差 7%,比服务注入差 5%)。

工厂方法

假设我们有一个带有两个私有变量lastName:stringfirstName:stringUser类。此外,这个简单的类提供了hello方法,打印出"Hi I am", this.firstName, this.lastName

class User{
     constructor(private lastName:string, private firstName:string){
     }
     hello(){
         console.log("Hi I am", this.firstName, this.lastName);
     }
 }

现在,考虑到我们通过 JSON API 接收用户。它很可能看起来像这样:

[{"lastName":"Nayrolles","firstName":"Mathieu"}...].  

通过以下代码片段,我们可以创建一个User

let userFromJSONAPI: User = JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]')[0];

到目前为止,TypeScript 编译器没有抱怨,并且执行顺利。这是因为parse方法返回any(例如,Java 对象的 TypeScript 等价物)。当然,我们可以将any转换为User。然而,userFromJSONAPI.hello();将产生以下结果:

json.ts:19
 userFromJSONAPI.hello();
                  ^
 TypeError: userFromUJSONAPI.hello is not a function
     at Object.<anonymous> (json.ts:19:18)
     at Module._compile (module.js:541:32)
     at Object.loader (/usr/lib/node_modules/ts-node/src/ts-node.ts:225:14)
     at Module.load (module.js:458:32)
     at tryModuleLoad (module.js:417:12)
     at Function.Module._load (module.js:409:3)
     at Function.Module.runMain (module.js:575:10)
     at Object.<anonymous> (/usr/lib/node_modules/ts-node/src/bin/ts-node.ts:110:12)
     at Module._compile (module.js:541:32)
     at Object.Module._extensions..js (module.js:550:10)

为什么?好吧,赋值的左侧被定义为User,但当我们将其转译为 JavaScript 时,它将被抹去。

在 TypeScript 中进行类型安全的方式如下:

let validUser = JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]')
 .map((json: any):User => {
     return new User(json.lastName, json.firstName);
 })[0];

有趣的是,函数的类型也不会帮助你。在这两种情况下,它都会显示object而不是User,因为 JavaScript 中并不存在用户的概念。

虽然直接的类型安全方法可以工作,但它并不是非常可扩展或可重用的。事实上,地图回调方法必须在接收 JSON 用户的任何地方重复。最方便的方法是通过Factory模式来做。工厂用于创建对象,而不会将实例化逻辑暴露给客户端。

如果我们要创建一个用户的工厂,它会是这样的:


 export class POTOFactory{

     /**
      * Builds an User from json response
      * @param  {any}  jsonUser
      * @return {User}         
      */
     static buildUser(jsonUser: any): User {

         return new User(
             jsonUser.firstName,
             jsonUser.lastName
         );
     }

 }

在这里,我们有一个名为buildUserstatic方法,它接收一个 JSON 对象,并从 JSON 对象中获取所有必需的值,以调用一个假设的User构造函数。这个方法是静态的,就像工厂的所有方法一样。事实上,在工厂中我们不需要保存任何状态或实例绑定的变量;我们只是将用户的创建封装起来。请注意,你的工厂可能会与你的 POTO 的其余部分共享。

观察者

允许一个名为主题的对象跟踪其他对象(称为观察者)对主题状态感兴趣的可观察模式。当主题状态改变时,它会通知观察者。这背后的机制非常简单。

让我们来看一下在纯 TypeScript 中(没有任何 Angular 2 或任何框架,只是 TypeScript)实现的观察者/主题实现。首先,我定义了一个Observer接口,任何具体的实现都必须实现:

export interface Observer{ 
    notify(); 
}

这个接口只定义了notify()方法。当被观察对象的状态改变时,主题(观察者观察的对象)将调用这个方法。然后,我有一个这个接口的实现,名为HumanObserver

export class HumanObserver implements Observer{ 
    constructor(private name:string){}

    notify(){

        console.log(this.name, 'Notified');
    } 
} 

这个实现利用了 TypeScript 属性构造函数,其中你可以在构造函数内部定义类的属性。这种表示法与以下表示法完全等效,但更短:

private name:string; 
constructor(name:string){

        this.name = name;
}

在定义了Observer接口和HumanObserver之后,我们可以继续进行主题。我定义了一个管理观察者的主题类。这个类有三个方法:attachObserverdetachObservernotifyObservers

export class Subject{ 
private observers:Observer[] = [];

/**
* Adding an observer to the list of observers
*/
attachObserver(observer:Observer):void{

        this.observers.push(observer);
}

/**
* Detaching an observer
*/
detachObserver(observer:Observer):void{

    let index:number = this.observers.indexOf(observer);

    if(index > -1){

        this.observers.splice(index, 1);
    }else{

        throw "Unknown observer";
    }
}

/**
* Notify all the observers in this.observers
*/
protected notifyObservers(){

    for (var i = 0; i < this.observers.length; ++i) {

        this.observers[i].notify();
    }
} 
} 

attachObserver方法将新的观察者推入observers属性中,而detachObserver则将它们移除。

主题实现通常以 attach/detach,subscribe/unsubscribe 或 add/delete 前缀的形式出现。

最后一个方法是notifyObservers,它遍历观察者并调用它们的通知方法。允许我们展示可观察机制的最后一个类是 IMDB,它扩展了subject。它将在添加电影时通知观察者:

export class IMDB extends Subject{

    private movies:string[] = [];

     public addMovie(movie:string){

         this.movies.push(movie);
         this.notifyObservers();
     }
 }

为了使这些部分彼此通信,我们必须:创建一个Subject,创建一个Observer,将Observer附加到Subject,并通过addMovie方法改变主题的状态。

更具体地说,以下是先前列表的实现:

let imdb:IMDB = new IMDB();
 let mathieu:HumanObserver = new HumanObserver("Mathieu");
 imbd.attachObserver(mathieu);
 imbd.addMovie("Jaws");

为了加快我们的开发过程,我们将安装ts-node。这个 node 包将把 TypeScript 文件转译成 JavaScript,并解决这些文件之间的依赖关系。

输出是Mathieu Notified。我们可以尝试分离mathieu并添加另一部电影:

imdb.detachObserver(mathieu);
 imdb.addMovie("Die Hard");

输出仍然是Mathieu Notified,这发生在我们添加Jaws电影之后。第二部电影(Die Hard)的添加并不会触发控制台打印Mathieu Notified,因为它已经被分离。

带参数的 TypeScript 可观察对象

因此,这是观察者模式的一个基本实现。然而,它并不完全成熟,因为HumanObserver只知道它观察的主题中的某些东西发生了变化。因此,它必须遍历它观察的所有主题,并检查它们的先前状态与当前状态,以确定发生了什么变化以及在哪里发生了变化。更好的方法是修改Observernotify,使其包含更多信息。例如,我们可以添加可选参数如下:

export interface Observer{

     notify(value?:any, subject?:Subject);
 }

 export class HumanObserver implements Observer{

     constructor(private name:string){}

     notify(value?:any, subject?:Subject){

         console.log(this.name, 'received', value, 'from', subject);
     }
 }

notify()方法现在接受一个可选的值参数,用于描述subject对象的新状态。我们还可以接收到Subject对象本身的引用。这在观察者观察多个主题时非常有用。在这种情况下,我们需要能够区分它们。因此,我们必须稍微更改 Subject 和 IMDB,以便它们使用新的通知:

export class Subject{

     private observers:Observer[] = [];

     attachObserver(oberver:Observer):void{

         this.obervers.push(oberver);
     }

     detachObserver(observer:Observer):void{
         let index:number = this.obervers.indexOf(observer);
         if(index > -1){
             this.observers.splice(index, 1);

         }else{

             throw "Unknown observer";
         }
     }

     protected notifyObservers(value?:any){

         for (var i = 0; i < this.obervers.length; ++i) {

             this.observers[i].notify(value, this);
         }
     }
 }

 export class IMDB extends Subject{

     private movies:string[] = [];

     public addMovie(movie:string){

         this.movies.push(movie);
         this.notifyObservers(movie);
     }
 }

最后,输出如下:

 Mathieu received Jaws from IMDB {

   observers: [ HumanObserver { name: 'Mathieu' } ],
   movies: [ 'Jaws' ] }

这比Mathieu Notified更具表现力。现在,当我们使用观察者模式进行异步编程时,我们真正的意思是要求某些东西,并且在其处理过程中不想等待做任何事情。相反,我们订阅响应事件以在响应到来时得到通知。在接下来的章节中,我们将使用相同的模式和机制与 Angular 一起使用。

观察 HTTP 响应

在本节中,我们将构建一个 JSON API,根据搜索参数返回电影。我们不仅仅是等待 HTTP 查询完成,而是利用观察者设计模式的力量,让用户知道我们正在等待,并且如果需要,执行其他进程。首先,我们需要一个数据源来构建我们的类似 IMDB 的应用程序。构建和部署一个能够解释 HTTP 查询并相应发送结果的服务器端应用程序现在相对简单。然而,这超出了本书的范围。相反,我们将获取托管在bit.ly/mastering-angular2-marvel的静态 JSON 文件。该文件包含漫威电影宇宙的一些最新电影。它包含一个描述 14 部电影的 JSON 数组作为 JSON 对象。这是第一部电影:

 {
 "movie_id" : 1,
 "title" : "The Incredible Hulk",
 "phase" : "Phase One: Avengers Assembled",
 "category_name" : "Action",
 "release_year" : 2005,
 "running_time" : 135,
 "rating_name" : "PG-13",
 "disc_format_name" : "Blu-ray",
 "number_discs" : 1,
 "viewing_format_name" : "Widescreen",
 "aspect_ratio_name" : " 2.35:1",
 "status" : 1,
 "release_date" : "June 8, 2008",
 "budget" : "150,000,000",
 "gross" : "263,400,000",
 "time_stamp" : "2018-06-08"
 },

您可以找到类似 IMDB 的应用程序提供的标准信息,例如发行年份,播放时间等。我们的目标是设计一个异步的 JSON API,使每个字段都可以搜索。

由于我们正在获取一个静态的 JSON 文件(我们不会插入、更新或删除任何元素),可接受的 API 调用如下:

IMDBAPI.fetchOneById(1);
 IMDBAPI.fetchByFields(MovieFields.release_date, 2015);

第一个调用只是获取movie_id = 1的电影;第二个调用是一个更通用的调用,可以在任何字段中工作。为了防止 API 使用者请求我们电影中不存在的字段,我们使用在Movie类内部定义的枚举器限制字段值。现在,这里的重要部分是这些调用的实际返回。实际上,它们将触发一个可观察机制,在这种机制中,调用者将附加到一个可观察的 HTTP 调用。然后,当 HTTP 调用完成并根据查询参数过滤结果时,被调用者将通知调用者有关响应。因此,调用者不必等待被调用者(IMDBAPI),因为他们将在请求完成时收到通知。

实施

让我们深入实现。首先,我们需要使用 Angular CLI 创建一个新的 Angular 项目:

mkdir angular-observable
 ng init
 ng serve

接下来,我们需要一个模型来表示电影概念。我们将使用ng g class models/Movie 命令行生成这个类。然后,我们可以添加一个构造函数,定义Movie模型的所有私有字段,这与我们为 getter 和 setter 所做的相同。

export class Movie {

     public constructor(
         private _movie_id:number,
         private _title: string,
         private _phase: string,
         private _category_name: string,
         private _release_year: number,
         private _running_time: number,
         private _rating_name: string,
         private _disc_format_name: string,
         private _number_discs: number,
         private _viewing_format_name: string,
         private _aspect_ratio_name: string,
         private _status: string,
         private _release_date: string,
         private _budget: number,
         private _gross: number,
         private _time_stamp:Date){
     }

     public toString = () : string => {

         return `Movie (movie_id: ${this._movie_id},
         title: ${this._title},
         phase: ${this._phase},
         category_name: ${this._category_name},
         release_year: ${this._release_year},
         running_time: ${this._running_time},
         rating_name: ${this._rating_name},
         disc_format_name: ${this._disc_format_name},
      number_discs: ${this._number_discs},
         viewing_format_name: ${this._viewing_format_name},
         aspect_ratio_name: ${this._aspect_ratio_name},
         status: ${this._status},
         release_date: ${this._release_date},
         budget: ${this._budget},
         gross: ${this._gross},
         time_stamp: ${this._time_stamp})`;

     }
    //GETTER
    //SETTER
 }

 export enum MovieFields{
     movie_id,
     title,
     phase,
     category_name,
     release_year,
     running_time,
     rating_name,
     disc_format_name,
     number_discs,
     viewing_format_name,
     aspect_ratio_name,
     status,
     release_date,
     budget,
     gross,
     time_stamp
 }

在这里,电影 JSON 定义的每个字段都使用构造函数属性声明映射到Movie类的私有成员

TypeScript。我们还覆盖了toString方法,以便打印每个字段。在toString方法中,我们利用反引号( )提供的多行字符串和${}语法,允许我们连接字符串和不同的变量。然后,我们有一个名为MovieFields的枚举器,它将允许我们限制可搜索的字段。

接下来,我们需要生成IMDBAPI类。由于IMDBAPI类可能会在程序的任何地方使用,我们将其定义为服务。其优势在于服务可以被注入到任何组件或指令中。此外,我们可以选择是否让 Angular 2 在每次注入时创建IMDBAPI的实例,或者始终注入相同的实例。如果为IMDBAPI创建的提供者是在应用程序级别的话,那么请求它的任何人都会得到同一个IMDBAPI的实例。然而,在组件级别,每次实例化该组件时都会创建新的IMDBAPI实例并提供给该组件。在我们的情况下,只有一个IMDBAPI实例更合理,因为它不会有任何特定于从一个组件到另一个组件可能会发生变化的状态。让我们生成IMDBAPI服务(ng g s``services/IMDBAPI),并实现我们之前定义的两个方法:

IMDBAPI.fetchOneById(1);
 IMDBAPI.fetchByFields(MovieFields.release_date, 2015);

这是带有fetchOneById方法的 IMDAPI 服务:

import { Injectable } from '@angular/core';
 import { Http }  from '@angular/http';
 import { Movie, MovieFields } from '../models/movie';
 import { Observable } from 'rxjs/Rx';
 import 'rxjs/Rx';

 @Injectable()

 export class IMDBAPIService {

   private moviesUrl:string = "app/marvel-cinematic-universe.json";

   constructor(private http: Http) { }
   /**
    * Return an Observable to a Movie matching id
    * @param  {number}           id
    * @return {Observable<Movie>}  
    */
   public fetchOneById(id:number):Observable<Movie>{
     console.log('fetchOneById', id);

         return this.http.get(this.moviesUrl)
         /**
         * Transforms the result of the HTTP get, which is observable
         * into one observable by item.
         */
         .flatMap(res => res.json().movies)

         /**
         * Filters movies by their movie_id

         */
         .filter((movie:any)=>{

             console.log("filter", movie);
             return (movie.movie_id === id)
         })

         /**
         * Map the JSON movie item to the Movie Model
         */
         .map((movie:any) => {

             console.log("map", movie);

             return new Movie(

                 movie.movie_id,
                 movie.title,
                 movie.phase,
                 movie.category_name,
                 movie.release_year,
                 movie.running_time,
                 movie.rating_name,
                 movie.disc_format_name,
                 movie.number_discs,
                 movie.viewing_format_name,
                 movie.aspect_ratio_name,
                 movie.status,
                 movie.release_date,
                 movie.budget,
                 movie.gross,
                 movie.time_stamp
             );
         });
   }
 }

理解实现

让我们一步步来分解。首先,服务的声明非常标准:

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

import { Movie, MovieFields } from '../models/movie'; 
import { Observable } from 'rxjs/Rx'; 
import 'rxjs/Rx';

@Injectable()
 export class IMDBAPIService {
  private moviesUrl:string = "app/marvel-cinematic-universe.json";
  constructor(private http: Http) { }

服务是可注入的。因此,我们需要导入并添加@Injectable注解。我们还导入HttpMovieMovieFieldsObservable以及Rxjs的操作符。RxJS代表JavaScript 的响应式扩展。它是用于执行观察者、迭代器和函数式编程的 API。当涉及到 Angular 2 中的异步操作时,大部分情况下会依赖于 RxJS。

值得注意的是,我们使用的是 RxJS 5.0,它是一次完整的重写,基于相同概念的 RxJS 4.0。

IMDBAPIService还有一个对我们的 JSON 文件路径的引用,以及一个接收 HTTP 服务注入的构造函数。在fetchOneById方法的实现中,我们可以看到四个不同的操作链接在一起:getflatMapfiltermapGet返回 HTTP 请求的主体上的 observable。 flatMap通过应用您指定的每个发射项目的 observable 函数来转换get observable,其中该函数返回发出项目的observable。然后,flatMap合并这些结果的发射,将这些合并的结果作为其序列发射。在我们的情况下,这意味着我们将对从 HTTP 获取的所有项目应用接下来的两个操作(filter 和 map)。筛选器检查当前电影的 ID 是否是我们要查找的 ID,Map 将电影的 JSON 表示转换为电影的 TypeScript 表示(例如Movie类)。

最后一个操作虽然反直觉,但却是必须的。事实上,人们可能会认为 JSON 表示和 TypeScript 表示是相同的,因为它们拥有相同的字段。然而,TypeScript 表示以及其属性定义了toString,getter 和 setter 等函数。移除map将返回一个包含Movie所有字段的Object实例,而不是Movie。此外,类型转换也无济于事。事实上,TypeScript 转换器将允许您将Object转换为Movie,但它仍然不会包含Movie类中定义的方法,因为当 TypeScript 转换为 JavaScript 时,静态类型概念消失。以下情况将无法在执行时转换:

movie.movie_id(25) TypeError: movie.movie_id is not a function at Object.<anonymous>
movie: Movie = JSON.parse(`{
                             "movie_id" : 1,
                              "title" : "Iron Man",
                              "phase" : "Phase One: Avengers Assembled",
                             "category_name" : "Action",
                             "release_year" : 2015,
                              "running_time" : 126,
                              "rating_name" : "PG-13",
                              "disc_format_name" : "Blu-ray",
                              "number_discs" : 1,
                              "viewing_format_name" : "Widescreen",
                              "aspect_ratio_name" : " 2.35:1",
                              "status" : 1,
                              "release_date" : "May 2, 2008",
                              "budget" : "140,000,000",
                              "gross" : "318,298,180",
                              "time_stamp" : "2015-05-03"
        }`);
 Console.log(movie.movie_id(25));

现在,如果我们想要使用我们的IMDB服务,则需要进一步修改由 Angular CLI 生成的代码。首先,我们需要修改main.ts,使其看起来像这样:

import{ bootstrap } from '@angular/platform-browser-dynamic';
 import{ enableProdMode } from '@angular/core';
 import{ AngularObservableAppComponent, environment } from './app/';
 import{ IMDBAPIService } from './app/services/imdbapi.service';
 import { HTTP_PROVIDERS } from '@angular/http';
 if(environment.production)  {
     enableProdMode();
}

 bootstrap(AngularObservableAppComponent, 
    [IMDBAPIService , HTTP_PROVIDERS]
);

粗体的行表示新增内容。我们导入我们的IMDBServiceHTTP_PROVIDERS。这两个提供者在应用程序级别声明,这意味着将被注入到控制器或指令中的实例始终是相同的。

然后,我们修改了生成的angular-observable.component.ts文件,并添加了以下内容:

import { Component } from '@angular/core';
import { IMDBAPIService } from './services/imdbapi.service';
import { Movie } from './models/movie';

@Component({
  moduleId: module.id, 
  selector: 'angular-observable-app', 
  templateUrl: 'angular-observable.component.html', 
  styleUrls: ['angular-observable.component.css']
 })
 export class AngularObservableAppComponent {
   title = 'angular-observable works!'; 
   private movies:Movie[] = [];
   private error:boolean = false; 
   private finished:boolean = false;

 constructor(private IMDBAPI:IMDBAPIService){
    this.IMDBAPI.fetchOneById(1).subscribe(
       value => {this.movies.push(value); console.log("Component",value)},
       error => this.error = true, 
       () => this.finished =true 
      )
   }
 }

我们将几个属性添加到AngularObservableAppComponentmovieserrorfinished。第一个属性是存储我们查询结果的Movie数组,而第二个和第三个属性是errortermination的标志。在构造函数中,我们注入了IMDBAPIService,并订阅了fetchOneById方法的结果。subscribe方法期望三个回调函数:

  • 观察者:接收被观察方法产生的值。这是本章前面看到的通知方法的 RxJS 等效物。

  • 错误可选):在观察到对象产生错误的情况下触发。

  • Complete可选):完成时触发。

最后,我们可以修改angular-ob``servable.component.html文件来映射AngularObservableAppComponent数组的movie属性:

<h1>
  {{title}}
</h1>

<ul>
   <li *ngFor = "let movie of movies">{{movie}}</li> 
</ul>

我们可以看到,第一个电影条目已经被正确插入到我们的ul/li HTML 结构中。关于这段代码真正有趣的地方在于事物执行的顺序。分析日志有助于我们掌握 Angular 与 RxJS 中异步性的真正力量。我们的代码执行后,控制台如下所示:

javascript fetchOneById 1 :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:34 map Object :4200/app/angular-observable.component.js:21 Component Movie_aspect_ratio_name: " 2.35:1"_budget: "140,000,000"_category_name: "Action"_disc_format_name: "Blu-ray"_gross: "318,298,180"_movie_id: 1_number_discs: 1_phase: "Phase One: Avengers Assembled"_rating_name: "PG-13"_release_date: "May 2, 2008"_release_year: 2015_running_time: 126_status: 1_time_stamp: "2015-05-03"_title: "Iron Man"_viewing_format_name: "Widescreen"aspect_ratio_name: (...)budget: (...)category_name: (...)disc_format_name: (...)gross: (...)movie_id: (...)number_discs: (...)phase: (...)rating_name: (...)release_date: (...)release_year: (...)running_time: (...)status: (...)time_stamp: (...)title: (...)toString: ()viewing_format_name: (...)__proto__: Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object :4200/app/services/imdbapi.service.js:30 filter Object

如你所见,AngularObservableAppComponent在过滤函数分析所有项之前就收到了匹配查询的电影的通知。提醒一下,在按 ID 获取时,fetchOneById方法的操作顺序是:getflatMapfiltermap,而且filtermap方法也有日志记录语句。因此,这里的filter操作分析了第一项,恰好是我们寻找的那一项(movie_id===1),并将其转发给将其转化为Moviemap操作。这个Movie被立刻发送给了AngularObservableAppComponent。我们清楚地看到,在AngularObservableAppComponent组件中收到的对象是Movie类型,因为控制台给出了我们对toString方法的覆盖。然后,filter操作继续处理剩下的项。它们中没有一个匹配。因此,我们不会再收到任何通知了。让我们更进一步地用第二种方法IMDBAPI.fetchByField进行测试:


 public fetchByField(field:MovieFields, value:any){
 console.log('fetchByField', field, value); 
 return this.http.get (this.moviesUrl)
      .flatMap(res => res.json().movies)
 /**
 * Filters movies by their field
 */
 .filter((movie:any) =>{

     console.log("filter" , movie);
     return (movie[MovieFields[field]] === value)
  })

 /**
 * Map the JSON movie item to the Movie Model
 */
 .map(( movie: any) => {
     console.log ("map", movie);  
     return new Movie( 
         movie.movie_id, 
         movie.title,  
         movie.phase, 
         movie.category_name, 
         movie.release_year,  
         movie.running_time,  
         movie.rating_name,  
         movie.disc_format_name,  
         movie.number_discs, 
         movie.viewing_format_name,
         movie.aspect_ratio_name,  
         movie.status,
         movie.release_date,
         movie.budget,
         movie.gross,  
         movie.time_stamp
      );
   });
}

对于fetchByField方法,我们使用与fetchById相同的机制。毫不奇怪,操作仍然是一样的:getflatMapfiltermap。唯一的变化在于过滤操作,这里我们现在必须根据作为参数接收的字段进行过滤:

return (movie[MovieFields[field]] === value).

对于 TypeScript 或 JavaScript 初学者来说,这个声明可能有点令人困惑。首先,MovieFields[field]部分的解释是enum将被转译为以下 JavaScript 函数:

(function(MovieFields) {
   MovieFields[MovieFields["movie_id"] = 0] = "movie_id";
   MovieFields[MovieFields["title"] = 1] = "title";
   MovieFields[MovieFields["phase"] = 2] = "phase"; 
   MovieFields[MovieFields["category_name"] = 3] = "category_name";
   MovieFields[MovieFields["release_year"] = 4] = "release_year";
   MovieFields[MovieFields["running_time"] = 5] = "running_time"; 
   MovieFields[MovieFields["rating_name"] = 6] = "rating_name";
   MovieFields[MovieFields["disc_format_name"] = 7] ="disc_format_name";
   MovieFields[MovieFields["number_discs"] = 8] = "number_discs";
   MovieFields[MovieFields["viewing_format_name"] = 9] = "viewing_format_name";
 MovieFields[MovieFields["aspect_ratio_name"] = 10] =  "aspect_ratio_name";
 MovieFields[MovieFields["status"] = 11] = "status"; 
 MovieFields[MovieFields["release_date"] = 12] = "release_date";
 MovieFields[MovieFields["budget"] = 13] = "budget";
 MovieFields[MovieFields["gross"] = 14] = "gross";
 MovieFields[MovieFields["time_stamp"] = 15] = "time_stamp";
 })(exports.MovieFields || (exports.MovieFields =  {}));
 var MovieFields = exports.MovieFields;

结果,MovieFields.release_year的值实际上是 4,而MovieFields是一个静态数组。因此,请求MovieFields数组的第四个索引会使我得到字符串release_year is。因此,在我们当前的示例中,movie[MovieFields[field]]被解释为movie["release_year is"]

现在我们有了五个匹配项而不是一个。分析控制台,我们可以看到通知仍然在找到合适的对象时立即出现,而不是在所有被过滤完后出现:

fetchByField 4 2015
 imdbapi.service.js:43  filter Object  {movie_id: 1,  title: "Iron Man", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 imdbapi.service.js:47 map Object {movie_id: 1, title: "Iron Man", phase: "Phase One: Avengers Assembled", category_name: "Action",  release_year: 2015...}
 angular-observable.component.js:22 Component Movie {_movie_id: 1, _title: "Iron Man", _phase: "Phase One: Avengers Assembled", _category_name: "Action", _release_year: 2015...}
 imdbapi.service.js:43 filter Object {movie_id: 2, title: "The Incredible Hulk", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2008...}
 imdbapi.service.js:43 filter Object {movie_id: 3, title: "Iron Man 2", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 imdbapi.service.js:47map Object {movie_id: 3 =, title: "Iron Man 2", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 angular-observable.component.js:22 Component Movie{_movie_id: 3, _title: "Iron Man 2", _phase: "Phase One: Avengers Assembled", _category_name: "Action", _release_year:2015...}
 imdbapi.service.js:43 filter Object {movie_id: 4, title: "Thor", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year:2011...}
 imdbapi.service.js:43filter Object {movie_id: 5, title: "Captain America", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2011...}
 imdbapi.service.js:43 filter Object {movie_id: 6, title: "Avengers, The", phase: "Phase One: Avengers Assembled", category_name: "Science Fiction", release_year: 2012...}
 imdbapi.service.js:43 filter Object {movie_id: 7, title: "Iron Man 3", phase: "Phase Two", category_name: "Action", release_year : 2015...}
 imdbapi.service.js:47 map Object {movie_id: 7, title: "Iron Man 3", phase: "Phase Two", category_name: "Action", release_year:2015...}
 angular-observable.component.js: 22 Component Movie {_movie_id: 7, _title: "Iron Man 3", _phase: "Phase Two", _category_name:"Action", _release_year: 2015...}
 imdbapi.service.js:43 filter Object {movie_id: 8, title: "Thor: The Dark World", phase: "Phase Two", category_name: "Science Fiction", release_year: 2013...}
 imdbapi.service.js:43 filter Object {movie_id: 9, title: "Captain America: The Winter Soldier", phase: "Phase Two", category_name: "Action", release_year: 2014...}
 imdbapi.service.js:43 filter Object {movie_id: 10, title: "Guardians of the Galaxy", phase: "Phase Two", category_name: "Science Fiction", release_year: 2014...}
 imdbapi.service.js:43 filter Object {movie_id: 11, title: "Avengers: Age of Ultron", phase: "Phase Two", category_name: "Science Fiction", release_year: 2015...}
 imdbapi.service.js:47 map Object {movie_id: 11, title: "Avengers: Age of Ultron", phase:  "Phase Two", category_name: "Science Fiction", release_year: 2015...}
 angular-observable.component.js:22 Component Movie {_movie_id: 11, _title: "Avengers: Age of Ultron", _phase: "Phase Two", _category_name: "Science Fiction", _release_year:2015...}
 imdbapi.service.js:43 filter Object {movie_id: 12, title: "Ant-Man", phase: "Phase Two", category_name: "Science Fiction", release_year: 2015...}
 imdbapi.service.js:47 map Object {movie_id: 12, title: "Ant-Man", phase: "Phase Two", category_name: "Science Fiction", release_year: 2015...}
 angular-observable.component.js:22 Component Movie {_movie_id: 12, _title: "Ant-Man", _phase: "Phase Two", _category_name: "Science Fiction", _release_year: 2015...}
 imdbapi.service.js:43 filter Object {movie_id: 13, title: "Captain America: Civil War",phase: "Phase Three", category_name: "Science Fiction", release_year: 2016...}
imdbapi.service.js:43 filter Object {movie_id: 14, title: "Doctor Strange", phase: "Phase Two", category_name: "Science Fiction", release_year: 2016...}

现在,这种设计模式的另一个优势是能够自行取消订阅。要这样做,你只需获取对订阅的引用并调用unsubscribe()方法,如下所示:

constructor(private IMDBAPI:IMDBAPIService{ 
 let imdbSubscription = this.IMDBAPI.fetchByField(MovieFields.release_year, 2015).subscribe(
       value=> {
            this.movies.push(value);
            console.log("Component", value)
            if(this.movies.length > 2){
                    imdbSubscription.unsubscribe();
             }
      },
     error => this.error = true,
     () => this.finished = true
    );
 }

在这里,我们在第三个通知后取消订阅。除此之外,可观察对象甚至会检测到没有人再观察它,然后停止它正在做的任何事情。事实上,上一个带有unsubscribe的代码产生了:

fetchByField 4 2015
 imdbapi.service.js:43 filter Object {movie_id: 1, title: "Iron Man", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 imdbapi.service.js:49 map Object {movie_id: 1, title: "Iron Man", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 angular-observable.component.js:24 Component Movie {_movie_id: 1, _title: "Iron Man", _phase: "Phase One: Avengers Assembled", _category_name: "Action", _release_year: 2015...}
 imdbapi.service.js:43 filter Object {movie_id: 2, title: "The Incredible Hulk", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2008...}
 imdbapi.service.js:43 filter Object { movie_id: 3, title: "Iron Man 2", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 imdbapi.service.js:49 map Object {movie_id: 3, title: "Iron Man 2", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 angular-observable.component.js:24 Component Movie {_movie_id: 3, _title: "Iron Man 2", _phase:  "Phase One: Avengers Assembled", _category_name: "Action",_release_year: 2015...}
 imdbapi.service.js:43 filter Object {movie_id: 4, title: "Thor", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2011...}
 imdbapi.service.js:43 filter Object {movie_id: 5, title: "Captain America", phase: "Phase One: Avengers Assembled", category_name: "Action",release_year: 2011...}
 imdbapi.service.js:43 filter Object {movie_id: 6, title: "Avengers, The", phase: "Phase One: Avengers Assembled", category_name: "Science Fiction", release_year: 2012...}
 imdbapi.service.js:43 filter Object {movie_id: 7, title: "Iron Man 3", phase: "Phase Two", category_name: "Action", release_year: 2015...}
 imdbapi.service.js:49 map Object {movie_id: 7, title: "Iron Man 3", phase: "Phase Two", category_name: "Action", release_year: 2015...}
 angular-observable.component.js:24 Component Movie {_movie_id: 7, _title: "Iron Man 3", _phase: "Phase Two", _category_name: "Action", _release_year: 2015...}

所有事情在第三次通知后停止了。

Promises

Promise 是 Angular 2 提供的另一个有用的异步概念。它承诺提供与Observer相同的功能:处理某些事情,并且异步地通知调用者答案已经准备好了。那么,为什么要同时存在两个做相同事情的概念呢?嗯,Observer的冗长使得Promise无法实现的一件事情是:取消订阅。因此,如果你永远不打算使用观察者模式的取消订阅功能,那么最好使用Promises,在我看来,它们在书写和理解上更直观。为了强调观察者和 Promise 之间的差异,我们将采用与之前相同的例子——从 JSON API 获取电影。AngularObservableAppComponent将向IMDBAPIService发出异步调用,并在答案到来时更新 HTML 视图。

这是使用Promise而不是ObservablefetchOneById方法:


 /**
 * Return a Promise to a Movie matching id
 *@param  {number}  id
 *@return {Promise<Movie>}
 */
 public fetchOneById(id:number) : Promise <Movie>{
 console.log('fecthOneById', id);

      return this.http.get(this.moviesUrl)
     /**
     * Transforms the result of the HTTP get, which is observable
     * into one observable by item.
     */
     .flatMap(res => res.json().movies)
     /**
     * Filters movies by their movie_id
     */
    .filter((movie:any) =>{
        console.log("filter", movie);
       return (movie.movie_id === id)
   })
   .toPromise()
   /**
 * Map the JSON movie item to the Movie Model
 */
    .then((movie:any) => {

       console.log("map", movie);
       return new Movie(
              movie.movie_id,
              movie.title,
              movie.phase,
              movie.category_name,
              movie.release_year,
              movie.running_time,
              movie.rating_name,
              movie.disc_format_name,
              movie.number_discs,
              movie.viewing_format_name,
              movie.aspect_ratio_name,
              movie.status,
              movie.release_date,
              movie.budget,
              movie.gross,
              movie.time_stamp
      )
});
 }

如此代码所示,我们从flatMapfiltermap变为了flatMapfilterPromisethen。新的操作toPromisethen将创建一个包含filter操作结果的Promise对象,在filter操作完成时,then操作将被执行。then操作可以被视为一个 map;它做的事情是一样的。为了使用这段代码,我们还需要修改在AngularObservableAppComponent中调用IMDBAPIService的方式如下:


 this.IMDBAPI.fetchOneById(1).then(
        value => {
              this.movies.push(value);

              console.log("Component", value)
       },
       error => this.error = true
 );

一次又一次,我们可以看到一个then操作,该操作将在IMDBAPIService.FetchOneById的 promise 完成时执行。then操作接受两个回调函数:onCompletiononError。第二个回调函数onError是可选的。现在,onCompletion回调函数仅在 Promise 完成时执行一次,如控制台所示:

imdbapi.service.js:30 filter Object {movie_id: 2, title: "The Incredible Hulk", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2008...}
 imdbapi.service.js:30 filter Object {movie_id: 3, title: "Iron Man 2", phase : "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 imdbapi.service.js:30 filter Object {movie_id: 4, title: "Thor", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2011...}
 imdbapi.service.js:30 filter Object {movie_id: 5, title: "Captain America", phase:  "Phase One: Avengers Assembled", category_name: "Action", release_year: 2011...}
 imdbapi.service.js:30 filter Object {movie_id: 6, title: "Avengers, The", phase: "Phase One: Avengers Assembled", category_name:"Science Fiction", release_year: 2012...}
 imdbapi.service.js:30 filter Object {movie_id: 7, title: "Iron Man 3", phase: "Phase Two", category_name: "Action", release_year: 2015...}
 imdbapi.service.js:30 filter Object {movie_id: 8, title: "Thor: The Dark World", phase: "Phase Two", category_name: "Science Fiction", release_year: 2013...}
 imdbapi.service.js:30 filter Object {movie_id: 9, title: "Captain America: The Winter Soldier", phase: "Phase Two", category_name: "Action",release_year: 2014...}
 imdbapi.service.js:30 filter Object {movie_id: 10, title: "Guardians of the Galaxy", phase: "Phase Two", category_name: "Science Fiction", release_year: 2014...}
 imdbapi.service.js:30 filter Object { movie_id: 11, title: "Avengers: Age of Ultron", phase: "Phase Two", category_name: "Science Fiction", release_year: 2015...}
 imdbapi.service.js:30 filter Object {movie_id: 12, title: "Ant-Man", phase: "Phase Two", category_name: "Science Fiction", release_year: 2015...}
 imdbapi.service.js:30 filter Object {movie_id: 13, title: "Captain America: Civil War", phase: "Phase Three", category_name: "Science Fiction", release_year: 2016...}
 imdbapi.service.js:30 filter Object {movie_id: 14, title: "Doctor Strange", phase: "Phase Two", category_name: "Science Fiction", release_year: 2016...}
 imdbapi.service.js:35 map Object {movie_id: 1, title: "Iron Man", phase: "Phase One: Avengers Assembled", category_name: "Action", release_year: 2015...}
 angular-observable.component.js:23 Component Movie {_movie_id: 1, _title: "Iron Man", _phase: "Phase One: Avengers Assembled", _category_name: "Action",  _release_year: 2015...}

虽然对于fetchOneById方法,对IMDBAPIService的修改很小,但我们需要更显著地修改fetchByField。实际上,onComplete回调函数只会执行一次,所以我们需要返回一个Movie数组而不仅仅是一个Movie。以下是fetchByField方法的实现:

public fetchByField(field: MovieFields, value: any) :Promise<Movie[]>{
       console.log('fetchByField', field, value);
       return this.http.get(this.moviesUrl)
          .map(res => res.json().movies.filter(
              (movie)=>{
                  return (movie[MovieFields[field]] === value)
              })
         )
         .toPromise()
         /**
          * Map the JSON movie items to the Movie Model
         */
        .then((jsonMovies:any[]) => {
           console.log("map",jsonMovies);
           let movies:Movie[] = [];
           for (var i = 0; i < jsonMovies.length; i++) {
               movies.push(
                  new Movie(
                      jsonMovies[i].movie_id,
                      jsonMovies[i].title,
                      jsonMovies[i].phase,
                      jsonMovies[i].category_name,
                      jsonMovies[i].release_year,
                      jsonMovies[i].running_time,
                      jsonMovies[i].rating_name,
                      jsonMovies[i].disc_format_name,
                      jsonMovies[i].number_discs, 
                      jsonMovies[i].viewing_format_name, 
                      jsonMovies[i].aspect_ratio_name, 
                      jsonMovies[i].status,
                      jsonMovies[i].release_date, 
                      jsonMovies[i].budget, 
                      jsonMovies[i].gross,
                      jsonMovies[i].time_stamp
                  )
                )
              }
              return movies;  
           });
 }

为了实现这一点,我将flatMap替换为了一个经典的 map 作为第一个操作。在 map 中,我直接获取 JSON movie数组的引用并应用字段过滤。结果被转换为 promise 并在then中处理。then操作接收到一个 JSON movies数组并将其转换为一个Movie数组。这产生了一个被承诺的结果返回给调用者的Movie数组。在AngularObservableAppComponent中的调用也有些不同,因为我们现在期望一个数组:


 this.IMDBAPI.fetchByField(MovieFields.release_year, 2015).then(
     value => {
        this.movies = value;
        console.log("Component", value)
     },
     error => this.error = true
 )

使用 Promise 的另一种方式是通过 fork/join 范式。实际上,可以启动许多进程(fork),并等待所有 promise 完成后再将聚合结果发送给调用者(join)。因此,相对来说很容易增强 fetchByField 方法,因为它可以使用逻辑 or 在多个字段中运行。以下是我们需要实现这个逻辑 or 的三个非常简短的方法:


 /**
 * Private member storing pending promises
 */
 private promises:Promise<Movie[]>[] = [];
 /**
  * Register one promise for field/value. Returns this
  * for chaining i.e.
  *
  * byField(Y, X)
  * .or(...)
  * .fetch()
  *
  * @param {MovieFields} field
  * @param {any}         value
  * @return {IMDBAPIService}
  */
public byField(field:MovieFields, value:any):IMDBAPIService{

   this.promises.push(this.fetchByField(field, value));
   return this; 
 }
 /**
 * Convenient method to make the calls more readable, i.e.
 *
 * byField(Y, X)
 * .or(...)
 * .fetch()
 *
 * instead of
 *
 * byField(Y, X)
 * .byField(...)
 * .fetch()
 *
 * @param {MovieFields} field
 * @param {any}         value
 * @return {IMDBAPIService}
 */
public or(field:MovieFields, value:any):IMDBAPIService{
 return this.byField(field, value);

}

 /** 
  * Join all the promises and return the aggregated result. 
  * 
  *@return {Promise<Movie[]>} 
  */
public fetch():Promise<Movie[]>{
 return Promise.all(this.promises).then((results:any) => {
         //result is an array of movie arrays. One array per
         //promise. We need to flatten it.
         return [].concat.apply([], results);
 }); 
}

这里我提供了两种便捷的方法 fieldor,它们以 MovieField 和一个值作为参数,创建一个新的 promise。它们都返回 this 以支持链式调用。fetch 方法将所有 promise 连接在一起,并合并它们各自的结果。在 AngularObservableAppComponent 中,我们现在有以下内容:


 this.IMDBAPI.byField(MovieFields.release_year, 2015) 
             .or(MovieFields.release_year, 2014)
             .or(MovieFields.phase, "Phase Two") 
             .fetch()
             .then (
                value => {
                    this.movies = value;
                    console.log("Component", value)
                },
            error => this.error = true
        );

这很容易阅读和理解,同时保持了 Angular 2 的所有异步能力。

总结

在本章中,我们学习了如何使用一些最有用的经典模式:组件、单例和观察者。我们学会了如何在纯 TypeScript 中以及使用 Angular 2 构建块来实现。本章的代码可以在这里找到:github.com/MathieuNls/Angular-Design-Patterns-and-Best-Practices/tree/master/chap4

在下一章中,我们将专注于模式,旨在简化和组织我们的 Angular 2 应用程序中的导航。

第四章:导航模式

在本章中,我们将探讨一些最有用的导航面向对象模式,并学习如何在 Angular 方式中应用它们。导航模式用于组织与用户在我们应用程序上的导航相关的事件。

Angular 本身是一个面向对象的框架,它强制你以某种方式进行大部分开发。例如,你需要有组件、服务、管道等。强制这些构建块对你有利,有助于构建良好的架构,就像 Zend 框架对 PHP 或 Ruby on Rails 对 Ruby 所做的那样。当然,此外,框架还能让你的生活更轻松,加快开发时间。

虽然 Angular 的设计方式远远超出平均水平,但我们总是可以做得更好。我并不认为我在本章中提出的是最终设计,你将能够用它们解决从面包店单页到火星一号任务的仪表板的任何问题——不幸的是,这样的设计并不存在,但它肯定会提高你的工具箱。

在这一章中,我们将学习以下模式:

  • 模型-视图-控制器

  • Redux

MVC

哦,MVC,老朋友 MVC。多年来你为我们效力。现在,人们希望你退休,最好不要闹腾。即使我也能看到,年轻的、单向的用户界面架构可以比你更聪明,让你看起来像过去的遗物。

在本节中,我们将首先描述模型-视图-控制器是什么,不管用什么编程语言来实现它,然后我们将看到将 MVC 应用于前端编程的缺点。最后,我将介绍一种在 Angular 中实现 MVC 的方法,这种方法在实现、维护和性能方面是有意义的。

大型的模型-视图-控制器

模型-视图-控制器设计模式背后的整个原则相对简单。事实上,如下图所示,它由三个块组成:模型、视图和控制器:

模型-视图-控制器概述

组件如下:

  • 模型根据控制器发送的命令存储应用程序所需的数据。

  • 控制器接收用户的操作(例如按钮的点击)并相应地指导模型更新。它还可以在任何给定时刻切换使用的视图。

  • 视图在模型更改时生成并更新。

就是这样。

让我们看看纯 TypeScript 中简单的 MVC 实现会是什么样子。

首先,让我们像在第三章中那样定义一个Movie类,经典模式。在这个版本的Movie类中,我们只有两个属性:titlerelease_year,它们是使用 TypeScript 构造函数定义的:

class Movie{ 

    constructor(private title:string, private release_year:number){} 

    public getTitle():string{ 
        return this.title; 
    } 
    public getReleaseYear():number{ 
        return this.release_year; 
    } 
} 

然后,我们定义一个Model类,它使用reference关键字导入包含Movie类的movie.ts文件。这个模型类将负责更新视图,它有一个电影数组和两个方法。第一个方法addMovie(title:string, year:number)public的,它在movies属性的末尾添加一个新电影。它还调用类的第二个方法appendView(movie:Movie),这个方法是private的。这个第二个方法按照模型-视图-控制器的定义来操作视图。视图操作相当简单:我们在视图的#movie元素中添加一个新的li标签。新创建的li标签的内容是电影标题和发行年份的连接。

/// <reference path="./movie.ts"/> 

class Model{ 

    private movies:Movie[] = []; 

    constructor(){ 
    } 

    public addMovie(title:string, year:number){ 
        let movie:Movie = new Movie(title, year); 
        this.movies.push(movie); 
        this.appendView(movie); 
    } 

    private appendView(movie:Movie){ 
        var node = document.createElement("LI");  
        var textnode = document.createTextNode(movie.getTitle() + "-" + movie.getReleaseYear());  
        node.appendChild(textnode); 
        document.getElementById("movies").appendChild(node); 
    } 

} 

现在我们可以为我们的纯 TypeScript 模型-视图-控制器定义一个控制器。控制器有一个private model:Model属性,在构造函数中初始化。此外,定义了一个click方法。此方法以参数形式接受stringnumber,分别用于标题和发行年份。正如你所看到的,click方法将标题和发行年份转发给模型的addMovie方法。然后,控制器的工作就完成了。它不操作视图。你还会注意到controller.ts文件的最后一行:let controller = new Controller();。这行允许我们创建一个控制器的实例,以便视图可以绑定到它:


/// <reference path="./model.ts"/> 

class Controller{ 

    private model:Model; 

    constructor(){ 

        this.model = new Model(); 
    } 

    click(title:string, year:number){ 

        console.log(title, year); 
        this.model.addMovie(title, year); 

    } 

} 
let controller = new Controller(); 

我们模型-视图-控制器实现的最后一部分将是视图。我们有一个简单的 HTML 表单,提交时会调用以下操作:controller.click(this.title.value, this.year.value); return false;controller已在controller.ts文件中定义为let controller = new Controller();。然后,对于参数,我们发送this.title.valuethis.year.value,其中this指的是<form>titleyear分别指电影的标题和发行年份的字段。我们还必须添加return false;以防止页面重新加载。确实,HTML 表单在提交时的默认行为是导航到操作 URL:

<html> 
    <head> 
        <script src="mvc.js"></script> 
    </head> 
    <body> 
        <h1>Movies</h1> 

        <div id="movies"> 

        </div> 

        <form action="#" onsubmit="controller.click(this.title.value, this.year.value); return false;"> 

            Title: <input name="title" type="text" id="title"> 
            Year: <input name="year" type="text" id="year"> 
           <input type="submit"> 
        </form> 

    </body> 
</html> 

在页眉中,我们添加了通过以下命令生成的mvc.js脚本:tsc --out mvc.js controller.ts model.ts movie.ts。生成的 JavaScript 如下所示:

var Movie = /** @class */ (function () { 
    function Movie(title, release_year) { 
        this.title = title; 
        this.release_year = release_year; 
    } 
    Movie.prototype.getTitle = function () { 
        return this.title; 
    }; 
    Movie.prototype.getReleaseYear = function () { 
        return this.release_year; 
    }; 
    return Movie; 
}()); 
/// <reference path="./movie.ts"/> 
var Model = /** @class */ (function () { 
    function Model() { 
        this.movies = []; 
    } 
    Model.prototype.addMovie = function (title, year) { 
        var movie = new Movie(title, year); 
        this.movies.push(movie); 
        this.appendView(movie); 
    }; 
    Model.prototype.appendView = function (movie) { 
        var node = document.createElement("LI"); 
        var textnode = document.createTextNode(movie.getTitle() + "-" + movie.getReleaseYear()); 
        node.appendChild(textnode); 
        document.getElementById("movies").appendChild(node); 
    }; 
    return Model; 
}()); 
/// <reference path="./model.ts"/> 
var Controller = /** @class */ (function () { 
    function Controller() { 
        this.model = new Model(); 
    } 
    Controller.prototype.click = function (title, year) { 
        console.log(title, year); 
        this.model.addMovie(title, year); 
    }; 
    return Controller; 
}()); 
var controller = new Controller(); 

在执行方面,在加载时,HTML 页面将如下截图所示:

加载点处的模型-视图-控制器

然后,如果您使用表单并添加电影,它将自动影响视图并显示新的电影:

使用表单后的模型-视图-控制器

前端的模型-视图-控制器的限制

那么,为什么模型-视图-控制器模式在前端编程中并不被广泛使用,尤其是在像 Angular 这样的框架支持的情况下?首先,如果您正在使用 Angular 开发提供服务的应用程序,您很可能会有一个后端,您需要与其交换某种信息。然后,如果您的后端也使用模型-视图-控制器设计模式,您将得到以下层次结构:

前端和后端的模型-视图-控制器

在这个层次结构中,我们在另一个 MVC 实现的顶部有一个 MVC 实现。这些实现通过一个 API 服务进行通信,该服务向后端控制器发送请求并解析生成的视图。具体示例是,如果用户需要在您的应用程序中登录,他们将在前端看到“登录”视图,该视图由“用户”模型和“登录”控制器提供支持。一旦所有信息(电子邮件地址,密码)都已输入,用户点击登录按钮。这个点击触发了模型更新,然后模型使用 API 服务触发 API 调用。API 服务向您的 API 的“用户/登录”端点发出请求。在后端,请求被“用户”控制器接收并转发到“用户”模型。后端“用户”模型将查询您的数据库,以查看是否有提供的电子邮件地址和密码匹配的用户。最后,如果登录成功,将输出一个视图,其中包含用户信息。回到前端,API 服务将解析生成的视图并将相关信息返回给前端“用户”模型。然后,前端“用户”模型将更新前端“视图”。

对于一些开发者来说,这么多层次以及架构在前端和后端之间的重复似乎有些不对,尽管它通过明确定义的关注点分离带来了可维护性。

双重模型-视图-控制器不是唯一的问题。另一个问题是,前端模型不会是模型,因为它们必须考虑到与 UI 本身相关的变量,比如可见标签、表单有效性等。因此,你的前端模型往往会变成代码的丑陋堆积,其中 UI 变量与用户的实际表示相互交织。

现在,像往常一样,你可以避免这些陷阱,并利用 MVC 模式的优势。让我们在下一节中看看如何做到这一点。

Angular 的模型-视图-控制器

在这一部分,我提出了一个在 Angular 中证明有效的 MVC 架构。在过去的 18 个月里,我在toolwatch.io(Web、Android 和 iOS)上使用了这个架构。显然,我们在 Web 版本或移动应用上提出的功能是相同的,并且以相同的方式工作。改变的是视图和导航模式。

以下图表代表了整体架构:

Angular 的 MVC

从上到下,我们有后端、前端的可重用部分以及专门的前端(移动或 Web)。正如你所看到的,在后端,没有任何变化。我们保持了我们经典的 MVC。请注意,前端部分也可以与非 MVC 后端一起工作。

我们的模型将使用该服务通过一个假设的 JSON API 从远程数据库获取、放置和删除一个普通的 TypeScript 对象。

我们的user TypeScript 对象如下所示:

class User { 

    public constructor(private _email:string, private _password:string){} 

    get email():string{ 
        return this._password; 
    } 

    get password():string{ 
        return this._email; 
    } 

    set email(email:string){ 
        this._password = email; 
    } 

    set password(password:string){ 
        this._email = password; 
    } 
} 

这里没有太多花哨的东西;只是一个包含两个属性的普通 TypeScript 对象:email:_stringpassword:_string。这两个属性在构造函数中使用 TypeScript 内联声明样式进行初始化。我们还利用了 TypeScript 的 getter/setter 来访问_password:string_email:string属性。你可能已经注意到,TypeScript 的 getter/setter 看起来像 C#属性。嗯,微软是 TypeScript 的主要工业研究者之一,所以这是有道理的。

我喜欢写作的简洁性,特别是与构造函数中的内联属性声明相结合时。然而,我不喜欢的是需要使用下划线变量名。问题在于,再一次强调,这个 TypeScript 将被转译为 JavaScript,在 JavaScript 中,变量和函数比如 Java 或 C#更加抽象。

实际上,在我们当前的示例中,我们可以调用User类的 getter 如下:

user:User = new User('mathieu.nayrolles@gmail.com', 'password');

 console.log(user.email); // will print mathieu.nayrolles@gmail.com

正如你所看到的,TypeScript 并不关心它调用的目标的类型。它可以是一个名为email的变量,也可以是一个名为email()的函数。无论哪种方式,它都可以工作。这些奇怪行为背后的基本原理是,对于面向对象的程序员来说,在 JavaScript 中,可以做以下操作是可以接受的:


 var email = function(){
 return "mathieu.nayrolles@gmail.com";
 }
 console.log(email);

因此,我们需要区分函数的实际变量与不同的名称,因此有了_

现在我们有了一个经过验证的用户对象来操作,让我们回到我们的 MVC 实现。现在,我们可以有一个user模型来操作user POTO(普通的旧 TypeScript 对象)和图形界面所需的变量:

import { User } from '../poto/user'; 
import { APIService } from '../services/api.service'; 

export class UserModel{ 

    private user:User; 
    private _loading:boolean = false; 

 public constructor(private api:APIService){} 

    public signin(email:string, password:string){ 

        this._loading = true; 

        this.api.getUser(email, password).then( 

            user => { 
                this.user = user; 
                this._loading = false; 
            } 
        ); 
    } 

    public signup(email:string, password:string){ 

        this._loading = true; 
        this.api.postUser(email, password).then( 
            user => { 
                this.user = user; 
                this._loading = false; 
            }    
        ); 
    } 

    get loading():boolean{ 
        return this._loading; 
    } 

} 

我们的模型,名为UserModel,接收一个APIService的注入。APIService的实现留给读者作为练习。除了APIService之外,UserModel拥有user:Userloading:bool属性。user:User代表具体的用户,包括密码和电子邮件地址。然而,loading:bool将用于确定视图中是否应该显示加载旋转器。正如你所看到的,UserModel定义了signinsignup方法。在这些方法中,我们调用APIServicegetUserpostUser方法,两者都接受一个用户作为参数,并返回一个包含已通过 JSON API 同步的用户的 promise。收到这些 promise 后,我们关闭loading:bool旋转器。

以下是APIService

import { Injectable } from '@angular/core'; 
import { Http }  from '@angular/http'; 
import { User } from '../poto/user'; 
import { Observable } from 'rxjs/Rx'; 
import 'rxjs/Rx'; 
import { resolve } from 'dns'; 
import { reject } from 'q'; 

@Injectable() 
export class APIService { 

  private userURL:string = "assets/users.json"; 

  constructor(private http: Http) { } 

  /** 
   * Return a Promise to a USer matching id 
   * @param  {string}            email 
   * @param  {string}            password 
   * @return {Promise<User>}    
   */ 
  public getUser(email:string, password:string):Promise<User>{ 
      console.log('getUser', email, password); 

        return this.http.get(this.userURL) 
        /** 
         * Transforms the result of the http get, which is observable 
         * into one observable by item. */ 
        .flatMap(res => res.json().users) 
        /** 
         * Filters users by their email & password 
         */ 
        .filter((user:any)=>{ 
            console.log("filter", user); 
            return (user.email === email && user.password == password) 
        }) 
        .toPromise() 
        /** 
         * Map the json user item to the User model 
        */ 
        .then((user:any) => { 
            console.log("map", user);  
            return new User( 
                email, 
                password 
            ) 
        }); 
  }  

   /** 
   * Post an user Promise to a User 
   * @param  {string}            email 
   * @param  {string}            password 
   * @return {Promise<User>}    
   */ 
  public postUser(email:string, password:string):Promise<User>{ 

    return new Promise<User>((resolve, reject) => { 
        resolve(new User( 
            email, 
            password 
        )); 
    }); 
  } 

} 

APIService发出 HTTP 调用以解析包含用户信息的本地 JSON 文件:

{ 
    "users":[{ 
        "email":"mathieu.nayrolles@gmail.com", 
        "password":"password" 
    }] 
} 

getUser(email:string, password:string):Promise<User>postUser(email:string, password:string):Promise<User>都使用了 promise,就像我们在上一章中展示的那样。

然后,还有控制器,它也将是 Angular 环境中的一个组件,因为 Angular 组件控制显示的视图等等:

@Component({
 templateUrl: 'user.html'
 })
 export class UserComponent{

 private model:UserModel;

 public UserComponent(api:APIService){

 this.model = new UserModel(api);
 }

 public signinClick(email:string, password:string){
 this.model.signin(email, password);
 }

 public signupClick(email:string, password:string){
 this.model.signup(email, password);
 }

 }

正如你所看到的,控制器(组件)非常简单。我们只有一个对模型的引用,并且我们接收一个注入的APIService来传递给模型。然后,我们有signinClicksignupClick方法,它们从视图接收用户输入并将其传递给模型。最后一部分,视图,看起来像这样:


 <h1>Signin</h1>

 <form action="#" onsubmit="signinClick(this.email.value, this.password.value); return false;">

 email: <input name="email" type="text" id="email">
 password: <input name="password" type="password" id="password">
 <input [hidden]="model.loading" type="submit">
 <i [hidden]="!model.loading" class="fa fa-spinner" aria-hidden="true"></i>
 </form>

 <h1>Signup</h1>

 <form action="#" onsubmit="signupClick(this.email.value, this.password.value); return false;">

 email: <input name="email" type="text" id="email">
 password: <input name="password" type="password" id="password">
 <input [hidden]="model.loading" type="submit">
 <i [hidden]="!model.loading" class="fa fa-spinner" aria-hidden="true"></i>
 </form>

在这里,我们有两种形式:一种用于登录,一种用于注册。这两种表单除了它们使用的onsubmit方法不同之外,它们是相似的。登录表单使用我们控制器的signinClick方法,注册表单使用signupClick方法。除了这两种表单,我们还在每个表单上有一个font awesome旋转器,只有当用户模型正在加载时才可见。我们通过使用[hidden]Angular 指令来实现这一点:[hidden]="!model.loading"。同样,当模型正在加载时,提交按钮也是隐藏的。

所以,这就是一个应用于 Angular 的功能性 MVC。

正如我在本节开头所说的,对我来说,Angular 中 MVC 模式的真正用处来自于它的可扩展性。事实上,利用 TypeScript 的面向对象方面(以及随之而来的内容)允许我们为不同的 Angular 应用程序专门化控制器和模型。例如,如果你像我一样有一个 Angular 网站和一个 Angular 移动应用程序,那么你可以在两边使用业务逻辑。当我们可以只有一个时,如果随着时间的推移,我们需要编写和维护两个登录、两个注册和两个所有内容,那将是一件遗憾的事情!

例如,在toolwatch.io,Web 应用程序使用标准的 Angular,我们使用 Ionic 和 Angular 构建了移动应用程序。显然,我们在移动应用程序(Android 和 iOS)和网站之间共享了大量前端逻辑。最终,它们倾向于实现相同的目的和功能。唯一的区别是使用的媒介来利用这些功能。

在下图中,我粗略地表示了一种更完整地利用 MVC 模式的方式,重点放在可重用性和可扩展性上:

Angular 的 MVC

再次强调,后端保持不变。我们在那里仍然有相同的 MVC 模式。作为提醒,后端的 MVC 模式完全取决于你,你可以利用前端的 MVC 模式与功能性的 Go 后端进行结合,例如。与此处公开的 MVC 的先前版本不同的是可重用前端部分的引入。在这部分中,我们仍然有一个负责消费我们的 JSON API 的 API 服务。然后,我们有一个实现了IModel接口的模型。


 export interface IModel{

 protected get(POTO):POTO;
 protected put(POTO):POTO;
 protected post(POTO):POTO;
 protected delete(POTO):boolean;
 protected patch(POTO):POTO;

 }

这个接口定义了必须在后续模型中实现的putpostdeletepatch方法。这些方法所接受的参数和返回的POTO类型是你程序中任何领域模型的母类。领域模型代表了你的业务逻辑中的可同步实体,比如我们之前使用的User。领域模型和模型-视图-控制器中的模型部分不应混淆。它们根本不是同一回事。在这种架构中,User会扩展POTO

这次的模型(模型-视图-控制器)除了实现IModel接口之外,还包含了一个POTO。它还包含了你需要更新视图的变量和方法。模型本身的实现相当简单,就像我在本节前面展示的那样。然而,我们可以通过利用 TypeScript 的泛型特性来提升一些东西,设想以下情况:


 export class AbstractModel<T extends POTO> implements IModel{
 protected T domainModel;

 public AbstractModel(protected api:APIService){}

 protected get(POTO):T{
 //this.api.get ...
 };
 protected put(T):T{
 //this.api.put...
 };
 protected post(T):T{
 //this.api.post...
 };
 protected delete(T):boolean{
 //this.api.delete...
 };
 protected patch(T):T{
 //this.api.patch...
 };
 }

 export class UserModel extends AbstractModel<User>{

 public AbstractModel(api:APIService){
 super(api);
 }

 public signin(email:string, password:string){

 this._loading = true;

 this.get(new User(email, password)).then(

 user => {
 this.user = user;
 this._loading = false;
 }
 );
 }

 public signup(email:string, password:string){

 this._loading = true;
 this.post(new User(email, password)).then(
 user => {
 this.user = user;
 this._loading = false;
 } 
 );
 }
 //Only the code specialized for the UI ! 
 }

在这里,我们有一个通用的AbstractModel,它受到POTO的约束。这意味着AbstractModel泛型类的实际实例(在诸如 C++的语言中称为模板)受到了对专门化POTO的类的约束。换句话说,只有像User这样的领域模型才能被使用。到目前为止,关注点的分离以及其可重用性都非常出色。可重用部分的最后一部分是控制器。在我们的注册/登录示例中,它看起来会非常像这样:

export class UserController{

 public UserComponent(protected model:UserModel){
 }

 public signin(email:string, password:string){
 this.model.signin(email, password);
 }

 public signup(email:string, password:string){
 this.model.signup(email, password);
 }

 }

那么,为什么我们在这里需要一个额外的构建模块,为什么我们不能像我们在 Angular 模型-视图-控制器的简化版本中那样使用一个简单的 Angular 组件呢?嗯,问题在于,取决于你在 Angular 核心之上使用的是什么(Ionic、Meteor 等),组件并不一定是主要的构建模块。例如,在 Ionic2 世界中,你使用Pages,这是他们对经典组件的自定义版本。

因此,例如,移动部分会是这样的:

export class LoginPage extends UserController{

 public LoginPage(api:APIService){
 super(new UserModel(api));
 }

 //Only what's different on mobile !

 }

如果需要,您还可以扩展UserModel并添加一些专业化,就像前面的图表所示的那样。在浏览器端:

@Component({
 templateUrl: 'login.html'
 })
 export class LoginComponent extends UserController{

 public UserComponent(api:APIService){

 super(new UserModel(api));
 }

 //Only what's different on browser !

 }

您也可以再次扩展UserModel并添加一些专业化。唯一剩下的要涵盖的部分是视图。令我绝望的是,没有办法使用 extends 或样式文件。因此,除非移动应用程序和浏览器应用程序之间的 HTML 文件完全相同,否则我们注定会在客户端之间存在 HTML 文件的重复。根据经验,这种情况并不经常发生。

整个可重用的前端可以作为 Git 子模块、独立库或NgModule进行发布。我个人使用 git 子模块方法,因为它允许我在执行对共享前端进行修改时,在我正在工作的客户端上享受自动刷新,同时拥有两个单独的存储库。

请注意,这种模型-视图-控制器也适用于多个前端命中相同的后端,而不是多种类型的前端。例如,在电子商务设置中,您可能希望拥有不同品牌的网站来销售在同一个后端中管理的不同产品,就像 Magento 的视图所能实现的那样。

Redux

Redux 是一种模式,可以让您以安全的方式管理事件和应用程序状态。它可以确保您的应用程序范围的状态,无论是由导航事件还是其他事件引起的,都在一个单一的不可访问的地方进行管理。

通常,应用程序的状态存储在 TypeScript 接口中。根据我们在上一节中使用的示例,我们将使用自定义的APIService来为用户实现登录/注销功能,该服务消耗 JSON。在我们的情况下,应用程序只有一个状态:logged。因此,接口看起来像这样:

export interface IAppState { 
    logged: boolean; 
} 

这个接口只包含一个单一的 logged 布尔值。对于这样一个常见的变量来说,拥有一个接口可能看起来有点多余,但是当您的应用程序开始增长时,您会发现它很方便。我们的应用程序的状态只能通过Action来操作。Action是 redux 框架中的一种事件类型,由Reducer触发和拦截。Reducer拦截这些动作并操作我们应用程序的状态。Reducer是唯一可以发生状态变化的地方。

现在我们已经快速概述了 redux 模式,现在是时候深入其实现了。首先,我们需要创建一个新的 Angular 项目并安装所需的包:

  • **ng new ng-redux**

  • **cd ng-redux**

  • **npm install  – save redux @angular-redux/store**

接下来,我们将创建我们的操作。作为提醒,操作是由应用程序触发的,并被reducer拦截,以便操作应用程序状态。在我们的应用程序中,我们只有两个操作,登录和注销:

import { Injectable } from '@angular/core'; 
import { Action } from 'redux'; 

@Injectable() 
export class LoginAction { 
  static LOGIN = 'LOGIN'; 
  static LOGOUT = 'LOGOUT'; 

  loggin(): Action { 
    return { type: LoginAction.LOGIN }; 
  } 

  logout(): Action { 
    return { type: LoginAction.LOGOUT }; 
  } 
} 

正如我们在前面的代码中所看到的,LoginAction类是一个 Angular 服务,因为它是可注入的。因此,我们架构的任何一个部分都可以通过 Angular 的自动依赖注入机制接收操作列表,这些机制在前一章中已经介绍过。还要注意的一点是,我们的两个操作都返回Actionsaction类由一个type字段组成,我们使用静态字符串变量来填充它们。

列表上的下一个项目是reducer,它拦截触发的操作,并相应地操作我们应用程序的状态。reducer可以这样实现:

import { Action } from 'redux'; 
import { LoginAction } from './app.actions'; 

export interface IAppState { 
    logged: boolean; 
} 

export const INITIAL_STATE: IAppState = { 
  logged: false, 
}; 

export function rootReducer(lastState: IAppState, action: Action): IAppState { 
  switch(action.type) { 
    case LoginAction.LOGIN: return { logged: !lastState.logged }; 
    case LoginAction.LOGOUT: return { logged: !lastState.logged }; 
  } 

  // We don't care about any other actions right now. return lastState; 
}

目前,我们的reducer只管理两个操作:登录和注销。在接收到操作时,我们使用 switch 语句检查操作类型,然后简单地反转已登录状态的值。由于我们的接口,这是我们唯一可以修改应用程序状态的地方。乍一看,这可能被视为一个瓶颈和关注点分离不足。现在,瓶颈部分,也就是所有事情都发生在那里,是有意设计的。Redux 背后的主要思想是,复杂的有状态 JavaScript 应用程序很难管理,因为应用程序的状态可以以多种方式改变。例如,异步调用和导航事件都可以以微妙且难以调试的方式改变应用程序的整体状态。在这里,使用 Redux 功能,一切都在同一个地方管理。至于关注点分离的论点,这是非常有效的,没有什么能阻止我们在良好命名的、松散耦合的函数中操作状态(例如,在我们的情况下return { logged: !lastState.logged };)。

现在我们的商店、Redux 和操作已经实现,我们可以开始在我们的组件内操作它们:

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

import { NgRedux } from '@angular-redux/store'; 
import { LoginAction } from './app.actions'; 
import { IAppState } from "./store"; 
import { APIService } from './api.service'; 

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

  constructor(                           
    private ngRedux: NgRedux<IAppState>, 
    private api:APIService) { 

      this.subscription = ngRedux.select<boolean>('logged') 
      .subscribe(logged => this.logged = logged);    
    }  

  login(email:string, password:string) { 
    this.api.login(email, password); 
  } 

  logout() { 
    this.api.logout(); 
  } 

  ngOnDestroy() {                     
    this.subscription.unsubscribe();  
  }     
} 

这里发生了很多事情。让我们一点一点地分解。首先是构造函数:

constructor(                           
    private ngRedux: NgRedux<IAppState>, 
    private api:APIService) { 

      this.subscription = ngRedux.select<boolean>('logged') 
      .subscribe(logged => this.logged = logged);    
    } 

在这个构造函数中,我们期望接收一个 NgRedux<IAppState> 的注入,它可以操作我们的状态,以及稍微修改过的 APIService,以适应我们的新模式。在构造函数内部,我们有 ngRedux.select<boolean>('logged') 指令,它允许我们访问来自 IAppState 接口的 logged 变量的可观察对象。正如你所看到的,按设计,在这里无法更改 logged 的值,因为你只能获取它的可观察对象。作为一个可观察对象,我们可以订阅它,并在其值发生变化时定义一个组件。在我们的情况下,我们将 logged 类成员的值影响到 logged 状态的新值。

接下来是登录和注销方法,它们作为对 ApiService 调用的代理:

 login(email:string, password:string) { 
    this.api.login(email, password); 
  } 

  logout() { 
    this.api.logout(); 
  } 

最后,我们可以看到 ngOnDestroy 函数的实现,这是通过实现 OnDestroy 接口而成为强制性的。虽然不是强制性的,ngOnDestroy 函数会取消订阅 logged 观察者,这样如果 logged 状态发生变化并且组件不再存在,就会节省我们几毫秒:

 ngOnDestroy() {                     
    this.subscription.unsubscribe();  
  } 

让我们来看一下与我们的组件相关联的 HTML。它非常简单,只显示了 logged 状态的值和两个按钮,你猜对了,它们允许我们登录和退出我们的应用程序:

<div style="text-align:center"> 
  <p>{{logged}}</p> 
  <button (click)="login('foo', 'bar')">Login</button> 
  <button (click)="logout()">Logout</button> 
</div> 

看起来是这样的:

列表中的下一个项目是修改 APIService,使其使用我们的新模式,而不是 MVC:

import { Injectable } from '@angular/core'; 
import { Http }  from '@angular/http'; 
import { User } from './user'; 
import 'rxjs/Rx'; 
import { NgRedux } from '@angular-redux/store'; 
import { LoginAction } from './app.actions'; 
import {IAppState } from './store'; 

@Injectable() 
export class APIService { 

  private userURL:string = "assets/users.json"; 

  constructor( 
      private http: Http,  
      private ngRedux: NgRedux<IAppState>,  
      private actions: LoginAction) { } 

  /** 
   * Return a Promise to a USer matching id 
   * @param  {string}            email 
   * @param  {string}            password 
   * @return {Promise<User>}    
   */ 
  public login(email:string, password:string){ 
        console.log('login', email, password); 

        this.http.get(this.userURL) 
        /** 
         * Transforms the result of the http get, which is observable 
         * into one observable by item. */ 
        .flatMap(res => res.json().users) 
        /** 
         * Filters users by their email & password 
         */ 
        .filter((user:any)=>{ 
            console.log("filter", user); 
            return (user.email === email && user.password == password) 
        }) 
        .toPromise() 
        /** 
         * Map the json user item to the User model 
        */ 
        .then((user:any) => { 
            console.log("map", user);  
            this.ngRedux.dispatch(this.actions.loggin()); 
        }); 
  }  

   /** 
   * Logout a User 
   */ 
  public logout(){ 
        this.ngRedux.dispatch(this.actions.logout()); 
  } 

} 

在这个版本中,我们使用相同的技术,只是不再返回 promises。实际上,在这个版本中,我们只是向我们的 reducer 分派动作,如下所示:

this.ngRedux.dispatch(this.actions.loggin()); 

还有:

this.ngRedux.dispatch(this.actions.logout()); 

再次强调,状态的修改是间接的;我们只是分派一个动作,这个动作将被 reducer 捕获,而不是直接操作状态。换句话说,这是安全的,并且集中在一个单一的点上。

最后,我们需要调整主应用模块以反映所有我们的更改:

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

import { NgReduxModule, NgRedux } from '@angular-redux/store'; 
import { AppComponent } from './app.component'; 

import { rootReducer, IAppState, INITIAL_STATE } from './store'; 
import { LoginAction } from './app.actions'; 
import { APIService } from './api.service'; 

@NgModule({ 
  declarations: [ 
    AppComponent 
  ], 
  imports: [ 
    NgReduxModule, 
    HttpModule, 
  ], 
  providers: [APIService, LoginAction], 
  bootstrap: [AppComponent] 
}) 
export class AppModule {  

  constructor(ngRedux: NgRedux<IAppState>) { 
    // Tell @angular-redux/store about our rootReducer and our initial state. // It will use this to create a redux store for us and wire up all the 
    // events. ngRedux.configureStore( 
      rootReducer, 
      INITIAL_STATE); 
  } 
} 

我们首先导入了 NgRedux 模块和 HttpModule,它们将在应用程序中使用。然后,AppModule 的构造函数将接收一个注入的 NgRedux 实例,并配置我们的 Redux 存储。存储还接收了我们之前初始化的默认状态。

总结

在这一章中,我们看到了两种模式:Redux 和 MVC。Redux 和 MVC 可以用来实现相同的目的(在异步事件或用户操作的反应中管理应用程序的状态)。这两种模式都有优点和缺点。在我的观点中,Angular 应用程序中 MVC 的优点是一切都被很好地定义和分离。事实上,我们有一个领域对象(User),一个模型(UserModel),以及一个与组件相关联的视图。我们看到了相同的模型和领域对象在许多组件和视图中被重复使用。问题在于,在我们的应用程序中创建新功能可能会变得很昂贵,因为你将不得不创建或者至少修改大量的架构。

此外,无论是出于错误还是设计,如果您在多个组件和服务之间共享模型,要识别和消除错误的来源可能会非常痛苦。Redux 模式更加新颖,而且更适应 JavaScript 生态系统,因为它是为其创建的。在我们的应用程序中相对容易地添加状态功能,并以安全的方式操纵它们。根据经验,我可以向您保证,当使用 Redux 模式时,整个团队数天都被困惑的错误要少得多。然而,在应用程序内部的关注点分离不太明确,你可能最终会在最复杂的应用程序中得到一千行的 Redux。当然,我们可以创建几个额外的 reducer,将我们的存储与大功能分开,并创建辅助函数来操纵我们的状态。由于这些不是模式所强加的,我经常发现自己在审查昂贵的 reducer 时需要进行大量的重构。

在下一章中,我们将研究 Angular 应用程序的稳定性模式,这将确保我们的应用程序在面临一切困难时仍然可用。

第五章:稳定性模式

稳定性是软件工程的基石之一。无论如何,你都必须对你的环境和用户做最坏的打算,并做好准备。当你的后端处于燃烧状态时,你的 Angular 应用程序应该能够在降级模式下运行,并在其恢复在线时平稳恢复。

在本章中,我们将学习稳定性模式和反模式,例如以下内容:

  • 超时

  • 断路器

  • 工厂

  • 纪念品

  • 原型和可重用池

超时

在之前的章节中,我们尝试了使用 API 服务,目的是消费由我们假设的后端创建的任何类型的内容的 API。如果我不得不分享我在网上冒险中学到的一句话,那就是不要相信任何人...尤其不要相信自己。我的意思是,你永远不能相信 API 会按预期工作,即使是你自己的 API。你应该始终期望一切可能出错的事情都会出错。在尝试与后端通信时可能发生的一件较不严重的事情是它不会响应。虽然这种单向通信对你的 Angular 应用程序是无害的,但对你的用户来说是最令人沮丧的。在这个配方中,我们将学习如何在我们的外部调用中实现超时,以及如何对不响应的 API 做出反应。

幸运的是,有一种非常简单的方法可以防止我们的用户对不响应的 API 感到沮丧:超时。超时是一种简单的防御机制,允许你的应用程序等待固定的时间,而不是一毫秒更多。让我们创建一个新的项目来测试一下:

    ng new timeout
    cd timeout
    ng g service API

这将创建一个新的项目和一个名为API的服务。乍一看,没有什么可看的:

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

@Injectable() 
export class ApiService { 

  constructor() { } 

} 

我们需要在app.module.ts中添加HttpClient组件如下:

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

import { AppComponent } from './app.component'; 
import { ApiService } from './api.service'; 

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

然后,我们希望将HttpClient组件注入到我们的 API 服务客户端中,以便可以访问其方法:

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

@Injectable() 
export class ApiService { 

  constructor(private http:HttpClient) { } 

} 

我们将在我们的APIService中添加一个新的方法,简单地对包含本书代码的 GitHub 存储库进行http.getgithub.com/MathieuNls/Angular-Design-Patterns-and-Best-Practices):

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

@Injectable() 
export class ApiService { 

  constructor(private http: HttpClient) { } 

  public getURL(url: string): void { 
    this.http.get(url) 
    .subscribe(data => { 
      console.log(data); 
    }); 
  }  

} 

接下来是对ApiService的注入,并在AppComponent中调用新的getURL方法:

import { Component } from '@angular/core'; 
import { ApiService } from './api.service'; 

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

  constructor(private api: ApiService){ 
    api.getURL("https://github.com/MathieuNls/Angular-Design-Patterns-and-Best-Practices") 
  } 
}

现在,如果我们执行这个操作,我们将得到一个优雅的 HTTP 响应,并且网页的 HTML 将被打印到控制台中。然而,问题在于,如果github.com宕机并且没有响应,我们没有采取任何对策:

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

@Injectable() 
export class ApiService { 

  constructor(private http: HttpClient) { } 

  public getURL(url: string): void { 

    let timeout; 

    let sub = this.http.get(url) 
      .subscribe((res) => { 
        console.log(res); 
        clearTimeout(timeout) 
      }); 

    timeout = setTimeout( 
      () => { sub.unsubscribe() }, 1000 
    ); 
  } 

} 

在这个版本的getURL函数中,我们必须首先声明一个超时变量,该变量将包含一个 NodeJS 超时。然后,我们将订阅响应,而不是执行常规的HTTP.get。最后,在订阅结果后,我们使用setTimeout函数为超时变量赋值。我们使用这个函数在 1,000 毫秒后取消订阅响应。因此,我们只等待 1 秒钟的http回复。如果回复在这段时间内没有到达,我们将自动取消订阅并允许我们的应用程序继续。当然,我们的用户必须以某种方式被警告操作失败。

断路器

我们在上一节中实现的超时模式有效地保护了用户的耐心,最终也保护了我们的 Angular 应用程序。然而,如果 API 没有响应是因为服务器端出了问题,比如你的服务器 80%宕机,剩下的 20%在尝试管理负载,你的客户很可能会反复重试超时的操作。因此,这会给我们濒临崩溃的后端基础设施带来更大的压力。

电路是一种自动装置,用于作为安全措施停止电路中的电流流动。断路器用于检测故障并封装防止故障不断发生的逻辑(在维护期间、临时外部系统故障或意外系统困难期间)。

具体来说,在 Angular 应用程序的框架内,断路器将在出现太多故障时阻止客户端执行 API 请求。在一定时间后,电路将允许一些查询通过并使用 API。如果这些查询没有任何问题返回,那么电路将关闭自身并允许所有请求通过:

在上图中,我们可以看到断路器是如何运作的。所有请求都经过断路器,如果供应商及时回应请求,断路器保持关闭状态。当问题开始出现时,断路器会注意到,如果足够多的请求超时,那么断路器就会打开,阻止请求通过。

最后,在给定的时间后,断路器尝试重新发送请求给供应商:

从实现的角度来看,我们需要ApiStatusCall类,它们负责跟踪我们对不同 API 的调用。

//ApiStatus class 
class ApiStatus { 

  public lastFail: number 
  public calls: Call[] 

  constructor(public url: string) { } 

  //Compute the fail percentage 
  public failPercentage(timeWindow: number): number { 

    var i = this.calls.length - 1; 
    var success = 0 
    var fail = 0; 

    while (this.calls[i].time > Date.now() - timeWindow && i >= 0) { 
      if (this.calls[i].status) { 
        success++; 
      } else { 
        fail++; 
      } 
   i--; 
    } 

    return fail / (fail + success) 
  } 

} 

APIStatus包含了根 API 的统计信息。我们要考虑到我们的应用程序可能会使用多个 API。每个 API 都必须与自己的断路器相连。首先,我们有lastFail变量,其中包含了上次调用此 API 失败的日期。然后,我们有一个calls数组,其中包含了对给定 API 的所有调用。除了定义 URL 属性的构造函数之外,我们还有failPercentage函数。这个函数负责计算在timeWindows时间内失败的调用百分比。为了做到这一点,我们以相反的时间顺序迭代所有的调用,直到达到Date.now() - timeWindowcalls数组的末尾。在while循环内,我们根据当前调用的状态递增两个名为successfail的数字变量。最后,我们返回失败调用的百分比。这个百分比将用于确定断路器的状态。

Call类非常简单:

//An Api Call 
class Call { 
  constructor(public time: number, public status: boolean) { } 
} 

它只包含两个属性:时间和状态。现在,我们准备为我们的Angular应用程序实现一个实现断路器的 API 客户端。首先,我们必须创建这个类:

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

@Injectable() 
export class ApiwithBreakerService { 

  constructor(private http: HttpClient) { } 

然后,我们必须为ApiwithBreakerService添加属性:

 private apis: Map<string, ApiStatus>; 
  private failPercentage: number = 0.2; 
  private timeWindow : number = 60*60*24; 
  private timeToRetry : number = 60;

这些属性将允许我们实现断路器模式。首先,我们有一个stringApiStatus的映射,用于存储许多 API 的 API 状态。然后,我们有failPercentage,它定义了在打开电路之前有多少调用可以失败,作为百分比。timeWindow变量定义了用于计算failPercentage的时间量。在这里,我们在 24 小时窗口内最多可以有 20%的调用失败,然后我们打开这个电路并阻止其他调用。最后,我们有timeToRetry,它规定了在尝试重新关闭电路之前我们需要等待多长时间。

以下是来自超时部分的修改后的getURL函数:

 //Http get an url 
  public getURL(url: string): void { 

    var rootUrl = this.extractRootDomain(url); 

    if(this.isClosed(rootUrl) || this.readyToRetry(rootUrl)){ 
      let timeout; 

      let sub = this.http.get(url) 
        .subscribe((res) => { 
          console.log(res); 
          clearTimeout(timeout); 
          this.addCall(rootUrl, true); 
        }); 

      timeout = setTimeout( 
        () => {  
          sub.unsubscribe(); 
          this.addCall(rootUrl, false); 
        }, 1000 
      ); 
    } 
  } 

我们保留了前一部分中的超时的核心功能,但将其嵌入到了一个if语句中:

if(this.isClosed(rootUrl) || this.readyToRetry(rootUrl)) 

if语句检查电路是否关闭,或者我们是否准备在打开的电路上重试。

我们还添加了对addCall函数的调用:

 //Add a call 
  private addCall(url: string, status: boolean) { 

    let res = this.apis.get(url); 

    if (res == null) { 
      res = new ApiStatus(url); 
      this.apis.set(url, res); 
    } 

    res.calls.push(new Call(Date.now(), status)); 

    if(!status){ 
      res.lastFail = Date.now(); 
    } 
  } 

addCall函数将一个新的调用添加到存储在apis映射内的ApiStatus中。如果调用不成功,它还会更新ApiStatus实例的lastFail属性。

剩下的是readyToRetryisClosed函数:

 //Are we ready to retry 
  private readyToRetry(url:string): boolean { 

    return this.apis.get(url).lastFail < (Date.now() - this.timeToRetry) 
  } 

  //Is it closed ? private isClosed(url :string) : boolean { 

    return this.apis.get(url) == null ||  
      !(this.apis.get(url).failPercentage(this.timeWindow) > this.failPercentage); 
  } 

readyToRetry函数中,我们只需检查最新的失败是否比现在减去timeToRetry的时间早。在isClosed函数中,我们检查在时间窗口内失败调用的百分比是否大于允许的最大值。以下是完整的实现:

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

//ApiStatus class 
class ApiStatus { 

  public lastFail: number 
  public calls: Call[] 

  constructor(public url: string) { } 

  //Compute the fail percentage 
  public failPercentage(timeWindow: number): number { 

    var i = this.calls.length - 1; 
    var success = 0 
    var fail = 0; 

    while (this.calls[i].time > Date.now() - timeWindow && i >= 0) { 
      if (this.calls[i].status) { 
        success++; 
      } else { 
        fail++; 
      } 
      i--; 
    } 
 return fail / (fail + success) 
  } 

} 

//An Api Call 
class Call { 
  constructor(public time: number, public status: boolean) { } 
} 

@Injectable() 
export class ApiwithBreakerService { 

  constructor(private http: HttpClient) { } 

  private apis: Map<string, ApiStatus>; 
  private failPercentage: number = 0.2; 
  private timeWindow : number = 60*60*24; 
  private timeToRetry : number = 60; 

  //Http get an url 
  public getURL(url: string): void { 

    var rootUrl = this.extractRootDomain(url); 

    if(this.isClosed(rootUrl) || this.readyToRetry(rootUrl)){ 
      let timeout; 

      let sub = this.http.get(url) 
        .subscribe((res) => { 
          console.log(res); 
          clearTimeout(timeout); 
          this.addCall(rootUrl, true); 
        }); 

      timeout = setTimeout( 
        () => {  
          sub.unsubscribe(); 
          this.addCall(rootUrl, false); 
        }, 1000 
      ); 
    } 
  } 

  //Add a call 
  private addCall(url: string, status: boolean) { 

    let res = this.apis.get(url); 

    if (res == null) { 
      res = new ApiStatus(url); 
      this.apis.set(url, res); 
    } 

    res.calls.push(new Call(Date.now(), status)); 

    if(!status){ 
      res.lastFail = Date.now(); 
    } 
  } 

  //Are we ready to retry 
  private readyToRetry(url:string): boolean { 

    return this.apis.get(url).lastFail < (Date.now() - this.timeToRetry) 
  } 

  //Is it closed ? private isClosed(url :string) : boolean { 

    return this.apis.get(url) == null ||  
      !(this.apis.get(url).failPercentage(this.timeWindow) > this.failPercentage); 
  } 

  private extractHostname(url: string) : string { 
    var hostname; 
    //find & remove protocol (http, ftp, etc.) and get hostname 

    if (url.indexOf("://") > -1) { 
      hostname = url.split('/')[2]; 
    } 
    else { 
      hostname = url.split('/')[0]; 
    } 

    //find & remove port number 
    hostname = hostname.split(':')[0]; 
    //find & remove "?" hostname = hostname.split('?')[0]; 

    return hostname; 
  } 

  private extractRootDomain(url: string) : string{ 
    var domain = this.extractHostname(url), 
      splitArr = domain.split('.'), 
      arrLen = splitArr.length; 

    //extracting the root domain here 
    //if there is a subdomain  
    if (arrLen > 2) { 
      domain = splitArr[arrLen - 2] + '.' + splitArr[arrLen - 1]; 
      //check to see if it's using a Country Code Top Level Domain (ccTLD) (i.e. ".me.uk") 
      if (splitArr[arrLen - 1].length == 2 && splitArr[arrLen - 1].length == 2) { 
        //this is using a ccTLD 
        domain = splitArr[arrLen - 3] + '.' + domain; 
      } 
    } 
    return domain; 
  } 
} 

请注意,我们有两个辅助函数,它们并不直接参与电路模式的实现,只是提取调用的根 URL,以便通过根 API 计算共享状态。由于这些辅助函数,我们可以使someapi.com/userssomeapi.com/sales共享相同的状态,而anotherapi.com/someCall则有其自己分离的ApiStatus

超时和断路器模式并行工作,以减少自我否认。自我否认是自己毁灭后端服务器的艺术。当您的应用程序表现不当并且每秒向后端架构发出数千次调用时,这种情况往往会发生。

工厂

假设我们有一个User类,有两个私有变量:lastName:stringfirstName:string。此外,这个简单的类提供了hello方法,打印"Hi I am", this.firstName, this.lastName

class User{
 constructor(private lastName:string, private firstName:string){
 }
 hello(){
 console.log("Hi I am", this.firstName, this.lastName);
 }
 }

现在,考虑我们通过 JSON API 接收用户。它很可能看起来像这样:

[{"lastName":"Nayrolles","firstName":"Mathieu"}...]. 

通过以下代码片段,我们可以创建一个User

let userFromJSONAPI: User = JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]')[0]; 

到目前为止,TypeScript 编译器还没有抱怨,并且它执行得很顺利。这是因为parse方法返回any(例如,Java 对象的 TypeScript 等价物)。当然,我们可以将any转换为User。然而,userFromJSONAPI.hello();将产生以下结果:

json.ts:19
 userFromJSONAPI.hello();
 ^
 TypeError: userFromUJSONAPI.hello is not a function
 at Object.<anonymous> (json.ts:19:18)
 at Module._compile (module.js:541:32)
 at Object.loader (/usr/lib/node_modules/ts-node/src/ts-node.ts:225:14)
 at Module.load (module.js:458:32)
 at tryModuleLoad (module.js:417:12)
 at Function.Module._load (module.js:409:3)
 at Function.Module.runMain (module.js:575:10)
 at Object.<anonymous> (/usr/lib/node_modules/ts-node/src/bin/ts-node.ts:110:12)
 at Module._compile (module.js:541:32)
 at Object.Module._extensions..js (module.js:550:10)

为什么?嗯,赋值的左侧被定义为User,但当我们将其转译为 JavaScript 时,它将被抹去。

在 TypeScript 中进行类型安全的方式如下:

let validUser = JSON.parse('[{"lastName":"Nayrolles","firstName":"Mathieu"}]')
 .map((json: any):User => {
 return new User(json.lastName, json.firstName);
 })[0];

有趣的是,typeof函数也无法帮助你。在这两种情况下,它都会显示Object而不是User,因为User的概念在 JavaScript 中根本不存在。

虽然直接的类型安全方法有效,但它并不是非常可扩展或可重用的。事实上,每当你接收一个 JSON user时,map回调方法都必须在各处重复。最方便的方法是通过Factory模式来做到这一点。Factory用于创建对象,而不将实例化逻辑暴露给客户端。

如果我们要创建一个用户的factory,它会像这样:


 export class POTOFactory{

 /**
 * Builds an User from json response
 * @param  {any}  jsonUser
 * @return {User} 
 */
 static buildUser(jsonUser: any): User {

 return new User(
 jsonUser.firstName,
 jsonUser.lastName
 );
 }

 }

在这里,我们有一个名为buildUser的静态方法,它接收一个 JSON 对象,并从 JSON 对象中获取所有必需的值,以调用一个假想的User构造函数。该方法是静态的,就像工厂的所有方法一样。事实上,在工厂中我们不需要保存任何状态或实例绑定的变量;我们只需要封装用户的创建过程。请注意,你的工厂可能会在你的 POTOs 的其余部分中共享。

备忘录

备忘录模式在 Angular 的上下文中是一个非常有用的模式。在由 Angular 驱动的应用程序中,我们经常过度使用两种方式绑定领域模型,比如UserMovie

让我们考虑两个组件,一个名为Dashboard,另一个名为EditMovie。在Dashboard组件上,你有一个电影列表,显示在我们的类似 IMDb 的应用程序的上下文中。这样的仪表板视图可能如下所示:


 <div *ngFor="let movie of model.movies">
 <p>{{movie.title}}</p>
 <p>{{movie.year}}</p>
 </div>

这个简单的视图拥有一个ngFor指令,它遍历模型中包含的电影列表。然后,对于每部电影,它分别显示包含标题和发行年份的两个p元素。

现在,EditMovie组件访问model.movies数组中的一部电影,并允许用户对其进行编辑:

<form>
 <input id="title" name="title" type="text" [(ngModel)]="movie.title" />
 <input id="year" name="year" type="text" [(ngModel)]="movie.year" />
 </form>

 <a href="/back">Cancel</a>

感谢在这里使用的双向绑定,对电影标题和年份的修改将直接影响仪表板。正如你所看到的,我们这里有一个“取消”按钮。虽然用户可能期望修改是“实时”同步的,但他也期望取消按钮/链接可以取消对电影所做的修改。

这就是备忘录模式发挥作用的地方。这种模式允许在对象上执行撤消操作。它可以以许多种方式实现,但最简单的方式是使用克隆。使用克隆,我们可以在给定时刻存储对象的一个版本,并且在需要时返回到它。让我们按照以下方式增强我们的Movie对象从“原型”模式:

export class Movie implements Prototype {

 private title:string;
 private year:number;
 //...

 public constructor()
 public constructor(title:string = undefined, year:number = undefined)
 {
 if(title == undefined || year == undefined){
 //do the expensive creation
 }else{
 this.title = title;
 this.year = year;
 }
 }

 clone() : Movie {
 return new Movie(this.title, this.year);
 }

 restore(movie:Movie){
 this.title = movie.title;
 this.year = movie.year;
 }
 }

在这个新版本中,我们添加了restore(movie:Movie)方法,它以Movie作为参数,并将本地属性影响到接收到的电影的值。

然后,在实践中,我们的EditMovie组件的构造函数可能如下所示:


 private memento:Movie;

 constructor(private movie:Movie){

 this.memento = movie.clone();
 }

 public cancel(){
 this.movie.restore(this.memento);
 }

有趣的是,你不限于随时间只有一个备忘录,你可以有任意多个。

总结

在本章中,我们看到的模式旨在提高我们的 Angular 应用程序的稳定性。值得注意的是,事实上,大部分目的是为了保护我们的后端基础设施免受过热。事实上,当超时和断路器结合在一起时,它们可以让我们的后端得到休息,同时它们重新上线。此外,备忘录和可重用池旨在保留我们可能已经从后端重新请求的客户端信息,如果我们不存储它们的话。

在下一章中,我们将介绍性能模式和改进应用程序运行速度的最佳实践。

第六章:性能模式

在上一章中,我们调查了稳定性模式。稳定性模式是为了使您的应用程序能够在出现错误时生存下来。期望应用程序在没有任何错误的情况下发货是荒谬的,而试图实现这一点将使您的团队筋疲力尽。相反,我们学会了如何与之共存,并确保我们的应用程序足够弹性,可以经受住错误。在本章中,我们将专注于性能模式和反模式。这些模式定义了架构和实践,对您的应用程序的性能产生积极或消极的影响。

具体来说,我们将学习以下内容:

  • AJAX 过度使用

  • 无限结果集

  • 代理

  • 过滤器和管道

  • 循环

  • 变更检测

  • 不可变性

  • 原型和可重用池

AJAX 过度使用

如果您的应用程序不仅仅是一个一次性的原型或一个华丽的单页应用程序,那么您可能正在处理远程 API。这些远程 API 又在与后端层(例如 PHP、Ruby 或 Golang)和数据库(例如 MySQL、MS SQL 或 Oracle)进行通信。

虽然本书侧重于Angular应用程序,但我们不能忽视它们通常不会单独存在的事实。事实上,任何有意义的应用程序都需要从某个地方拉取和推送数据。

考虑到这一点,让我们想象一下,您的应用程序是某种在线电子商务网站(如亚马逊)的前端。这个虚构的应用程序肯定会有一个个人资料页面,用户可以在其中查看他们的过去和正在进行的命令。

让我们进一步指定我们的应用程序,假设您的 API,端点如下所示:

GET /orders

这将返回已登录用户的订单。

以下是一个 JSON 返回调用的示例:

{
 "orders":[
 {
 "id":"123",
 "date": "10/10/10",
 "amount": 299,
 "currency": "USD"
 },
 {
 "id":"321",
 "date": "11/11/11",
 "amount": 1228,
 "currency": "USD"
 },
 {
 "id":"322",
 "date": "11/12/11",
 "amount": 513,
 "currency": "USD"
 },

 ...

 ]
}

为了清晰和简洁起见,我们将假设我们的用户被神奇地认证,并且他们访问特定 API 端点的授权也是神奇的。

对于每个命令,您可以访问一个GET /command_details API,在其中,您可以检索给定 ID 的命令的详细信息:

{
 "items":[
 {
 "id":123,
 "qty":1,
 "price": 2,
 "tax_rate": 0.19,
 "currency": "USD",
 "shipped_at": "10/10/10",
 "received_at": "11/10/10"
 },
 {
 "id":124,
 "qty":2,
 "price": 3,
 "tax_rate": 0.19,
 "currency": "USD",
 "shipped_at": "10/10/10",
 "received_at": "11/10/10"
 }
 ...
 ]
}

在 Angular 方面,可以是一个简单的扩展面板,使用 Google Material Design 组件套件的扩展面板实现,如下面的屏幕截图所示:

我们还可以添加一个GET /items_details,返回项目的详细信息,但现在让我们暂停在这里。

现在,让我们假设每个 API 调用需要 100 毫秒才能完成,另外需要 10 毫秒来将 JSON 转换为 TypeScript 对象。有经验的开发人员肯定会首先获取给定用户的所有命令,并预先获取每个命令的细节,这样用户在展开给定面板时就不必等待。如果我们的 API 能够处理每秒 100 个请求,这是令人尊敬的,那么我们每秒只能为九个客户提供服务,假设他们每个人都有十个命令。每秒只能为九个客户提供服务听起来并不令人印象深刻...

事实上,同时点击订单简历页面的 10 个客户将耗费我们 1/10 的容量,并引发额外的 100 次调用(10 个客户×10 个命令)。因此,第十个客户在第一秒内将得不到服务。这可能听起来并不那么令人担忧,但是,我们只谈论了 10 个用户。

这种效果被称为 AJAX 过度性能反模式。作为前端开发人员,我可以访问满足我所有需求的 API,并且我使用它们来让我的客户满意。然而,预加载每个命令的每个细节,甚至可能是每个项目的每个细节,都是一个糟糕的主意。你会在后端架构上施加不必要的压力,只是因为你的客户可能想立即访问最后命令的细节。

出于后端基础设施的考虑,当用户真正想要查看命令的详细信息时,只请求命令的细节可能是值得的。

这与无限制的 API 密切相关。再次强调,后端架构不在本书的范围内,但是,如果我们要谈论 Angular 应用程序的性能,我们就必须提到它。如果你能控制你使用的 API,那么确保它们暴露出某种分页,并且你要正确地使用它。

代理模式

在我们对无限制的 API 和 AJAX 过度的调查中,我们在前一篇文章中确定了两者都应该避免,但是解决这个问题的方法是使 API 在 API 没有分页的情况下发生变化。这假设你能访问这些 API 或者能够找到有这种访问权限的人。虽然这是一个合理的假设,但并非在所有情况下都成立。

除了不发出请求(显然),我们还能做什么来保护那些设计不良且失控的 API?嗯,解决这个问题的一个优雅方式是使用代理模式。代理模式用于控制对对象的访问。您肯定知道 Web 代理可以根据用户的凭据控制对网页的访问。在这个示例中,我们不会讨论 Web 代理,而是面向对象的代理。在面向对象的代理中,我们不太关心对象的安全访问,而是关心功能访问。

例如,图像处理软件要列出并显示文件夹中的高分辨率照片对象,但用户并不总是会查看给定文件夹中的所有图像。因此,一些图像将会被无谓地加载。

然而,这与我们的 API 问题有什么关系呢?使用代理模式,我们可以控制我们实际想要执行 API 请求的时间,同时保持我们的命令集合整洁有序。首先,让我们看一下代理 UML:

首先,我们有一个定义doOperation()方法的Subject接口。这个接口由ProxyRealSubject类实现。Proxy类包含对realSubject类的引用,该引用将在适当的时候填充。对于我们的目的,它可能是什么样子呢?

首先,我们有一个名为OnlineCommand的简单接口:

import { Item } from "./item";
export interface OnlineCommand {
fetchItems() : Item[]
}

在这个接口中,只定义了一个方法:fetchItems()。这个方法返回命令中包含的项目。

然后,我们的组件有一个代表我们客户命令的命令数组:

import { Component } from '@angular/core';
import { OnlineCommand } from './online-command';

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

在这个简短的组件中,我们只有我们客户的命令,以及使 Angular 组件成为组件的内容。

对于 HTML 部分,我们只需遍历命令集合,并在点击时调用fetchItems函数:

<ul>
 <li *ngFor="let item of commands; let i = index" (click)="item.fetchItems()">
 {{i}} {{item}}
 </li>
</ul>

然后,我们有一个实现OnlineCommand接口的RealCommand类:

import { OnlineCommand } from "./online-command";
import { Item } from "./item";

//RealCommand is a real command that has the right to do
//API calls
export class RealCommand implements OnlineCommand{

 public fetchItems() : Item[] {
 //This would come from an API call
 return [new Item(), new Item()];
 }
}

谜题的最后一部分,尽管是最重要的一部分,是在线命令的代理版本:

import { OnlineCommand } from "./online-command";
import { RealCommand } from "./real-command";
import { Item } from "./item";

//A Proxified Command
export class ProxyfiedCommand implements OnlineCommand{

 //Reference to the real deal
 private real:RealCommand;

 //Constructor
 constructor() {
 this.real = new RealCommand();
 }

 //The Proxified fetchItems.
 //It only exists as a placeholder and if we need it
 //we' ll the real command.
 public fetchItems() : Item[] {
 console.log("About the call the API");
 let items = this.real.fetchItems();
 console.log("Called it");
 return items;
 }
}

如前所述,在线命令的代理版本包含对实际命令的引用,实际上就是我们的实际命令。关键在于,昂贵的操作是我们只在真正需要时才想要访问的功能。在 HTML 方面,一切都优雅地隐藏在封装后。在 TypeScript 方面,我们只在用户请求详细信息时才执行调用,而不是之前。

循环计数

任何类型的网络应用程序通常都充满了循环。它可能是 Amazon.com 上的产品循环,银行网站上的交易循环,电话运营商网站上的电话循环等等。最糟糕的是,页面上可能有很多循环。当这些循环遍历静态集合时,在生成页面时肯定需要花费时间,除非你无能为力。你仍然可以应用我们在本章前面看到的模式,来减少集合深度,并节省每个项目的大量调用。然而,真正的性能问题出现在这些循环与异步发展的集合绑定时。确实,Angular 和所有允许这种绑定的框架,每次集合发生变化时都会重新绘制集合。它现在可以显示集合中哪些项目已被修改,以及如何在 DOM 中选择它们。因此,如果集合中有 1,000 个元素,如果其中一个元素被修改,那么整个集合都必须重新绘制。实际上,这对用户和开发人员来说是相当透明的。然而,根据 JavaScript 集合的值选择和更新 1,000 个 DOM 元素在计算上是昂贵的。

让我们模拟一组书籍:

export class Book {
 public constructor(public id:number, public title:string){

 this.id = id;
 this.title = title;
 }
}

Book 类很简单。它只包含两个属性:idtitle。在默认的应用组件中,我们添加了一系列书籍和一些方法。在构造函数中,我们填充了书籍。我们还有一个刷新方法,它会随机选择一本书并更新其标题。最后,makeid 方法生成一个随机字符串 ID,我们可以用它来填充书名:

import { Component } from '@angular/core';
import { Book } from './books'
@Component({
 selector: 'app-root',
 templateUrl: './app.component.html',
 styleUrls: ['./app.component.css']
})
export class AppComponent {
 title = 'app';
 books: Book[] = [];
 constructor(){
 for (let i = 0; i < 10; i++) {

 this.books.push(new Book(i, this.makeid()))
 }
 }
 refresh(){
 let id =Math.floor(Math.random() * this.books.length)
 this.books[id].title = this.makeid();
 console.log(id, "refreshed")
 }
 private makeid(): string {
 var text = "";
 var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
 for (var i = 0; i < 15; i++)
 text += possible.charAt(Math.floor(Math.random() * possible.length));
 return text;
 }
}

我们实验的最后一部分是下面的 HTML 模板:

<ul>
 <li *ngFor="let book of books; let i = index">{{book.id}} - {{book.title}}</li>
</ul>
<button (click)="refresh()">Refresh</button>

我们的书籍类、应用组件和 html 模板放在一起,创建了以下页面:

我们有我们的 10 本书和我们的刷新按钮,它链接到refresh函数。按下时,将随机选择并更新一本书。现在,默认情况下,整个列表都必须重新计算。当然,这里的刷新机制是手动的,但在更现实的情况下,刷新将是异步的,例如来自远程 API 的更新。为了帮助 Angular 找出哪个元素已更改并需要刷新,我们可以使用ngFortrackBy选项,如下所示:

<ul>
 <li *ngFor="let book of books; trackBy: trackByFn; let i = index">{{book.id}} - {{book.title}}</li>
</ul>
<button (click)="refresh()">Refresh</button>
The trackBy: trackByFn;we added references a function of our component named trackByFn
  trackByFn(index, item) {
returnindex; // or item.id
 }

这个函数帮助 Angular 知道如何跟踪我们书集合中的元素。现在,当按下刷新按钮时,只有修改过的元素将被重新计算和重绘。换句话说,只有一个 DOM 元素将被操作。再次强调,对于 10 个元素,差异是不明显的。然而,对于几十个元素,根据硬件的不同,页面可能会变得有点迟缓。我们可以通过使用 Chrome 开发工具来确认trackByFn函数的操作方式。在检查 DOM 时,如果单击刷新按钮,那么只有一个<li>标记会亮起。DOM 元素在修改时会亮起。在下面的截图中,您可以看到只有索引 6 的元素被重新计算,而不是列表中的所有元素:

变更检测和不可变状态

我们在上一篇文章中提到的问题是任何映射某种视图和模型的框架固有的。这不是 Angular 的特殊性。也就是说,这个问题虽然在循环中被加剧,但也存在于其他地方。准确地说,它存在于我们的模型和视图之间的每一个绑定的地方。换句话说,每当我们的 HTML 模型中有{{ myValue }}时,这对我们的应用程序来说都是性能上的打击。

那么,解决方案是什么呢?完全停止使用绑定吗?嗯,这并不是非常实际的,因为这样我们就放弃了 JavaScript 最初的吸引力。不,真正的解决方案是使我们的对象不可变。然而,要理解为什么需要这样做,我们需要看一下 Angular 是如何实现变更检测的。变更检测就像它的名字所暗示的那样,是 Angular 执行的用于检测是否有任何变化的过程。如果有变化,对象将被重新处理并重新绘制到 DOM 中。Angular 默认的做法是将一个watcher附加到我们的模型上。观察者会观察模型,并为视图中绑定的每个值保留一些信息。它会保留绑定对象的引用,对象的每个属性的旧值和新值。当对象的状态发生变化时,旧值和新值就会被使用。在上一节的书籍示例中,我们的模型的观察者会为每本书保留其引用、旧 ID 和新 ID,以及旧标题和新标题。在每个检测周期,Angular 都会检查对象的旧属性和新属性是否匹配,如下所示:

book == book ? No; repaintBook.title == Book.title? No; repaintBook.id == Book.it ? No; repaint

通常情况下,单独进行这些操作并不会有太大的影响。但是,当页面中有数百个对象,每个对象都有几十个映射属性时,性能就会受到影响。正如我之前所说,解决这个问题的方法就是不可变性。对象的不可变性意味着我们的对象不能改变它们的属性。如果我们想要改变视图中显示的值,那么我们必须整体改变对象。如果你遵循不可变性的原则,那么之前的控制流将如下所示:

book == book ? No; repaint

这样可以节省我们在应用程序中到处使用的大量条件语句,但这也意味着我们在模型中绑定变量的修改,比如 book.title = "qwerty",不会在视图中反映出来。为了使这种修改可见,我们需要用一个新的书籍对象来更新视图。让我们用这个新概念做一些实验。这是我们的 HTML 模板:

{{ book.id }} - {{ book.title }}<br/><button (click)="changeMe()">CHANGE</button>

这是我们的组件:

import { Component } from '@angular/core';
import { Book } from './book'
@Component({
 selector: 'app-root',
 templateUrl: './app.component.html',
 styleUrls: ['./app.component.css']
})
export class AppComponent {
 title = 'app';
 book: Book;
 constructor(){
 this.book = new Book(1, "Some Title");
 }
 changeMe(){
 this.book.title = "Some Other Title";
 }
}

书籍类保持在上一节中所呈现的状态。现在,在提供此应用程序时,您将会看到以下内容:

按下“CHANGE”按钮将会改变显示的标题,如下所示:

如果我们告诉 Angular,我们更希望只检查引用是否发生了变化,而不是通过使用ChangeDetection.OnPush方法检查每个属性的值,那么按钮将不再对视图产生任何影响。实际上,模型的值已经发生了变化,但是变化不会被变化检测算法捕捉到,因为书的引用仍然是相同的,正如我们之前解释的那样。因此,如果你确实想要将你的变化传播到视图中,你必须改变引用。考虑到所有这些,我们的组件看起来是这样的:

import { Component, Input } from '@angular/core';
import { Book } from './book'
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
 selector: 'app-root',
 templateUrl: './app.component.html',
 styleUrls: ['./app.component.css'],
 changeDetection: ChangeDetectionStrategy.OnPush
})
export class AppComponent {
 title = 'app';
 @Input() book: Book;
 constructor(){
 this.book = new Book(1, "Some Title");
 }
 changeMe(){
 this.book = new Book(this.book.id, "Some Other Title");
 }
}

我们向我们的组件添加了changeDetection: ChangeDetectionStrategy.OnPush,并修改了changeMe方法,使其创建一个新的书,而不是更新旧的书。当然,创建一个新对象比更新现有对象更昂贵。然而,这种技术为 Angular 应用程序带来了更好的性能,因为有无数个周期,什么都没有发生,但是每个对象的属性仍然与它们的旧值进行比较,比实际发生变化的周期要多得多。

通过这种技术,我们显著提高了应用程序的性能,但代价是必须考虑何时希望将对象的更新传播到视图中。请注意,这也适用于过滤器和管道。如果你的应用程序只有一个从模型到视图的绑定值,你可能会认为这并不重要,你可以一路使用可变的方式。如果你的应用程序确实只有一个绑定值,并且这个值从未使用{{ myValue | myPipe }}符号进行管道或过滤,那么你是对的。

事实上,每个管道都是由我们的应用程序异步处理的。实际上,如果你调用了 100 次myPipe,你实际上创建了相当于 100 个观察者来观察myValue的值,并将你的管道应用到它。这是有道理的,因为你的管道无法知道它将要处理什么,并且无法预料到其计算结果对于这 100 次调用来说是相同的。因此,它会根据需要观察和执行多次。如果你发现自己的模板中充满了返回相同值的管道调用,最好是创建一个带有该值作为输入的虚拟组件,或者完全将转换后的值存储在你的模型中。

原型和可重用池

面向对象的开发人员寻找减少创建对象成本的方法-特别是当这些对象因为需要数据库拉取或复杂的数学运算而昂贵时。减少特定对象创建成本的另一个原因是当你创建大量对象时。如今,后端开发人员倾向于忽视优化的这一方面,因为按需的 CPU/内存已经变得便宜且易于调整。每月多花几美元就可以在后端拥有额外的核心或 256 MB 的 RAM。

这对于桌面应用程序开发人员来说曾经也是一个大问题。在客户端桌面上,没有办法按需添加 CPU/RAM,但是相当节奏的四核处理器和消费级 PC 上可怕的大量 RAM 使这个问题变得不那么棘手。如今,似乎只有游戏和密集的分析解决方案开发人员才关心这个问题。那么,毕竟为什么你应该关心对象的创建时间呢?嗯,你正在构建的东西很可能会被旧设备访问(我仍然在厨房或沙发上使用 iPad 1 进行休闲浏览)。虽然桌面应用程序开发人员可以发布最低和推荐配置,并通过拒绝安装它们来强制执行它们,但是作为 Web 开发人员,我们没有这种奢侈。现在,如果你的网站表现不佳,用户不会质疑他们的设备,而是质疑你的技能...最终,即使在一台性能强大的机器上,他们也不会使用你的产品。让我们看看如何使用“原型”设计模式。首先,我们需要一个“原型”接口,如下所示:

export interface Prototype{
 clone():Prototype;
}

“原型”接口只定义了返回符合“原型”标准的对象的“克隆”方法。你已经猜到了,创建对象的优化方式是在需要时克隆它们!所以,假设你有一个名为“电影”的对象,由于某些原因,需要花费时间来构建:

export class Movie implements Prototype {

 private title:string;
 private year:number;
 //...

 public constructor()
 public constructor(title:string = undefined, year:number = undefined)
 {
 if(title == undefined || year == undefined){
 //do the expensive creation
 }else{
 this.title = title;
 this.year = year;
 }
 }

 clone() : Movie {
 return new Movie(this.title, this.year);
 }
 }

 expansiveMovie:Movie = new Movie();
 cheapMovie = expansiveMovie.clone();

正如你所看到的,TypeScript 中覆盖函数的方式与大多数语言不同。在这里,构造函数的两个签名位于彼此之上,并共享相同的实现。这就是Prototype模式的全部内容。另一个经常与Prototype模式一起使用的模式是对象池模式。在处理昂贵的创建对象时,克隆它们确实会产生巨大的差异。更大的差异是根本不做任何事情:不创建,不克隆。为了实现这一点,我们可以使用池模式。在这种模式中,我们有一组对象池,可以被任何客户端或组件共享,特别是在 Angular 2 应用程序的情况下。池的实现很简单:

export class MoviePool{

 private static movies:[{movie:Movie, used:boolean}] = [];
 private static nbMaxMovie = 10;
 private static instance:MoviePool;

 private static constructor(){}

 public static getMovie(){

 //first hard create
 if(MoviePool.movies.length == 0){

 MoviePool.movies.push({movie:new User(), used:true});
 return MoviePool.movies[0].movie;

 }else{

 for(var reusableMovie:{movie:Movie, used:boolean} of MoviePool.movies){
 if(!reusableMovie.used){
 reusableMovie.used = true;
 return reusableMovie.movie;
 }
 }
 }

 //subsequent clone create
 if(MoviePool.movie.length < MoviePool.nbMaxMovie){

 MoviePool.movies.push({movie:MoviePool.movies[MoviePool.movies.length - 1].clone(), used:true});
 return MoviePool.movies[MoviePool.movies.length - 1].movie;
 }

 throw new Error('Out of movies');
 }

 public static releaseMovie(movie:Movie){
 for(var reusableMovie:{movie:Movie, used:boolean} of MoviePool.movies){
 if(reusableMovie.movie === movie){
 reusableMovie.used = false;
 }
 return;
 }
 }
 }

首先,这个池也是一个单例。实际上,如果任何人都可以随意创建池,那么这种昂贵的可重用设计就没有多大意义。因此,我们有静态属性instance:MoviePool和私有构造函数,以确保只能创建一个池。然后,我们有以下属性:private static movies:[{movie:Movie, used:boolean}] = [];

movies属性存储了一系列电影和一个布尔值,用于确定当前是否有人在使用任何给定的电影。由于假设电影对象在内存中创建或维护是很耗费资源的,因此有必要对我们的对象池中可以拥有多少这样的对象进行硬性限制。这个限制由私有静态属性nbMaxMovie = 10;来管理。要获取电影,组件必须调用getMovie():Movie方法。这个方法在第一部电影上进行硬性创建,然后利用Prototype模式来创建任何后续的电影。每当从池中取出一部电影时,getMovie方法会将used布尔值更改为 true。需要注意的是,在池满了并且没有空闲电影可供分配的情况下,会抛出错误。

最后,组件需要一种方法来将他们的电影归还给池,以便其他人可以使用它们。这是通过releaseMovie方法实现的。这个方法接收一个假设已经取出的电影,并遍历池中的电影,根据布尔值将它们设置为 false。因此,电影对其他组件变得可用。

摘要

在本章中,我们学习了如何通过限制我们的 AJAX 调用和代理设计模式来避免在Angular应用程序中遇到主要性能问题。我们还学习了如何在性能方面控制循环的不良影响。然后,我们深入研究了 Angular 的变更检测过程,以使其与不可变对象很好地配合,以应对对象数量过高的情况。最后,我们还学习了关于原型和可重用池模式,这可以帮助减少应用程序所需资源的占用空间。

在下一章中,我们将学习关于我们 Angular 应用程序的操作模式。操作模式是帮助监视和诊断实时应用程序的模式。

第七章:操作模式

在这最后一章中,我们将专注于改进企业规模的 Angular 应用程序的操作模式。虽然前几章侧重于稳定性、性能和导航,但如果我们无法顺利操作我们的应用程序,这一切可能都会崩溃。在操作应用程序时,有几个值得考虑的理想情况,例如:

  • 透明度

  • 日志记录

  • 诊断

现在,后端应用的操作策略和模式可以更容易实现。虽然后端应用可以在不同类型的容器、虚拟机甚至裸机中运行,但与前端应用相比,操作它们更容易。事实上,您可以注册正在进行的程序、CPU 使用率、内存使用率、磁盘使用率等,这是因为您直接或间接(通过您的服务提供商)可以访问这些服务器。对于前端应用程序,这些统计数据仍然是可取的。假设我们有一个用 Angular 编写的前端应用程序,在测试期间在各个方面表现良好,但在实际运行时失败。为什么会发生这种情况呢?例如,如果您开发的 Angular 应用程序正在使用本地部署的 API,您必须考虑到您的用户遭受网络延迟。这些延迟可能导致您的应用程序表现异常。

通用健康指标

我们可以采取的第一步行动是监视一些通用健康指标,以实现我们的 Angular 应用程序的可观察性。我们将要处理的通用健康指标分为几类。首先,我们有两个来自 Angular 分析器的指标:

  • msPerTick:每个滴答所需的平均ms。滴答可以被视为刷新操作或重绘。换句话说,重新绘制所有变量所需的毫秒数。

  • numTicks:经过的滴答数。

我们收集的其他类型的指标与客户端工作站相关:

  • core:逻辑核心数

  • appVersion:所使用的浏览器

我们还可以提取有关连接的信息:

  • cnxDownlink:下行连接速度

  • cnxEffectiveType:连接类型

最后,最后一组指标涉及 JavaScript 堆本身的大小:

  • jsHeapSizeLimit:堆的最大大小。

  • totalJSHeapSize:这是 JavaScript 堆的当前大小,包括未被任何 JavaScript 对象占用的空闲空间。这意味着usedJsHeapSize不能大于totalJsHeapSize

  • usedJSHeapSize:JavaScript 对象使用的内存总量,包括 V8 内部对象。

为了收集这些指标,我们将创建一个专门的 Angular 服务。该服务将负责访问正确的变量,将它们组装成一个完美的对象,并通过 API post 将它们发送回我们的基础设施。

第一组指标可以通过 Angular 分析器访问。分析器注入了一个名为ng的变量,可以通过浏览器命令行访问。大多数用于监视 Angular 应用程序性能的工具都是在开发过程中使用的。为了访问这些工具,我们可以使用window变量并像这样抓取它:

window["ng"].profiler

然后,我们可以访问timeChangeDetection方法,该方法为我们提供了msPerTicknumTicks指标。

在一个方法中,这可以转化为以下内容:

var timeChangeDetection = window["ng"].profiler.timeChangeDetection()

在任何 JavaScript 应用程序中都可以找到的另一个有用的变量是 navigator。navigator 变量暴露了有关用户使用的浏览器的信息。window.navigator.hardwareConcurrencywindow.navigator.appVersion分别给出了逻辑核心数和应用程序版本。

虽然前面提到的变量可以在任何能够运行Angular应用程序的浏览器上访问,但在撰写本文时,其余的指标只在 Chrome 上可用。如果我们的用户使用的不是 Chrome,那么我们将无法访问这些指标。然而,Chrome 仍然是最常用的浏览器,目前没有迹象表明这种情况会很快改变。因此,对于我们的大部分用户群,我们将能够检索到这些指标。

下一批指标与我们应用程序的内存性能有关:jsHeapSizeLimittotalJSHeapSizeusedJSHeapSize。在 Chrome 上,它们是window.performance["memory"]对象的属性。然而,在其他浏览器上,我们需要提供一个 polyfill:

var memory:any = window.performance["memory"] ? window.performance["memory"] : {
"jsHeapSizeLimit":0,
"totalJSHeapSize":0,
"usedJSHeapSize":0,
}

在前面的代码中,我们检查了memory对象是否存在。如果对象存在,我们将其赋值给本地的memory变量。如果对象不存在,我们提供一个简单的 polyfill,其中指标的值为 0。

最后一组指标与用户连接有关。与内存对象一样,它只能在 Chrome 上访问。我们将使用与之前相同的技术:

var connection:any = window.navigator["connection"] ? window.navigator["connection"] : {
"effectiveType": "n/a",
"cnxDownlink": 0,
}

这是Monitor服务的实现,其中在metric方法中收集指标。在方法结束时,我们将指标发送到 API 端点:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable()
export class MonitorService {
constructor(private http:HttpClient) { }
public metrics(){
var timeChangeDetection = window["ng"].profiler.timeChangeDetection()
var memory:any = window.performance["memory"] ? window.performance["memory"] : {
"jsHeapSizeLimit":0,
"totalJSHeapSize":0,
"usedJSHeapSize":0,
}
var connection:any = window.navigator["connection"] ? window.navigator["connection"] : {
"effectiveType": "n/a",
"cnxDownlink": 0,
}
var perf = {
"msPerTick": timeChangeDetection.msPerTick,
"numTicks": timeChangeDetection.numTicks,
"core": window.navigator.hardwareConcurrency,
"appVersion": window.navigator.appVersion,
"jsHeapSizeLimit": memory.jsHeapSizeLimit,
"totalJSHeapSize": memory.totalJSHeapSize,
"usedJSHeapSize": memory.usedJSHeapSize,
"cnxEffectiveType": connection.effectiveType,
"cnxDownlink": connection.downlink,
}
this.http.post("https://api.yourwebsite/metrics/", perf)
return perf;
}
}

这是perf对象中的变量的示例:

  • msPerTick: 0.0022148688576149405

  • numTicks: 225747

  • core: 12

  • appVersion: 5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537....L, like Gecko) Chrome/66.0.3359.139 Safari/537.36" jsHeapSizeLimit: 2190000000, ...}appVersion: "5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.139 Safari/537.36

  • cnxDownlink: 10

  • cnxEffectiveType: 4g

  • core: 12

  • jsHeapSizeLimit: 2190000000

  • msPerTick: 0.0022148688576149405

  • numTicks: 225747

  • totalJSHeapSize: 64000000

  • usedJSHeapSize: 56800000

在服务器端,这些指标可以被馈送到 ELK 堆栈或您选择的类似堆栈中,并增强您的应用程序的可观察性。

特定指标

除了我们之前查看的指标,我们可以在我们的服务中添加一个方法,以便我们能够发送特定的指标,如下所示:

public metric(label:string, value:any){
this.http.post("https://api.yourwebsite/metric/", {
label:label,
value:value,
})
}

错误报告

增强应用程序的透明度和可观察性的另一种方法是报告在客户端发生的每一个 JavaScript 错误。在 JavaScript 中,这样做相对简单;你只需要将一个回调函数附加到window.onerror事件上,如下所示:

window.onerror = function myErrorHandler(errorMsg, url, lineNumber) {
alert("Error occured: " + errorMsg);
}

这将简单地在每次发生错误时创建一个警报。然而,使用 Angular 时,你不能使用相同的简单技术——不是因为它很复杂,而是因为它需要创建ne类。这个新类将实现 Angular 错误处理程序接口,如下所示:

class MyErrorHandler implements ErrorHandler {
handleError(error) {
// do something with the exception
}
}

我们将继续改进monitor服务,以便它也可以成为我们的ErrorHandler

import { Injectable, ErrorHandler } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable()
export class MonitorService implements ErrorHandler{
constructor(private http:HttpClient) { }
handleError(error) {
this.http.post("https://api.yourwebsite/errors/", error)
}
...
}

然后,这些错误可以被馈送到您的ELK堆栈,甚至直接插入到您的 Slack 频道中,就像我们在Toolwatch.io中所做的那样:

为了使用这个错误处理程序来替代 Angular 的默认错误处理程序,你需要在声明模块时提供它:

providers : [{ provide : ErrorHandler, useClass : MonitorService }]

使用 AOP 的方法指标

到目前为止,我们只能在特定时刻监控我们的系统:调用度量、度量和发生的错误。在我们的应用程序中监控所有内容的一种可靠方法是在Angular应用程序中使用AOP面向方面的编程)。AOP 并不是一种新技术,但在 JavaScript 生态系统中并不广泛使用。AOP 包括定义方面。方面是与我们应用程序的指定部分相关联的子程序。方面在编译时编织到方法中,并在编织到的方法之前和/或之后执行。在基于 Angular 的应用程序中,该方法将在从 TypeScript 到 JavaScript 的转译时编织。在纯 JavaScript 中将方面编织到方法是很简单的。考虑以下示例:

function myFunc(){
Console.log("hello");
}
function myBeforeAspect(){
Console.log("before...")
}
function myAfterAspect(){
Console.log("after");
}
var oldFunc = myFunc;
myFunc = function(){
myBeforeAspect();
oldFunc();
myAfterAspect();
}

在这个片段中,我们声明了三个函数:myBeforeAspectmyFuncmyAfterAspect。在它们各自的声明之后,我们创建了oldFunc变量,并将其赋值为myFunc。然后,我们用新的实现替换了myFunc的实现。在这个新的实现中,除了oldFunc之外,我们还调用了myBeforeAspectmyAfterAspect。这是在 JavaScript 中实现方面的一种简单方法。我们已经添加了行为到myFunc的调用中,而不会破坏我们的内部 API。实际上,如果在程序的另一个部分中调用了myFunc函数,那么我们的程序仍然是有效的,并且会执行得就像没有改变一样。此外,我们还可以继续向增强函数添加其他方面。

在 Angular-flavored TypeScript 中也可以实现这一点:

constructor(){
this.click = function(){
this.before();
this.click();
this.after();
}
}
after(){
console.log("after")
}
before(){
console.log("before");
}
click(){
console.log("hello")
}

在这里,我们的构造函数将两个方面编织到click方法中。click方法将执行其行为,以及方面的行为。在 HTML 中,AOP 的任何内容都不会显现出来:

<button (click)="click()">click</button>

现在,我们可以手动将这种技术应用到所有的方法上,并调用我们监控服务的metric方法。幸运的是,存在各种库可以为我们处理这个问题。到目前为止,最好的一个叫做aspect.jsgithub.com/mgechev/aspect.js)。

aspect.js利用了 ECMAScript 2016 的装饰器模式。

我们可以使用npm install aspect.js -save来安装它,然后我们可以定义一个类似这样的方面:

class LoggerAspect {
@afterMethod({
classNamePattern: /^someClass/,
methodNamePattern: /^(some|other)/
})
invokeAfterMethod(meta: Metadata) {
console.log(`Inside of the logger. Called ${meta.className}.${meta.method.name} with args: ${meta.method.args.join(', ')}.`);
@beforeMethod({
classNamePattern: /^someClass/,
methodNamePattern: /^(get|set)/
})
invokeBeforeMethod(meta: Metadata) {
console.log(`Inside of the logger. Called ${meta.className}.${meta.method.name} with args: ${meta.method.args.join(', ')}.`);
}
}

在这方面,我们有几个部分。首先,我们有一个@afterMethod方法,它接受一个classNamePattern和一个methodNamePattern。这些模式是正则表达式,用于定义编织到特定方面的哪些类和方法。然后,在invokeAfterMethod中,我们定义要应用的行为。在这个方法中,我们只是记录调用的方法以及调用该方法的参数值。

我们使用@beforeMethod重复这个操作。

如果我们保持这样的情况,日志将在客户端打印出来。如果我们想获得这些日志,我们将不得不再次修改我们的Monitor服务。

我们将添加一个名为log的静态方法和一个静态的HTTP客户端。这些是静态的,因为我们可能会编织不接收Monitor服务注入的组件。这样,所有服务,无论是否注入,都将能够发送它们的日志:

static httpStatic:HttpClient
constructor(private http:HttpClient) {
MonitorService.httpStatic = http;
}
static sendLog(log:string){
MonitorService.httpStatic.post("https://api.yourwebsite/logs/", log)
}

Monitor服务的构造函数中,我们填充了静态客户端。这将在我们的应用程序启动并且服务是单例时完成。因此,我们只做一次。

这是Monitor服务的完整实现:

import { Injectable, ErrorHandler } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable()
export class MonitorService implements ErrorHandler{
static httpStatic:HttpClient
constructor(private http:HttpClient) {
MonitorService.httpStatic = http;
}
public static log(log:string){
MonitorService.httpStatic.post("https://api.yourwebsite/logs/", log)
}
handleError(error) {
this.http.post("https://api.yourwebsite/metrics/", error)
}
public metric(label:string, value:any){
this.http.post("https://api.yourwebsite/metric/", {
label:label,
value:value,
})
}
public metrics(){
var timeChangeDetection = window["ng"].profiler.timeChangeDetection()
var memory:any = window.performance["memory"] ? window.performance["memory"] : {
"jsHeapSizeLimit":0,
"totalJSHeapSize":0,
"usedJSHeapSize":0,
}
var connection:any = window.navigator["connection"] ? window.navigator["connection"] : {
"effectiveType": "n/a",
"cnxDownlink": 0,
}
this.metric("msPerTick", timeChangeDetection.msPerTick);
this.metric("numTicks", timeChangeDetection.numTicks);
this.metric("core", window.navigator.hardwareConcurrency);
this.metric("appVersion", window.navigator.appVersion);
this.metric("jsHeapSizeLimit", memory.jsHeapSizeLimit);
this.metric("totalJSHeapSize", memory.totalJSHeapSize);
this.metric("usedJSHeapSize", memory.usedJSHeapSize);
this.metric("cnxEffectiveType", connection.effectiveType);
this.metric("cnxDownlink", connection.downlink);
}
}

该方面可以修改为调用新的静态方法:

class LoggerAspect {
@afterMethod({
classNamePattern: /^SomeClass/,
methodNamePattern: /^(some|other)/
})
invokeBeforeMethod(meta: Metadata) {
MonitorService.log(`Called ${meta.className}.${meta.method.name} with args: ${meta.method.args.join(', ')}.`);
}
@beforeMethod({
classNamePattern: /^SomeClass/,
methodNamePattern: /^(get|set)/
})
invokeBeforeMethod(meta: Metadata) {
MonitorService.log(`Inside of the logger. Called ${meta.className}.${meta.method.name} with args: ${meta.method.args.join(', ')}.`);
}
}

除了classNamemethodNameargs之外,我们可以使用@Wove语法填充每个组件的元变量,如下面的代码所示:

@Wove({ bar: 42, foo : "bar" })
class SomeClass { }

自定义元变量的一个有趣用例是使用它们来存储每个方法的执行时间,因为元变量值从 before 方法传递到 after 方法。

因此,我们可以在我们的@Wove注释中有一个名为startTime的变量,并像这样使用它:

@Wove({ startTime: 0 })
class SomeClass { }
class ExecutionTimeAspect {
@afterMethod({
classNamePattern: /^SomeClass/,
methodNamePattern: /^(some|other)/
})
invokeBeforeMethod(meta: Metadata) {
meta.startTime = Date.now();
}
@beforeMethod({
classNamePattern: /^SomeClass/,
methodNamePattern: /^(get|set)/
})
invokeBeforeMethod(meta: Metadata) {
MonitorService.metric(`${meta.className}.${meta.method.name`,
Date.now() - meta.startTime;
}
}

现在,我们有另一个方面将被编织到我们的类中,它将测量其执行时间并使用MonitorServicemetric方法报告它。

总结

操作 Angular 应用程序可能很复杂,因为在运行时观察我们的应用程序相对困难。虽然观察后端应用程序很简单,因为我们可以访问运行环境,但我们习惯使用的技术不能直接应用。在本章中,我们看到了如何通过使用收集性能指标、自定义指标和日志,并通过面向方面的编程自动应用所有这些来使 Angular 应用程序监视自身。

虽然本章介绍的技术可以提供对应用程序的 100%可观察性,但它们也有一些缺点。实际上,如果您的应用程序很受欢迎,您将不仅需要为您的页面提供服务并回答您的 API 调用,还需要接受日志和指标,这将过度消耗您的后端基础设施。另一个缺点是,恶意的人可能会通过您的 API 向您提供错误的指标,并为您提供关于当前正在发生的实时应用程序情况的偏见图片。

这些缺点可以通过仅监视客户端的子集来解决。例如,您可以根据随机生成的数字仅为 5%的客户端激活日志记录和跟踪。此外,您可以通过为每个请求提供 CSRF 令牌来验证希望向您发送指标的用户的真实性。

posted @ 2024-05-18 12:03  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报