Angular-应用架构指南-全-
Angular 应用架构指南(全)
原文:
zh.annas-archive.org/md5/e32b0f23109b0daf0acc38b5411ab61e译者:飞龙
前言
NgRx 是 Redux 流行模式的实现,旨在与 Angular 一起使用。完全有可能创建一个不使用 NgRx 的 Angular 应用程序。你甚至可能会在这方面非常成功。然而,在某些情况下,Redux 确实可以提供帮助;你可以通过使用 NgRx 来获得这种帮助。
那么,什么是 Redux,我们何时需要它呢?Redux 是关于给你的应用程序增加可预测性。可预测性意味着知道谁在应用程序中对状态做了什么。
单一的真实来源是一个 Redux 实现的概念,因为它促进了将所有数据添加到一个存储中。在任何给定时刻,你都将能够了解你的应用程序处于什么状态。如果你想在游戏中创建一个保存点并稍后从该保存点恢复游戏,那么保存状态并返回到它是非常好的(也称为重新水合)。
这不仅关乎有一个单一的真实来源;还关乎知道谁被允许更改存储的内容或状态。你经常面临的一个挑战是,随着应用程序的增长,你需要添加许多视图和控制台,你逐渐失去了对哪些代码影响应用程序中哪些状态的全面了解。Redux 通过确保视图不能直接更改状态,而必须发出代表你希望状态如何更改的意图的操作来帮助你解决这个问题。
另一个可能发生的情况是,大量的用户交互会触发对状态的许多更改。其中一些操作应导致立即更改,而另一些则导致最终会改变应用程序状态的异步操作。在这个阶段,重要的是确保所有这些更改都按正确的顺序发生。Redux 通过排队所有操作并确保我们的应用程序以可预测的方式更改状态来帮助我们做到这一点。
Redux 的一个非常重要的方面是,当它更改状态时,它不会对其进行修改。它用旧状态的副本替换状态,但应用了最新的操作。如果我们再次使用我们的游戏类比,想象一下你有一个游戏,你想要在你的背包中添加一瓶药水。当你在 Redux 中这样做时,我们替换了主要角色;我们用背包里有药水的主要角色来替换它。我们这样做的事实使得我们很容易记住每个先前的状态,并在需要时返回到较早的状态,这被称为时间旅行调试。为了使我们能够用新状态替换旧状态,我们正在使用一种称为纯函数的东西。纯函数确保我们只创建数据的副本,而不是对其进行修改。
了解应用在特定时刻包含的状态有很多好处。然而,并非应用中的所有状态都需要 Redux。这取决于个人偏好。有些人喜欢将所有状态放入存储中,有些人喜欢将部分状态放入存储中,而有些状态如果只作为特定组件的本地状态存在也是可以的。可以这样想,如果你要恢复应用,哪些状态是可以丢失的;答案可能是下拉选择或其他,如果有的话。将所有内容放入存储中可以确保你不会因为错误地调用额外的 Ajax 请求,如果存储中已经持有数据,因此这也是一种帮助你缓存的方式。
这本书面向的对象
这本书旨在为编写过一个或两个应用的 Angular 开发者提供帮助,他们正在寻找一种更结构化的方式来处理数据管理。这意味着你理想情况下应该对 JavaScript、HTML 和 CSS 有相当的了解,知道如何使用 angular-cli 搭建 Angular 应用,并且知道如何使用 Angular 的 HttpClient 服务进行 Ajax 请求。
这本书涵盖的内容
第一章,简单应用的快速回顾数据服务,介绍了如何使用服务和 Angular 依赖注入如何提供帮助。它还提到了 MVC 模式,并讨论了内聚性和耦合性。
第二章,1.21 吉瓦——Flux 模式解释,讲解了 Flux 模式是什么以及它包含哪些概念。它展示了如何使用存储、分发器和几个视图来实现 Flux 模式。
第三章,异步编程,探讨了异步的含义,并讨论了回调、承诺、async/await 以及异步库如何帮助我们创建有序的异步代码。
第四章,函数式响应式编程,比较了声明式和命令式编程,并查看声明式编程的一个子集——函数式编程。我们深入探讨了函数式编程的某些特性,如高阶函数、不可变性和递归。此外,我们探讨了如何使代码响应式以及响应式的含义。
第五章,RxJS 基础,介绍了 RxJS 库。此外,它还提出了诸如可观察对象、生产者和观察者等概念。它进一步讨论了可观察对象是如何将所有异步概念统一为一个异步概念。我们还涉及到操作符及其含义。最后,我们尝试构建自己的 RxJS 微实现,以进一步理解底层的工作原理。
第六章,操作流及其值,着重于教育读者了解操作符,这是 RxJS 获得其力量的关键。读者应该带着更多关于如何操作数据和 Observables 的知识离开这一章。
第七章,RxJS 高级,深入探讨了 RxJS 中更高级的概念,例如热和冷 Observables、subjects、错误处理,以及如何使用 Marble 测试来测试您的 RxJS 代码。
第八章,Redux,展示了 Redux 模式,并解释了它是如何从 Flux 模式演变而来,并改进了一些其范式和概念。读者将通过学习构建自己的 Redux 以及使用相同的模式,包括几个视图,来了解它在实践中的工作方式。
第九章,NgRx – Reduxing that Angular App,探讨了 NgRx 是什么以及它由什么组成。它还向读者展示了如何将其添加到 Angular 应用程序中以投入使用。解释并演示了诸如 store 等概念,读者将学习如何使用 Effects 库进行调试和处理副作用。
第十章,NgRx – In Depth,涵盖了 Entity 库,它通过减少大量样板代码,允许你编写更少的代码。它还展示了如何将路由的状态放入 store 中。此外,我们探讨了如何测试 NgRx,如何自己构建它,最后,我们涵盖了 Schematics,这将通过允许我们构建在 NgRx 中需要工作的最常见结构来进一步帮助我们。
为了最大限度地利用本书
本书是关于使用 NGRX 构建 Angular 应用程序的。为了最大限度地利用本书,您需要对 Angular 框架有一个基本的了解,并且应该能够使用 Angular-CLI 构建 Angular 应用程序,或者如果您更喜欢这种方式,能够通过 Webpack 设置 Angular 项目。对 JavaScript 和 TypeScript 有一个良好的理解是很好的。最重要的是,一个好奇的心是您真正需要的。在您的机器上安装 NodeJs 是个好主意。
下载示例代码文件
您可以从www.packtpub.com的账户下载此书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载后,请确保使用最新版本的软件解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 下的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Architecting-Angular-Applications-with-Redux-RxJs-and-NgRx。我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。去看看吧!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“我们需要在终端中键入以下内容来安装 webpack。”
代码块设置如下:
interface IPrinter {
print(IPrintable printable);
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
interface IPrintable {
String getContent();
}
interface IPrinter {
print(IPrintable printable);
}
任何命令行输入或输出都按以下方式编写:
npm install webpack webpack-cli --save-dev
粗体:表示新术语、重要单词或您在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“让我们通过向我们的输入元素添加一个值并按保存按钮来向我们的存储中添加一个条目。”
警告或重要提示如下所示。
小技巧和技巧如下所示。
联系我们
我们始终欢迎读者的反馈。
总体反馈:请发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过电子邮件联系我们的 questions@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一错误。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能向我们提供位置地址或网站名称。请通过电子邮件联系我们的 copyright@packtpub.com,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用过这本书,为什么不在这本书购买的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packtpub.com。
第一章:快速回顾简单应用的数据服务
欢迎来到本书的第一章。你之所以选择这本书,可能是因为你在设置 Angular 应用架构时遇到了问题。你的应用在成长过程中,你逐渐感觉到你失去了对应用在某个特定时刻所知道的内容的追踪,这就是我们所说的应用状态。可能还有其他问题,比如应用的一部分可能没有与它们所知道的内容达成一致。一个部分发生的更新可能没有被应用到其他部分,你挠头思考,这是否太难了,是否有更好的解决方案?
很可能你只是因为听说 NgRx 是结构化应用的一种方式而选择这本书,你好奇并想了解更多。
无论是什么动机驱使你阅读这本书,本书都是关于学习如何结构化你的应用,以及如何以所有应用部分都同意的方式设置和传达应用的状态及其变化。NgRx 的底层架构模式是 Redux,它将数据限制在仅一个地方,并确保数据仅向一个方向流动。我们将在本书的专门章节中更深入地探讨 Redux。
要达到掌握 NgRx 的水平,我们首先需要在学习过程中掌握一些范式和模式。我们需要建立一个良好的知识基础。良好的基础包括学习诸如函数式响应式编程(FRP)、架构模式 Flux 以及一种关于异步概念的新颖且令人兴奋的思考方式,即 Observables。
那么,为什么这些对我们学习掌握 NgRx 的旅程是相关的?Flux 模式与 Redux 有很多共同之处,正是它的不足导致了 Redux 的创建。NgRx 本身是使用 RxJS 实现的,它促进了函数式响应式编程风格。所以你看,我们即将探索的基础知识都有助于我们掌握 NgRx 的理论和动机。
在本章中,我们通过讨论众所周知的模型-视图-控制器(MVC)模式为本书奠定基础。为了验证我们是否理解 MVC 模式,我们使用 Angular 框架来简化问题。虽然谈论架构很有趣,但如果你不看到它应用于现实事物,可能很难掌握。
我们继续深入探讨 Angular 应用的工作流程及其依赖注入机制。在结束本章之前,我们还将了解如何通过 API 获取数据,因为毕竟数据是从那里来的,应该流向那里。
在本章中,我们将:
-
描述 MVC 模式的构建块
-
描述 Angular 中的 MVC 以及帮助支持它的核心结构
-
复习 HTTP 服务以及如何处理 Ajax
模型-视图-控制器 – 我们都熟知的模式
无论你是一名程序员一年还是 20 年,你几乎肯定以某种形式遇到过 MVC 模式。这个模式本身,MVC,由三个相互关联的部分组成:模型、视图和控制器。了解所有部分的重要性不如了解它解决的问题。它通过解耦视图逻辑、数据逻辑和业务逻辑来解决问题。MVC 模式催生了以下内容:
-
模型-视图-适配器(MVA)
-
模型-视图-呈现器(MVP)
-
模型-视图-视图模型(MVVM)
内聚和耦合 – 建立共同语言
没有像 MVC 这样的模式,你的代码可能很难维护,因为它可能具有低内聚和高耦合。这些是华丽的词藻,那么我们的意思是什么?内聚是关于关注点和类应该做什么。内聚越低,一个类执行的不同事情就越多,因此它没有明确的意图知道它应该做什么。
以下代码展示了当一个类具有低内聚性时会发生什么;它做的不仅仅是存储发票数据,例如能够将日志记录到文件或与数据库通信:
Invoice
details
total
date
validate()
print()
log()
saveToDatabase()
现在我们已经引入了新的专用类,并将方法从Invoice类中移出,以确保每个类现在都具有高内聚,即更专注于做好一件事。因此,我们现在有Invoice、Printer、Logger和InvoiceRepository这些类:
Invoice
details
total
date
validate()
Printer
print(document)
Logger
log()
InvoiceRepository
saveToDatabase(invoice)
我在这里试图说明的是,一个类应该只做好一件事。这可以通过将缺乏关注的Invoice类拆分为四个不同的类来展示,每个类都只专注于做好一件事。
因此,我们解决了内聚/关注点的问题。那么耦合呢?耦合是关于一个软件元素与另一个软件元素连接的强度。最终,耦合越高,更改就越困难/繁琐。让我们看看以下用 Java 编写的具有高耦合的示例:
// cohesion-and-coupling/invoice-system.java
class Printer {
print(Invoice invoice) {
String total ="";
total += invoice.getTitle();
total += invoice.getDetails();
total += invoice.getDate();
//print 'total'
}
}
class Invoice {
String title;
String details;
int total;
Date date;
public String getTitle() { return this.title; }
public String getDetails() { return this.details; }
public String getDate() { return this.date; }
}
public class Program {
private Printer printer = new Printer();
public void run(ArrayList list) {
for(int i=0; i< list.length; i++) {
Object item = list.getItem(i);
if(item instanceof Invoice) {
Invoice invoice = (Invoice) item;
printer.print(invoice);
}
}
}
public static void main(String [] args) {
ArrayList list = new ArrayList();
list.add(new Invoice());
Program program = new Program();
program.run( list );
}
}
这段代码存在多个问题,尤其是如果你打算以任何方式更改代码。假设我们想要打印一封电子邮件。我们可能会想,我们需要一个Email类,并需要向Printer类添加另一个print()方法重写。我们还需要向Program类添加分支逻辑。此外,测试Program类无法不引起副作用:调用run()方法会导致实际调用打印机。我们现在的测试方式是每次代码更改时都运行测试,因为我们正在开发程序,这可能导致代码更改非常频繁。我们可能会在开发代码的过程中打印出数千张纸。因此,我们需要在开发代码和测试时将自己与副作用隔离开。我们最终想要测试的是代码的行为是否正确,而不是物理打印机是否看起来工作正常。
在下面的代码中,我们看到一个高耦合的例子。我们添加了另一个类型 Email。这样做是为了看到这样做的影响,即我们需要同时向几个地方添加代码。必须这样做是一个代码坏味的迹象。你需要做的更改越少,通常越好:
// cohesion-and-coupling/invoice-systemII.java
class Email {
String from;
String to;
String subject;
String body;
String getSubject() { return this.subject; }
String getFrom() { return this.from; }
String getTo() { return this.to; }
String getBody() { return this.body; }
}
class Invoice {
String title;
String details;
int total;
Date date;
String getTitle(){ return this.title; }
String getDetails() { return this.details; }
Date getDate() { return this.date; }
}
class Printer {
print(Invoice invoice) {
String total ="";
total += invoice.getTitle();
total += invoice.getDetails();
total += invoice.getDate();
//print 'total'
}
print(Email email) {
String total ="";
total += email.getSubject();
total += email.getFrom();
total += email.getTo();
total += email.getBody();
}
}
class Program {
private Printer printer = new Printer();
run(ArrayList list) {
for(int i=0; i< list.length; i++) {
Object item = list.getItem(i);
if(item instanceof Invoice) {
Invoice invoice = (Invoice) item;
printer.print( invoice );
} else if( item instanceof Email ) {
Email email = (Email) item;
printer.print( email );
}
}
}
public static void main(String [] args) {
ArrayList list = new ArrayList();
list.add( new Invoice() );
list.add( new Email() );
Program program = new Program();
program.run( list );
}
}
因此,让我们稍微调整一下代码:
// cohesion-and-coupling/invoice-systemIII.java
class Email implements IPrintable {
String from;
String to;
String subject;
String body;
String getSubject() { return this.subject; }
String getFrom() { return this.from; }
String getTo() { return this.to; }
String getBody() { return this.body; }
public String getContent() {
String total = "";
total += email.getSubject();
total += email.getFrom();
total += email.getFrom();
total += email.getBody();
return total;
}
}
class Invoice implements IPrintable {
String title;
String details;
int total;
Date date;
String getTitle() { return this.title; }
String getDetails() { return this.details; }
String getDate() { return this.date; }
public String getContent() {
String total = "";
total += invoice.getTitle();
total += invoice.getDetails();
total += invoice.getDate();
return total;
}
}
interface IPrintable {
String getContent();
}
interface IPrinter {
print(IPrintable printable);
}
class Printer implements IPrinter {
print( IPrintable printable ) {
String content = printable.getContent();
// print content
}
}
class Program {
private IPrinter printer;
public Program(IPrinter printer) {
this.printer = printer;
}
run(ArrayList<IPrintable> list) {
for(int i=0; i< list.length; i++) {
IPrintable item = list.getItem(i);
printer.print(item);
}
}
public static void main(String [] args) {
ArrayList<IPrintable> list = new ArrayList<IPrintable>();
Printer printer = new Printer();
list.add(new Invoice());
list.add(new Email());
Program program = new Program(printer);
}
}
到目前为止,我们已经使我们的程序可以扩展。你怎么说呢?显然,我们已经从 printer 中移除了 printer 方法。我们还从 Program 类的方法 run 中移除了切换逻辑。我们还添加了抽象 IPrintable,这使得任何可打印的内容都负责告诉打印机其可打印的内容是什么。
你可以清楚地看到,当我们引入 Document 和 Note 类型时,我们是如何从高耦合转变为低耦合的。它们引起的唯一变化是它们自己被添加并实现了 IPrintable 接口。其他什么都不需要改变。成功!
// invoice-systemIV.java
class Document implements IPrintable {
String title;
String body;
String getContent() {
return this.title + this.body;
}
}
class Note implements IPrintable {
String message;
String getContent() {
return this.message;
}
}
// everything else stays the same
// adding the new types to the list
class Program {
public static void main(String[] args) {
list.add(new Note());
list.add(new Document());
}
}
好吧,所以总结一下我们的更改:
-
我们添加了
IPrintable接口 -
我们简化/移除了
Program.run()方法中的分支逻辑 -
我们让每个可打印类实现
IPrintable -
我们在之前的代码片段末尾添加了一些代码,以展示添加新类型是多么容易
-
我们通过
Program类构造函数注入了IPrinter,以确保我们可以轻松测试Program类
特别注意,当我们添加 Document 和 Note 类型时,我们不需要在 Printer 或 Program 中更改任何逻辑。我们唯一需要做的是添加 Document 和 Notes 作为类,并确保它们实现了 IPrintable 接口。为了强调这一点,任何对程序的添加都不应该导致代码的整体系统变化。
让我们重申一下添加 IPrinter 的最后一条。可测试性是一个非常好的衡量标准,可以用来判断你的代码是否具有低耦合。如果你依赖于抽象而不是实际类,你就可以轻松地用另一个具体类替换一个具体类,同时保持高级行为。
将 Printer 切换到 IPrinter 的另一个原因是,当我们测试代码时,可以消除程序中的副作用。副作用是指我们与文件交互、改变状态或通过网络进行通信等情况。测试 Program 类意味着我们希望消除实际打印等副作用,而是调用一些虚假的函数,或者每次运行测试时都会有一大堆纸张。因此,为了测试目的实例化我们的 Program 类,我们会写一些像这样的事情:
// cohesion-and-coupling/invoice-systemV.java
class FakePrinter implements IPrinter {
print(IPrintable printable) { System.out.println("printing"); }
}
class Program {
FakePrinter fakePrinter;
Program(FakePrinter fakePrinter) {
this.fakePrinter = fakePrinter;
}
public static void main(String[] args) {
ArrayList<IPrintable> list = new ArrayList<IPrintable>();
Printer printer = new FakePrinter();
list.add(new Invoice());
list.add(new Email());
Program program = new Program(printer);
}
}
从这段代码中,我们可以看到我们是如何从实例化打印到真实打印机的 Printer 类,转变为使用 FakePrinter 实例的 Program 类。在测试场景中,这正是你想要测试 Program 类时你会做的事情。你最关心的可能是 print() 方法被正确地调用。
好吧,这已经是一种相当长的表达低耦合的方式了。然而,在谈论模式时,建立诸如耦合和内聚等关键术语的重要性是至关重要的。
解释 MVC 的组成部分
回到 MVC 模式。使用该模式意味着我们获得高内聚和低耦合;这是由于代码被分割成具有不同责任的不同层。视图逻辑属于视图,控制器逻辑属于控制器,模型逻辑属于模型。
模型
这是应用程序的关键部分。这并不依赖于任何特定的用户界面,而更多地定义了你在其中操作的范围。规则、逻辑和数据都生活在这里。
视图
这可以是任何从原生应用视图到柱状图,甚至是网页。关键是它最终显示模型中的数据。可能有不同的视图显示相同的内容,但根据它们的设计对象,它们可能看起来不同。管理员可能看到的视图与用户看到的相同信息完全不同。
控制器
这实际上就是网中的蜘蛛。它能够从视图或数据中获取输入并将其转换为命令。
交互 – 组件之间的行为
所有这三个提到的组件在相互交谈时表现不同。模型根据命令存储从控制器接收到的数据。视图根据模型中的变化改变其外观。控制器可以根据用户交互向模型发送命令。一个这样的例子是用户决定在基于页面的记录之间浏览。需要根据新的视觉位置检索一组新的数据。
这两个基本流程是大多数基于 MVC 的应用程序中发生的事情:
-
用户交互:控制器向模型发送命令 => 模型发生变化 => 视图被更新
-
视图请求数据:控制器向模型发送命令 => 模型被创建/更改 => 视图被更新
MVC 概述
关于 MVC 及其许多变体有很多可以说的,但让我们现在就满足于总结我们已识别的模式属性:
-
低耦合
-
高内聚,将表示关注点从模型中分离出来
-
同时开发是可能的;由于存在许多层,人们可以并行地完成任务
-
易于更改;由于事物是分离的,添加未来的概念或进行更改变得更容易
Angular 中的 MVC 流程
让我们看看以下问题以及我们如何在 Angular 中解决这些问题:
-
创建和渲染模型数据到屏幕
-
学习 MVC 模式如何映射到 Angular 框架
-
学习如何以不同的构建块结构化 Angular 应用程序
-
获取数据/持久化数据
模型
Angular 中的模型是一个普通的类,因为我们使用 TypeScript。它可能看起来像以下代码:
// mvc/MvcExample/src/app/product.model.ts
export class Product {
constructor(
private id: number,
private title: string,
private description: string,
private created: Date
) {}
method() {}
anotherMethod() {}
}
它是一个普通的 TypeScript 文件,或者更确切地说是一个 ES2015 模块,不要与 Angular 模块混淆。我们将在下一主要部分讨论 Angular 模块是什么,以及它的设置和消费方式。现在,记住模型是一个简单的东西。
组件 – 控制器和构建块
在 MVC 的上下文中,组件是 V 和 C,即视图和控制。组件允许你定义一个单独的模板文件或内联模板。模板是视图部分。
在这个上下文中,控制器是一个处理用户交互并从模板显示所需数据的组件类文件。
组件已经成为许多当今流行的框架的核心概念,例如 React、Vue.js 和 Polymer。组件可以接受输入,这些输入可以是数据或方法。它由一段代码和一个 HTML 模板组成,这些模板渲染有趣的数据,并存在于组件中。Angular 中的组件由三个主要部分组成:
-
一个装饰器函数
-
一个类
-
一个模板
一个组件由一个控制器类和一个模板组成。在 Angular 应用程序中,它可以扮演两个不同的角色:要么作为路由的响应者,要么作为构建块。在前一种情况下,当发生新的路由时,Angular 将实例化它,并使用该组件进行响应。在后一种情况下,组件直接由另一个组件作为子组件创建。
我们将在下一部分解释前一段话的含义。
路由的第一个响应者
如前所述,组件可以用作路由的响应者。所以,假设应用程序由于用户交互或程序性地路由到 /products 路由,Angular 处理这种情况的方式是将 /products 路由与一个组件关联。借助组件的类和 HTML 标记,我们能够生成包含我们的标记和数据一起渲染的 HTML 片段。将组件指定为路由的响应者是在定义所谓的路由映射时完成的,如下所示:
// example of what routing might look like
export const appRoutes: Routes = [
{
path: '',
component: HomeComponent
},
{
path: 'payments',
component: ProductsComponent,
data: { title: 'Products' }
}
]
实质上,路由被定义为具有 path 属性的对象,指明我们的路由,以及一个指向响应组件的 component 属性。我们可以将其他属性附加到路由上,例如 data,以给响应组件提供一些初始数据以进行渲染。
作为构建块使用
将组件作为构建块使用意味着它将成为另一个组件模板的一部分。本质上,它将被视为该组件的子组件。这种思维方式相当自然,意味着我们可以将我们的应用程序视为组件的分层树。如前所述,Angular 中的组件由一个控制器类和一个模板组成。一个典型的组件看起来是这样的:
// an example component
@Component({
selector: 'example-component'
})
export class ExampleComponent {}
@Component装饰器函数为类添加元数据。这指导 Angular 如何创建组件,以便 Angular 可以将组件放置在 DOM 中。这使得你可以将其用作对路由的响应者,或者作为你自己的自定义元素。属性selector决定了如果你的组件用作自定义元素,它应该被称为什么。以下是一个示例用法:
// an example container component
@Component({
selector: `
{{ title }}
<example-component>
`
})
export class ContainerComponent {
title ="container component";
}
组件可以以这种方式使用,这使得将应用视为由组件组成的分层树变得容易。因此,一个待办事项应用可能看起来如下所示:
AppComponent
TodoList
TodoItem
TodoItem
TodoItem
...
让我们开始创建这个应用,从AppComponent开始。由于这是最顶层的组件,它也被称作根组件。AppComponent应该在它的模板中渲染TodoListComponent,如下所示:
// mvc/MvcExample/src/app/app.component.ts
import { Component } from "@angular/core";
@Component({
selector: "app-root",
template: `
<todo-list></todo-list>
`,
styleUrls: ["./app.component.css"]
})
export class AppComponent {
title = "app";
}
下一步是定义TodoListComponent,并知道它应该能够在其模板中渲染多个TodoItemComponent实例。列表的大小通常是未知的。这正是结构指令*ngFor的作用。因此,在定义TodoListComponent时,我们将利用以下代码:
// mvc/MvcExample/src/app/todo-list.component.ts
import { Component } from "@angular/core";
@Component({
selector: "todo-list",
template: `
<h1>{{title}}</h1> <custom></custom>
<div *ngFor="let todo of todos">
<todo-item [todo]="todo" ></todo-item>
</div>
` . // the view
})
export class TodoListComponent { // the controller class
title: string;
todos = [{
title: "todo1"
},{
title: "todo1"
}]
}
在这里,我们可以看到我们通过在模板中循环todos数组来渲染一系列todo项,如下所示:
<div *ngFor="let todo of todos">
<todo-item [todo]="todo" ></todo-item>
</div>
在前面的代码中,我们可以看到我们渲染了todo-item选择器,它指向一个我们尚未定义的TodoItemComponent。值得注意的是,我们如何传递一个todo对象并将其分配给TodoItemComponent上的一个输入属性。该组件的定义如下:
// mvc/MvcExample/src/app/todo-item.component.ts
import { Component, Input } from "@angular/core";
@Component({
selector: "todo-item",
template: `<h1>{{todo.title}}</h1>`
})
export class TodoItemComponent {
@Input() todo;
}
思考哪些组件应该作为其他组件的一部分存在,这是你需要投入大量时间去做的事情。
从架构角度来看的组件
鼓励你在你的 Angular 应用中创建大量的组件。在前面的示例中,创建了一个todo列表应用,很容易想到创建一个只包含一个组件AppComponent的应用。这意味着一个组件将负责很多事情,比如显示todo项、保存这些项、删除它们等等。组件旨在用于解决一个问题。这就是为什么我们创建了TodoItemComponent,它的唯一任务是显示一个todo项。对于TodoListComponent也是如此。它应该只关心显示列表,其他什么都不管。你将应用拆分成更小、更专注的区域越多,效果越好。
NgModule – 我们的新门面(以及一些其他部分)
到目前为止,我们讨论组件时,是按照它们专注于解决一个任务来进行的。然而,Angular 中还有其他可以使用的结构,例如管道、指令和服务。我们的大部分组件都会发现自己属于一个共同的主题,比如产品或用户管理等。当我们意识到哪些结构属于同一主题时,我们也会意识到其中一些结构是我们想在应用程序的其他地方使用的。相反,有些结构只意味着在提到的主题上下文中使用。为了保护这些结构免受意外使用,我们希望以门面方式将它们分组,并在结构与其他应用程序的其余部分之间放置一个保护层。在纯 ES2015 模块中这样做的方法是创建一个门面文件,在其中公开结构,而其他结构则不公开,如下所示:
// an old facade file, index.ts
import { MyComponent } from 'my.component';
import { MyService } from 'my.service';
export MyComponent;
export MyService;
假设我们有一个包含以下文件的目录:
/my
MyComponent.ts
MyService.ts
MyOtherService.ts
index.ts
创建门面文件的目的是确保只有一个地方可以从中导入所有需要的结构。在这种情况下,那就是 index.ts 文件。前一个目录的消费者会这样做:
// consumer.ts
import * as my from './my';
let component = new my.MyComponent();
let service = new MyService();
MyOtherService 并没有在 index.ts 文件中公开,所以尝试像在 consumer.ts 中那样访问它会导致错误。理论上,您可以指定结构的完整路径,但您应该使用桶。桶通常用于轻松访问您的结构,而无需编写长达五英里的导入语句,如下所示:
// index.ts
import { Service } from '../../../path-to-service';
import { AnotherService } from '../../path-to-other-service';
export Service;
export AnotherService;
// consumer.ts
// the long and tedious way
import { Service } from '../../../path-to-service';
import { AnotherService } from '../../path-to-other-service';
// the easier way using a barrel
import * as barrel from './index';
let service = new barrel.Service();
let anotherService = new barrel.AnotherService();
如您所见,该桶,index.ts 是负责知道所有结构所在位置的那个。这也意味着,如果您移动文件,更改某些结构的目录,那么更新这些结构路径的唯一文件就是桶文件。
Angular 处理这个问题的方式是使用 Angular 模块。一个 Angular 模块看起来如下所示:
// mvc/MvcExample/src/app/my/my.module.ts
import { NgModule } from "@angular/core";
import { MyComponent } from "./my.component";
import { MyPipe } from "./my.pipe";
@NgModule({
imports: [],
exports: [MyComponent],
declarations: [MyComponent, MyPipe],
providers: []
})
export class MyModule {}
将 MyComponent 和 MyPipe 放入模块的声明属性中的效果是,这些组件可以在 MyModule 中自由使用。例如,您可以在 MyComponent 模板中使用 MyPipe。然而,如果您想在模块外部使用 MyComponent,在一个属于另一个模块的组件中,您需要将其导出。我们通过将其放置在属于 exports 属性的数组中来做到这一点:
exports: [MyComponent]
Angular 将模块的概念远远超出了分组。我们 NgModule 中的某些指令是为了让编译器知道如何组装组件。我们给出的其他指令是为了依赖注入树。将 Angular 模块视为一个配置点,同时也是您在逻辑上将应用程序划分为代码块的地方。
在发送给 @NgModule 装饰器的对象上,你可以设置具有不同含义的属性。最重要的属性包括:
-
declarations属性是一个数组,指定了属于我们的模块的内容 -
imports属性是一个数组,指定了我们依赖的其他 Angular 模块;它可能是基本的 Angular 指令或我们想在模块内部使用的通用功能 -
exports属性是一个数组,指定了应该对任何导入此模块的模块可用的内容;MyComponent被公开,而MyPipe只对此模块是私有的 -
providers属性是一个数组,指定了哪些服务应该注入到属于此模块的构造函数中,也就是说,注入到在声明数组中列出的构造函数中。
使用 ES2015 模块
到目前为止,我们已经提到模型只是普通的类。ES2015 模块只是一个文件。在这个文件中,既有公共构造函数也有私有构造函数。私有的事物只在该文件内部可见。公共的事物可以在文件外部使用。在 Angular 中,ES2015 模块不仅用于模型,还用于所有可想象的构造函数,如组件、指令、管道、服务等等。这是因为 ES2015 模块是我们将项目拆分成更小部分的一种回答,这为我们提供了以下好处:
-
许多小文件使得并行处理你的工作变得更加容易,并且可以同时让许多开发者工作
-
通过使应用程序的一些部分公开而其他部分私有来隐藏数据的能力
-
代码重用
-
更好的可维护性
为了理解这些陈述,我们必须记住过去网页开发的样子。当网络还很年轻时,我们的 JavaScript 代码通常只有一个文件。这很快变成了一个巨大的混乱。多年来,我们采用了不同的技术来找到一种方法将我们的应用程序拆分成许多小文件。许多小文件使得维护变得更加容易,同时也更容易获得对正在发生的事情的良好概述,以及其他许多好处。然而,也存在其他问题。由于所有这些小文件在与应用程序一起发布之前都必须重新拼接在一起,这个过程称为打包,我们突然有一个巨大的文件,其中函数和变量可能会因为命名冲突而意外地相互影响。解决这个问题的方法之一是处理称为信息隐藏的东西。这是为了确保我们创建的变量和函数只能对某些其他构造函数可见。当然,解决这个问题有多种方法。ES2015 提供了一种默认为私有的方式。在 ES2015 中声明的所有内容默认都是私有的,除非你明确导出它,从而使它对导入上述模块的其他模块公开可访问。
那么,这与前面的陈述有什么联系呢?任何模块系统实际上都允许我们在项目随着我们成长而增长时保持可见性。另一种选择是只有一个文件,那将是一团糟。对于同时工作的多个开发者来说,任何逻辑上划分应用的方法都会使开发者之间的工作流划分更容易。
消费模块
在 ES2015 中,我们使用import和from关键字导入一个或多个结构,如下所示:
import { SomeConstruct } from './module';
导入的文件看起来是这样的:
export let SomeConstruct = 5;
涉及的基本操作,使用 ES2015 模块,可以总结如下:
-
定义一个模块并编写模块的业务逻辑
-
导出你想要公开的结构
-
使用
import关键字从消费者文件中导入该模块
当然,这不仅仅是那样,所以让我们在下一小节中看看你还能做什么。
一个 Angular 示例
我们已经在本章中广泛使用了 ES2015 导入,但让我们强调一下那是何时。如前所述,所有结构都使用了 ES2015 模块、模型、服务、组件和模块。对于模块,它看起来是这样的:
import { NgModule } from '@angular/core';
@NgModule({
declarations: [],
imports: [],
exports: [],
providers: []
})
export class FeatureModule {}
在这里,我们看到我们导入了我们需要的功能,并最终导出这个类,使其可供其他结构消费。模块也是如此:
import { Component } from '@angular/core';
@Component({
selector: 'example'
})
export class ExampleComponent {}
管道、指令和过滤器都遵循相同的模式,导入它们需要的部分,并将自己导出以作为NgModule的一部分:
多重导出
到目前为止,我们只展示了如何导出一个结构。通过在所有希望导出的结构旁边添加export关键字,从一个模块中导出多个东西是可能的,如下所示:
export class Math {
add() {}
subtract() {}
}
export const PI = 3.14
实际上,对于你想要公开的每一件事,你都需要在它前面添加一个export关键字。还有一个替代语法,我们可以在大括号内定义应该导出哪些结构,而不是在每个结构前添加export关键字。它看起来像这样:
class Math {
add() {}
subtract() {}
}
const PI = 3.14
export {
Math, PI
}
无论你是将export放在每个结构前面,还是将它们全部放在export {}中,最终结果都是相同的,只是使用哪种方式取决于个人喜好。要从这个模块中消费结构,我们就会输入:
import { Math, PI } from './module';
在这里,我们有选择指定我们想要import的内容。在先前的例子中,我们选择了导出Math和PI,但我们可以只满足于导出Math,例如;这取决于我们。
默认导入/导出
到目前为止,我们已经非常明确地说明了我们导入和导出了什么。然而,我们可以创建一个所谓的默认导出,它的消费方式略有不同:
export default class Player {
attack() {}
move() {}
}
export const PI = 3.13;
要消费这个,我们可以编写以下代码:
import Player from './module';
import { PI } from './module'
特别注意第一行,我们不再使用花括号{}来导入特定的构造函数。我们只需使用我们自己设定的名称。在第二行,我们必须正确地命名为PI,但在第一行我们可以选择名称。玩家指向的是我们默认导出的内容,即Player类。正如你所见,如果我们想的话,我们仍然可以使用正常的花括号{}来导入特定的构造函数。
重命名导入
有时我们可能会遇到冲突,构造函数被赋予相同的名称。这种情况可能发生:
import { productService } from './module1/service'
import { productService } from './module2/service'; // name collision
这是我们需要解决的问题。我们可以使用as关键字来解决这个问题,如下所示:
import { productService as m1_productService }
import { productService as m2_productService }
多亏了as关键字,编译器现在可以没有问题地区分不同的事物。
该服务
我们在主部分开始时讨论了 ES2015 模块在 Angular 中适用于所有构造函数。本节是关于服务的,当使用 ES2015 模块时,服务并没有什么不同。我们使用的服务应该在单独的文件中声明。如果我们打算使用服务,我们需要导入它。不过,导入的原因取决于服务的类型。服务可以分为两种类型:
-
无依赖的服务
-
带有依赖的服务
无依赖的服务
无依赖的服务是一个构造函数为空的服务:
export Service {
constructor(){}
getData() {}
}
要使用它,你只需输入:
import { Service } from './service'
let service = new Service();
service.getData();
任何消费此服务的模块都将获得自己的代码副本,这种代码。然而,如果你想让消费者共享一个公共实例,你只需稍微修改service模块的定义,如下所示:
class Service {
constructor() {}
getData() {}
}
const service = new Service();
export default service;
在这里,我们导出服务的一个实例而不是服务声明。
带有依赖的服务
带有依赖的服务在其构造函数中有依赖项,我们需要帮助解决这些依赖项。如果没有这个解决过程,我们无法创建服务。这样的服务可能看起来像这样:
export class Service {
constructor(
Logger logger: Logger,
repository:Repository
) {}
}
在此代码中,我们的服务有两个依赖项。在构建服务时,我们需要一个Logger实例和一个Repository实例。我们完全可以通过输入类似以下内容来找到Logger实例和Repository实例:
import { Service } from './service'
import logger from './logger';
import { Repository } from './repository';
// create the service
let service = new Service( logger, new Repository() )
这绝对是可以做到的。然而,每次我想获取服务实例时,编写代码都有些繁琐。当你开始有 100 多个具有深层对象依赖的类时,DI 系统很快就会显示出其价值。
这就是依赖注入库帮助你解决的一个问题,即使它不是其存在背后的主要动机。DI 系统的主要动机是在系统的不同部分之间创建松散耦合,并依赖于契约而不是具体实现。以我们的服务为例。DI 可以帮助我们做两件事:
-
用另一个具体实现替换一个
-
轻松测试我们的构造函数
为了说明我的意思,让我们首先假设Logger和Repository是接口。不同的具体类可能会以不同的方式实现接口,如下所示:
import { Service } from './service'
import logger from './logger';
import { Repository } from './repository';
class FileLogger implements Logger {
log(message: string) {
// write to a file
}
}
class ConsoleLogger implements Logger {
log(message: string) {
console.log('message', message);
}
}
// create the service
let service = new Service( new FileLogger(), new Repository() )
这段代码展示了如何通过选择FileLogger而不是ConsoleLogger或反之,轻松切换Logger的实现。如果只依赖外部提供的依赖项,测试用例也会变得简单得多,因此可以轻松地进行模拟。
依赖注入
实质上,当我们请求一个结构实例时,我们希望得到帮助来构建它。当请求解析一个实例时,DI 系统可以以两种方式之一行事:
-
瞬态模式:依赖关系始终被重新创建
-
单例模式:依赖关系被重用
Angular 只创建单例,这意味着每次我们请求一个依赖项时,它只会被创建一次,如果我们不是第一个请求该依赖项的结构,我们将得到一个已经存在的依赖项。
任何 DI 框架的默认行为都是使用类的默认构造函数,并从类中创建一个实例。如果该类有依赖项,那么它必须首先解决这些依赖项。想象一下以下情况:
export class Logger { }
export class Service {
constructor(logger: Logger) { }
}
DI 框架会遍历依赖链,找到没有任何依赖的结构,并首先实例化它。然后它会向上遍历,最终解决你请求的结构。所以用这段代码:
import { Service } from './service';
export class ExampleComponent {
constructor(srv: Service) { }
}
DI 框架会:
-
首先实例化日志记录器
-
第二次实例化服务
-
第三次实例化组件
使用提供者进行 Angular 依赖注入
到目前为止,我们只讨论了依赖注入的一般概念,但 Angular 有一些结构或装饰器来确保依赖注入能够完成其工作。首先想象一个简单的场景,一个没有依赖的服务:
export class SimpleService {}
如果存在一个需要服务实例的组件,如下所示:
@Component({
selector: 'component'
})
export class ExampleComponent {
constructor(srv: Service) {}
}
Angular 依赖注入系统介入并尝试解析它。因为服务没有依赖项,解决方案就是简单地实例化Service,Angular 为我们做这件事。然而,我们需要告诉 Angular 这个结构,以便 DI 机制能够工作。需要知道这个结构的东西被称为提供者。Angular 模块和组件都可以访问一个提供者数组,我们可以将Service结构添加到其中。不过,关于这一点,自从 Angular 模块出现以来,建议不要为组件使用提供者数组。下面的段落只是为了告诉你组件的提供者是如何工作的。
这将确保当请求Service实例时,它会在正确的位置创建和注入。让我们告诉 Angular 模块一个服务结构:
import { Service } from "./Service";
@NgModule({
providers: [Service]
})
export class FeatureModule{}
这通常就足够让它工作了。然而,你可以将Service结构注册到component类中,它看起来是一样的:
@Component({
providers: [Service]
})
export ExampleComponent {}
这会有不同的效果。你会告诉 DI 机制这个构造函数,并且它将能够解析它。然而,有一个限制。它只能为这个组件及其所有视图子组件解析它。有些人可能会认为这是一种限制组件可以看到哪些服务的方法,因此将其视为一个特性。让我通过展示 DI 机制何时能够确定我们提供的服务来解释这一点:
每个人的父母——它起作用了:在这里,我们可以看到,只要最高层的组件将Service声明为提供者,所有后续的组件都能够注入Service:
AppComponent // Service added here, Can resolve Service
TodosComponent // Can resolve Service
TodoComponent // Can resolve Service
让我们通过一些代码来举例说明:
// example code on how DI for works for Component providers, there is no file for it
// app.component.ts
@Component({
providers: [Service] // < - provided,
template : `<todos></todos>`
})
export class AppComponent {}
// todos.component.ts
@Component({
template : `<todo></todo>`,
selector: 'todos'
})
export class TodosComponent {
// this works
constructor(private service: Service) {}
}
// todo.component.ts
@Component({
selector: 'todo',
template: `todo component `
})
export class TodoComponent {
// this works
constructor(private service: Service) {}
}
TodosComponent——对其子组件有效但对其上级无效:在这里,我们向下提供Service到TodosComponent。这使得Service对TodosComponent的子组件可用,但它的父组件AppComponent则无法访问:
AppComponent // Does not know about Service
TodosComponent // Service added here, Can resolve Service
TodoComponent // Can resolve Service
让我们尝试用代码来展示这一点:
// this is example code on how it works, there is no file for it
// app.component.ts
@Component({
selector: 'app',
template: `<todos></todos>`
})
export class AppComponent {
// does NOT work, only TodosComponent and below knows about Service
constructor(private service: Service) {}
}
// todos.component.ts
@Component({
selector: 'todos',
template: `<todo></todo>`
providers: [Service]
})
export class TodosComponent {
// this works
constructor(private service: Service) {}
}
// todo.component.ts
@Component({
selector: 'todo',
template: `a todo`
})
export class TodoComponent {
// this works
constructor(private service: Service) {}
}
我们在这里可以看到,将我们的Service添加到组件的providers数组中存在限制。将其添加到 Angular 模块中是确保它能够被该数组内所有构造函数解析的可靠方法。但这并非全部。将我们的Service添加到 Angular 模块的providers数组中确保它在整个应用程序中都是可访问的。你可能会问,这是如何实现的?这与模块系统本身有关。想象一下,在我们的应用程序中我们有以下 Angular 模块:
AppModule
SharedModule
为了能够使用我们的SharedModule,我们需要通过将其添加到AppModule的imports数组中来将其导入到AppModule中,如下所示:
//app.module.ts
@NgModule({
imports: [ SharedModule ],
providers: [ AppService ]
})
export class AppModule{}
我们知道这会从SharedModule的exports数组中拉取所有构造函数,但这也会将SharedModule的提供者数组连接到AppModule的提供者数组上。想象一下SharedModule看起来可能像这样:
//shared.module.ts
@NgModule({
providers : [ SharedService ]
})
export class SharedModule {}
在导入完成后,合并后的提供者数组现在包含:
-
AppService -
SharedService
因此,这里的经验法则是,如果你想将服务暴露给应用程序,那么请将其放入 Angular 模块的providers数组中。如果你想限制对服务的访问,那么请将其放入组件的providers数组中。然后,你将确保它只能被该组件及其视图子组件访问。
接下来,让我们谈谈当你想要覆盖注入时的情况。
覆盖现有构造函数
有时候,你想要覆盖构造函数的默认解析。你可以在模块级别做这件事,也可以在组件级别做。你所做的就是简单地表达你正在覆盖哪个构造函数以及用哪个其他构造函数来覆盖。它看起来像这样:
@Component({
providers: [
{ provide: Service, useClass : FakeService }
]
})
provide是我们已知的构造函数,而useClass是它应该指向的对象。让我们假设我们这样实现了我们的Service:
export class Service {
no: number = 0;
constructor() {}
}
我们还向一个组件添加了以下覆盖:
@Component({
providers: [{ provide : Service, useClass: FakeService }]
})
FakeService类具有以下实现:
export class FakeService {
set no(value) {
// do nothing
}
get no() {
return 99;
}
}
现在组件及其所有视图子组件在请求服务构造函数时都将始终得到FakeService。
运行时覆盖
有一种方法可以在运行时决定为构造函数注入什么。到目前为止,我们一直非常明确地说明了何时覆盖,但我们可以通过添加一些逻辑并使用useFactory关键字来实现这一点。它的工作方式如下:
let factory = () => {
if(condition) {
return new FakeService();
} else {
return new Service();
}
}
@Component({
providers : [
{ provide : Service, useFactory : factory }
]
})
这个工厂本身可能存在依赖;我们使用deps关键字来指定这些依赖,如下所示:
let factory = (auth:AuthService, logger: Logger) => {
if(condition) {
return new FakeService();
} else {
return new Service();
}
}
@Component({
providers : [
{ provide : Service, useFactory : factory,
deps: [AuthService, Logger] }
]
})
在这里,我们突出了condition变量,它是一个布尔值。我们可能有无数的理由想要能够切换实现。一个很好的例子是当端点还不存在时,我们想要确保它调用我们的FakeService。另一个原因可能是我们处于测试模式,只需更改这个变量,我们就可以让所有服务都依赖于它们自己的假版本。
覆盖常量
并非所有需要解析的东西都是类;有时它是一个常量。对于这些情况,我们不是使用useClass,而是可以使用useValue,如下所示:
providers: [ { provide: 'a-string-token', useValue: 12345678 } ]
这实际上不是一个类类型,所以您不能在构造函数中这样写:
constructor(a-string-token) . // will not compile
这样是无法编译的。我们可以做的是使用以下方式使用@Inject装饰器:
constructor( @Inject('a-string-token') token) // token will have value 12345678
useValue在如何覆盖方面与useClass没有区别。当然,区别在于我们需要在我们的指令中键入useValue而不是useClass。
使用@Injectable 解析依赖
在上一节中,我们对依赖注入(DI)进行了一些深入探讨,但几乎忘记了一个非常重要的装饰器@Injectable。@Injectable对于一般的服务来说并不是强制使用的。然而,如果该服务有依赖项,那么它就需要使用。未能用@Injectable装饰具有依赖项的服务会导致编译错误,编译器会抱怨它不知道如何构造提到的服务。让我们看看我们需要使用@Injectable装饰器的一个案例:
import { Injectable } from '@angular/core';
@Injectable()
export class Service {
constructor(logger:Logger) {}
}
在这种情况下,Angular 的 DI 机制将查找Logger并将其注入到Service构造函数中。所以,只要我们做了以下操作:
providers: [Service, Logger]
在组件或模块中,它应该可以工作。记住,当不确定时,如果服务在构造函数中有依赖项或将在不久的将来有依赖项,请将@Injectable添加到您的服务中。如果您的服务缺少@Injectable关键字,并且您尝试将其注入到组件的构造函数中,那么它将抛出错误,并且您的组件将不会被创建。
本节旨在从一般角度解释 DI(依赖注入)的工作原理,以及在 Angular 中的具体应用。对于后者,它涵盖了如何将构造函数注册到 Angular 的 DI 机制中,以及如何覆盖它。很明显,DI 机制相当复杂。它可以通过向 Angular 模块的提供者数组添加构造函数来应用于应用级别,也可以应用于组件级别及其视图子组件。描述 DI 机制的主要原因是为了向您展示其可能性,这样您在定义应用程序架构时就能知道如何最好地利用它。
使用 HTTP 获取和持久化数据——介绍使用 Observables 的服务
到目前为止,我们已经经历了一个数据流,其中组件是我们的对外视图,同时也是控制器。组件使用服务来获取数据,也用来持久化数据。然而,直到这一点,数据一直生活在服务中,这并不是一个很合适的数据存放位置。几乎可以肯定,这些数据应该被获取并持久化到端点上。这个端点是一个暴露在互联网上某个地方的后端系统的 URL。我们可以使用 HTTP 来访问这个端点。Angular 在原始通过 HTTP 获取数据的方式之上创建了一个包装器。这个包装器是一个类,它封装了一个名为XmlHttpRequest的对象的功能。Angular 的包装器类被称为HttpClient服务。
使用 HTTP 服务获取数据
通过 HTTP 进行通信的方式不止一种。一种方式是使用XmlHttpRequest对象,但这是一种相当繁琐且低级的方法。另一种方式是使用新的 fetch API,您可以在以下链接中了解更多信息:developer.mozilla.org/en-US/docs/Web/API/Fetch_API。
Angular 有自己的抽象层,即 HTTP 服务,它可以在HTTPModule中找到。要使用它,只需导入HttpModule:
import { HttpClientModule } from '@angular/common/http';
@NgModule({
imports: [HttpClientModule]
})
然后,将HttpClient服务注入到您想要使用它的地方,如下所示:
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'consumer',
template: ``
})
export class ConsumerComponent {
constructor(private http:HttpClient) {}
}
到目前为止,我们已经准备好使用它了。让我们快速了解一下这个 HTTP 服务有哪些方法:
-
get('url', <可选的 options 参数>)为我们获取数据 -
post('url', payload,<可选的 options 参数>)创建资源 -
put('url', payload,<可选的 options 参数>)更新资源 -
delete('url',<可选的 options 参数>)删除资源 -
request是一个原始请求,您可以根据需要配置要进行的调用、要添加的标头等。
当我们使用http.get()时,我们得到一个称为 Observable 的结构。Observable 就像Promise一样,是一个异步概念,它使我们能够将回调附加到未来某个时间数据到达时,以及将回调附加到发生错误时。RxJS 对 Observable 的实现包含了许多操作符,帮助我们转换数据并与其他 Observable 交互。其中一个操作符叫做toPromise(),它使我们能够将 Observable 转换为 Promise。有了这个,我们可以以两种不同的方式,或者说两种风味进行 HTTP 调用。第一种方式是我们使用toPromise()操作符将我们的Observable转换为Promise,另一种方式是使用我们的 Observable 并以此方式处理数据。
一个典型的调用有两种不同的风味:
- 使用 Promise
// converting an Observable to a Promise using toPromise()
http
.get('url')
.toPromise()
.then(x => x.data)
.then(data => console.log('our data'))
.catch(error => console.error('some error happened', error));
这个版本感觉熟悉。如果你需要复习一下 Promise,在继续之前,请查看以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise。我们认识到.then()方法是在数据到达时被调用的方法,而.catch()方法是在我们的请求出现问题时被调用的方法。这就是我们在处理 Promise 时的期望。
- 使用 RxJS
// calling http.get() and gets an Observable back
http
.get('url')
.map( x => x.data )
.subscribe( data => console.log('our data', data))
.catch( error => console.error('some error happened', error))
第二个版本看起来不同。在这里,我们使用.map()方法的方式与使用.then()方法的方式非常相似。这个声明需要一些解释。让我们再次查看 Promise 风格的代码,并突出我们所说的话:
http
.get('url')
.toPromise()
.then(x => x.data)
.then(data => console.log('our data'))
.catch(error => console.error('some error happened', error));
突出的部分是在数据首次从服务到达时被调用的方法。我们在这次调用中做的事情是创建数据的投影,如下所示:
.then(x => x.data)
后续的then()调用仅处理将数据打印到控制台:
.then(data => console.log('our data'))
现在我们来看看 RxJS 版本的不同之处,突出显示投影部分和打印结果的部分:
http
.get('url')
.map( x => x.data )
.subscribe( data => console.log('our data', data) )
.catch( error => console.error('some error happened', error) )
我们突出显示的代码的第一行表明了我们的投影:
.map( x => x.data )
订阅的调用是我们打印数据的地方,如下所示:
.subscribe( data => console.log('our data', data) )
当我们使用http.get()时,我们得到一个称为 Observable 的结构。Observable 就像 Promise 一样,是一个异步概念,它使我们能够将回调附加到未来某个时间数据到达时,以及将回调附加到发生错误时。
Observable 是 RxJS 库的一部分,这是在HttpClient服务中提供动力的。这是一个强大的库,不仅用于简单的请求/响应模式。我们将在未来的章节中进一步探索 RxJS 库,并发现 Observable 实际上是一个多么强大的范式,它带来了哪些其他重要概念,以及它不仅仅关于 HTTP 工作的事实,而是所有异步概念。
摘要
我们以尝试解释在应用架构方面打下良好基础的重要性开始本章,因此我们研究了 MVC 模式。然后我们继续描述 MVC 模式在 Angular 中的应用,尽管它被称为 MVW,模型视图随意。我们这样做是为了理解 Angular 框架由许多帮助我们以易于扩展、维护和并行化工作的方式组织应用程序的结构组成。
尽管 Angular 带来了许多新事物,例如 ES2015 模块,它试图以可管理的方式解决如何分割代码的问题。之后,我们争论尽管 ES2015 模块很棒,但在创建复杂对象时,它们附带了很多仪式。为了帮助我们摆脱这种仪式,我们描述了 Angular 依赖注入如何成为解决该问题的方案。实际上,您将使用 ES2015 导入您的结构。Angular DI 帮助我们的是创建我们结构所需的依赖项。
最后,我们通过简单地说数据实际上并不永久地存在于模型、控制器或视图中,而是可以通过与一个可通过 HTTP 访问的端点进行交互来检索和持久化,从而结束了 MVC 模式的解释。我们通过描述 Angular 4.x HTTP 服务如何帮助我们做到这一点来结束本章。
所有这些从教育角度来看都很有趣。它没有描述房间里的大象,当事情变得复杂时我们如何管理我们的数据?我们必须处理的担忧是:
-
双向数据流
-
缺乏可预测性(一个变化可能导致级联变化)
-
状态分散(没有单一的真实来源,并且我们的组件可以位于部分更新的状态上)
在我们继续进入第二章,1.21 吉瓦特 – 流量模式解释时,让我们牢记这些担忧。
第二章:1.21 吉瓦 – Flux 模式解释
让我们先来解释一下我们的标题。我们所说的 1.21 吉瓦是什么意思?我将引用电影《回到未来》中的角色 Doc Brown 的话(www.imdb.com/name/nm0000502/?ref_=tt_trv_qu):
"马蒂,我很抱歉,但唯一能够产生 1.21 吉瓦电力的电源是一道闪电。"
为什么我们要谈论电影《回到未来》?这就是 Flux 这个名字的由来。现在,是时候从同一部电影中引用另一句话了:
"是的!当然!1955 年 11 月 5 日!那就是我发明时间旅行的日子。我记忆犹新。我站在马桶边缘挂钟,瓷器是湿的,我滑倒了,头撞到了水槽上,当我醒来时,我有一个启示!一个愿景!一个脑海中的画面!这就是这个画面!这就是使时间旅行成为可能的原因:flux电容器!"
所以,正如你所看到的,Flux 这个名字有一个解释。它显然允许我们进行时间旅行。至少对于 Redux 来说,我们将在本书的后面部分讨论,时间旅行可以通过称为时间旅行调试的东西来实现。是否需要一道闪电,亲爱的读者,将由你来发现。
Flux 是由 Facebook 创建的一种架构模式。它是在人们意识到 MVC 模式根本无法扩展的情况下出现的。对于大型代码库来说,它无法扩展,因为它们往往变得脆弱,随着越来越多的功能被添加,通常变得复杂,最重要的是,不可预测。现在让我们暂停一下,关注一下“不可预测”这个词。
人们认为大型系统变得不可预测,因为当模型和视图的数量真正增长时,它们在模型和视图之间出现了双向数据流,如下面的图所示:

在这里,我们可以看到模型和视图的数量开始增长。只要一个模型与一个视图通信,反之亦然,一切似乎都在控制之中。然而,这种情况很少发生。在前面的图中,我们可以看到视图突然可以与多个模型通信,反之亦然,这意味着系统出现了级联效应,我们突然失去了控制。当然,只有一个偏离的箭头看起来并不那么糟糕,但想象一下,如果这是一十个箭头,那么我们真的会遇到大问题。
正是因为我们允许双向数据流发生,事情才变得复杂,我们失去了可预测性。针对这种情况的药物或治疗方法被认为是一种更简单的数据流类型,即单向流。现在,有一些关键角色参与启用单向数据流,这把我们带到了本章旨在教给我们的内容。
在本章中,我们将学习:
-
动作和动作创建者的定义
-
分发器如何在您的应用程序中扮演中心角色,作为消息的中心
-
使用存储进行状态管理
-
如何通过编写 Flux 应用程序流程来将我们对 Flux 的知识付诸实践
核心概念概述
Flux 模式的核心是一个单向数据流。它使用一些核心概念来实现这种流。主要思想是当在 UI 上创建一个事件,通过用户的交互,会创建一个动作。这个动作由意图和有效载荷组成。意图是你试图实现的目标。将意图视为一个动词。添加项目、删除项目等等。有效载荷是需要发生以实现我们意图的数据更改。如果我们试图添加一个项目,那么有效载荷就是新创建的项目。然后,在调度器的帮助下,动作在流程中传播。动作及其数据最终进入存储。
构成 Flux 模式的概念包括:
-
动作和动作创建者,其中我们设置了一个意图和数据有效载荷
-
调度器,我们网络中的蜘蛛,能够左右发送消息
-
存储,我们中央的状态和状态管理的地方
所有这些共同构成了 Flux 模式,并促进了单向数据流。考虑以下图示:

这里展示的是一个非方向性数据流。数据从视图流向动作,从动作流向调度器,从调度器流向存储。触发流程有两种可能的方式:
-
应用程序首次加载时,数据将从存储中拉取以填充视图。
-
视图中发生用户交互,导致改变某物的意图。这个意图封装在一个动作中,然后通过调度器发送到存储。在存储中,它可能通过API持久化到数据库中,或保存为应用程序状态,或者两者兼而有之。
让我们在接下来的章节中更详细地探讨每个概念,同时突出一些代码示例。
统一数据流
让我们从最顶层开始介绍我们统一数据流中的所有参与者,并逐步向下,概念到概念。我们将构建一个包含两个视图的应用程序。在第一个视图中,用户将从列表中选择一个项目。这应该会导致创建一个动作。然后,通过调度器,这个动作将被分发。动作及其有效载荷将最终进入存储。与此同时,另一个视图监听存储的变化。当选择一个项目时,第二个视图将得知并可以在其用户界面中指示已选择特定项目。从高层次来看,我们的应用程序及其流程将如下所示:

动作 – 捕获意图
动作是像意图伴随数据一样简单的东西,即消息。然而,动作是如何产生的呢?动作是在用户与 UI 交互时产生的。用户可能在列表中选择一个特定的项目,或者按下按钮以提交表单。提交表单应该反过来导致创建一个产品。
让我们看看两种不同的动作:
-
在列表中选择一个项目,我们感兴趣的是保存所选项目的索引
-
将待办事项保存到待办事项列表
动作由一个对象表示。该对象有两个有趣的属性:
-
类型:这是一个唯一的字符串,告诉我们动作的意图,例如,
SELECT_ITEM -
数据:这是我们打算持久化的数据,例如,所选项目的数值索引
给定我们的第一个示例动作,该动作的代码表示如下:
{
type: 'SELECT_ITEM',
data: 3 // selected index
}
好的,所以我们已经准备好了我们的动作,我们也可以将其视为消息。我们希望消息被发送,以便在 UI 中突出显示所选项目。由于这是一个单向流,我们需要遵循既定的路线,并将我们的消息传递给下一方,即调度器。
调度器 – 网络中的蜘蛛
将调度器视为网络中处理传递给它的消息的蜘蛛。你也可以将调度器视为一个邮递员,他承诺你的消息将到达目标目的地。调度器存在的一个原因是为了将消息发送给任何愿意监听的人。在一个 Flux 架构中通常只有一个dispatcher,典型的用法看起来像这样:
dispatcher.dispatch(message);
监听调度器
我们已经确定调度器将消息发送给任何愿意监听的人。现在是我们成为那个监听者的时刻。调度器需要一个register或subscribe方法,这样你,作为监听者,就有能力监听传入的消息。这种设置的通常看起来是这样的:
dispatcher.register(function(message){});
现在,当你以这种方式设置监听器时,它将能够监听任何正在发送的消息类型。你希望缩小这个范围;通常,监听器被指定为只处理围绕某个主题的少数几种消息类型。你的监听器可能看起来像这样:
dispatcher.register((message) => {
switch(message.type) {
case 'SELECT_ITEM':
// do something
}
});
好的,所以我们能够过滤出我们关心的消息类型,但在实际填写代码之前,我们需要考虑这个监听者是谁。答案是简单的:它是存储。
存储 – 管理状态、数据检索和回调
容易将存储视为我们的数据所在的地方。然而,这并不是它的全部。存储的职责可以通过以下列表来表示:
-
持有状态
-
管理状态,能够在需要时更新它
-
能够处理副作用,例如通过 HTTP 获取/持久化数据
-
处理回调
如您所见,这不仅仅是存储状态。现在让我们重新连接到我们在设置dispatcher监听器时所做的操作。让我们将这段代码移动到我们的存储文件store.js中,并将我们的消息内容持久化到存储中:
// store.js
let store = {};
function selectIndex(index) {
store["selectedIndex"] = index;
}
dispatcher.register(message => {
switch (message.type) {
case "SELECT_INDEX":
selectIndex(message.data);
break;
}
});
好的,所以现在存储已经被告知新的索引,但缺少一个重要的部分,我们如何告诉 UI?我们需要一种方法来告诉 UI 发生了变化。变化意味着 UI 应该重新读取其数据。
视图
为了告诉视图发生了什么并对其采取行动,需要发生三件事:
-
视图需要向存储注册为监听器
-
存储需要发出一个事件,传达已发生更改
-
视图需要重新加载数据
从存储开始,我们需要构建它,以便您可以注册为它的事件监听器。因此,我们添加了addListener()方法:
// store-with-pubsub.js
function selectIndex(index) {
store["selectedIndex"] = index;
}
// registering with the dispatcher
dispatcher.register(message => {
switch (message.type) {
case "SELECT_INDEX":
selectIndex(message.data);
// signals to the listener that a change has happened
store.emitChange();
break;
}
});
class Store {
constructor() {
this.listeners = [];
}
addListener(listener) {
if (!this.listeners["change"]) {
this.listeners["change"] = [];
}
this.listeners["change"].push(listener);
}
emitChange() {
if (this.listeners["change"]) {
this.listeners["change"].forEach(cb => cb());
}
}
getSelectedItem() {
return store["selectedIndex"];
}
}
const store = new Store();
export default store;
在前面的代码中,我们还添加了通过添加emitChange()方法来发出事件的能力。您可以通过轻松切换此实现来使用EventEmitter或类似的东西。所以现在是我们将视图连接到存储的时候了。我们通过调用addListener()方法来实现这一点:
// view.js
import store from "./store-with-pubsub";
class View {
constructor(store) {
this.index = 0;
store.addListener(this.notifyChanged);
}
// invoked from the store
notifyChanged() {
// rereads data from the store
this.index = store.getSelectedItem();
// reloading the data
render();
}
render() {
const elem = document.getElementById('view');
elem.innerHTML = `Your selected index is: ${this.index}`;
}
}
let view = new View();
// view.html
<html>
<body>
<div id="view"></div>
</body>
</html>
在前面的代码中,我们实现了notifyChanged()方法,当调用该方法时,它会从存储中调用getSelectedItem()方法,从而接收新的值。
在这一点上,我们已经描述了整个链:一个视图如何接收用户交互,将其转换为动作,然后将其分发给存储,存储更新其状态。存储随后发出一个事件,另一个视图正在监听这个事件。当接收到事件时,视图会重新读取存储的状态,然后视图可以自由地以它认为合适的方式渲染它刚刚读取的状态。
在这里,我们描述了两件事:
-
如何设置流程
-
Flux 中信息流的方式
设置流程可以用以下图表表示:

对于第二个场景,信息在系统中的流动可以以下方式表示:

展示统一数据流
好的,所以我们已经描述了我们的应用程序由哪些部分组成:
-
一个用户可以从中选择索引的视图
-
一个允许我们发送消息的分发器
-
包含我们选择索引的存储
-
从存储中读取选择索引的第二个视图
让我们从所有这些构建一个真正的应用程序。以下代码位于代码库中的Chapter2/demo目录下。
创建选择视图
首先,我们需要我们的视图,在其中我们将执行选择操作:
// demo/selectionView.js
import dispatcher from "./dispatcher";
console.log('selection view loaded');
class SelectionView {
selectIndex(index) {
console.log('selected index ', index);
dispatcher.dispatch({
type: "SELECT_INDEX",
data: index
});
}
}
const view = new SelectionView();
export default view;
我们在上面的selectIndex()方法上加了粗体,这是我们打算使用的。
添加分发器
接下来,我们需要一个能够接收我们的消息的分发器:
// demo/dispatcher.js
class Dispatcher {
constructor() {
this.listeners = [];
}
dispatch(message) {
this.listeners.forEach(listener => listener(message));
}
register(listener) {
this.listeners.push(listener);
}
}
const dispatcher = new Dispatcher();
export default dispatcher;
添加存储
存储将作为我们状态的数据源,但也可以告诉任何监听器存储何时发生变化:
// demo/store.js
import dispatcher from './dispatcher';
function selectIndex(index) {
store["selectedIndex"] = index;
}
// 1) store registers with dispatcher
dispatcher.register(message => {
switch (message.type) {
// 3) message is sent by dispatcher ( that originated from the first view)
case "SELECT_INDEX":
selectIndex(message.data);
// 4) listener, a view, is being notified of the change
store.emitChange();
break;
}
});
class Store {
constructor() {
this.listeners = [];
}
// 2) listener is added by a view
addListener(listener) {
if (!this.listeners["change"]) {
this.listeners["change"] = [];
}
this.listeners["change"].push(listener);
}
emitChange() {
if (this.listeners["change"]) {
this.listeners["change"].forEach(cb => cb());
}
}
getSelectedItem() {
return store["selectedIndex"];
}
}
const store = new Store();
export default store;
添加一个选择视图
这个视图将向存储注册自己并请求其内容的更新。如果有任何更新,它将收到通知,并从存储中读取数据,然后这个视图将传达存储的当前值:
// demo/selectedView.js
import store from "./store";
console.log('selected view loaded');
class SelectedView {
constructor() {
this.index = 0;
store.addListener(this.notifyChanged.bind(this));
}
notifyChanged() {
this.index = store.getSelectedItem();
console.log('new index is ', this.index);
}
}
const view = new SelectedView();
export default SelectedView;
运行演示
在我们能够运行我们的演示之前,我们需要一个应用程序文件,app.js。app.js文件应该引入我们的视图并执行选择:
// demo/app.js
import selectionView from './selectionView';
import selectedView from './selectedView';
// carry out the selection
selectionView.selectIndex(1);
为了运行我们的演示,我们需要编译它。上面我们使用 ES2015 模块。为了编译这些模块,我们将使用webpack。我们需要在终端中键入以下内容来安装webpack:
npm install webpack webpack-cli --save-dev
一旦我们这样做,我们需要创建一个webpack.config.js文件,告诉 Webpack 如何编译我们的文件以及将结果包放在哪里。这个文件看起来如下:
// webpack.config.js
module.exports = {
entry: "./app.js",
output: {
filename: "bundle.js"
},
watch: false
};
这告诉 Webpack,app.js是应用程序的入口点,它应该在创建输出文件bundle.js时爬取所有依赖项。Webpack 默认将bundle.js放在dist目录中。
再说一件事,我们需要一个名为index.html的 HTML 文件。我们将将其放在dist文件夹下。它应该看起来像这样:
// demo/dist/index.html
<html>
<body>
<script src="img/bundle.js"></script>
</body>
</html>
最后,为了运行我们的应用程序,我们需要用 Webpack 编译它,启动一个 HTTP 服务器并启动一个浏览器。我们将使用以下命令从demo目录中完成所有这些:
webpack && cd dist && http-server -p 5000
现在,启动一个浏览器并导航到http://localhost:5000。你应该看到以下内容:

所有这些都展示了如何通过使用dispatcher和存储使视图能够进行通信。
向我们的流程中添加更多动作
让我们在这里做一个现实检查。我们没有像我们本可以做的那样把 Flux 流程做得那么漂亮。整体图景是正确的,但如果我们可以稍微清理一下,为更多的动作腾出空间,那么我们就能真正感受到应用程序应该如何从这里开始增长。
清理视图
第一件事是看看我们的第一个视图以及它是如何响应用户交互的。它目前看起来是这样的:
// first.view.js
import dispatcher from "./dispatcher";
class FirstView {
selectIndex(index) {
dispatcher.dispatch({
type: "SELECT_INDEX",
data: index
});
}
}
let view = new FirstView();
在混合中添加更多动作意味着我们需要扩展视图以包含一些方法,如下所示:
// first.viewII.js
import dispatcher from "./dispatcher";
class View {
selectIndex(data) {
dispatcher.dispatch({
type: "SELECT_INDEX",
data
});
}
createProduct(data) {
dispatcher.dispatch({
type: "CREATE_PRODUCT",
data
});
}
removeProduct(data) {
dispatcher.dispatch({
type: "REMOVE_PRODUCT",
data
});
}
}
let view = new View();
好的,所以现在我们知道了如何添加动作。不过,由于所有这些对dispatcher和魔法字符串的调用,它看起来有点丑陋,所以我们通过创建一个包含常量的文件来清理一下,这个文件叫做product.constants.js,它包含以下代码:
// product.constants.js
export const SELECT_INDEX = "SELECT_INDEX",
export const CREATE_PRODUCT = "CREATE_PRODUCT",
export const REMOVE_PRODUCT = "REMOVE_PRODUCT"
让我们再做一些事情。让我们把dispatcher移动到product.actions.js文件中;这通常被称为动作创建器。这将包含dispatcher并引用我们的product.constants.js文件。所以让我们创建这个文件:
// product.actions.js
import {
SELECT_INDEX,
CREATE_PRODUCT,
REMOVE_PRODUCT
} from "./product-constants";
import dispatcher from "./dispatcher";
import ProductConstants from "./product.constants";
export const selectIndex = data =>
dispatcher.dispatch({
type: SELECT_INDEX,
data
});
export const createProduct = data =>
dispatcher.dispatch({
type: CREATE_PRODUCT,
data
});
export const removeProduct = data =>
dispatcher.dispatch({
type: REMOVE_PRODUCT,
data
});
使用这些结构,我们可以大大清理我们的视图,使其看起来像这样:
// first.viewIII.js
import {
selectIndex,
createProduct,
removeProduct
} from 'product.actions';
function View() {
this.selectIndex = index => {
selectIndex(index);
};
this.createProduct = product => {
createProduct(product);
};
this.removeProduct = product => {
removeProduct(product)
};
}
var view = new View();
清理存储
我们可以在存储上做一些改进。实际上,没有必要编写我们目前所做的所有代码。事实上,有一些库在处理某些功能方面做得更好。
在我们应用所有这些我们心中的变化之前,让我们回顾一下我们的商店能做什么,以及清理工作之后还需要哪些功能。
让我们提醒自己,我们的商店目前能做什么:
-
处理状态变化:它处理状态变化;商店能够在创建、更新、列出或删除状态时改变状态。
-
订阅功能:它允许您订阅它;对于商店来说,拥有订阅功能非常重要,这样视图就可以在状态改变时监听商店的状态。视图的一个合适的反应是,例如,根据新数据重新渲染。
-
可以通信状态变化:它可以发送一个事件,表明其状态已更改;这与能够订阅商店的能力相一致,但这实际上是通知监听器状态已更改的行为。
添加EventEmitter
最后两个要点实际上可以归纳为一个主题,即事件处理,或者注册和触发事件的能力。
那么,商店的清理看起来是什么样子,我们为什么需要清理它?清理的原因是它使代码更简单。在构建商店时,经常使用的一个标准库是EventEmitter。这个库处理的就是我们之前提到的,即它能够注册和触发事件。这是一个简单的发布/订阅模式的实现。基本上,EventEmitter允许您订阅某些事件,并允许您触发事件。有关该模式的更多信息,请参阅以下链接:en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern。
您当然可以为此编写自己的代码,但能够使用一个专门的库来专注于其他重要的事情,比如解决业务问题,这会很好。
我们决定使用EventEmitter库,并且我们以以下方式使用它:
// store-event-emitter.js
export const Store = (() => {
const eventEmitter = new EventEmitter();
return {
addListener: listener => {
eventEmitter.on("changed", listener);
},
emitChange: () => {
eventEmitter.emit("changed");
},
getSelectedItem: () => store["selectedItem"]
};
})();
这使得我们的代码更加简洁,因为我们不再需要保留一个内部订阅者列表。尽管如此,我们还可以做出更多改变,所以让我们在下一节中讨论这一点。
添加和清理注册方法
商店的一个任务就是处理事件,特别是当商店想要向视图传达其状态发生变化时。在store.js文件中,还有其他事情在进行,比如用dispatcher注册自己并能够接收分发的动作。我们使用这些动作来改变商店的状态。让我们提醒自己这看起来是什么样子:
// store.js
let store = {};
function selectIndex(index) {
store["selectedIndex"] = index;
}
dispatcher.register(message => {
switch (message.type) {
case "SELECT_INDEX":
selectIndex(message.data);
break;
}
});
在这里,我们只支持一个动作,即SELECT_INDEX。这里我们需要做两件事:
-
添加其他两个动作
CREATE_PRODUCT和REMOVE_PRODUCT,以及相应的函数createProduct()和removeProduct() -
停止使用魔法字符串,开始使用我们的常量文件
-
使用我们在
store-event-emitter.js文件中创建的存储
让我们实施我们前面列表中的建议更改:
// store-actions.js
import dispatcher from "./dispatcher";
import {
SELECT_INDEX,
CREATE_PRODUCT,
REMOVE_PRODUCT
} from "./product.constants";
let store = {};
function selectIndex(index) {
store["selectedIndex"] = index;
}
export const Store = (() => {
var eventEmitter = new EventEmitter();
return {
addListener: listener => {
eventEmitter.on("changed", listener);
},
emitChange: () => {
eventEmitter.emit("changed");
},
getSelectedItem: () => store["selectedItem"]
};
})();
dispatcher.register(message => {
switch (message.type) {
case "SELECT_INDEX":
selectIndex(message.data);
break;
}
});
const createProduct = product => {
if (!store["products"]) {
store["products"] = [];
}
store["products"].push(product);
};
const removeProduct = product => {
var index = store["products"].indexOf(product);
if (index !== -1) {
store["products"].splice(index, 1);
}
};
dispatcher.register(({ type, data }) => {
switch (type) {
case SELECT_INDEX:
selectIndex(data);
break;
case CREATE_PRODUCT:
createProduct(data);
break;
case REMOVE_PRODUCT:
removeProduct(data);
}
});
进一步改进
我们肯定可以对这段代码进行更多的改进。我们确实使用了 ES2015 导入来导入其他文件,但大部分代码是用 ES5 编写的,所以为什么不使用 ES2015 提供的大部分功能呢?我们可以做出的另一个改进是引入不可变性,并确保我们的存储不被修改,而是从一个状态过渡到另一个状态。
让我们看看存储文件,主要是因为那里我们可以添加最多的 ES2015 语法。我们的模块揭示模式目前看起来是这样的:
// store-event-emitter.js
var Store = (function(){
const eventEmitter = new EventEmitter();
return {
addListener: listener => {
eventEmitter.on("changed", listener);
},
emitChange: () => {
eventEmitter.emit("changed");
},
getSelectedItem: () => store["selectedItem"]
};
})();
这可以用一个简单的类来替换,而不是实例化一个 EventEmitter,我们可以从它继承。公平地说,我们本可以使用 ES2015 继承或合并库来避免创建单独的 EventEmitter 实例,但这展示了 ES2015 可以如何使事情变得优雅:
// store-es2015.js
import { EventEmitter } from "events";
import {
SELECT_INDEX,
CREATE_PRODUCT,
REMOVE_PRODUCT
} from "./product.constants";
let store = {};
class Store extends EventEmitter {
constructor() {}
addListener(listener) {
this.on("changed", listener);
}
emitChange() {
this.emit("changed");
}
getSelectedItem() {
return store["selectedItem"];
}
}
const storeInstance = new Store();
function createProduct(product) {
if (!store["products"]) {
store["products"] = [];
}
store["products"].push(product);
}
function removeProduct(product) {
var index = store["products"].indexOf(product);
if (index !== -1) {
store["products"].splice(index, 1);
}
}
dispatcher.register(({ type, data }) => {
switch (type) {
case SELECT_INDEX:
selectIndex(data);
storeInstance.emitChange();
break;
case CREATE_PRODUCT:
createProduct(data);
storeInstance.emitChange();
break;
case REMOVE_PRODUCT:
removeProduct(data);
storeInstance.emitChange();
}
});
添加不可变性
我们可以做的另一件事是添加不可变性。最初使用不可变性的原因是为了使你的代码更具可预测性,并且一些框架可以使用这一点来进行更简单的变更检测,并依赖于引用检查而不是脏检查。当 AngularJS 在 Angular 被编写时,其整个变更检测机制发生了变化,这就是这种情况。从实际的角度来看,这意味着我们可以在存储中针对某些函数并应用不可变原则。第一个原则是不修改,而是创建一个全新的状态,而不是新的状态是 旧状态 + 状态变更。一个简单的例子如下:
var oldState = 3;
var newState = oldState + 2
在这里,我们创建了一个新的变量 newState,而不是修改我们的 oldState 变量。有一些函数可以帮助我们完成这个任务,称为 Object.assign 和 filter 函数。我们可以使用这些函数来更新场景,以及从列表中添加或删除事物。让我们使用这些函数并重写我们存储代码的一部分。让我们突出显示我们打算更改的代码:
// excerpt from store-actions.js
const createProduct = product => {
if (!store["products"]){
store["products"] = [];
}
store["products"].push(product);
};
const removeProduct = product => {
var index = store["products"].indexOf(product);
if (index !== -1) {
store["products"].splice(index, 1);
}
};
让我们应用 Object.assign 和 filter(),并记住不要修改任何东西。最终结果应该看起来像这样:
// excerpt from our new store-actions-immutable.js
const createProduct = product => {
if (!store["products"]) {
store["products"] = [];
}
store.products = [...store.products, Object.assign(product)];
};
const removeProduct = product => {
if (!store["products"]) return;
store["products"] = products.filter(p => p.id !== product.id);
};
我们可以看到,createProduct() 方法使用了 ES2015 的一个构造,即扩展运算符 ...,它将一个列表的成员转换成一个以逗号分隔的项目列表。我们使用 Object.assign() 来复制对象中的所有值,因此我们存储的是对象值而不是其引用。当我们使用过滤方法时,removeProduct() 方法变得非常简单。我们只需创建一个不包括我们要删除的产品的新投影;删除从未如此简单或优雅。我们没有对任何东西进行修改。
总结
我们的清理工作从视图开始;我们想要移除与 dispatcher 的直接连接,并停止使用魔法字符串,因为这很容易出错,也容易拼错。相反,我们可以依赖常量。为了解决这个问题,我们创建了一个与 dispatcher 通信的动作创建器类。
我们还创建了一个常量模块来移除魔法字符串。
此外,我们通过开始使用 EventEmitter 来改进了商店。最后,我们通过向其中添加更多动作并开始引用常量来进一步改进了商店。
到目前为止,我们的解决方案已经准备好添加更多操作,我们应该对需要添加哪些文件有相当清晰的认识,因为我们支持越来越多的用户交互。
最后,我们在 ES2015 和不可变性方面进行了改进,这使得我们的代码看起来干净得多。有了这个基础,我们现在可以准备好从静态数据过渡到涉及副作用和 Ajax 的下一节。
让我们通过一个显示我们流程中添加的结构的图表来总结我们的所有改进:

很明显,使用动作创建器并不是严格必要的,但它确实使代码更加整洁,同样,在商店中使用 EventEmitter 也是如此;它很好,但不是必要的。
添加 AJAX 调用
到目前为止,我们只在我们 Flux 流中处理静态数据。现在,是时候向流程添加真实的数据连接和真实数据了。是时候开始通过 AJAX 和 HTTP 与 API 通信了。由于有了 fetch API 和像 RxJS 这样的库,获取数据现在相当简单。在将它们融入流程时,你需要考虑的是:
-
HTTP 调用的放置位置
-
如何确保商店更新并且通知感兴趣的视图
我们有一个注册商店到 dispatcher 的点,使用以下代码:
// excerpt from store-actions-immutable.js
const createProduct = (product) => {
if (!store["products"]) {
store["products"] = [];
}
store.products = [...store.products, Object.assign(product)];
}
dispatcher.register(({ type, data }) => {
switch (type) {
case CREATE_PRODUCT:
createProduct(data);
store.emitChange();
break;
/* other cases below */
}
})
如果我们真的这样做,也就是说,调用 API 来持久化此产品,createProduct() 就是我们进行 API 调用的地方,如下所示:
// example use of fetch()
fetch(
'/products' ,
{ method : 'POST', body: product })
.then(response => {
// send a message to the dispatcher that the list of products should be reread
}, err => {
// report error
});
调用 fetch() 返回一个 Promise。但是,让我们使用 async/await,因为它使调用更加易读。代码中的差异可以在以下示例中看到:
// contrasting example of 'fetch() with promise' vs 'fetch with async/await'
fetch('url')
.then(data => console.log(data))
.catch(error => console.error(error));
// using async/await
try {
const data = await fetch('url');
console.log(data);
} catch (error) {
console.error(error);
}
用这个替换 createProduct() 中的内容会增加很多噪音,所以将你的 HTTP 交互包装在一个 API 构造中是个好主意,如下所示:
// api.js
export class Api {
createProduct(product) {
return fetch("/products", { method: "POST", body: product });
}
}
现在让我们将 createProduct() 方法的内容替换为对 API 构造的调用,如下所示:
// excerpt from store-actions-api.js
import { Api } from "./api";
const api = new Api();
createProduct() {
api.createProduct();
}
但这还不够。因为我们通过 API 调用创建了一个产品,所以我们应该分发一个动作来强制重新读取产品列表。我们没有在商店中处理这种情况的动作或支持方法,所以让我们添加一个:
// product.constants.js
export const SELECT_INDEX = "SELECT_INDEX";
export const CREATE_PRODUCT = "CREATE_PRODUCT";
export const REMOVE_PRODUCT = "REMOVE_PRODUCT";
export const GET_PRODUCTS = "GET_PRODUCTS";
现在让我们在商店中添加所需的方法和处理它的案例:
// excerpt from store-actions-api.js
import { Api } from "./api";
import {
// other actions per usual
GET_PRODUCTS,
} from "./product.constants";
const setProducts = (products) => {
store["products"] = products;
}
const setError = (error) => {
store["error"] = error;
}
dispatcher.register( async ({ type, data }) => {
switch (type) {
case CREATE_PRODUCT:
try {
await api.createProduct(data);
dispatcher.dispatch(getProducts());
} catch (error) {
setError(error);
storeInstance.emitError();
}
break;
case GET_PRODUCTS:
try {
const products = await api.getProducts();
setProducts(products);
storeInstance.emitChange();
}
catch (error) {
setError(error);
storeInstance.emitError();
}
break;
}
});
我们可以看到,CREATE_PRODUCT 情况将调用相应的 API 方法 createProduct(),在完成之后将分派 GET_PRODUCTS 动作。这样做的原因是,当我们成功创建一个产品时,我们需要从端点读取以获取产品列表的更新版本。我们没有看到详细情况,但它是通过我们调用 getProducts() 来触发的。再次强调,有一个包装器来包装所有被分派的操作是件好事,这个包装器就是一个动作创建器。
整个文件看起来像这样:
// store-actions-api.js
import dispatcher from "./dispatcher";
import { Action } from "./api";
import { Api } from "./api";
import {
CREATE_PRODUCT,
GET_PRODUCTS,
REMOVE_PRODUCT,
SELECT_INDEX
} from "./product.constants";
let store = {};
class Store extends EventEmitter {
constructor() {}
addListener(listener) {
this.on("changed", listener);
}
emitChange() {
this.emit("changed");
}
emitError() {
this.emit("error");
}
getSelectedItem() {
return store["selectedItem"];
}
}
const api = new Api();
const storeInstance = new Store();
const selectIndex = index => {
store["selectedIndex"] = index;
};
const createProduct = product => {
if (!store["products"]) {
store["products"] = [];
}
store.products = [...store.products, Object.assign(product)];
};
const removeProduct = product => {
if (!store["products"]) return;
store["products"] = products.filter(p => p.id !== product.id);
};
const setProducts = products => {
store["products"] = products;
};
const setError = error => {
store["error"] = error;
};
dispatcher.register(async ({ type, data }) => {
switch (type) {
case "SELECT_INDEX":
selectIndex(message.data);
storeInstance.emitChange();
break;
case CREATE_PRODUCT:
try {
await api.createProduct(data);
storeInstance.emitChange();
} catch (error) {
setError(error);
storeInstance.emitError();
}
break;
case GET_PRODUCTS:
try {
const products = await api.getProducts();
setProducts(products);
storeInstance.emitChange();
} catch (error) {
setError(error);
storeInstance.emitError();
}
break;
}
});
一个更大的解决方案
到目前为止,我们一直在描述一个只包含产品主题和通信只发生在视图之间的解决方案。在一个更现实的应用中,我们会有很多主题,比如用户管理、订单等等;它们的确切名称取决于你应用程序的领域。至于视图,完全有可能你会有很多视图在监听另一个视图,就像这个例子一样:

这描述了一个包含四个不同视图组件的应用程序,这些组件围绕它们自己的主题。客户视图包含客户列表,并允许我们更改我们当前想要关注的客户。其他三个辅助视图显示订单、消息和朋友,它们的内容取决于当前突出显示的客户。从 Flux 的角度来看,订单、消息和朋友视图可以轻松注册到存储中,以便知道何时更新,这样它们就可以获取/重新获取所需的数据。然而,想象一下,辅助视图本身也想支持 CRUD 操作;那么,它们就需要自己的常量集、动作创建器、API 和存储。因此,现在你的应用程序可能看起来像这样:
/customers
constants.js
customer-actions.js
customer-store.js
customer-api.js
/orders
constants.js
orders-actions.js
orders-store.js
orders-api.js
/messages
constants.js
messages-actions.js
messages-store.js
messages-api.js
/friends
constants.js
friends-actions.js
friends-store.js
friends-api.js
/common
dispatcher.js
这里存在两种有趣的情况:
-
你有一个自包含的视图;所有的 CRUD 操作都发生在这个视图内
-
你有一个需要监听其他视图的视图
对于第一种情况,一个好的经验法则是创建它自己的常量集、动作创建器、API 和存储。
对于第二种情况,确保你的视图将自己注册到该主题的存储中。例如,如果朋友视图需要监听客户视图,那么它需要将自己注册到客户存储中。
摘要
我们最初的目标只是解释 Flux 架构模式。提到它与 React 的结合以及有哪些优秀的库和工具支持 Flux 和 React,那会非常简单。然而,那样做可能会使我们的注意力从更框架无关的角度解释模式上转移。因此,本章的其余部分旨在解释核心概念,如动作、动作创建者、调度器、存储和统一数据流。我们逐步改进代码,开始使用常量、动作创建者以及像 EventEmitter 这样的优秀支持库。我们解释了 HTTP 如何融入其中,最后讨论了如何构建我们的应用程序。关于 Flux 有很多可以说的,但我们选择限制范围,以便理解基础知识,这样我们就可以在后续章节深入探讨 Redux 和 NgRx 时进行比较,而这本书的主要关注点就是这些内容。
第三章:异步编程
要了解异步代码是什么,我们首先来了解一下同步代码是什么。使用同步代码,一个语句在另一个语句之后执行。代码是可预测的;你知道会发生什么以及何时发生。这是因为你可以像这样从上到下阅读代码:
print('a')
print('b')
print('c')
// output
a, b, c
现在,使用异步代码,你将失去同步代码提供的所有美好的可预测性。事实上,关于异步代码,你了解的很少,除了它最终会执行完成。所以,异步代码,或者称为 async 代码,看起来更像是这样的:
asyncPrint('a')
asyncPrint('b')
asyncPrint('c')
// output
c, b, a
如你所见,一个语句完成的顺序并不是由该语句在代码中出现的顺序决定的。相反,有一个时间元素参与其中,它决定了何时一个语句完成了它的运行过程。
异步代码在事件循环中运行。这意味着 async 代码按照以下顺序运行:
-
运行异步代码
-
等待响应准备好,然后触发中断
-
运行事件处理器
在这里需要强调的一个重要事情是,异步代码是非阻塞的——其他操作可以在异步代码运行时进行。因此,异步代码是处理 I/O、长时间运行的任务和网络请求的好候选。
在本章中,我们将:
-
了解异步编程是什么以及它与同步编程有何不同
-
解释回调模型
-
描述承诺以及它们是如何完全改变我们编写异步代码的方式
-
看看其他存在的异步库以及它们应该在什么情况下使用
-
发现新的标准 async/await
回调模式
以前,我们描述了当你在作为开发者的日常生活中遇到异步和同步代码时,它们看起来是什么样子。可能有趣的是了解操作系统如何看待此类代码以及它是如何处理它们的。操作系统通过以下概念来处理异步代码:
-
事件,这些是向操作系统发出信号的消息,表明已经发生某种类型动作
-
事件处理器,这是当事件发生时应该运行的代码片段
-
事件队列,这是放置所有事件及其事件处理器的位置,等待执行
让我们在以下图中说明这种流程:

在前面的图像中,我们可以看到事件是如何从一个事件队列中被选取的。在这里,当分发器告诉它时,点击事件会被执行,并且相应的事件处理程序被执行。事件处理程序运行事件处理程序中的相关代码行,当完成时,将控制权交还给分发器。之后,队列中的下一个事件开始新一轮的循环。这就是在单线程系统中通常的样子,其中一次只执行一个事件处理程序。也存在多线程系统。在多线程系统中,存在多个线程。这意味着我们可能同时执行几个事件处理程序。但尽管有多个线程,只有一个活动线程。系统本身仍然是单线程的。困惑吗?这里的关键是:多线程系统中的线程是协作的,这意味着它们可以被中断。这意味着在完成一个工作单元后,活动线程会被改变。这产生了一种效果,似乎所有事情都在并行发生。让我们为了清晰起见来举例说明:

在这里,我们可以看到一段代码被分成了不同的区域。当某个区域被执行后,它将控制权交给下一个线程,这个线程成为新的活动线程。一旦该线程通过其某个区域执行了代码,它将控制权交给下一个线程。随着多个 CPU 的出现,我们能够从感知的并行性(之前已描述)转变为实际的并行执行。在这种现实中,每个 CPU 存在一个线程,因此我们有多条活动线程。
这些是您可以执行异步代码的不同方式。我们将关注单线程执行,因为这是 JavaScript 和网页中实现的方式。
网页上的回调模式
处理它的方法是附加函数到未来的事件上。当事件发生时,我们附加的函数被执行。一个例子是XMLHttpRequest,它看起来像这样:
const xhr = new XMLHttpRequest();
xhr.open('GET','/path', true);
xhr.onload = () => {
// run me when the request is finished
}
xhr.send(null);
在这里,我们可以看到除了xhr.onload之外的所有行都是同步执行的。将函数附加到onload是同步的,但是运行onload指向的函数不会发生,直到请求完成。我们也可以定义其他事件,例如onreadystatechange,并将一个函数附加到它上:
xhr.onreadystatechange = () => {}
由于网页是单线程的,这就是我们处理异步代码的方式。onreadystatechange对象及其回调被注册到操作系统中。一旦异步部分完成,操作系统会被一个事件分发唤醒。之后,回调被调用。
Node.js 中的回调模式
Node.js 是单线程的,就像网络一样。为了处理长时间运行的操作,它也使用回调模式。Node.js 中的回调模式有一些更详细的细节,可以描述为具有以下属性:
-
只有一个函数来处理成功和错误响应
-
回调只被调用一次
-
函数是调用函数的最后一个参数
-
回调包含参数的错误和结果,顺序排列,这也被称为错误优先
现在我们来展示调用代码的样子,其中回调作为函数的最后一个参数提供:
callAsync('1',2, (error, response) => {
if(error) {
console.error(error);
} else {
console.log('response', response);
// do something with the response
}
})
这段代码满足了模式所规定的所有属性,即函数调用中的最后一个参数是回调函数。此外,回调函数将错误作为第一个参数,将响应作为第二个参数。此外,回调函数的主体首先检查是否存在错误,然后在没有错误的情况下处理我们得到的响应。
作为参考,让我们也看看callAsync()是如何实现的:
function callAsync(param, param2, fn) {
setTimeout(() => {
if(param > param2) {
fn(null, 'success');
} else {
fn('error', null);
}
}
之前的实现只是一个原型,但它确实展示了两个重要的方面。一方面是setTimeout()函数所代表的时间因素,以及函数需要时间才能完成的事实。
另一方面是我们的第三个参数fn(),它的调用方式不同。当一切顺利时,我们调用fn(null, 'success');当发生错误时,我们调用fn('error', null)。我们调用fn()的方式就是如何传达成功和失败。
结构化异步代码的问题——回调地狱
在上一节中,我们介绍了回调模式作为处理异步调用的方法。该模式提供了一种处理此类调用的结构化方式,因为我们总可以在其方法签名中知道期望什么;错误是第一个参数,第二个参数是响应,依此类推。但是,该模式确实有其缺点。这些缺点可能一开始并不明显,因为你可能只是像这样调用代码:
openFile('filename', (err, content) => {
console.log( content );
statement4;
statement5;
})
statement2;
statement3
我们在这里看到的是如何调用openFile()方法。一旦它运行完成,回调就会被调用,并在回调内部,我们继续调用statement4和statement5。
从可读性的角度来看,这看起来是不错的。但是,当你需要连续进行多个异步调用,并且这些调用相互依赖时,问题就出现了。可能首先需要登录到系统中,然后获取其他数据,或者可能意味着你需要进行一个调用,以确定哪些数据需要作为下一个调用的输入,就像这个例子中一样:
getData('url', (err, data) => {
getMoreData('newurl/'+ data.id, (moreData) => {
getEvenMoreData('moreurl/'+ moreData.id, () => {
console.log('done here');
})
})
})
我们在这里看到的一个反模式是表格化和可读性的丧失。对于每一次调用,我们看到代码缩进了一步;它是嵌套的。当我们有三次这样的调用时,我们可以看到代码看起来并不美观;它是可读的,但并不那么吸引人。另一个缺点是,从技术上讲,正确地放置括号和大括号也是一项技术挑战,我们可能会在放置这些符号时遇到困难。如果在其中加入几个if...else语句,你将很难匹配所有符号。
有几种方法可以解决这个问题:
-
保持代码简洁,并使用命名函数而不是匿名函数
-
减少认知负担,并将函数移动到它们自己的模块中
-
使用更高级的结构,例如 Promise、生成器和 ES7 及其他异步库中的异步函数
保持代码简洁是关于给我们的匿名函数一个专有的名字并将它们拆分成自己的函数;这样我们的代码看起来会是这样:
function getEvenMoreDataCallback(err, evenMoreData) {
console.log('done here');
}
function getMoreDataCallback(err, moreData){
getEvenMoreData('moreurl/'+ moreData.id, getEvenMoreDataCallback);
}
function getDataCallback(err, data){
getMoreData('newurl/'+ data.id, getMoreDataCallback);
}
getData('url', getDataCallback)
这清楚地扁平化了代码,并使其更容易阅读。它还消除了正确匹配大括号的必要性,因为函数只有一层深度。
这将代码部分移除,但仍然存在认知负担,因为我们不得不处理三个函数定义和一个函数调用。我们可以将它们移动到它们自己的专用模块中,如下所示:
let getDataCallback = require('./datacallback');
getData('url', getDataCallback);
对于其他方法,它看起来会是这样:
function getEvenMoreDataCallback(err, evenMoreData) {
console.log('done here');
}
以及这个:
var getEvenMoreDataCallback = require('./evenmorecallback');
function getMoreDataCallback(err, moreData){
getEvenMoreData('moreurl/'+ moreData.id, getEvenMoreDataCallback);
}
现在我们已经移除了很多认知代码。在这个例子中,它可能没有物有所值,因为方法并不长,但想象一下,如果方法有 30 或 40 行长;将它们放入一个单独的模块中会更有意义。
第三个选择是使用更高级的结构来处理这类代码。我们将在接下来的章节中讨论这些内容。
Promises
Promises 的出现是对上一节中描述的回调地狱问题的一种回应。它们有着相当长的历史,可以追溯到 20 世纪 80 年代初,当时传奇人物芭芭拉·利斯科夫提出了Promise这个术语。Promise的想法是将异步代码扁平化。一个Promise据说有以下状态:
-
Pending:这意味着它尚未决定,或者数据尚未可用
-
Fulfilled:数据已返回
-
Rejected:操作过程中发生了错误
Thenables
有一个重要的事情要知道,Promise会立即返回,但结果不会立即可用。Promise 也被称为thenables,因为一旦数据接收,你需要使用其then()方法注册一个回调,如下所示:
const promise = new Promise((resolve, reject) => {
// either call resolve() if we have a success or reject() if it fails
});
// the 'promise' variable points to a construct
// that will eventually contain a value
promise((data) => { // <- registering a callback on then()
// our data has arrived at this point
})
在前面的代码中,我们展示了如何创建一个 Promise 以及如何使用then()方法注册它。promise变量实例包含一个会立即返回的结构。then()方法中的回调会在数据准备好供我们使用时被调用。从这个意义上说,Promise类似于回调模式。
Promise实际上只是围绕异步构造的一个包装。
简而言之,要使用 Promises,我们需要:
-
创建
promise并确保在数据到达或发生错误时调用resolve()或reject() -
使用其
then()方法注册回调 -
注册一个回调来处理错误,因为这是负责任的做法
要使用承诺,我们需要实例化它并使其成为方法的一部分,如下所示:
function getData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('data');
},1000);
})
}
我们看到,当我们实例化一个Promise对象时,它的构造函数接受两个参数,resolve和reject。让我们将这个与我们所知的承诺可以有的状态联系起来,即挂起、已解决和拒绝。当getData()最初被调用时,返回的promise处于pending状态。两秒后,承诺将得到解决,因为我们调用了resolve()方法。让我们看看getMoreData()方法,看看我们如何将Promise置于拒绝状态:
function getMoreData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject('error from more data')
},1000);
})
}
在这种情况下,我们在两秒后调用reject()方法。这将使承诺处于拒绝状态。要从promise实例获取数据,我们需要在其上调用then()方法,如下所示:
promise.then( successCallback, <optional error call back> );
承诺的then()方法接受两个回调:第一个回调是数据回调,第二个回调是可选的错误回调。让我们在我们的定义的getData()方法中使用它,如下所示:
getData().then( (data) => {
console.log('data', data);
})
很明显,我们不能直接在方法上调用getData()来获取数据,但我们需要在它返回的promise上调用.then()。一旦我们提供了一个回调,我们就能获取数据并按我们的意愿处理它。
处理拒绝的承诺
对于拒绝的承诺,我们有两种处理方式:我们可以在.then()方法中使用第二个回调,或者我们可以使用.catch()方法。以下是我们可以使用的两个版本:
// alternative 1
getMoreData().then(
data => {
console.log('data',data);
},
err => {
console.log('error',err);
}
)
// alternative 2
getMoreData().then(data => {
console.log('data', data);
})
.catch((err) => {
console.log('error', err);
});
在第一种情况下,我们在then()方法中添加了第二个回调,而在第二种版本中,我们将一个catch()方法链接到现有的then()方法上。它们是等效的,所以你可以使用任何一个,但只能使用一个。
链式操作 – 处理多个承诺
承诺最强大的功能在于其链式调用的能力,从而使代码看起来是同步的。一个链式调用看起来像这样:
getData()
.then(getMoreData)
.then(getEvenMoreData)
.catch(handleError)
这使得代码非常易于阅读。你可以知道事情发生的顺序;即,getData()之后是getMoreData(),然后是getEvenMoreData()。我们不仅能够按照我们想要的顺序运行方法,而且还可以访问前一个promise中的数据,如下所示:
function getData() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('data');
})
})
}
function getMoreData(data) { // data is from getData
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('data');
})
})
}
getData().then(getMoreData)
我们还可以看到如何将.catch()方法添加到末尾以处理错误。链式承诺的性质是这样的,错误会一直传播到catch()方法。
然而,在特定级别处理错误是完全可能的,如下所示:
getData()
.then(getMoreData, (err) => {}) // local error handler
.then(getEvenMoreData )
.then(data => {} )
.catch(handleError ) // global error handler
现在我们有两个错误处理器,一个在本地级别,即.then(getMoreData, (err) => {})作为then()方法的第二个参数。这与只在调用链底部添加.catch()的效果不同。如果只有底部的.catch()方法存在,那么链路就会短路。目前,当getMoreData()方法拒绝promise时,当前链路将调用本地错误函数、.catch()方法和最后一个.then()方法。然而,如果promise被拒绝,最后一个.then()方法中的数据参数将不会被设置。链式调用非常强大,它给我们带来了以下功能:
-
按顺序调用异步方法
-
将之前解析的承诺(promise)数据作为我们方法的输入
-
能够全局处理错误以及针对每个承诺(promise)处理错误,尽管结果可能不同
异步库
到目前为止,我们讨论了回调模式以及使用承诺如何给你的代码带来急需的秩序感。编写异步代码不仅仅是停止自己陷入混乱的代码中,它还关乎生产力。有些库可以让你在真正致力于直接处理异步编程时变得非常高效。在撰写本文时,最知名的库包括:
-
Async:这是最广为人知的。它可以在
caolan.github.io/async/找到。 -
步骤:这个库将自己定位为一个可以帮助你进行串行执行、并行执行,并承诺使错误处理变得轻松的库。它可以在
github.com/creationix/step找到。 -
Node fibers:这是一个与前面两个库非常不同的库,可以将其视为为 JavaScript 带来轻量级线程(light-thread)支持。它可以在
github.com/laverdet/node-fibers找到。
Async 库
我们已经展示了回调和承诺。我们从回调的问题,即回调地狱,以及承诺是如何解决这个问题的。然而,有一个名为async的库,它是回调和承诺的替代品。那么,我们为什么要使用 async 库呢?async 库旨在在异步上下文中操作集合。库的作者自己是这样说的:
Async 是一个提供直接、强大函数的实用模块,用于处理异步 JavaScript
因此,如果你的异步代码开始变得难以管理,而你又发现自己想要操作异步集合而不是零散的几个调用,这个库可能适合你。在大多数情况下,承诺(promises)可能是你想要的。
Async 库提供了许多有用的功能。Async 库的思路是让你的代码看起来更好,这样你就可以专注于构建事物,而不是挣扎着去理解代码在做什么。
要使用它,只需通过输入以下命令进行安装:
npm install async --save
async.map()
让我们看看一个例子,其中async大放异彩,能够移除不必要的代码。以下示例展示了我们如何调用fs.stat()方法,该方法将异步地告诉我们关于文件的信息,例如其大小、创建时间等。一个普通的调用fs.stat()看起来像这样:
// async-demo/app.js
const fs = require('fs');
const basePath = __dirname + '/files/';
const files = ['file1.txt', 'file2.txt', 'file3.txt'];
fs.stat(basePath + 'file1.txt', (err, result) => {
if(err) {
console.log('err');
} else {
const { size, birthtime } = result;
console.log('Size',size);
console.log('Created', birthtime);
}
});
如果我们想要进行多个电话并且想要知道几个文件的状态呢?一次发送多个电话——每个文件一个电话——意味着我们的电话会在不同时间返回,这取决于文件的大小。如果我们不介意在所有电话都返回之前不关心响应怎么办?这正是异步库能帮助我们解决的问题。这里有一个map()函数,它允许我们同时发送多个电话,并且只有在所有电话都完成后才返回,如下所示:
// app-map.js
const async = require('async');
const fs = require('fs');
const basePath = __dirname + '/files/';
const files = ['file1.txt', 'file2.txt', 'file3.txt'];
const mappedFiles = files.map( f => basePath + f);
async.map(mappedFiles, fs.stat,(err, results) => {
if(err) {
console.log('error', err);
}else {
// looping through our results array
results.forEach(({size, birthtime}) => {
console.log('Size',size);
console.log('Created', birthtime);
});
}
});
那么,是什么让它如此出色呢?首先,我们的代码旨在找出每个文件的一些文件统计信息。让我们看看没有异步库的生活会是什么样子:
// example of running a callback method in a forEach()
['file1','file2','file3'].forEach( f => {
var states = [];
fs.stat(f, (err, stat) => {
console.log('stat', stat);
states.push( stat );
})
})
我们可以看到,我们需要引入一个状态数组来收集所有结果,即使这样,我们可能还需要添加一些逻辑来知道我们是否处于数组的最后一个项目,因此可以根据我们现在已经拥有所有结果的事实开始处理。
因此,从所有这些中我们可以得出的结论是,async.map()帮助我们调用一系列异步调用到一个调用中,使我们能够在每个电话完成后处理所有结果,而不是在之前。
async.parallel()
这个库中另一个重要的方法是async.parallel(),它允许我们并行发送很多语句,如下所示:
// async-demo/app-parallell.js
const async = require('async');
function getMessages(fn) {
setTimeout(() => {
fn(null,['mess1', 'mess2', 'mess3']);
}, 3000);
}
function getOrders(fn) {
setTimeout(() => {
fn(null, ['order1', 'order2', 'order3']);
}, 5000);
}
async.parallel([
getMessages,
getOrders
],(error, results) => {
if(error) {
console.log('error', error);
} else {
console.log('results', results);
}
});
从前面的代码中我们可以看到,它允许我们并行启动多个电话。我们在提供给async.parallell([])方法的数组中指定电话。从你在这里可以辨别出的信息来看,我们提供的函数接受一个参数,fn,它是回调函数,例如getOrders(fn) {}。
async.series()
另一种情况是,你可能希望电话一个接一个地发生。为此,我们得到了async.series()方法,我们这样调用它:
async.series([
function login(){}
function loadUserDetails() {}
],(result) => {})
以这种方式运行代码保证了代码的运行顺序,同时也确保了如果发生错误,调用链不会继续。
这个库中有许多有用的函数,我们强烈建议您查看caolan.github.io/async/docs.html上的文档。
Async/await
async/await 是 ECMAScript 标准 ES2017 的一部分。这个构造在处理异步操作时提供了同步的体验。目前,您需要像 Babel 这样的工具在前端运行它,但对于 Node.js 来说,在版本>= 8 上运行它就足够了。Async/await 在后台通过一个称为生成器(generators)的概念来实现。生成器是可以在之后退出和重新进入的函数。要了解更多关于生成器的信息,请查看以下链接:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function*。这是处理异步代码的新方法,它确实有助于使我们的代码看起来更同步,从而减少了与异步编程相关的认知痛苦。
让我们回顾一下我们的旧例子,说明回调地狱的情况:
getData()
.then( data => {
getMoreData(moreData => {
getEvenMoreData(() => {
// do stuff
})
})
});
我们清楚地看到了以这种方式调用代码的缺点。async/await 扮演了救世主的角色,因为它确实清理了这里的事情。然而,让我们首先解释不同的部分以及我们如何努力重构先前的例子。使用 async/await 的方法通常是最高级的方法;在链式async方法中的最高级意味着它是第一个被调用的方法。在先前的例子中,这将是由getData()方法。让我们将getData()转换成如下形式:
async function getData() {
// more to come
}
在这一点上,我们需要意识到的是,我们需要将另外两个方法getMoreData()和getEvenMoreData()重构为返回 Promise 而不是基于回调的方法。为什么你会这样问呢?好吧,当我们使用 async/await 时,我们希望以某种方式调用代码。正如之前所暗示的,我们将在getData()函数前使用关键字async。更重要的是,我们希望以下方式使用关键字await:
async function getData() {
let data = await getMoreData();
let otherData = await getEvenMoreData();
}
看看前面的代码,我们意识到我们的现有方法签名存在不匹配。不匹配不是我们需要将我们的实现切换为基于 Promise 的主要原因。真正的原因是await关键字能够展开 Promise,但不能展开基于回调的方法。展开意味着它可以从我们的异步操作的结果中取出值并返回。
在将它们转换为 Promise 之前,我们的方法当前的状态是:
function getMoreData(cb) {
setTimeout(() => cb('more data'), 3000);
}
function getEvenMoreData(cb) {
setTimeout( () => cb('even more data'), 3000 );
}
将它们转换为基于 Promise 的方法意味着它们现在应该看起来像这样:
function getMoreData() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve('more data'))
});
}
function getEvenMoreData() {
return new Promise((resolve, reject) => {
setTimeout(() => resolve('more data'))
});
}
在这一点上,我们准备返回到getData()方法并添加缺失的代码。当我们调用getMoreData()和getEvenMoreData()时,我们现在可以使用关键字await等待 Promise 解析,如下所示:
async function getData() {
var data = await Promise.resolve('data');
var moreData = await getMoreData(data);
var evenMoreData = await getEvenMoreData(moreData);
return evenMoreData;
}
现在我们得到的是完全同步看起来似的代码。那么我们如何从getData()中检索数据呢?很简单——它返回一个promise。因此,我们可以这样调用它:
getData().then((result) => console.log('result', result) );
async/await 真的是一个强大的结构,因为它消除了由回调地狱引起的许多认知痛苦,并进一步改进了承诺的概念。
摘要
在本章中,我们介绍了异步代码的使用情况及其目的。很明显,随着异步代码的增多,其可读性和可维护性会越来越低,从而产生了诸如回调地狱等模式。本章描述了处理这些问题的几种技术。改变你的编码风格是一种方法。查看诸如承诺(promises)之类的结构,尤其是在与 async/await 结合使用时,是另一种方法。使用 async/await 意味着我们突然获得了一种可以类比为异步代码中的顺序的东西。我们尽量保持尽可能不依赖框架,因为理解所有提到的概念而不将它们与特定应用框架的概念混淆是很重要的。尽管如此,可以说:Angular 允许你使用任何你想要的异步方法来组织你的代码。例如,进行 HTTP 调用时,使用的是与 RxJS 库紧密相关的 Angular 服务,但你也可以自由地使用基于承诺的样式,如fetch() API。使用 Babel 和它支持的转换器,也可以利用 Angular 中的 async/await。
本章为异步编程奠定了基础。下一章将通过介绍函数式响应式编程(FRP)的概念来在此基础上进行构建。它更多地涉及如何处理数据似乎在它想出现的时候出现的事实。尽管听起来很混乱,但如果我们将数据视为流,即使是这种情况也可以被建模,以创建一种结构和秩序的感觉。更多内容将在下一章中介绍。
第四章:函数式响应式编程
根据维基百科,函数式响应式编程(FRP)是一种响应式编程的编程范式,它使用函数式编程的构建块。好吧,这听起来很复杂,但它究竟意味着什么呢?为了理解整个句子,我们需要将其分解一下。让我们尝试定义以下内容:
-
编程范式是一个涵盖性理论,或工作方式,它围绕程序应该如何组织和结构化。面向对象编程和函数式编程是编程范式的例子。
-
响应式编程简而言之,是使用异步数据流的编程。异步数据流是值可以在任何时间点到达的数据流。
-
函数式编程是一种编程范式,它采用更数学化的方法,将函数调用视为数学计算,从而避免改变状态或处理可变数据。
因此,简而言之,我们的维基百科定义意味着我们有一种函数式编程方法来处理可能在任何时间点到达的值。这实际上并没有太多意义,但希望到本章结束时,事情会有所澄清。
在本章中,我们将学习以下内容:
-
声明式编程与命令式编程之间的区别
-
异步数据流
-
如何操作这些流
函数式编程与命令式编程的比较
我们将讨论和描述两种不同的编程风格:命令式编程和声明式编程。函数式编程是声明式编程的一个子集。解释声明式编程的最简单方法就是将其与它的对立面——命令式编程——进行比较。命令式编程关注的是程序应该如何达到其结果。另一方面,函数式编程是一种声明式编程范式,这意味着它的重点是程序应该完成什么,或者说“什么”。这是一个重要的区别。
命令式编程与声明式编程的比较
命令式编程由帮助改变程序状态的语句组成。如前所述,它关注的是“如何”而不是“什么”。让我们看看代码中这可能是什么样子,以便使其更清晰:
let sum = 0;
function updateSum(records) {
for( let i = 0; i< records.length; i++ ) {
sum += records[i];
}
}
updateSum([1,2,3,4]);
上述代码具有以下效果:当我们调用updateSum()时,变量sum会被更新。我们还可以看到,该函数非常明确地说明了求和应该“如何”发生。
声明式编程更关注“什么”要实现。很容易将其视为更高级的,因为你说出了你想要实现什么。让我们看看一些 SQL 代码。SQL 是一种声明式编程语言:
// content of table 'orderitem'
-------------------
id price productId
-------------------
1 100 1
1 50 11
SELECT
SUM(price) as total
FROM orderitem
// result of the query
150
在这里,我们正在查询一个表以获取多个记录,同时告诉 SQL 我们想要汇总的内容。我们明显在进行相同类型的操作,即汇总某物。区别在于,在我们的声明性示例中,我们告诉 SQL 我们想要做什么;我们信任 SQL 知道如何汇总。
一等高阶函数
“一等”这个术语意味着语言本身将函数视为值;它们可以作为其他函数的参数传递。高阶函数是接受其他函数作为参数的函数。让我们通过一个例子来使这一点更清晰:
function project(obj, fn) {
return fn(obj);
}
project( { name : 'chris', age: 37 }, (obj) => obj['name'] ); // 'chris'
project({ name : 'chris', age: 37 }, (obj) => obj['age'] ) // 37
在这里,我们可以看到我们的project()函数的第二个参数是一个函数。该函数被应用于第一个参数。我们还可以看到,根据我们给高阶函数作为其第二个参数的输入参数,高阶函数的行为会有所不同。
纯函数
纯函数是一个没有副作用的函数。函数所做的任何操作都不会影响它之外的变量。这意味着在计算中使用输入参数时,不应引起副作用,例如与文件系统交互或打开网络连接等。让我们来看一个例子:
function notAPureFunction(filePath) {
const fileContent = fs.readFileSync(filePath);
const rows = fileContent.split(',');
let sum = 0;
rows.forEach(row => { sum += row; });
return sum;
}
如我们所见,我们的函数打开一个文件,遍历其行,并计算所有行内容的总和。不幸的是,这个函数与文件系统进行交互,这被认为是一个副作用。这看起来可能有点牵强,但在一个更长的函数中,同时看到计算——记录日志和与数据库交互——发生,或者至少是我的经验是这样的。这样的代码远非理想,它存在关注点分离和其他许多问题。然而,当涉及到纯函数时,将纯部分隔离到它们自己的函数中是一个好主意,这将导致以下结果:
function calculateSum(rows) { // now it's pure
let sum = 0;
rows.forEach(row => { sum += row; });
return sum;
}
function getRows(filePath) { // still not pure, but some things needs to perform side-effects
const fileContent = fs.readFileSync(filePath);
const rows = fileContent.split(',');
}
如您所见,我们现在有两个函数。我们设法将纯部分隔离到一个名为calculateSum()的函数中,并最终创建了执行副作用的getRows()函数。大多数程序都以某种形式具有副作用,但作为程序员,你的任务是尽可能地将这些函数与纯函数分开。
实际上,我们在这里描述了两件事:
-
纯函数:它们更像是没有副作用的数学计算。
-
单一职责原则(SRP):做好函数式编程的一部分是编写小而专注的函数。尽管这并不是函数式编程或纯函数的严格属性,但它是一个重要的原则,将帮助你采用函数式编程生活方式时拥有正确的思维方式。
我们没有提到的一件事是为什么纯函数在函数式编程中扮演着至关重要的角色。它们通过其计算性质是可预测的,这使得它们易于测试。构建主要由许多小型可预测函数组成的系统,使整个系统可预测。
递归
“要理解递归这个词,请看递归这个词。”
这是在大多数工程学院中流传的一个笑话,它以非常简短的方式解释了它。递归是一个数学概念。让我们进一步解释一下。官方定义如下:
递归是当程序的一个步骤涉及调用程序本身时,程序所经历的过程。经历递归的程序被称为“递归”。
好吧,这在人类语言中意味着什么?它说在运行我们的函数的某个时刻,我们将调用自己。这意味着我们有一个看起来像这样的函数:
function something() {
statement;
statement;
if(condition) {
something();
}
return someValue;
}
我们可以看到,在函数something()的某个地方,它的主体调用了自身。一个递归函数应该遵守以下规则:
-
应该调用自身
-
最终应该遇到退出条件
如果递归函数没有退出条件,由于函数将无限期地调用自身,我们将耗尽内存。某些类型的问题比其他类型的问题更适合应用递归编程。这些问题的例子包括:
-
遍历树
-
编译代码
-
编写压缩算法
-
排序列表
有许多更多例子,但重要的是要记住,尽管它是一个伟大的工具,但它不应该到处使用。让我们看看递归真正闪耀的例子。我们的例子是一个链表。链表由知道它们连接到的节点的节点组成。Node结构的代码如下:
class Node {
constructor(
public left,
public value
) {}
}
使用Node这样的结构,我们可以构建由多个链接节点组成的链表。我们可以以下列方式连接一组节点实例:
const head = new Node(null, 1);
const firstNode = new Node(head, 2);
const secondNode = new Node(firstNode, 3);
上述代码的图形表示如下图。在这里,我们可以清楚地看到我们的节点由什么组成以及它们是如何连接的:

在这里,我们有一个链表,其中我们有三个连接的节点实例。头节点没有连接到左侧的节点。然而,第二个节点连接到第一个节点,第一个节点连接到头节点。以下类型的列表操作可能很有趣:
-
给定链表中的任何节点,找到头节点
-
在链表中给定位置插入一个节点
-
从链表中给定位置删除一个节点
让我们看看我们如何解决第一个要点。首先,我们将使用命令式方法,然后我们将使用递归方法来查看它们之间的区别。更重要的是,让我们讨论为什么递归方法可能更受欢迎:
// demo of how to find the head node, imperative style
const head = new Node(null, 1);
const firstNode = new Node(head, 2);
const secondNode = new Node(firstNode, 3);
function findHeadImperative (startNode) {
while (startNode.left !== null) {
startNode = startNode.left;
}
return startNode;
}
const foundImp = findHeadImperative(secondNode);
console.log('found', foundImp);
console.log(foundImp === head);
正如我们所见,我们正在使用一个while循环遍历列表,直到我们找到left属性为空的节点实例。现在,让我们展示递归方法:
// demo of how to find head node, declarative style using recursion
const head = new Node(null, 1);
const firstNode = new Node(head, 2);
const secondNode = new Node(firstNode, 3);
function findHeadRecursive(startNode) {
if(startNode.left !== null) {
return findHeadRecursive(startNode.left);
} else {
return startNode;
}
}
const found = findHeadRecursive(secondNode);
console.log('found', found);
console.log(found === head);
在前面的代码中,我们检查startNode.left是否为空。如果是这种情况,我们就达到了退出条件。如果我们还没有达到退出条件,我们就继续调用自己。
好的,所以我们有两种方法:命令式方法和递归方法。为什么后者会好得多呢?嗯,使用递归方法,我们从一个长长的列表开始,每次调用自己时都会使列表变短:有点像一种分而治之的方法。递归方法中明显突出的一点是我们通过说“不,我们的退出条件还没有满足,继续处理”来推迟执行。继续处理意味着我们像在if子句中那样调用自己。递归编程的目的是我们得到更少的代码行数吗?嗯,这可能是一个结果,但更重要的是:它改变了我们解决问题的思维方式。在命令式编程中,我们有一种从上到下解决问题的思维方式,而在递归编程中,我们的思维方式更倾向于,定义何时完成,将问题切割成更容易处理的部分。在前面的例子中,我们丢弃了不再有趣的链表部分。
没有更多的循环
当开始以更函数式的方式编写代码时,一个更显著的变化是我们摆脱了for循环。现在我们知道了递归,我们可以直接使用它。让我们看看一个简单的命令式代码片段,它打印一个数组:
// demo of printing an array, imperative style
let array = [1, 2, 3, 4, 5];
function print(arr) {
for(var i = 0, i < arr.length; i++) {
console.log(arr[i]);
}
}
print(arr);
使用递归的相应代码看起来像这样:
// print.js, printing an array using recursion
let array = [1, 2, 3, 4, 5];
function print(arr, pos, len) {
if (pos < len) {
console.log(arr[pos]);
print(arr, pos + 1, len);
}
return;
}
print(array, 0, array.length);
如我们所见,我们的命令式代码在精神上仍然存在。我们仍然从0开始。此外,我们继续进行,直到我们到达数组的最后一个位置。一旦我们达到退出条件,我们就退出方法。
重复模式
到目前为止,我们还没有真正将递归作为一个概念推销出去。我们有点理解,但可能还没有说服自己为什么好的老式while或for循环不能被替换。递归在解决看起来像重复模式的问题时特别出色。一个例子就是树。树有一些类似的概念,比如由节点组成。没有子节点连接的节点被称为叶子。有子节点但没有向上节点连接的节点被称为根节点。让我们用一张图来展示这一点:

我们在树上想要执行的一些有趣的操作包括:
-
总结节点值
-
计算节点数量
-
计算宽度
-
计算深度
为了尝试解决这个问题,我们需要思考如何将树作为数据结构存储。最常见的方法是通过创建一个表示节点具有值、left属性和right属性的表示,然后这两个属性依次指向节点。因此,该节点类的代码可能看起来像这样:
class NodeClass {
constructor(left, right, value) {
this.left = left;
this.right = right;
this.value = value;
}
}
下一步是思考如何创建树本身。此代码显示了如何创建一个具有根节点和两个子节点的树,以及如何将这些节点绑定在一起:
// tree.js
class NodeClass {
constructor(left, right, value) {
this.left = left;
this.right = right;
this.value = value;
}
}
const leftLeftLeftChild = new NodeClass(null, null, 7);
const leftLeftChild = new NodeClass(leftLeftLeftChild, null, 1);
const leftRightChild = new NodeClass(null, null, 2);
const rightLeftChild = new NodeClass(null, null, 4);
const rightRightChild = new NodeClass(null, null, 2);
const left = new NodeClass(leftLeftChild, leftRightChild, 3);
const right = new NodeClass(rightLeftChild, rightRightChild, 5);
const root = new NodeClass(left, right, 2);
module.exports = root;
值得注意的是,实例left和right没有子节点。我们可以看到这一点,因为我们创建时将它们的值设置为null。另一方面,我们的根节点有对象实例left和right作为子节点。
总结
此后,我们需要思考如何总结节点。仅从外观上看,这似乎意味着我们应该总结顶层节点及其两个子节点。因此,代码实现将开始如下:
// tree-sum.js
const root = require('./tree');
function summarise(node) {
return node.value + node.left.value + node.right.value;
}
console.log(summarise(root)) // 10
如果我们的树增长并突然看起来像这样:

让我们向前面的代码添加一些内容,使其看起来像这样:
// example of a non recursive code
function summarise(node) {
return node.value +
node.left.value +
node.right.value +
node.right.left.value +
node.right.right.value +
node.left.left.value +
node.left.right.value;
}
console.log(summarise(root)) // 19
这实际上是正常工作的代码,但可以改进。此时,我们应该在树中看到的是重复的模式。我们有以下三角形:

一个三角形由2、3、5组成,另一个由3、1、2组成,最后一个由5、4、2组成。每个三角形通过取节点本身,加上其左子节点和右子节点来计算其和。递归就是关于这个:发现重复的模式并将其编码化。我们现在可以使用递归来实现我们的summarise()函数,如下所示:
function summarise(node) {
if(node === null) {
return 0;
}
return node.value + summarise(node.left) + summarise(left.right);
}
我们在这里所做的是将重复的模式表示为节点 + 左节点 + 右节点。当我们调用summarise(node.left)时,我们只是再次为该节点运行summarise()。前面的实现既简短又优雅,能够遍历整个树。一旦你发现你的问题可以看作是一个重复的模式,递归就真正变得优雅了。完整的代码如下:
// tree.js
class NodeClass {
constructor(left, right, value) {
this.left = left;
this.right = right;
this.value = value;
}
}
const leftLeftLeftChild = new NodeClass(null, null, 7);
const leftLeftChild = new NodeClass(leftLeftLeftChild, null, 1);
const leftRightChild = new NodeClass(null, null, 2);
const rightLeftChild = new NodeClass(null, null, 4);
const rightRightChild = new NodeClass(null, null, 2);
const left = new NodeClass(leftLeftChild, leftRightChild, 3);
const right = new NodeClass(rightLeftChild, rightRightChild, 5);
const root = new NodeClass(left, right, 2);
module.exports = root;
// tree-sum.js
const root = require("./tree");
function sum(node) {
if (node === null) {
return 0;
}
return node.value + sum(node.left) + sum(node.right);
}
console.log("sum", sum(root));
计数
现在我们开始理解递归的本质,实现一个计算树中所有节点数量的函数变得相当简单。我们可以重用之前的总结函数,简单地计算每个非空节点为1,空节点为0。所以,我们只需修改现有的总结函数,如下所示:
//tree-count.js
const root = require("./tree");
function count(node) {
if (node === null) {
return 0;
} else {
return 1 + count(node.left) + count(node.right);
}
}
console.log("count", count(root));
前面的代码确保我们成功遍历了每个节点。我们的退出条件发生在我们到达 null 时。也就是说,我们试图从一个节点移动到其不存在的子节点之一。
宽度
要创建一个宽度函数,我们首先需要定义我们所说的宽度是什么。让我们再次看看我们的树:

这棵树的宽度是4。这是怎么回事?对于树中的每一步向下,我们的节点向左和向右各扩展一步。这意味着为了正确计算宽度,我们需要遍历树的边缘。每次我们必须向左或向右遍历一个节点时,我们就会增加宽度。从计算的角度来看,我们感兴趣的是像这样遍历树:

因此,代码应该反映这一事实。我们可以这样实现:
// tree-width.js
const root = require("./tree");
function calc(node, direction) {
if (node === null) {
return 0;
} else {
return (
1 + (direction === "left" ?
calc(node.left, direction) :
calc(node.right, direction))
);
}
}
function calcWidth(node) {
return calc(node.left, "left") + calc(node.right, "right");
}
console.log("width", calcWidth(root));
特别注意在calcWidth()函数中,我们分别用node.left和node.right作为参数调用calc()。我们还添加了left和right参数,在calc()方法中意味着我们将继续朝那个方向前进。我们的退出条件是最终遇到 null。
异步数据流
异步数据流是一系列数据,其中值一个接一个地发出,它们之间有延迟。异步这个词意味着发出的数据可以在任何时间出现,比如一秒后甚至两分钟后。异步流的一种建模方法是将在时间轴上放置发出的值,如下所示:

有很多事情可以被认为是异步的。其中之一是通过 AJAX 获取数据。数据何时到达取决于许多因素,例如:
-
您的连接速度
-
后端 API 的响应性
-
数据的大小,以及许多其他因素。
重点是数据并不是在这个非常时刻到达。
可以被认为是异步的其他事情包括用户发起的事件,如滚动或鼠标点击。这些是在任何时间点都可能发生的事件,取决于用户的交互。因此,我们可以将这些 UI 事件视为时间轴上的连续数据流。以下图表描绘了代表用户多次点击的数据流。每次点击都会导致一个点击事件,c,我们将它放置在时间轴上:

初看我们的图表描绘了四个点击事件。仔细观察后,我们发现点击事件似乎被分组了。前一个图表包含以下两条信息:
-
发生了多次点击事件
-
点击事件之间有特定的延迟发生
在这里,我们可以看到前两次点击似乎在时间上非常接近;当两个事件在时间上非常接近时,这将被解释为双击。因此,我们上面的图像告诉我们发生了哪些事件;它还告诉我们它们何时以及多久发生一次。查看前面的图表,区分单击和双击相当容易。
我们可以为每种点击行为分配不同的动作。双击可能意味着我们想要放大,而单击可能意味着我们想要选择某个东西;具体取决于你正在编写的应用程序。
第三个例子是输入的情况。如果我们有一个用户在输入一段时间后停止输入的情况呢?在经过一定时间后,用户期望 UI 做出反应。这就是搜索字段的情况。在这种情况下,用户可能在搜索字段中输入一些内容,并在完成后按下搜索按钮。在 UI 中模拟这种情况的另一种方式是提供一个搜索字段,并等待用户停止输入,作为开始搜索用户想要的内容的信号。最后一个例子被称为自动完成行为。它可以按以下方式建模:

输入的前三个字符似乎属于同一个搜索查询,而第四个字符输入得晚得多,可能属于另一个查询。
本节的目的在于强调不同的事物适合作为流来建模,以及时间轴上发出的值的放置可以意味着某些东西。
将列表与异步流比较——为 RxJS 做准备
到目前为止,我们已经讨论了如何将异步事件建模为时间轴上的连续数据流,或称为流建模。事件可以是 AJAX 数据、鼠标点击或其他类型的事件。以这种方式建模事物为事物提供了一个有趣的视角,但例如,在双击的情况下,除非我们能够挖掘出数据,否则这并没有什么意义。可能还有另一种情况,我们需要过滤掉某些数据。我们在这里讨论的是如何操作流。如果没有这种能力,流建模本身就没有实际价值。
有不同的方法来操作数据:有时我们希望将发出的数据改变为其他数据,有时我们可能希望改变数据被发送到监听器的频率。有时,我们希望我们的数据流变成一个完全不同的数据流。我们将尝试模拟以下情况:
-
投影:改变发出的值的 数据
-
过滤:改变被发出的内容
将函数式编程范式与流结合
本章已经涵盖了函数式编程和异步数据流。使用 RxJS 不需要对函数式编程有深入的了解,但你确实需要理解声明式意味着什么,以便专注于正确的事情。你的关注点应该是你想要完成的事情,而不是你想要如何完成它。作为一个库,RxJS 将负责如何完成。更多内容将在下一章中介绍。
这些可能看起来是两个不同的主题。然而,将它们结合起来,我们就能获得操纵流的能力。流可以被看作是一系列数据,其中数据在某个时间点可用。如果我们开始将我们的流视为列表,特别是不可变列表,那么就有一些与列表一起进行的操作,通过应用操作符来操纵列表。操纵的结果是一个新列表,而不是一个被修改的列表。所以,让我们开始应用我们的列表哲学及其操作符到以下情况。
投影

在这里,我们可以看到我们的流正在发出值1、2、3和4,然后发生了一个操作,将每个值增加一个。这是一个相当简单的情况。如果我们将其视为一个列表,我们可以看到我们在这里所做的只是一个投影,我们会这样编码:
let newList = list.map(value => value + 1)
过滤
列表和流中可能有一些你不希望的项目。为了解决这个问题,你需要创建一个过滤器来过滤掉不需要的数据。通过模拟初始数组、操作和结果数组,我们得到以下内容:

在 JavaScript 中,我们可以通过编写以下代码来完成这个任务:
let array = [1,2,3];
let filtered = array.filter(data => data % 2 === 0);
结合思维模式
那么,我们在这个部分试图表达什么?显然,我们已经展示了如何操纵列表的例子。好吧,我们所做的是展示了我们如何显示轴上的项目。从这个意义上说,我们可以看到异步事件和值列表以相同的方式思考是很容易的,因为我们以相同的方式图形化地描绘它们。问题是,我们为什么要这样做?原因在于,这正是 RxJS 库希望你在下一章开始操纵和制作流时拥有的心态。
概述
本章已经确立,我们可以将异步事件建模为时间轴上的值。我们引入了将这些流与列表进行比较的想法,并因此应用了不会改变列表本身但仅创建一个新列表的功能方法。应用函数式范式的优点在于,我们可以专注于要实现什么而不是如何实现,从而采用声明式方法。我们意识到将异步和列表结合起来并从中创建可读的代码并不容易。幸运的是,这正是 RxJS 库为我们做的事情。正是这种认识使我们为即将到来的第五章,RxJS 基础,做好了准备,在那里我们介绍 RxJS 作为一个库:在异步混乱中创建秩序,将一切建模为流。有了 RxJS,我们真正可以专注于要实现什么而不是如何实现,因为它附带了一系列流操作函数。在阅读下一章之后,你将了解 RxJS 在基本层面的工作原理,以及它如何解决本章中提到的问题。
第五章:RxJS 基础
JavaScript 的响应式扩展(RxJS)是由 Matt Podwysocky 创建的一系列库。库的第四版由微软维护和开发。第四版可以在以下链接找到:github.com/Reactive-Extensions/RxJS。
第五版是对第四版的完全重写,可以在以下地址找到:github.com/ReactiveX/rxjs。其最大贡献者是 Ben Lesh,其他值得注意的贡献者包括 Andre Staltz。第五版也是 Angular 在处理 HTTP 等方面的库选择。
在本章中,您将学习:
-
组成 RxJS 的模式有哪些
-
RxJS 的核心概念
-
如何手动创建自己的 Observables 并订阅它们
-
你可以创建 Observable 的多种方式
-
管理清理的重要性
-
通过学习实现 RxJS 库的核心部分来理解其底层原理
观察者模式
观察者模式是四人帮模式。这是一个因被包含在 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 所著的《设计模式:可复用面向对象软件元素》一书中而闻名的设计模式。该模式有两个关键参与者:一个 Subject 和一个 Observer。Subject 被观察者观察。通常,Subject 持有一个内部观察者列表,当 Subject 上发生更改时应该通知这些观察者。Subject 通常是一个模型,而观察者是一些 UI 组件。简而言之,Subject 应该能够:
-
维护观察者列表
-
添加观察者
-
移除观察者
-
当发生更改时通知所有观察者
相反,观察者应该只持有一个属性,那就是一个可以在更新发生时由主题调用的 update() 方法。这个模式背后的想法是创建不同层之间的松散耦合。主题和观察者都不应该直接通过名称了解对方,而应该通过抽象。因此,一个主题的类图可能如下所示:

在这里,我们包括了所有必需的方法:attach()、detach() 和 notify(),并且我们明确指出我们处理的是抽象观察者,而不是具体类型。至于观察者,这通常是一个只有一个方法 update() 的接口,可以由以下类图表示:

给定这些类图,让我们编写一些代码来演示实现可能的样子,并且我们从主题开始。对于这个例子,我们将使用 TypeScript,因为 TypeScript 知道接口是什么:
// observer-subject/subject.ts
import { Observer } from "./observer";
export class Subject {
observers: Array<Observer>;
constructor() {
this.observers = new Array<Observer>();
}
attach(observer: Observer) {
if (this.observers.indexOf(observer) === -1) {
this.observers.push(observer);
}
}
detach(observer) {
let index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers = this.observers.slice(index, 1);
}
}
notify() {
this.observers.forEach(observer => observer.update());
}
}
如您所见,基本实现非常简短,但它是一个强大的结构。至于 Observer,它甚至更短:
// observer-subject/observer.ts
export interface Observer {
update();
}
我们可以通过创建一个文件,例如 app.ts,来尝试这个例子:
// observer-subject/app.ts
import { Subject } from "./subject";
import { Observer } from "./observer";
const subject = new Subject();
const observer = <Observer>{
update: () => console.log("First Observer Updated")
};
const observer2 = <Observer>{
update: () => console.log("Second Observer updated")
};
subject.attach(observer);
subject.attach(observer2);
subject.notify();
// should emit:
// First Observer Updated
// Second Observer updated
通过运行前面的代码,我们看到Subject实例允许我们通过调用attach()方法将其附加到Observer实例上。然后我们在Subject实例上调用notify(),以确保所有订阅的Observer实例都得到通知。
好的,所以现在我们已经有一些核心实现,一个实际的使用案例是什么样的呢?想象一下,我们有一个扮演Subject角色的ProductModel类和一个扮演Observer角色的ProductUI类。ProductModel类的一个简单实现可能如下所示:
// product-model/product.model.ts
import { Subject } from "./subject";
export class ProductModel extends Subject {
private titleValue = "";
private makeValue = "";
get title(){
return this.titleValue;
}
set title(value) {
this.titleValue = value;
this.notify();
}
get make() {
return this.makeValue;
}
set make(value) {
this.makeValue = value;
this.notify();
}
}
在这里,我们可以看到我们有两个属性,title和make,当它们两者都发生变化时,我们调用从基类Subject继承的notify()方法。让我们看看ProductUI类可能是什么样子:
// product-model/product.ui.ts
import { Observer } from "./observer";
import { ProductModel } from "./product.model";
export class ProductUI implements Observer {
constructor(private model: ProductModel) {
this.model.attach(this); // add ProductUI to the observer list
this.renderUI();
}
renderUI() {
console.log("calling renderUI");
this.draw();
}
draw() {
// implement
console.log("calling draw");
}
update() {
console.log("calling update");
this.renderUI(); // rerender the UI when update() is called
}
}
在前面的代码中,我们看到我们在构造函数中接收一个ProductModel实例,并且我们还对该实例调用attach()方法,以便将其注册为Observer。我们还定义了一个update()方法,其中我们决定如果它被调用,我们将重新渲染 UI。
这是一个使用观察者模式并用于模型到 UI 通信的典型示例,这只是许多使用可能性之一。一般原则是在Subject和Observer实例之间以松耦合的方式进行通信。真正的优势是能够在单个Subject上拥有多个Observer实例,这样如果Subject发生变化,所有其Observer实例也会随之变化。这也被称为发布/订阅,通常简称为 Pub/Sub。
RxJS 核心概念
RxJS 由一些核心概念组成,这些概念对于你早期理解非常重要。那些是:
-
可观察的:这是一个表示数据流的类。
-
观察者:这是一个能够发出数据的类。
-
生产者:这是内部产生数据的东西,观察者最终会发出这些数据。
-
操作符:这是
Observable上的一个方法,它允许我们操作流本身或它发出的数据。 -
流:这与
Observable的一个实例同义。之所以称之为流,是因为你应该将数据视为连续的,而不是真正有结束,除非你明确地定义一个结束。
可观察的和观察者
在定义了我们最初需要了解的所有概念之后,现在是我们将这些概念放入上下文中,以进一步加深我们的理解。让我们从一个定义Observable开始,并逐步深入到之前提到的每个概念。Observable可以通过以下代码创建:
let stream$ = Rx.Observable.create(observer => observer.next(1));
这是创建一个Observable所需的最少代码量。在这个阶段,屏幕上没有写入任何内容,因为我们需要订阅流。让我们给我们的Observable添加一个订阅者。我们通过在流实例上调用subscribe()方法来实现这一点:
let stream$ = Rx.Observable.create(observer => observer.next(1));
stream$.subscribe(data => console.log('data',data) ) // write data, 1 to the console
看看这段代码,我们看到Observable调用了create()方法,该方法反过来创建了一个Observable的实例。有趣的是,create()方法接受一个函数作为参数;这个函数本身接受一个观察者实例。因此,我们有一个看起来像这样的 API:Observer.create(fn(observerInstance))。在这个函数内部发生的事情是,我们调用observer.next(1)。在更高层次上,我们通过使用create()这个factory函数创建了一个Observable。在这个例子中,我们的Observable行为非常简单,就是发射值 1。当我们调用observer.next(1)时,我们发射数据。为了获取发射的数据,我们需要调用subscribe()方法。
生产者
如果我们尝试将此与观察者模式进行比较,我们会看到一些概念是重复的,例如观察者。在这个模式中,当发生某些事情时,观察者会收到通知,而主题会主动改变。看看之前的代码,看起来像观察者是主动改变的一方。但这并不完全正确;它更像是一个中介,这带我们来到了 RxJS 的下一个概念,即Producer。Producer负责生成我们需要的值。通过在我们的代码中引入Producer,我们看到观察者更像是一个中介:
// rxjs-example/producer.js
const Rx = require("rxjs/Rx");
class Producer {
constructor() {
this.counterMax = 5;
this.current = 0;
}
hasValues() {
return this.current < this.counterMax;
}
next() {
return this.current++;
}
}
let stream$ = Rx.Observable.create(observer => {
let producer = new Producer();
while (producer.hasValues()) {
observer.next(producer.next());
}
});
stream$.subscribe(data => console.log("data", data));
// data 0, data 1, data 2, data 3, data 4
如我们所见,生产者是负责生成数据的一方,而观察者负责将数据传递给订阅者。
可观察的错误和完成
流不仅仅是生成数据;流还可以生成错误以及达到其完成状态。如果发生错误或完成,流将不再生成任何值。为了表示我们有一个错误,我们在观察者上调用error()方法,如下所示:
let stream$ = Rx.Observable.create(observer => {
observer.error('we have an error');
});
为了捕获发射的错误,我们需要在我们的subscribe()调用中引入第二个回调,如下所示:
// rxjs-example/error.js
const Rx = require("rxjs/Rx");
let stream$ = Rx.Observable.create(observer => {
observer.error("we have an error");
});
stream$.subscribe(
data => console.log("data", data),
error => console.error("err", error)
)
到目前为止,我们已经学习了如何发射数据,以及如何发出错误信号。我们能做的最后一件事是关闭流,或者完成它,因为关闭流也被称为完成。我们通过在观察者上调用complete()来实现这一点。这将确保不再发射任何值。为了捕获完成信号,我们需要在我们的subscribe()调用中添加另一个回调。你可以这样使用它:
// rxjs-example/completion.js
const Rx = require("rxjs/Rx");
let stream$ = Rx.Observable.create(observer => {
observer.next(1);
observer.complete();
});
stream$.subscribe(
data => console.log("data", data), // 1
error => console.error("err", error), // never hit
() => console.log("complete") ); // will be hit
操作符
我们要讨论的最后一个概念是操作符。操作符简单地说是一个作用于Observable并按某种方式改变流的函数。操作符本质上是不可变的。这种不可变性使得代码更容易测试和推理。RxJS 提供了 60 多个操作符,以帮助在大多数情况下定义你的流及其行为。
可能会有这样的情况,你需要创建自己的操作符,但很可能是已经有了一个操作符可以完成你想要的功能。
当你定义你的流及其行为时,你将使用一个或多个操作符。它可能看起来像以下这样:
let stream$ = Rx.Observable.of(1,2)
.map( x => x +1 )
.filter( x > 2 );
stream$.subscribe( data => console.log('data', data))
// data 3
在这里,我们可以看到我们正在使用 .map() 操作符和 .filter() 来改变我们的流数据。.map() 通过将每个值增加一来对流中的每个值进行操作。.filter() 对改变后的流进行操作;由调用 .map() 引起的改变。它也针对流中的每个值进行操作,但条件性地决定应该发出什么。最终结果只发出一个值,3。还有许多其他的操作符,但这应该能给你一个关于操作符是什么以及如何使用它们的想法。
创建 Observables
大多数时候,在创建 Observables 时,你不会使用create()方法。你将使用其他方法。为什么是这样呢?好吧,一个 Observable 实例通常源于某种异步概念。在使用 RxJS 创建 Angular 应用程序的上下文中,Observable 实例将通过执行以下操作之一来创建:
-
使用 AJAX 通过 HTTP 创建或获取数据
-
使用响应式表单监听输入变化
-
监听路由变化
-
监听 UI 事件
-
包装异步概念
在 RxJS 中,有一些不同的创建操作符可以帮助你解决这些任务,但 Angular 框架实际上可能在内部创建 Observables。让我们看看除了create()方法之外的一些创建操作符:
创建操作符
如我们之前所述,Observable 是一种表示随时间发出的数据的表现形式。有时,数据会立即到达,有时则需要时间。无论哪种情况,能够以相同的方式对数据进行建模都是非常强大的。
of()
让我们看看一个非常简单的创建操作符,of()。它接受一个可变数量的参数,这些参数将被作为值发出,如下所示:
let stream$ = Rx.Observable.of(1, 2, 3, 4);
stream$.subscribe( data => console.log(data)) // 1, 2, 3 ,4
值会立即触发。这在只想测试事情时非常有用。
interval()
另一个有趣的操作符是interval()操作符,它接受一个毫秒数作为参数。这定义了每条发出数据之间的延迟时间(以毫秒为单位)。它将从数字 0 开始。需要注意的是,除非例如应用了take()操作符,否则它将无限期地生成值。take()操作符将限制发出的值的数量并关闭流。该操作符的典型用法如下:
let stream$ = Rx.Observable.interval(1000)
.take(3); // 1s delay between values, starting at 0
stream$.subscribe(data => console.log(data))
// 0, 1, 2
from()
from()操作符允许我们从其他异步/同步概念创建一个Observable。当几乎所有东西都可以被制作成Observable时,这非常强大,因为它允许丰富的组合。以下是一个典型的代码片段示例:
let stream$ = Rx.Observable.from(new Promise(resolve, reject) => {
resolve('some data');
});
stream$.subscribe( data => console.log(data)); // some data
let stream2$ = Rx.Observable.from([1,2,3,4]);
stream2$.subscribe( data => console.log(data)); // 1,2,3,4
fromEvent()
我们已经多次提到丰富的组合以及将一切转换为 Observable 的力量。我们已经将承诺转换为 Observables,这使得一切变成了数据流,使得整个情况更容易推理。我们的意思是,当每个异步概念都被转换为 Observable 时,我们突然能够以相同的方式思考它们。可以应用于鼠标点击的操作符也可以应用于 AJAX 请求等等。
此外,我们甚至可以将 UI 事件转换为 Observables。通过使用 .fromEvent() 操作符,我们能够将一个元素及其对应的事件转换为一个 Observable。这是我们手中的真正力量,它允许我们将诸如自动完成等场景简化为 3-4 行代码。此操作符的典型用法如下:
let elem = document.getElementById('input');
// we assume we have a <input id="input"> in our markup
let keyStream$ = Rx.Observable.fromEvent(elem, 'keyUp');
// listens to the keyUp event
bindCallback()
到目前为止,我们已经列出了很多方法,无论是同步还是异步,都可以将一个结构转换为 Observable。回调是第一个尝试解决整个异步问题的模式,并且应该指出,由于可读性差,回调可能是解决异步代码的最差方式。幸运的是,有一个操作符可以将回调转换为 Observable,称为 bindCallback()。它可以按以下方式使用:
function fnWithCallback(cb) {
setTimeout(() => cb('data'), 3000);
}
let fnWithCallbackBinded = Rx.Observable.bindCallback(fnWithCallback);
let source$ = fnWithCallbackBinded();
source$.subscribe(data => console.log('callback', data));
我们可以看到,我们首先定义了一个名为 fnWithCallback() 的函数。我们将这个函数作为参数传递给 bindCallback() 方法。这会产生一个 fnWithCallbbackBinded() 函数。调用该函数将生成一个我们可以订阅的 Observable。因此,每当 fnWithCallback() 函数中的 cb('data') 因为 setTimeout() 而被调用时,这将导致我们的 source$ 的数据回调被调用。这在实践中是如何工作的呢?这实际上非常简单。让我们尝试实现我们自己的 Observable。我们已经学习了以下内容:
-
bindCallback()方法接受一个函数作为参数 -
调用
bindCallback()应该生成一个函数 -
调用
bindCallback()的结果应该生成一个Observable -
调用
subscribe()应意味着我们的数据回调应该是fnWithCallback()中的cb参数
因此,最终的实现应该看起来像这样:
// rxjs-creation-operators/bind-callback.ts
class Observable {
behaviorFn;
constructor(behaviorFn) {
this.behaviorFn = behaviorFn;
}
static bindCallback(behaviorFn): Function {
return (): Observable => {
return new Observable(behaviorFn);
};
}
subscribe(dataCb) {
this.behaviorFn(dataCb);
}
}
let fn = Observable.bindCallback(cb => {
setTimeout(() => cb("data"), 3000);
});
const stream$ = fn();
stream$.subscribe(data => console.log("data", data));
// outputs: data data
清理
我们现在已经涵盖了核心概念,如 Observable、Observer、Producer 和操作符。我们还探讨了如何手动创建一个 Observable,但意识到有不同类型的创建操作符可以帮助您从其他结构创建 Observable,有时 Angular 框架本身会为您创建 Observable。但我们遗漏了一个重要的事情,那就是清理。会有一些情况,Observable 会分配资源或简单地永远持续,就像interval()操作符一样。有一种明确的补救措施——在我们完成 Observable 后定义并运行一个清理函数。定义这样的函数迫使我们回到create操作符,并在其行为函数中修改一些代码,如下所示:
let stream$ = Rx.Observable.create(observer => {
let counter = 0;
let id = setInterval(() => observer.next(counter++), 1000);
return function cleanUpFn() { clearInterval(id); }
});
订阅
前面的代码描述了一个需要清理发生的情况。我们定义了一个setInterval()结构,它似乎会无限期地发出值。cleanUpFn()有取消这种行为的能力,前提是它被调用。我们在行为函数的末尾返回cleanUpFn()。
问题是,我们如何获取它?答案是,我们需要讨论一个新概念:订阅。订阅是在对流调用subscribe()时返回的东西。让我们用这个来修改前面的代码:
let stream$ = Rx.Observable.create(observer => {
let counter = 0;
let id = setInterval(() => observer.next(counter++), 1000);
return function cleanUpFn() { clearInterval(id); }
});
let subscription = stream$.subscribe((data) => console.log('data'));
setTimeout(() => subscription.unsubscribe(), 2000);
在前面的代码中,我们通过调用subscribe()创建了变量subscription,但真正有趣的部分发生在最后一行:我们定义了一个超时,它会在我们的subscription上调用unsubscribe()。这将调用我们的cleanUpFn(),以便取消间隔。
你处理的大多数流都不需要取消订阅,但那些分配资源或启动一些永远持续的结构,而我们没有拦截的,将需要有一个清理行为,我们在完成我们的流后需要调用这个行为。
创建 RxJS 的核心实现
理解某事物有不同的阶段。理解一个库就是学习其概念,并正确地利用其方法。然后是更深层次的理解,比如根据你在博客中找到的一些最佳实践指南,知道应该使用哪些方法。最后,你将达到一个真正深入的理解阶段,你想要理解正在发生的事情,开始对源代码本身进行修改,并可能通过向项目提交 Pull Request 来尝试增强它,这很可能是基于 GitHub 的。
本节旨在立即向您提供部分这种更深入的知识。我们意识到,在这个时候,您的大脑可能因为所有新学的概念和一些巧妙的操作符而有些混乱。让我们从头开始,先从最初介绍的概念入手,尝试逆向工程正在发生的事情。
实现 create()
在本章的开头,我们学习了如何创建一个 Observable。代码看起来是这样的:
let stream$ = Rx.Observable.create( observer => observer.next(1));
stream$.subscribe( data => console.log(data));
只需看一下代码,我们就可以对底层正在发生的事情做出有根据的猜测。很明显,我们需要一个Observable类。
这个类需要一个接受函数作为参数的create()方法。create()方法应该返回一个Observable。此外,我们的Observable类需要一个接受函数作为参数的subscribe()方法。让我们从这里开始,看看我们会走到哪里。
首先,让我们定义我们的Observable类,并使用上述方法:
class MyObservable {
static create(behaviourFn): MyObservable {}
constructor() {}
subscribe(dataFn) {}
}
好的,所以我们有一个包含三个方法的类;让我们尝试实现这些方法。让我们从我们所知道的create()方法开始:
class MyObservable {
static create(behaviourFn): MyObservable {
return new Observable(behaviourFn);
}
constructor(private behaviourFn) {}
subscribe(dataFn) {}
}
我们用粗体标出了所需更改,并在类中引入了一个名为behaviourFn()的字段。此外,我们的create()方法通过传递create()方法参数中的behaviourFn来实例化一个Observable。这意味着构造函数需要接受一个函数作为参数,并将其保存以供以后使用。关于传递给create方法的behaviourFn(),我们知道它接受一个观察者实例作为参数,并规定了观察者实例应该发出哪些值。为了使任何东西能够捕获这些发出的值,我们需要实现我们的最后一个方法,subscribe()。我们知道subscribe()接受dataFn()作为参数,并在调用subscribe()方法时以某种方式调用我们的behaviourFn以触发行为。因此,让我们在我们的现有代码中修改这一点:
class MyObservable {
static create(private behaviourFn): MyObservable {
return new MyObservable(behaviourFn);
}
constructor(behaviourFn) { this.behaviourFn = behaviourFn; }
subscribe(dataFn) {
this.behaviourFn(observer);
}
}
在这一点上,我们意识到我们需要一个Observer类,这样我们才能向behaviourFn()传递一些东西。我们还需要弄清楚如何调用dataFn()以及何时调用。经过一分钟思考,我们意识到观察者必须负责调用dataFn(),因此似乎只有将dataFn()传递到我们的Observer类的构造函数中,以便以后使用,才是合理的,如下所示:
class Observer {
constructor(private dataFn) {}
next(value) { this.dataFn(val) }
}
通过实现这个Observer类,我们做了三件事:一是通过构造函数传递dataFn()并将其作为Observer类的一个字段;二是创建了一个next()方法在Observer上,这是我们必须做的,因为我们了解到观察者实例应该调用next()来生成值;第三,我们确保在next()方法内部调用dataFn(),以确保每当通过调用next()方法生成值时,订阅者都会被告知。将所有这些代码放在一起,我们创建了一个非常基础的 RxJS 实现,实际上它是可以工作的!为了更好地理解我们目前所拥有的,让我们显示到目前为止使用的所有代码:
// rxjs-core/Observable.ts
class Observer {
constructor(private dataFn) {}
next(value) { this.dataFn(value) }
}
class MyObservable {
behaviourFn;
static create(behaviourFn): MyObservable {
return new Observable(behaviourFn);
}
constructor(behaviourFn) { this.behaviourFn = behaviourFn; }
subscribe(dataFn) {
let observer = new Observer(dataFn);
this.behaviourFn( observer );
}
}
let stream$ = MyObservable.create( observer => observer.next(1)); // 1
处理订阅
在上一节中,我们学习了如何实现一个非常基本的内核。然而,在本章的早期部分,提到有时你的 Observable 会分配资源或显示一种明显无法停止生成值的行为。处理这种情况是我们的责任。RxJS 明确地在这里指出了路径,即定义一个清理函数并确保在调用 unsubscribe() 时调用它。让我们展示这样一个场景,其中我们显然需要关注清理:
// rxjs-core/Observer-with-subscription.ts
interface Subscription {
unsubscribe();
}
class MyObservableWithSubscription {
static create(behaviourFn): MyObservableWithSubscription {
return new MyObservableWithSubscription(behaviourFn);
}
constructor(private behaviourFn) {}
subscribe(dataFn): Subscription {
let observer = new MyObserver(dataFn);
let cleanUpFn = this.behaviourFn(observer);
return {
unsubscribe: cleanUpFn
};
}
}
let streamWithSubscription$ = MyObservableWithSubscription.create(observer => {
let counter = 0;
let id = setInterval(() => observer.next(counter++), 1000);
return function cleanUpFn() {
clearInterval(id);
};
});
const subscription = streamWithSubscription$.subscribe(data =>
console.log("data", data)
);
subscription.unsubscribe();
查看代码,我们发现当我们定义行为函数(代码片段的底部)时,我们设置了一个 setInterval() 构造,该构造定期调用 observer.next()。我们确保将引用保存在变量 ID 中。我们需要确保当我们选择取消 setInterval() 行为时可以做到这一点。我们通过在 behaviourFn 函数的最后一行定义一个 cleanUpFn() 来做到这一点。这使我们来到了代码片段的上半部分。在这里,我们看到我们通过确保将调用 this.behaviourFn() 的结果保存到名为 cleanUpFn 的变量中来修改 subscribe() 方法。这确实是我们在 behaviourFn() 中定义的 cleanUpFn()。最后,我们通过将其作为对象的一部分返回并将其分配给 unsubscribe() 属性来公开 cleanUpFn() 属性。最后我们需要做的是调用 unsubscribe() 方法以确保我们的分配资源被释放,或者在这个特定例子中,取消 setInterval() 构造。调用 unsubscribe 将会调用 cleanUpFn(),然后它将调用 clearInterval(),这将取消间隔。
添加操作符
我们在定义自己的 RxJS 内核实现方面已经走了很长的路,但我们还缺少一个重要的拼图——操作符。操作符是 RxJS 的真正力量,可以被视为一个实用方法,它允许我们轻松地操作我们的流。让我们以 filter() 作为我们的示例目标。一个过滤操作符是一个你可以对其流调用的方法。想法是提供一个函数,能够逐个值地确定特定值是否应该被发出。一个典型的用例如下:
let stream$ = Observable.of(1,2,3)
.filter( x => x > 1 );
stream$.subscribe( data => console.log(data))
// will emit 2,3
在前面的代码中,我们可以看到我们提供给过滤函数的参数函数有效地排除了任何不符合条件的值。在这种情况下,所有大于 1 的值将被发出,从而对值 1 进行排序。让我们将 filter() 方法添加到我们之前定义的 MyObservable 类中,如下所示:
// rxjs-core/operator/Observable.ts, starting off with MyObservable, more to come
import { MyObserver } from "./Observer";
class MyObservable {
behaviorFn;
static create(behaviourFn): MyObservable {
return new MyObservable(behaviourFn);
}
constructor(behaviorFn) {
this.behaviorFn = behaviorFn;
}
filter(filterFn): FilterableObservable {
/* implement */
}
subscribe(dataFn) {
let observer = new MyObserver(dataFn);
let cleanUpFn = this.behaviorFn(observer);
return {
unsubscribe: cleanUpFn
};
}
}
从前面的代码片段中我们可以看到,filter()方法被添加到了MyObservable中,我们看到它本身返回一个 Observable,同时接受一个filterFn()参数。你需要问自己的问题是,我们现有的MyObservable构造函数是否足够。我们现有的构造函数接受一个behaviourFn(),我们很可能需要存储传入的filterFn参数,因此我们需要扩展构造函数或选择一个新的MyObservable实现。我们思考了一下,意识到选择一个新的、更专门的MyObservable可能更好,因为我们想避免大量的分支逻辑。因此,该方法的实现应该修改为类似以下的样子:
// rxjs-core/operator/Observable.ts, starting off with MyObservable, more to come
import { MyObserver } from "./Observer";
class MyObservable {
behaviorFn;
static create(behaviourFn): MyObservable {
return new MyObservable(behaviourFn);
}
constructor(behaviorFn) {
this.behaviorFn = behaviorFn;
}
filter(filterFn): FilterableObservable {
return new FilterableObservable(filterFn, this.behaviorFn);
}
subscribe(dataFn) {
let observer = new MyObserver(dataFn);
let cleanUpFn = this.behaviorFn(observer);
return {
unsubscribe: cleanUpFn
};
}
}
好的,现在我们有一个新的类要实现,FilterableObservable。这个类应该共享MyObservable的大部分行为,但展示我们如何发出数据。因此,我们是在从MyObservable继承,但有自己的特别之处。让我们尝试一个实现:
// rxjs-core/operator/Observable.ts
import { MyObserver } from "./Observer";
class MyObservable {
behaviorFn;
static create(behaviourFn): MyObservable {
return new MyObservable(behaviourFn);
}
constructor(behaviorFn) {
this.behaviorFn = behaviorFn;
}
filter(filterFn): FilterableObservable {
return new FilterableObservable(filterFn, this.behaviorFn);
}
subscribe(dataFn) {
let observer = new MyObserver(dataFn);
let cleanUpFn = this.behaviorFn(observer);
return {
unsubscribe: cleanUpFn
};
}
}
export class FilterableObservable extends MyObservable {
constructor(private filterFn, behaviourFn) {
super(behaviourFn);
}
subscribe(dataFn) {
let observer = new MyObserver(dataFn);
observer.next = value => {
if (this.filterFn(value)) {
dataFn(value);
}
};
let cleanUpFn = this.behaviorFn(observer);
return {
unsubscribe: cleanUpFn
};
}
}
const stream$ = new MyObservable(observer => {
observer.next(1);
observer.next(2);
observer.next(3);
}).filter(x => x > 2);
stream$.subscribe(data => console.log("data", data));
// prints 3
在前面的代码片段中,我们可以看到我们重写了subscribe()实现,或者更具体地说,我们在Observer实例上重写了next()方法。我们使用filterFn()来评估是否应该生成某个值。现在我们已经成功实现了filter()操作符。
回顾基础知识,添加错误和完成
在完成了 RxJS 基础实现的英勇壮举之后,我们希望对理解其内部工作原理感到相当满意。到目前为止,我们只在subscribe()中实现了dataFn;subscribe()方法中还有两个回调需要实现。让我们看一个代码片段并突出显示缺失的部分:
let stream$ = Rx.Observable.of(1,2,3);
stream$.subscribe(
data => console.log(data),
err => console.error(err),
() => console.log('complete');
)
我们已经突出了最后两个回调作为缺失的功能。我们知道,为了触发错误回调,我们需要调用observer.error('some message')。我们也知道,在抛出错误后不应再发出任何值。让我们提供一个这样的例子:
let stream$ = Rx.Observable.create( observer => {
observer.next(1);
observer.error('err');
observer.next(2);
});
stream$.subscribe(
data => console.log(data),
err => console.error(err)
);
// should emit 1, err
在这个阶段,我们意识到需要修改我们的Observer类以支持error()方法调用。我们还需要警惕我们刚才描述的条件,因为错误发生后不应再发出更多值。让我们直接进入实现:
class Observer {
hasError: boolean;
constructor(private dataFn, private errorFn) {}
next(value) {
if (!this.hasError) {
this.dataFn(value);
}
}
error(err) {
this.errorFn(err);
this.hasError = true;
}
}
在前面的代码片段中,我们可以看到我们向errorFn构造函数传递了另一个参数。next()方法需要更新,因此我们需要用条件包装它,以确定是否生成值。最后,我们需要定义error()方法,调用传入的errorFn并设置hasError字段为true。
我们还需要做一件事,那就是更新Observable类中的subscribe()方法:
class Observable {
behaviourFn;
static create(behaviourFn): Observable {
return new Observable(behaviourFn);
}
constructor(behaviourFn) {
this.behaviourFn = behaviourFn;
}
subscribe(dataFn, errorFn) {
let observer = new Observer(dataFn, errorFn);
let cleanUpFn = this.behaviourFn(observer);
return {
unsubscribe: cleanUpFn
};
}
}
提前提醒一下,当我们定义 filter() 操作符以覆盖 next() 方法时,我们需要确保这个操作符在确定是否生成值时考虑到 hasError。我们将把这个留给你,亲爱的读者,去实现。
最后一件待办事项是支持完成。完成与抛出错误有许多相似之处,即不应再发出更多值。区别在于我们应该触发最后一个回调。与 error() 方法实现一样,我们从 Observer 实现开始:
// rxjs-core/error-complete/Observer.ts
class Observer {
hasError: boolean;
isCompleted: boolean;
constructor(
private dataFn,
private errorFn,
private completeFn ) {}
next(value) {
if(!this.hasError && !this.isCompleted) {
this.dataFn(value);
}
}
error(err) {
this.errorFn(err);
this.hasError = true;
}
complete() {
this.completeFn();
this.isCompleted = true;
}
}
根据前面的代码,我们看到我们的更改包括添加一个 isCompleted 字段。我们还向构造函数中传递了一个 completeFn()。需要在 next() 值中添加逻辑,因为完成现在是我们需要寻找的另一个状态,除了错误之外。最后,我们添加了 complete() 方法,它只是调用传入的函数并将 isComplete 字段设置为 true。
与之前一样,我们需要更新 Observable 类以传递完成函数:
// rxjs-core/error-complete/Observable.ts
import { Observer } from './Observer';
class Observable {
behaviourFn;
static create(behaviourFn): Observable {
return new Observable(behaviourFn);
}
constructor(behaviourFn) {
this.behaviourFn = behaviourFn;
}
filter(filterFn):Observable {
return new FilterableObservable(
filterFn,
this.behaviourFn
);
}
subscribe(dataFn, errorFn, completeFn) {
let observer = new Observer(dataFn, errorFn, completeFn);
let cleanUpFn = this.behaviourFn( observer );
return {
unsubscribe: cleanUpFn
};
}
}
const stream$ = new Observable(observer => {
observer.next(1);
observer.error("error");
observer.next(2);
});
stream$.subscribe(
data => console.log("data", data),
err => console.log("error", err),
() => console.log("completed")
);
// prints 1, error, no more is emitted after that
这里做一个快速的实际情况检查:我们实际上已经实现了 RxJS 的核心功能——观察者、Observable 和一个操作符。我们离理解正在发生的事情更近了。我们意识到实现其他 59 个操作符是一项相当大的成就,而且当有一个团队维护现有的 RxJS 存储库时,这可能不是一个好主意。我们新获得的知识并非徒劳;理解正在发生的事情永远不会错。谁知道呢?也许你们中的某位读者将成为贡献者;你们确实已经得到了工具。
摘要
我们首先讨论了构成 RxJS 的模式。接着,我们描述了其核心概念。随后,我们解释了何时以及为什么需要创建自己的 Observable,选择 RxJS 的众多创建操作符之一,或者依赖 Angular 框架来完成这项工作。我们简要讨论了清理 Observable 的重要性以及何时这样做是个好主意。
最后,我们承担了实现 RxJS 核心部分的任务,以更深入地理解其核心概念以及它是如何结合在一起的。这希望给你们提供了一个相当坚实的基础和深入的理解,当我们进入下一章时,将涵盖更多操作符和一些更高级的概念。
第六章:操作流及其值
让我们从回顾上一章开始,提醒自己我们已经对 RxJS 有了多深的理解。我们学习了诸如Observable、Observer和Producer等概念,以及它们是如何相互作用的。此外,我们还了解了订阅过程,以便我们实际上可以接收我们渴望的值。我们还研究了如何从流中取消订阅,以及在哪些情况下需要定义这种行为。最后,我们通过学习如何构建 RxJS 的核心实现,从而看到了所有这些概念的实际应用。拥有所有这些知识,我们应该对 RxJS 的基础感到相当自信,但正如上一章提到的,我们需要操作符的帮助来真正对我们的流做些有意义的事情。
让我们不再拖延,开始讨论本章内容。操作符是我们可以在流上调用的函数,可以以许多不同的方式执行操作。操作符是不可变的,这使得流易于推理,也将使测试变得相当容易。正如你将在本章中看到的那样,我们很少只处理一个流,而是处理许多流,理解如何构建和控制这些流将使你从认为它是黑暗魔法转变为实际上能够在需要时应用 RxJS。
在本章中,我们将涵盖以下内容:
-
如何使用基本操作符
-
使用操作符以及现有工具调试流
-
深入了解不同的操作符类别
-
培养以 Rx 方式解决问题的思维方式
开始
你几乎总是通过创建一个静态值的流来开始使用 RxJS 进行编码。为什么是静态值呢?好吧,没有必要让它变得过于复杂,而你真正需要开始推理的只是一个Observable。随着你在解决问题的过程中逐渐进步,你可能会用更合适的 AJAX 调用或来自其他异步源值的调用来替换静态值。
然后,你开始思考你想要实现的目标。这会让你考虑你可能需要的操作符以及它们的顺序。你也可能会考虑如何将问题分解;这通常意味着创建多个流,其中每个流解决一个特定的问题,这些问题与你试图解决的更大问题相连接。
让我们从创建流开始,看看我们如何迈出与流一起工作的第一步。
以下代码创建了一个静态值的流:
const staticValuesStream$ = Rx.Observable.of(1, 2, 3, 4);
staticValuesStream$.subscribe(data => console.log(data));
// emits 1, 2, 3, 4
这是一个非常基础的例子,展示了我们如何创建一个流。我们使用of()创建操作符,它接受任意数量的参数。所有参数都会在有订阅者时依次发出。在上面的代码中,我们还通过调用subscribe()方法并传递一个以发出值为参数的函数来订阅staticValuesStream$。
让我们引入一个操作符 map(),它像一个投影一样工作,允许你改变正在发出的内容。map() 操作符在发出之前对流中的每个值进行调用。
你可以通过提供一个函数并执行一个投影来使用 map() 操作符,如下所示:
const staticValuesStream$ =
Rx.Observable
.of(1, 2, 3, 4)
.map(data => data + 1);
staticValuesStream$.subscribe(data => console.log(data))
// emits 2, 3, 4, 5
在前面的代码中,我们将 map() 操作符附加到 staticValuesStream$ 上,并在发出之前对每个值应用它,并将其增加一。因此,结果数据发生了变化。这就是将操作符附加到流上的方法:简单地创建流,或者使用现有的一个,然后逐个添加操作符。
让我们添加另一个操作符 filter(),以确保我们真正理解如何使用操作符。filter() 做什么?嗯,就像 map() 操作符一样,它应用于每个值,但它不是创建一个投影,而是决定哪些值将被发出。filter() 接收一个布尔值。任何评估为 true 的表达式意味着值将被发出;如果为 false,则表达式将不会发出。
你可以使用以下方式使用 filter() 操作符:
const staticValuesStream$ =
Rx.Observable
.of(1, 2, 3, 4)
.map(data => data + 1)
.filter(data => data % 2 === 0 );
staticValuesStream$.subscribe(data => console.log(data));
// emits 2, 4
我们通过将其链接到现有的 map() 操作符来添加 filter() 操作符。我们给 filter() 操作符的条件是只对能被 2 整除的值返回 true,这就是模运算符的作用。我们知道从之前的内容中,map() 操作符本身确保了值 2、3、4 和 5 被发出。这些是现在由 filter() 操作符评估的值。在这四个值中,只有 2 和 4 满足 filter() 操作符设定的条件。
当然,当在流上工作并应用操作符时,事情可能并不总是像前面的代码那样简单。可能无法准确预测将发出什么。在这些场合,我们有一些技巧可以使用。其中一种技巧是使用 do() 操作符,这将允许我们检查每个值而不改变它。这为我们提供了充足的机会来用于调试目的。根据我们在流中的位置,do() 操作符将输出不同的值。让我们看看 do() 操作符应用时不同情况下的不同影响:
const staticValuesStream$ =
Rx.Observable.of(1, 2, 3, 4)
.do(data => console.log(data)) // 1, 2, 3, 4
.map(data => data + 1)
.do(data => console.log(data)) // 2, 3, 4, 5
.filter(data => data % 2 === 0 )
.do(data => console.log(data)); // 2, 4
// emits 2, 4
staticValuesStream$.subscribe(data => console.log(data))
如您所见,仅通过使用 do() 操作符,我们就有了调试流的好方法,随着流复杂性的增加,这变得是必要的。
理解操作符
到目前为止,我们已经展示了如何创建一个流,并在它上面使用一些非常基本的运算符来改变发出的值。我们还介绍了如何使用 do() 运算符在不改变流的情况下检查流。并不是所有的运算符都像 map()、filter() 和 do() 运算符那样容易理解。你可以使用不同的策略来尝试理解每个运算符的作用,以便你知道何时使用它们。使用 do() 运算符是一种方法,但还有一种图形化的方法可以采用。这种方法被称为弹珠图。它由一个代表时间从左到右流逝的箭头组成。在这个箭头上有一些代表发出的值的圆圈或弹珠。弹珠中有一个值,但弹珠之间的距离也可能描述了随时间发生的事情。弹珠图通常至少由两个带有弹珠的箭头和一个运算符组成。其想法是表示应用运算符后流发生了什么。第二个箭头通常表示结果流。
这里是一个弹珠图的例子:

RxJS 中的大多数运算符都在 RxMarbles 网站上用弹珠图表示:rxmarbles.com/。这是一个真正伟大的资源,可以快速了解运算符的作用。然而,要真正理解 RxJS,你需要编写代码;这是不可避免的。当然,有不同方法可以做到这一点。你可以轻松设置自己的项目并从 NPM 安装 RxJS,通过 CDN 链接引用它,或者你可以使用像 JS Bin (www.jsbin.com) 这样的页面,它让你能够轻松地将 RxJS 作为库添加,并允许你立即开始编码。它看起来像这样:

JS Bin 让开始变得容易,但如果我们能将弹珠图和 JS Bin 结合起来,并得到你编码时的图形表示,那岂不是更好?你可以用 RxFiddle 实现这一点:rxfiddle.net/。你可以输入你的代码,点击运行,然后你会看到一个弹珠图,显示你刚刚编写的代码,它看起来像这样:

流中的流
我们一直在研究不同的运算符,它们会改变正在发出的值。流还有一个不同的方面:如果你需要从一个现有的流中创建一个新的流怎么办?另一个好问题是:这种情况下通常在什么时候发生?有很多情况,例如:
-
基于 keyup 事件流进行 AJAX 调用。
-
计算点击次数并确定用户是单击、双击还是三击。
你应该明白了;我们从一个需要变成另一种类型的流的流类型开始。
让我们先看看如何创建一个流,并看看当我们尝试使用运算符创建流时会发生什么:
let stream$ = Rx.Observable.of(1,2,3)
.map(data => Rx.Observable.of(data));
// Observable, Observable, Observable
stream$.subscribe(data => console.log(data));
在这个阶段,通过map()操作符传递的每个值都会产生一个新的Observable。当你订阅stream$时,每个发出的值都将是一个流。你的第一个本能可能是为这些值中的每一个都附加一个subscribe(),就像这样:
let stream$ = Rx.Observable
.of(1,2,3)
.map(data => Rx.Observable.of(data))
stream$.subscribe(data => {
data.subscribe(val => console.log(val))
});
// 1, 2, 3
抵制这种冲动。这将只会创建难以维护的代码。你想要做的是将这些流合并成一个,这样你只需要一个subscribe()。有一个操作符正是为此而设计的,称为flatMap()。flatMap()的作用是将你的流数组转换成一个流,一个元流。
它的使用方式如下:
let stream$ = Rx.Observable.of(1,2,3)
.flatMap(data => Rx.Observable.of(data))
stream$.subscribe(data => {
console.log(val);
});
// 1, 2, 3
好的,我们明白了,我们不想得到一个 Observable 流,而是一个值流。这个操作符看起来真的很棒。但我们仍然不确定何时使用它。让我们使这个例子更现实一些。想象一下,你有一个由一个输入字段组成的 UI。用户将字符输入到这个输入字段中。想象一下,你想要对输入的一个或多个字符做出反应,例如,在字符输入后执行一个 AJAX 请求。在这里,我们关注两个问题:如何收集输入的字符以及如何执行 AJAX 请求。
让我们从第一件事开始,捕捉输入字段中输入的字符。为此,我们需要一个 HTML 页面和一个 JavaScript 页面。让我们从 HTML 页面开始:
<html>
<body>
<input id="input" type="text">
<script src="img/Rx.min.js"></script>
<script src="img/app.js"></script>
</body>
</html>
这展示了我们的输入元素和 RxJS 的脚本引用,以及app.js文件的引用。然后是app.js文件,其中我们获取输入元素的引用,并在输入被输入时立即开始监听按键:
let elem = document.getElementById('input');
let keyStream$ = Rx.Observable
.fromEvent(elem, 'keyup')
.map( ev => ev.key);
keyStream$.subscribe( key => console.log(key));
// emits entered key chars
值得强调的是,我们通过调用fromEvent()创建操作符来开始监听由keyup事件发出的内容。然后,我们应用map()操作符来挖掘ev.key上的字符值存储。最后,我们订阅这个流。正如预期的那样,运行这段代码将在你输入 HTML 页面中的值时立即在控制台中打印出输入的字符。
让我们通过基于我们输入的内容执行一个 AJAX 请求来使这个例子更具体。为此,我们将使用fetch() API 和一个名为 swapi(swapi.com)的在线 API,它包含了一系列包含《星球大战》电影信息的 API。让我们首先定义我们的 AJAX 调用,然后看看它如何融入我们现有的键流中。
我们说过我们会使用fetch()。它允许我们像这样简单地制定一个 GET 请求:
fetch('https://swapi.co/api/people/1')
.then(data => data.json())
.then(data => console.log('data', data));
当然,我们希望将这个请求转换成一个Observable,以便它能很好地与我们的keyStream$协同工作。幸运的是,通过使用from()操作符,我们可以轻松地实现这一点。然而,让我们首先将fetch()调用重写成一个易于操作的方法。重写后的结果如下:
function getStarwarsCharacterStream(id) {
return fetch('https://swapi.co/api/people/' + id)
.then(data => data.json());
}
这段代码允许我们提供一个用于构造 URL 的参数,我们使用它通过 AJAX 获取一些数据。在这个阶段,我们准备将我们的函数连接到现有的流。我们通过输入以下内容来实现这一点:
let keyStream$ = Rx.Observable.fromEvent(elem, 'keyup')
.map(ev => ev.key)
.filter(key => key !== 'Backspace')
.flatMap( key =>
Rx.Observable
.from(getStarwarsCharacterStream(key))
);
我们使用from()转换操作符以粗体形式突出显示flatMap()操作符的使用。最后提到的操作符将我们的getStarwarsCharacterStream()函数作为参数。from()操作符将此函数转换为流。
在这里,我们学习了如何连接两个不同的流,以及如何将Promise转换为流。尽管这种方法在纸面上看起来很好,但使用flatMap()有其局限性,了解这些局限性非常重要。因此,让我们接下来谈谈switchMap()操作符。使用switchMap()操作符的好处将在执行长时间运行的任务时变得更加明显。为了论证,让我们定义这样一个任务,如下所示:
function longRunningTask(input) {
return new Promise(resolve => {
setTimeout(() => {
resolve('response based on ' + input);
}, 5000);
});
}
在此代码中,我们有一个执行需要 5 秒钟的函数;这足以展示我们试图说明的点。接下来,让我们展示如果我们继续在以下代码中使用flatMap()操作符会产生什么效果:
let longRunningStream$ = keyStream$
.map(ev => ev.key)
.filter(key => elem.value.length >3)
.filter( key => key !== 'Backspace')
.flatMap( key =>
Rx.Observable
.from(longRunningTask(elem.value))
);
longRunningStream$.subscribe(data => console.log(data));
上述代码的工作方式如下:每次我们按下键,它都会生成一个事件。然而,我们有一个.filter()操作符,它确保只有当至少输入了四个键时才会生成事件,filter(key => elem.value.length >3)。让我们谈谈此时用户的期望。如果一个用户在一个输入控件中输入键,他们最可能期望在完成输入时发起一个请求。用户定义完成输入为输入一些字符,并且如果输入错误,他们应该能够删除字符。因此,我们可以假设以下输入序列:
// enters abcde
abcde
// removes 'e'
到目前为止,他们已经输入了字符,并在合理的时间内编辑了他们的答案。用户期望根据abcd得到一个答案。然而,使用flatMap()操作符意味着用户将得到两个答案,因为在现实中,他们输入了abcde和abcd。想象一下,如果我们根据这两个输入得到一个结果列表;它很可能是两个看起来有些不同的列表。基于我们代码的响应将看起来像这样:

我们的代码很可能能够通过在收到新响应时立即重新渲染结果列表来处理所描述的情况。然而,这里有两个问题:首先,我们对abcde进行了不必要的网络请求,其次,如果后端响应足够快,我们将在结果列表渲染一次后,基于第二个响应再次渲染,从而在 UI 中看到闪烁。这不是一个好的情况,我们希望有一个情况,即如果我们继续输入,第一个请求将被放弃。这就是switchMap()操作符发挥作用的地方。它确实做到了这一点。因此,让我们将前面的代码更改为以下代码:
let longRunningStream$ = keyStream$
.map(ev => ev.key)
.filter(key => elem.value.length >3)
.filter( key => key !== 'Backspace')
.switchMap( key =>
Rx.Observable
.from(longRunningTask(elem.value))
);
在此代码中,我们只是将flatMap()切换为switchMap()。当我们现在以完全相同的方式执行代码时,即用户首先输入12345,然后很快将其更改为1234,最终结果是:

如我们所见,我们只得到一个请求。这是因为当发生新事件时,前一个事件会被中止——switchMap() 正在施展它的魔法。用户很高兴,我们也很高兴。
AJAX
我们已经触及了制作 AJAX 请求的主题。有许多方法可以制作 AJAX 请求;最常见的方法有两种:
-
使用 fetch API;fetch API 是一个网络标准,因此内置在大多数浏览器中
-
使用内置在 RxJS 库中的
ajax()方法;它曾经存在于一个名为 Rx.Dom 的库中
fetch()
fetch() API 是一个网络标准。您可以在以下链接中找到官方文档:developer.mozilla.org/en-US/docs/Web/API/Fetch_API。fetch() API 是基于 Promise 的,这意味着在使用之前我们需要将其转换为 Observable。该 API 暴露了一个 fetch() 方法,它以强制性的 URL 参数作为第一个参数,第二个参数是一个可选对象,允许您控制发送哪个正文(如果有),使用哪个 HTTP 动词,等等。
我们已经在 RxJS 的上下文中提到了如何最好地处理它。尽管如此,这仍然值得重复。但这并不像只是将我们的 fetch 操作符放入 from() 操作符中那么简单。让我们写一些代码来看看原因:
let convertedStream$ =
Rx.Observable.from(fetch('some url'));
convertedStream$.subscribe(data => 'my data?', data);
我们得到了我们的数据吗?抱歉,没有,我们得到了一个 Response 对象。但这很容易,只需在 map() 操作符中调用一个 json() 方法,然后我们肯定就有数据了?再次抱歉,没有,当你输入以下内容时,json() 方法返回一个 Promise:
let convertedStream$ = Rx.Observable.from(fetch('some url'))
.map( r=> r.json());
// returns PromiseObservable
convertedStream$.subscribe(data => 'my data?', data);
我们已经在上一节中展示了可能的解决方案,如下所示:
getData() {
return fetch('some url')
.then(r => r.json());
}
let convertedStream$ = Rx.Observable.from(getData());
convertedStream$.subscribe(data => console.log('data', data));
在这段代码中,我们所做的是在将数据交给 from() 操作符之前简单地处理我们的数据。与 RxJS 不太一样,感觉与 Promise 玩耍。你可以采取一个更基于流的方案;我们几乎做到了,我们只需要做一些小的调整:
let convertedStream$ = Rx.Observable.from(fetch('some url'))
.flatMap( r => Rx.Observable.from(r.json()));
// returns data
convertedStream$.subscribe(data => console.log('data'), data);
就这样:我们的 fetch() 调用现在像流一样提供数据。那么我们做了什么?嗯,我们将 map() 调用更改为 flatMap() 调用。这样做的原因是当我们调用 r.json() 时,我们得到了一个 Promise。我们通过将其包裹在 from() 调用中,Rx.Observable.from(r.json()) 来解决这个问题。如果不将 map() 更改为 flatMap(),那么流将发出一个 PromiseObservable。正如我们在上一节中学到的,如果我们冒着在流中创建流的危险,我们需要 flatMap() 来拯救我们,它确实做到了。
ajax() 操作符
与基于 Promise 的 fetch() API 不同,ajax() 方法实际上是基于 Observable 的,这使得我们的工作变得稍微容易一些。使用它非常直接,如下所示:
Rx.Observable
.ajax('https://swapi.co/api/people/1')
.map(r => r.response)
.subscribe(data => console.log('from ajax()', data));
如我们所见,前面的代码使用 URL 作为参数调用ajax()操作符。值得提及的第二件事是调用map()操作符,它从response属性中提取我们的数据。因为它是一个Observable,我们只需像往常一样通过调用subscribe()方法并给它提供一个监听函数作为参数来订阅它。
这涵盖了当你想使用 HTTP 动词GET获取数据时的简单情况。幸运的是,对于我们的需求来说,通过使用重载版本的ajax()操作符来创建、更新或删除数据相当容易,这个操作符接受一个AjaxRequest对象实例,它具有以下字段:
url?: string;
body?: any;
user?: string;
async?: boolean;
method?: string;
headers?: Object;
timeout?: number;
password?: string;
hasContent?: boolean;
crossDomain?: boolean;
withCredentials?: boolean;
createXHR?: () => XMLHttpRequest;
progressSubscriber?: Subscriber<any>;
responseType?: string;
从这个对象规范中我们可以看出,所有字段都是可选的,我们还可以通过我们的请求配置相当多的事情,例如headers、timeout、user、crossDomain等等;基本上这是我们期望从良好的 AJAX 包装功能中得到的。除了ajax()操作符的重载之外,还存在一些简写选项:
-
get(): 使用GET动词获取数据 -
put(): 使用PUT动词更新数据 -
post(): 使用POST动词创建数据 -
patch(): 使用PATCH动词的目的是更新部分资源 -
delete(): 使用DELETE动词删除数据 -
getJSON(): 使用GET动词获取数据,并将响应类型设置为application/json
级联调用
到目前为止,我们已经介绍了你将使用 AJAX 发送或接收数据的两种主要方式。当涉及到接收数据时,通常并不像获取数据并渲染它那样简单。实际上,你很可能依赖于何时可以获取哪些数据。一个典型的例子是在获取剩余数据之前需要执行登录调用。在某些情况下,可能需要首先登录,然后获取登录用户的资料,一旦有了这些资料,就可以获取消息、订单或任何可能特定于某个用户的数据。这种以这种方式获取数据的现象被称为级联调用。
让我们看看如何使用承诺(Promises)进行级联调用,并逐步学习如何使用 RxJS 做同样的事情。我们之所以这样做,是因为我们假设大多数阅读这本书的人对承诺(Promises)都很熟悉。
让我们看看我们最初提到的依赖情况,我们需要按以下顺序执行以下步骤:
-
用户首先登录到系统中
-
然后我们获取用户的资料
-
然后我们获取用户订单的信息
使用承诺(promises),代码中可能看起来是这样的:
// cascading/cascading-promises.js
login()
.then(getUser)
.then(getOrders);
// we collect username and password from a form
const login = (username, password) => {
return fetch("/login", {
method: "POST",
body: { username, password }
})
.then(r => r.json())
.then(token => {
localStorage.setItem("auth", token);
});
};
const getUser = () => {
return fetch("/users", {
headers: {
Authorization: "Bearer " + localStorage.getToken("auth")
}
}).then(r => r.json());
};
const getOrders = user => {
return fetch(`/orders/user/${user.id}`, {
headers: {
Authorization: "Bearer " + localStorage.getToken("auth")
}
}).then(r => r.json());
};
这段代码描述了我们首先使用login()方法登录系统,并获取一个令牌。我们使用这个令牌在未来的任何调用中确保我们进行认证调用。我们还看到我们如何执行getUser()调用并获取一个用户实例。我们使用相同的用户实例来执行我们的最后一个调用getOrders(),其中用户 ID 用作路由参数:`/orders/user/${user.id}`。
我们已经展示了如何使用承诺(promises)来执行级联调用;我们这样做是为了为我们要解决的问题建立一个共同的基础。RxJS 的方法非常相似:我们已经展示了ajax()操作符的存在,并且当处理 AJAX 调用时,它使我们的生活变得更简单。为了使用 RxJS 实现级联调用效果,我们只需简单地使用switchMap()操作符。这将使我们的代码看起来像这样:
// cascading/cascading-rxjs.js
let user = "user";
let password = "password";
login(user, password)
.switchMap(getUser)
.switchMap(getOrders);
// we collect username and password from a form
const login = (username, password) => {
return Rx.Observable.ajax("/login", {
method: "POST",
body: { username, password }
})
.map(r => r.response)
.do(token => {
localStorage.setItem("auth", token);
});
};
const getUser = () => {
return Rx.Observable.ajax("/users", {
headers: {
Authorization: "Bearer " + localStorage.getToken("auth")
}
}).map(r => r.response);
};
const getOrders = user => {
return Rx.Observable.json(`/orders/user/${user.id}`, {
headers: {
Authorization: "Bearer " + localStorage.getToken("auth")
}
}).map(r => r.response);
};
我们已经指出了前面代码中需要更改的部分。简而言之,更改如下:
-
fetch()被ajax()操作符替换 -
我们调用
.map(r => r.response)而不是.then(r => r.json()) -
我们对每个级联调用执行
.switchMap()调用,而不是.then(getOrders)
还有一个有趣的方面需要我们探讨,那就是并行调用。当我们获取用户和订单时,我们在发起下一个调用之前等待前一个调用完全完成。在很多情况下,这可能并不是严格必要的。想象一下,我们有一个与之前类似的情况,但围绕用户有很多有趣的信息我们需要获取。除了获取订单之外,用户可能还有一个朋友集合或消息集合。获取这些数据的前提条件只是我们已经获取了用户,因此我们知道应该查询哪个朋友集合以及需要查询哪个消息集合。在承诺的世界中,我们会使用Promise.all()构造来实现并行化。考虑到这一点,我们更新我们的Promise代码,使其看起来像这样:
// parallell/parallell-promise.js
// we collect username and password from a form
login(username, password) {
return new Promise(resolve => {
resolve('logged in');
});
}
getUsersData(user) {
return Promise.all([
getOrders(user),
getMessages(user),
getFriends(user)
// not implemented but you get the idea, another call in parallell
])
}
getUser() {
// same as before
}
getOrders(user) {
// same as before
}
login()
.then(getUser)
.then(getUsersData);
如前所述的代码所示,我们引入了新的getUsersData()方法,该方法并行获取订单、消息和朋友集合,使我们的应用更快地响应,因为数据将比逐个获取更快地到达。
我们可以通过引入forkJoin()操作符轻松地使用 RxJS 实现相同的效果。它接受一系列流,并并行获取所有内容。因此,我们更新我们的 RxJS 代码,使其看起来如下:
// parallell/parallell-rxjs.js
import Rx from 'rxjs/Rx';
// imagine we collected these from a form
let user = 'user';
let password = 'password';
login(user, password)
.switchMap(getUser)
.switchMap(getUsersData)
// we collect username and password from a form
login(username, password) {
// same as before
}
getUsersData(user) {
return Rx.Observable.forkJoin([
getOrders(),
getMessages(),
getFriends()
])
}
getUser() {
// same as before
}
getOrders(user) {
// same as before
}
login()
.then(getUser)
.then(getUsersData);
深入探讨
到目前为止,我们已经查看了一些可以让你使用map()和filter()操作符创建或更改流的操作符,我们学习了如何管理不同的 AJAX 场景,等等。基础是有的,但我们还没有以结构化的方式真正接近操作符的话题。我们这是什么意思呢?嗯,操作符可以被认为是属于不同的类别。我们可用的操作符数量令人震惊,有 60 多个。如果我们真的要学习所有这些,这将需要时间。不过,这里的关键是:我们只需要知道存在哪些不同类型的操作符,这样我们就可以在适当的地方应用它们。这减少了我们的认知负担和记忆。一旦我们知道有哪些类别,我们只需要深入挖掘,很可能会最终知道总共 10-15 个操作符,其余的我们可以在需要时查阅。
目前,我们有以下类别:
-
创建操作符:这些操作符帮助我们首先创建流。几乎任何东西都可以通过这些操作符转换为流。
-
组合操作符:这些操作符帮助我们结合值以及流。
-
数学操作符:这些操作符对正在发射的值执行数学评估。
-
基于时间的操作符:这些操作符改变值发射的速度。
-
分组操作符:这些操作符的思路是对一组值而不是单个值进行操作。
创建操作符
我们使用创建操作符来创建流本身,因为坦白说:我们需要转换为流的东西并不总是流,但通过将其转换为流,它将必须与其他流很好地协同工作,最好的是,将能够利用使用操作符的全部力量。
那么,这些其他非流由什么组成呢?嗯,可以是任何异步或同步的内容。重要的是,这些是需要在某一点发射的数据。因此,存在一系列创建操作符。在接下来的小节中,我们将展示所有这些操作符中的一部分,足够你认识到将任何事物转换为流的力量。
of()操作符
我们已经有机会使用这个操作符几次了。它接受未知数量的以逗号分隔的参数,可以是整数、字符串或对象。如果你只想发射一组有限的值,这是一个你想要使用的操作符。要使用它,只需输入:
// creation-operators/of.js
const numberStream$ = Rx.Observable.of(1,2, 3);
const objectStream$ = Rx.Observable.of({ age: 37 }, { name: "chris" });
// emits 1 2 3
numberStream$.subscribe(data => console.log(data));
// emits { age: 37 }, { name: 'chris' }
objectStream$.subscribe(data => console.log(data));
从代码中可以看出,我们在of()操作符中放置什么内容其实并不重要,它无论如何都能发射出来。
from()操作符
这个操作符可以接受数组或Promise作为输入,并将它们转换为流。要使用它,只需像这样调用:
// creation-operators/from.js
const promiseStream$ = Rx.Observable.from(
new Promise(resolve => setTimeout(() => resolve("data"),3000))
);
const arrayStream$ = Rx.Observable.from([1, 2, 3, 4]);
promiseStream$.subscribe(data => console.log("data", data));
// emits data after 3 seconds
arrayStream$.subscribe(data => console.log(data));
// emits 1, 2, 3, 4
这样做可以节省我们很多麻烦,不必处理不同类型的异步调用。
range()操作符
这个操作符允许你指定一个范围,一个起始数字和一个结束数字。这是一个很好的简写,可以快速创建一个具有数字范围的流。要使用它,只需输入:
// creation-operators/range.js
const stream$ = Rx.Observable.range(1,99);
stream$.subscribe(data => console.log(data));
// emits 1... 99
fromEvent() 操作符
现在事情变得非常有趣。fromEvent()操作符允许我们将 UI 事件(如click或scroll事件)混合起来,并将其转换成一个流。到目前为止,我们一直假设异步调用只与 AJAX 调用有关。这远非事实。我们可以将 UI 事件与任何类型的异步调用混合,这创造了一个非常有趣的情况,使我们能够编写非常强大、表达力丰富的代码。我们将在下一节中进一步探讨这个话题,在流中思考。
要使用这个操作符,你需要给它提供两个参数:一个 DOM 元素和事件名称,如下所示:
// creation-operators/fromEvent.js
// we imagine we have an element in our DOM looking like this <input id="id" />
const elem = document.getElementById("input");
const eventStream$ = Rx.Observable
.fromEvent(elem, "click")
.map(ev => ev.key);
// outputs the typed key
eventStream$.subscribe(data => console.log(data));
组合
组合操作符是关于组合不同流中的值。我们有几个操作符可以帮助我们。当我们需要从多个地方而不是一个地方获取数据时,这种类型的操作符是有意义的。如果没有我们即将描述的强大操作符,从不同来源组合数据结构可能会很繁琐且容易出错。
merge() 操作符
merge()操作符从不同的流中获取数据并将其合并。然而,这些流可以是任何类型,只要它们是Observable类型。这意味着我们可以将定时操作、承诺、of()操作符的静态数据等的数据组合起来。合并所做的就是交错发出的数据。这意味着在以下示例中,它将同时从两个流中发出。使用这个操作符有两种方式,作为静态方法,也可以作为实例方法:
// combination/merge.js
let promiseStream = Rx.Observable
.from(new Promise(resolve => resolve("data")))
let stream = Rx.Observable.interval(500).take(3);
let stream2 = Rx.Observable.interval(500).take(5);
// instance method version of merge(), emits 0,0, 1,1 2,2 3, 4
stream.merge(stream2)
.subscribe(data => console.log("merged", data));
// static version of merge(), emits 0,0, 1,1, 2, 2, 3, 4 and 'data'
Rx.Observable.merge(
stream,
stream2,
promiseStream
)
.subscribe(data => console.log("merged static", data));
这里的要点是,如果你只需要将一个流与另一个流组合,那么使用这个操作符的实例方法版本,但如果你有多个流,那么使用静态版本。此外,指定流的顺序也很重要。
combineLatest()
想象一下,你与几个提供数据的端点建立了连接。你所关心的是每个端点发出的最新数据。你可能处于这样的情况:一段时间后,一个或多个端点停止发送数据,你想要知道最后发生了什么。在这种情况下,我们希望能够结合所有相关端点的最新值。这就是combineLatest()操作符发挥作用的地方。你可以按照以下方式使用它:
// combination/combineLatest.js
let firstStream$ = Rx.Observable
.interval(500)
.take(3);
let secondStream$ = Rx.Observable
.interval(500)
.take(5);
let combinedStream$ = Rx.Observable.combineLatest(
firstStream$,
secondStream$
)
// emits [0, 0] [1,1] [2,2] [2,3] [2,4] [2,5]
combinedStream$.subscribe(data => console.log(data));
我们可以看到,由于take()操作符限制了项目数量,firstStream$在一段时间后停止发出值。然而,combineLatest()操作符确保我们仍然得到了firstStream$发出的最后一个值。
zip()
这个运算符的目的是尽可能多地拼接值。我们可能正在处理连续的流,也可能在处理有值数限制的流。你使用这个运算符的方式如下:
// combination/zip.js
let stream$ = Rx.Observable.of(1, 2, 3, 4);
let secondStream$ = Rx.Observable.of(5, 6, 7, 8);
let thirdStream$ = Rx.Observable.of(9, 10);
let zippedStream$ = Rx.Observable.zip(
stream$,
secondStream$,
thirdStream$
)
// [1, 5, 9] [2, 6, 10]
zippedStream$.subscribe(data => console.log(data))
如我们所见,在这里,我们垂直拼接值,并通过最小公倍数,thirdStream$ 是最短的,计算发出的值的数量。这意味着我们将从左到右取值并将它们压缩在一起。由于 thirdStream$ 只有两个值,我们最终只发出两个值。
concat()
初看,concat() 运算符看起来像另一个 merge() 运算符,但这并不完全正确。区别在于 concat() 会等待其他流完成后再从下一个流中发出流。你在调用 concat() 时的流排列方式很重要。运算符的使用方式如下:
// combination/concat.js
let firstStream$ = Rx.Observable.of(1,2,3,4);
let secondStream$ = Rx.Observable.of(5,6,7,8);
let concatStream$ = Rx.Observable.concat(
firstStream$,
secondStream$
);
concatStream$.subscribe(data => console.log(data));
数学
数学运算符是执行数学运算的运算符,例如找到最大或最小值,汇总所有值等。
max
max() 运算符用于找到最大值。它有两种形式:我们或者不带参数直接调用 max() 运算符,或者提供一个 compare 函数。然后 compare 函数决定某个值是否大于、小于或等于一个发出的值。让我们看看两种不同的版本:
// mathematical/max.js
let streamWithNumbers$ = Rx.Observable
.of(1,2,3,4)
.max();
// 4
streamWithNumbers$.subscribe(data => console.log(data));
function comparePeople(firstPerson, secondPerson) {
if (firstPerson.age > secondPerson.age) {
return 1;
} else if (firstPerson.age < secondPerson.age) {
return -1;
}
return 0;
}
let streamOfObjects$ = Rx.Observable
.of({
name : "Yoda",
age: 999
}, {
name : "Chris",
age: 38
})
.max(comparePeople);
// { name: 'Yoda', age : 999 }
streamOfObjects$.subscribe(data => console.log(data));
我们可以从前面的代码中看到,我们得到一个结果,并且它是最大的。
min
min() 运算符基本上是 max() 运算符的相反;它有两种形式:带参数和不带参数。它的任务是找到最小值。要使用它,请输入:
// mathematical/min.js
let streamOfValues$ = Rx.Observable
.of(1, 2, 3, 4)
.min();
// emits 1
streamOfValues$.subscribe(data => console.log(data));
sum
曾经有一个名为 sum() 的运算符,但它在几个版本中已经不存在了。取而代之的是 .reduce()。使用 reduce() 运算符,我们可以轻松地实现相同的功能。以下是如何使用 reduce() 编写 sum() 运算符的示例:
// mathematical/sum.js
let stream = Rx.Observable.of(1, 2, 3, 4)
.reduce((acc, curr) => acc + curr);
// emits 10
stream.subscribe(data => console.log(data));
这个运算符的作用是遍历所有发出的值并将结果相加。所以,本质上,它汇总了所有内容。当然,这种运算符不仅可以应用于数字,也可以应用于对象。区别在于你如何执行 reduce() 操作。以下示例涵盖了这种情况:
let stream = Rx.Observable.of({ name : "chris" }, { age: 38 })
.reduce((acc, curr) => Object.assign({},acc, curr));
// { name: 'chris', age: 38 }
stream.subscribe(data => console.log(data));
如前述代码所示,reduce() 运算符确保所有对象的属性都合并到一个对象中。
时间
当谈论流时,时间是一个非常重要的概念。想象一下,你有多个具有不同带宽的流,或者一个流比另一个流快,或者你有一个在特定时间间隔内重试 AJAX 调用的场景。在这些所有情况下,我们需要控制数据发出的速度,时间在这些场景中都起着重要作用。在我们手中,有一大堆运算符,就像魔术师一样,使我们能够根据需要构建和控制我们的值。
interval()操作符
在 JavaScript 中,有一个setInterval()函数,允许你以固定的时间间隔执行代码,直到你选择停止它。RxJS 有一个与此行为相同的操作符,即interval()操作符。它接受一个参数:通常是发出值之间的毫秒数。你可以按以下方式使用它:
// time/interval.js
let stream$ = Rx.Observable.interval(1000);
// emits 0, 1, 2, 3 ... n with 1 second in between emits, till the end of time
stream$.subscribe(data => console.log(data));
注意:这个操作符会一直发出,直到你停止它。停止它的最佳方法是将其与一个take()操作符结合。take()操作符接受一个参数,指定在停止之前它想要发出多少个值。更新后的代码如下:
// time/interval-take.js
let stream$ = Rx.Observable.interval(1000)
.take(2);
// emits 0, 1, stops emitting thanks to take() operator
stream$.subscribe(data => console.log(data));
timer()操作符
timer()操作符的任务是在一定时间后发出值。它有两种风味:你可以在一定毫秒数后发出一个值,或者你可以在它们之间保持一定的延迟继续发出值。让我们看看可用的两种不同风味:
// time/timer.js
let stream$ = Rx.Observable.timer(1000);
// delay with 500 milliseconds
let streamWithDelay$ = Rx.Observable.timer(1000, 500)
// emits 0 after 1000 milliseconds, then no more
stream$.subscribe(data => console.log(data));
streamWithDelay$.subscribe(data => console.log(data));
delay()操作符
delay()操作符延迟所有发出值,并按以下方式使用:
// time/delay.js
let stream$ = Rx.Observable
.interval(100)
.take(3)
.delay(500);
// 0 after 600 ms, 1 after 1200 ms, 2 after 1800 ms
stream.subscribe(data => console.log(data));
sampleTime()操作符
sampleTime()操作符用于在样本周期过后才发出值。一个很好的用例是当你想要有一个冷却功能。想象一下,你有用户频繁地按保存按钮。保存可能需要几秒钟才能完成。一种方法是保存时禁用保存按钮。另一种有效的方法是简单地忽略按钮的任何点击,直到操作有机会完成。以下代码正是这样做的:
// time/sampleTime.js
let elem = document.getElementById("btn");
let stream$ = Rx.Observable
.fromEvent(elem, "click")
.sampleTime(8000);
// emits values every 8th second
stream$.subscribe(data => console.log("mouse clicks",data));
debounceTime()操作符
sampleTime()操作符能够忽略用户一段时间,但debounceTime()操作符采取了不同的方法。防抖作为一个概念意味着我们在发出值之前等待事情平静下来。想象一下用户输入的输入元素。用户最终会停止输入。我们想确保用户确实已经停止了,所以我们等待一段时间后才真正采取行动。这正是debounceTime()操作符为我们做的事情。以下示例展示了我们如何监听用户在输入元素中输入,等待用户停止输入,最后执行一个 AJAX 调用:
// time/debounceTime.js
const elem = document.getElementById("input");
let stream$ = Rx.Observable.fromEvent(elem, "keyup")
.map( ev => ev.key)
.filter(key => key !== "Backspace")
.debounceTime(2000)
.switchMap( x => {
return new Rx.Observable.ajax(`https://swapi.co/api/people/${elem.value}`);
})
.map(r => r.response);
stream$.subscribe(data => console.log(data));
当用户在文本框中输入一个数字时,在 2 秒的无操作后,keyup 事件将被触发。之后,将使用我们的文本框输入执行一个 AJAX 调用。
分组
分组操作符允许我们对一组收集的事件进行操作,而不是一次只对一个发出的事件进行操作。
buffer()操作符
buffer()操作符的想法是我们可以收集大量事件,而无需立即发出。该操作符本身接受一个参数,一个Observable,它定义了何时停止收集事件。在那个时刻,我们可以选择对这些事件做什么。以下是你可以使用此操作符的方法:
// grouping/buffer.js
const elem = document.getElementById("input");
let keyStream$ = Rx.Observable.fromEvent(elem,"keyup");
let breakStream$ = keyStream$.debounceTime(2000);
let chatStream$ = keyStream$
.map(ev => ev.key)
.filter(key => key !== "Backspace")
.buffer(breakStream$)
.switchMap(newContent => Rx.Observable.of("send text as I type", newContent));
chatStream$.subscribe(data=> console.log(data));
这所做的是收集事件,直到有 2 秒的空闲时间。在那个时刻,我们释放所有已经缓冲起来的关键事件。当我们释放所有这些事件时,例如,我们可以通过 AJAX 将它们发送到某个地方。这在聊天应用中是一个典型的场景。使用前面的代码,我们总是可以发送最新输入的字符。
bufferTime()运算符
与buffer()非常相似的运算符是bufferTime()。这个运算符允许我们指定我们希望缓冲事件多长时间。它比buffer()稍微灵活一些,但仍然非常有用。
流式思维
到目前为止,我们已经经历了一系列场景,这些场景展示了我们有哪些运算符可供使用,以及它们如何被串联起来。我们还看到了像flatMap()和switchMap()这样的运算符如何在我们从一种类型的可观察对象移动到另一种类型时真正改变事情。那么,当与可观察对象一起工作时,你应该采取哪种方法?显然,我们需要使用运算符来表示一个算法,但我们应该从哪里开始呢?我们首先需要做的是思考起点和终点。我们想要捕获哪些类型的事件,最终结果应该是什么样子?这已经给我们提供了关于我们需要执行多少转换才能达到那里的线索。如果我们只想转换数据,那么我们可能只需要一个map()运算符和一个filter()运算符。如果我们想从一个Observable转换到下一个,那么我们需要一个flatMap()或switchMap()。我们是否有特定的行为,比如等待用户停止输入?如果有,那么我们需要查看debounceTime()或类似的运算符。这实际上与所有问题是一样的:分解问题,看看你有哪些部分,分解并征服。不过,让我们尝试将其分解成一系列步骤:
-
输入是什么?UI 事件或其他什么?
-
输出是什么?最终结果是什么?
-
根据第二个要点,我需要哪些转换才能达到目标?
-
我是否需要处理多个流?
-
我是否需要处理错误,如果是的话,应该如何处理?
希望这能让你了解如何思考流。记住,从小处着手,逐步实现你的目标。
摘要
我们着手学习更多关于基本操作符的知识。在这个过程中,我们遇到了map()和filter()操作符,它们使我们能够控制被发射的内容。对do()操作符的了解为我们提供了调试流的方法。此外,我们还了解了存在沙盒环境,例如 JS Bin 和 RxFiddle,以及它们如何帮助我们快速开始使用 RxJS。接下来,我们深入探讨了 AJAX 这一主题,并构建了对可能出现的不同场景的理解。在深入 RxJS 的过程中,我们研究了不同的操作符类别。我们对这一点只是略作了解,但它为我们提供了一种方法来了解库中哪些类型的操作符。最后,我们通过探讨如何改变和发展我们的思维方式来思考流,结束了这一章节。
正是凭借所有这些获得的知识,我们现在准备进入下一章更高级的 Rx 主题。我们掌握了基础知识,现在是时候精通它们了。
第七章:RxJS 高级
我们刚刚完成了最后一章,本章让我们更多地了解了有哪些操作符以及如何有效地利用它们。有了这些知识,我们现在将更深入地探讨这个主题。我们将从了解存在哪些部分,到真正理解 RxJS 的本质。了解 RxJS 的本质涉及到了解是什么让它运转。为了揭示这一点,我们需要涵盖诸如热、温、冷观测量之间的区别;了解主题及其用途;以及有时被忽视的主题——调度器。
我们还想要涵盖与 Observable 一起工作的其他方面,特别是如何处理错误以及如何测试您的观测量。
在本章中,您将了解以下内容:
-
热观测量、冷观测量和温观测量
-
主题:它们与 Observable 的区别,以及何时使用它们
-
可管道操作符,RxJS 库中的最新增项,以及它们如何影响您组合观测量的方式
-
大理石测试,这是帮助您测试观测量的测试设备
热观测量、冷观测量和温观测量
存在着热、冷、温观测量。我们实际上是什么意思呢?首先,让我们说,您将处理的大部分内容都是冷观测量。这有帮助吗?如果没有帮助?那么,让我们先谈谈 Promise。Promise 是热的。它们之所以是热的,是因为当我们执行它们的代码时,它会立即发生。让我们看看一个例子:
// hot-cold-warm/promise.js
function getData() {
return new Promise(resolve => {
console.log("this will be printed straight away");
setTimeout(() => resolve("some data"), 3000);
});
}
// emits 'some data' after 3 seconds
getData().then(data => console.log("3 seconds later", data));
如果您来自非 RxJS 背景,您在这个时候可能会想:好吧,是的,这正是我预期的。不过,我们想要表达的观点是:调用getData()会使您的代码立即运行。这与 RxJS 不同,因为在 RxJS 中,类似的代码实际上只有在存在一个关心结果的监听器/订阅者时才会运行。RxJS 回答了古老的哲学问题:如果森林里没有人来听,树倒下会发出声音吗?在 Promise 的情况下,会。在 Observable 的情况下,则不会。让我们用一个类似的代码示例来澄清我们刚才所说的,使用 RxJS 和 Observable:
// hot-cold-warm/observer.js
const Rx = require("rxjs/Rx");
function getData() {
return Rx.Observable(observer => {
console.log("this won't be printed until a subscriber exists");
setTimeout(() => {
observer.next("some data");
observer.complete();
}, 3000);
});
}
// nothing happens
getData();
在 RxJS 中,这样的代码被认为是冷的,或者说是懒的。我们需要一个订阅者才能使某些事情真正发生。我们可以添加一个订阅者,如下所示:
// hot-cold-warm/observer-with-subscriber
const Rx = require("rxjs/Rx");
function getData() {
return Rx.Observable.create(observer => {
console.log("this won't be printed until a subscriber exists");
setTimeout(() => {
observer.next("some data");
observer.complete();
}, 3000);
});
}
const stream$ = getData();
stream$.subscribe(data => console.log("data from observer", data));
这是 Observables 与 Promises 行为之间的一大区别,了解这一点很重要。这是一个冷 Observables;那么,什么是热 Observables 呢?在这个时候,人们可能会认为热 Observables 是立即执行的东西;然而,这不仅仅是那样。关于什么是热 Observables 的官方解释之一是,任何订阅它的东西都会与其他订阅者共享生产者。生产者是 Observables 内部内部产生值的来源。这意味着数据是共享的。让我们看看冷 Observables 订阅场景,并将其与热 Observables 订阅场景进行对比。我们将从冷场景开始:
// hot-cold-warm/cold-observable.js
const Rx = require("rxjs/Rx");
const stream$ = Rx.Observable.interval(1000).take(3);
// subscriber 1 emits 0, 1, 2
stream$.subscribe(data => console.log(data));
// subscriber 2, emits 0, 1, 2
stream$.subscribe(data => console.log(data));
// subscriber 3, emits 0, 1, 2, after 2 seconds
setTimeout(() => {
stream$.subscribe(data => console.log(data));
}, 3000);
在前面的代码中,我们有三个不同的订阅者,它们各自接收发出的值的副本。每次我们添加一个新的订阅者时,值都是从开始处开始的。当我们查看前两个订阅者时,这可能是个预期。至于第三个订阅者,它是在两秒后作为订阅者添加的。是的,甚至那个订阅者也会收到它自己的值集。解释是每个订阅者在订阅时都会收到它自己的生产者。
在热 Observables 的情况下,只有一个生产者,这意味着上述场景将会有不同的表现。让我们写下热 Observables 场景的代码:
// hot observable scenario
// subscriber 1 emits 0, 1, 2
hotStream$.subscribe(data => console.log(data));
// subscriber 2, emits 0, 1, 2
hotStream$.subscribe(data => console.log(data));
// subscriber 3, emits 2, after 2 seconds
setTimeout(() => {
hotStream$.subscribe(data => console.log(data));
}, 3000);
第三个订阅者只输出值2的原因是其他值已经发出。第三个订阅者没有看到这一发生。在第三个值发出时,它出现了,这就是它接收值2的原因。
使流变热
这个hotStream$是如何创建的呢?你确实说过大多数创建的流都是冷的吗?我们有一个操作符专门用于此,实际上有两个操作符。我们可以通过使用publish()和connect()操作符将流从冷变为热。让我们从一个冷 Observables 开始,并添加提到的操作符,如下所示:
// hot-cold-warm/hot-observable.js
const Rx = require("rxjs/Rx");
let start = new Date();
let stream = Rx.Observable
.interval(1000)
.take(5)
.publish();
setTimeout(() => {
stream.subscribe(data => {
console.log(`subscriber 1 ${new Date() - start}`, data);
});
}, 2000);
setTimeout(() => {
stream.subscribe(data => {
console.log(`subscriber 2 ${new Date() - start}`, data)
});
}, 3000);
stream.connect();
stream.subscribe(
data => console.log(
`subscriber 0 - I was here first ${new Date() - start}`,
data
)
);
从前面的代码中我们可以看到,我们创建了一个 Observable,并指示它每秒发出一个值。此外,它应该在发出五个值后停止。然后我们调用publish()操作符。这使我们处于准备模式。然后我们设置在两秒和三秒后分别发生的几个订阅。然后我们调用流上的connect()。这将使流从热变为冷。因此,我们的流开始发出值,任何订阅者,无论何时开始订阅,都将与任何未来的订阅者共享生产者。最后,我们在connect()调用后立即添加一个订阅者。让我们通过以下截图来展示输出结果:

我们的第一位订阅者在 1 秒后开始发出值。第二位订阅者在又过了 1 秒后开始工作。这次它的值是1,它错过了第一个值。又过了 1 秒,第三位订阅者被附加。该订阅者发出的第一个值是2,它错过了前两个值。我们清楚地看到publish()和connect()运算符如何帮助我们创建热可观察对象,同时也看到开始订阅热可观察对象的重要性。
我为什么要用热可观察对象呢?它的应用领域在哪里?嗯,想象一下你有一个实时流,一场足球比赛,你将其流式传输给许多订阅者/观众。他们不想看到比赛开始的第一分钟发生的事情,而是想看到比赛当前的状态,在订阅的时间(当他们坐在电视机前的时候)。所以,确实存在一些情况下,热可观察对象是最佳选择。
温流
到目前为止,我们一直在描述和讨论冷可观察对象和热可观察对象,但还有一种第三种类型:温可观察对象。温可观察对象可以想象成是一个冷可观察对象,但在某些条件下变成了热可观察对象。让我们通过引入refCount()运算符来看一个这样的例子:
// hot-cold-warm/warm-observer.js
const Rx = require("rxjs/Rx");
let warmStream = Rx.Observable.interval(1000).take(3).publish().refCount();
let start = new Date();
setTimeout(() => {
warmStream.subscribe(data => {
console.log(`subscriber 1 - ${new Date() - start}`,data);
});
}, 2000);
好吧,所以我们开始使用publish()运算符,看起来我们即将使用connect()运算符,并且有一个热可观察对象,对吧?嗯,是的,但我们的做法不是调用connect(),而是调用refCount()。这个运算符会加热我们的可观察对象,使得当第一个订阅者到来时,它会表现得像冷可观察对象。好吗?这听起来就像一个冷可观察对象,对吧?让我们先看看输出结果:

为了回答前面的问题,是的,它确实表现得就像一个冷的可观察对象;我们没有错过任何发出的值。有趣的事情发生在我们得到第二个订阅者的时候。让我们添加第二个订阅者,看看会有什么效果:
// hot-cold-warm/warm-observable-subscribers.js
const Rx = require("rxjs/Rx");
let warmStream = Rx.Observable.interval(1000).take(3).publish().refCount();
let start = new Date();
setTimeout(() => {
warmStream.subscribe(data => {
console.log(`subscriber 1 - ${new Date() - start}`,data);
});
}, 1000);
setTimeout(() => {
warmStream.subscribe(data => {
console.log(`subscriber 2 - ${new Date() - start}`,data);
});
}, 3000);
第二位订阅者被添加;现在,让我们看看结果是什么:

从上面的结果中我们可以看到,第一位订阅者是唯一接收数字0的人。当第二位订阅者到来时,它的第一个值是1,这证明了流从表现得像冷可观察对象转变为热可观察对象。
我们还可以通过使用share()运算符来做温可观察对象。share()运算符可以看作是一个更智能的运算符,它允许我们的可观察对象根据情况从冷状态变为热状态。有时候这确实是个好主意。所以,对于可观察对象有以下几种情况:
-
作为热可观察对象创建;流还没有完成,而且没有任何订阅者的订阅次数超过一次
-
回退为冷可观察对象;在新的订阅到达之前,任何之前的订阅都已经结束
-
作为冷可观察对象(cold Observable)创建;在订阅发生之前,可观察对象本身已经完成
让我们尝试用代码来展示第一点可以发生的情况:
// hot-cold-warm/warm-observable-share.js
const Rx = require("rxjs/Rx");
let stream$ = Rx.Observable.create((observer) => {
let i = 0;
let id = setInterval(() => {
observer.next(i++);
}, 400);
return () => {
clearInterval(id);
};
}).share();
let sub0, sub;
// first subscription happens immediately
sub0 = stream$.subscribe(
(data) => console.log("subscriber 0", data),
err => console.error(err),
() => console.log("completed"));
// second subscription happens after 1 second
setTimeout(() => {
sub = stream$.subscribe(
(data) => console.log("subscriber 1", data),
err => console.error(err),
() => console.log("completed"));
}, 1000);
// everything is unscubscribed after 2 seconds
setTimeout(() => {
sub0.unsubscribe();
sub.unsubscribe();
}, 2000);
上述代码描述了一种情况,我们定义了一个带有立即发生的订阅的流。第二个订阅在一秒后发生。现在,根据share()操作符的定义,这意味着流将作为一个冷可观察对象创建,但在第二个订阅者到达时,它将变成热可观察对象,因为有一个预先存在的订阅者,并且流尚未完成。让我们检查我们的输出以验证这一点:

第一个订阅者似乎在它得到的值中是明显独立的。当第二个订阅者到达时,它似乎与生产者共享,因为它不是从零开始,而是从第一个订阅者所在的位置开始监听。
主题(Subjects)
我们习惯于以某种方式使用可观察对象(Observables)。我们从某个东西构建它们,并开始监听它们发出的值。通常,我们几乎无法在创建点之后影响正在发出的内容。当然,我们可以改变和过滤它,但除非我们将它与其他流合并,否则几乎不可能向我们的Observable添加更多内容。让我们看看当我们真正控制可观察对象(Observables)发出内容时的情况,使用create()操作符:
let stream$ = Rx.Observable.create(observer => {
observer.next(1);
observer.next(2);
});
stream$.subscribe(data => console.log(data));
我们看到可观察对象(Observable)就像一个包装器,围绕真正发出我们值的对象——观察者(Observer)。在我们的观察者实例中,观察者正在调用next(),并传递一个参数来发出值——这些值是我们通过subscribe()方法监听的。
本节是关于主题(Subject)的。主题与可观察对象(Observable)的不同之处在于它可以在创建后影响流的内容。让我们通过以下代码片段来看看这一点:
// subjects/subject.js
const Rx = require("rxjs/Rx");
let subject = new Rx.Subject();
// emits 1
subject.subscribe(data => console.log(data));
subject.next(1);
我们首先注意到的是,我们只是调用构造函数,而不是像在可观察对象(Observable)上那样使用create()或from()等工厂方法。第二件事是我们注意到在第二行我们订阅了它,而只有在最后一行我们才通过调用next()来发出值。为什么代码要按照这种顺序编写呢?好吧,如果我们不这样写,并且next()调用发生在第二件事,我们的订阅就不会存在,值会立即发出。尽管如此,我们知道两件事是确定的:我们正在调用next(),我们正在调用subscribe(),这使得Subject具有双重性质。我们之前还提到Subject能够做到的另一件事:在创建后改变流。我们的next()调用实际上就是在做这件事。让我们添加更多的调用,以确保我们真正理解这个概念:
// subjects/subjectII.js
const Rx = require("rxjs/Rx");
let subject = new Rx.Subject();
// emits 10 and 100 2 seconds after
subject.subscribe(data => console.log(data));
subject.next(10);
setTimeout(() => {
subject.next(100);
}, 2000);
如我们之前所述,我们对next()方法的每一次调用都能影响流;我们在subscribe()方法中看到,每次对next()的调用都会触发subscribe(),或者技术上,我们传递给它的第一个函数。
使用主题进行级联列表
那么,重点是什么?为什么我们应该使用主题而不是可观察对象?这实际上是一个相当深刻的问题。解决大多数与流相关的问题有很多方法;对于那些诱使我们使用主题的问题,通常可以通过其他方式解决。尽管如此,让我们看看我们可以用它来做什么。让我们来谈谈级联下拉列表。我们所说的意思是,我们想知道一个城市中存在哪些餐馆。想象一下,因此,我们有一个下拉列表,允许我们选择我们感兴趣的国家。一旦我们选择了一个国家,我们应该从城市下拉列表中选择我们感兴趣的城市。然后,我们可以从餐馆列表中进行选择,最后选择我们感兴趣的餐馆。在标记中,它可能看起来像这样:
// subjects/cascading.html
<html>
<body>
<select id="countries"></select>
<select id="cities"></select>
<select id="restaurants"></select>
<script src="img/Rx.min.js"></script>
<script src="img/cascadingIV.js"></script>
</body>
</html>
在应用程序开始时,我们还没有选择任何内容,唯一被选中的下拉列表是第一个,它填充了国家。想象一下,因此,我们在 JavaScript 中设置了以下代码:
// subjects/cascadingI.js
let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementBtyId("cities");
let restaurantsElem = document.getElementById("restaurants");
// talk to /cities/country/:country, get us cities by selected country
let countriesStream = Rx.Observable.fromEvent(countriesElem, "select");
// talk to /restaurants/city/:city, get us restaurants by selected restaurant
let citiesStream = Rx.Observable.fromEvent(citiesElem, "select");
// talk to /book/restaurant/:restaurant, book selected restaurant
let restaurantsElem = Rx.Observable.fromEvent(restaurantsElem, "select");
到目前为止,我们已经确定我们想要监听每个下拉列表的选择事件,并且我们想要在国家和城市下拉列表的情况下过滤即将到来的下拉列表。比如说我们选择了一个特定的国家,那么我们希望重新填充/过滤城市下拉列表,使其只显示所选国家的城市。对于餐馆下拉列表,我们希望根据我们的餐馆选择进行预订。听起来很简单,对吧?我们需要一些订阅者。城市下拉列表需要监听国家下拉列表的变化。因此,我们将此添加到我们的代码中:
// subjects/cascadingII.js
let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementBtyId("cities");
let restaurantsElem = document.getElementById("restaurants");
fetchCountries();
function buildList(list, items) {
list.innerHTML ="";
items.forEach(item => {
let elem = document.createElement("option");
elem.innerHTML = item;
list.appendChild(elem);
});
}
function fetchCountries() {
return Rx.Observable.ajax("countries.json")
.map(r => r.response)
.subscribe(countries => buildList(countriesElem, countries.data));
}
function populateCountries() {
fetchCountries()
.map(r => r.response)
.subscribe(countries => buildDropList(countriesElem, countries));
}
let cities$ = new Subject();
cities$.subscribe(cities => buildList(citiesElem, cities));
Rx.Observable.fromEvent(countriesElem, "change")
.map(ev => ev.target.value)
.do(val => clearSelections())
.switchMap(selectedCountry => fetchBy(selectedCountry))
.subscribe( cities => cities$.next(cities.data));
Rx.Observable.from(citiesElem, "select");
Rx.Observable.from(restaurantsElem, "select");
因此,在这里,当我们选择一个国家时,我们有一个执行 AJAX 请求的行为;我们得到一个过滤后的城市列表,并引入新的主题实例cities$。我们用过滤后的城市作为参数调用它的next()方法。最后,我们通过在流上调用subscribe()方法来监听cities$流的变化。如您所见,当数据到达时,我们在那里重建我们的城市下拉列表。
我们意识到我们的下一步是响应我们在城市下拉列表中进行选择时的变化。所以,让我们设置一下:
// subjects/cascadingIII.js
let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementBtyId("cities");
let restaurantsElem = document.getElementById("restaurants");
fetchCountries();
function buildList(list, items) {
list.innerHTML = "";
items.forEach(item => {
let elem = document.createElement("option");
elem.innerHTML = item;
list.appendChild(elem);
});
}
function fetchCountries() {
return Rx.Observable.ajax("countries.json")
.map(r => r.response)
.subscribe(countries => buildList(countriesElem, countries.data));
}
function populateCountries() {
fetchCountries()
.map(r => r.response)
.subscribe(countries => buildDropList(countriesElem, countries));
}
let cities$ = new Subject();
cities$.subscribe(cities => buildList(citiesElem, cities));
let restaurants$ = new Rx.Subject();
restaurants$.subscribe(restaurants => buildList(restaurantsElem, restaurants));
Rx.Observable.fromEvent(countriesElem, "change")
.map(ev => ev.target.value)
.do( val => clearSelections())
.switchMap(selectedCountry => fetchBy(selectedCountry))
.subscribe( cities => cities$.next(cities.data));
Rx.Observable.from(citiesElem, "select")
.map(ev => ev.target.value)
.switchMap(selectedCity => fetchBy(selectedCity))
.subscribe( restaurants => restaurants$.next(restaurants.data)); // talk to /book/restaurant/:restaurant, book selected restaurant
Rx.Observable.from(restaurantsElem, "select");
在前面的代码中,我们添加了一些代码来响应我们城市下拉列表中的选择。我们还添加了一些代码来监听restaurants$流的变化,这最终导致了我们的餐厅下拉列表被重新填充。最后一步是监听我们在餐厅下拉列表中选择餐厅时的变化。这里应该发生什么取决于你,亲爱的读者。一个建议是查询一些 API 以获取所选餐厅的营业时间或菜单。发挥你的创造力。不过,我们将给你一些最终的订阅代码:
// subjects/cascadingIV.js
let cities$ = new Rx.Subject();
cities$.subscribe(cities => buildList(citiesElem, cities));
let restaurants$ = new Rx.Subject();
restaurants$.subscribe(restaurants => buildList(restaurantsElem, restaurants));
function buildList(list, items) {
list.innerHTML = "";
items.forEach(item => {
let elem = document.createElement("option");
elem.innerHTML = item;
list.appendChild(elem);
});
}
function fetchCountries() {
return Rx.Observable.ajax("countries.json")
.map(r => r.response)
.subscribe(countries => buildList(countriesElem, countries.data));
}
function fetchBy(by) {
return Rx.Observable.ajax(`${by}.json`)
.map(r=> r.response);
}
function clearSelections() {
citiesElem.innerHTML = "";
restaurantsElem.innerHTML = "";
}
let countriesElem = document.getElementById("countries");
let citiesElem = document.getElementById("cities");
let restaurantsElem = document.getElementById("restaurants");
fetchCountries();
Rx.Observable.fromEvent(countriesElem, "change")
.map(ev => ev.target.value)
.do(val => clearSelections())
.switchMap(selectedCountry => fetchBy(selectedCountry))
.subscribe(cities => cities$.next(cities.data));
Rx.Observable.fromEvent(citiesElem, "change")
.map(ev => ev.target.value)
.switchMap(selectedCity => fetchBy(selectedCity))
.subscribe(restaurants => restaurants$.next(restaurants.data));
Rx.Observable.fromEvent(restaurantsElem, "change")
.map(ev => ev.target.value)
.subscribe(selectedRestaurant => console.log("selected restaurant", selectedRestaurant));
这个代码示例相当长,应该指出的是,这并不是解决这类问题的最佳方式,但它确实展示了 Subject 的工作原理:它可以在想要的时候向流中添加值,并且可以被订阅。
BehaviorSubject
到目前为止,我们一直在查看 Subject 的默认类型,并揭露了一些它的秘密。然而,还有许多其他类型的 Subject。其中一种有趣的 Subject 类型是BehaviorSubject。那么,为什么我们需要BehaviorSubject,它有什么用呢?好吧,当我们处理默认 Subject 时,我们能够向流中添加值,以及订阅流。BehaviorSubject给我们提供了一些额外的能力,形式如下:
-
一个起始值,如果我们能在等待 AJAX 调用完成时向 UI 展示一些内容,那就太好了
-
我们可以查询最新值;在某些情况下,知道最后一个发出的值是什么很有趣
针对第一个要点,让我们编写一些代码并展示这一功能:
// subjects/behavior-subject.js
let behaviorSubject = new Rx.BehaviorSubject("default value");
// will emit 'default value'
behaviorSubject.subscribe(data => console.log(data));
// long running AJAX scenario
setTimeout(() => {
return Rx.Observable.ajax("data.json")
.map(r => r.response)
.subscribe(data => behaviorSubject.next(data));
}, 12000);
ReplaySubject
对于一个普通的 Subject,我们开始订阅的时间很重要。如果我们在我们设置订阅之前开始发出值,这些值就会简单地丢失。如果我们有一个BehaviorSubject,我们有一个稍微好一点的场景。即使我们订阅得晚,已经发出了一个值,我们仍然可以访问到最后一个发出的值。那么,接下来的问题是:如果在订阅发生之前发出了两个或更多值,而我们又关心这些值,那么会发生什么?
让我们通过一个场景来展示 Subject 和BehaviorSubject分别会发生什么:
// example of emitting values before subscription
const Rx = require("rxjs/Rx");
let subject = new Rx.Subject();
subject.next("subject first value");
// emits 'subject second value'
subject.subscribe(data => console.log("subscribe - subject", data));
subject.next("subject second value");
let behaviourSubject = new Rx.BehaviorSubject("behaviorsubject initial value");
behaviourSubject.next("behaviorsubject first value");
behaviourSubject.next("behaviorsubject second value");
// emits 'behaviorsubject second value', 'behaviorsubject third value'
behaviourSubject.subscribe(data =>
console.log("subscribe - behaviorsubject", data)
);
behaviourSubject.next("behaviorsubject third value");
从前面的代码中我们可以看到,如果我们关心在我们订阅之前的值,Subject 不是一个好的选择。BehaviorSubject构造函数在这种情况下稍微好一些,但如果我们真的关心之前的值,而且有很多这样的值,那么我们应该看看ReplaySubject。ReplaySubject有能力指定两件事:缓冲大小和窗口大小。缓冲大小简单地说是它应该记住过去多少个值,窗口大小指定了它应该记住它们多长时间。让我们通过代码来展示这一点:
// subjects/replay-subject.js
const Rx = require("rxjs/Rx");
let replaySubject = new Rx.ReplaySubject(2);
replaySubject.next(1);
replaySubject.next(2);
replaySubject.next(3);
// emitting 2 and 3
replaySubject.subscribe(data => console.log(data));
在前面的代码中,我们可以看到我们发射了 2 和 3,即最后两个发射的值。这是因为我们在 ReplaySubject 构造函数中指定了缓冲区大小为 2。我们唯一丢失的值是 1。相反,如果我们构造函数中指定了 3,那么所有三个值都会到达订阅者。关于缓冲区大小及其工作原理就这么多;那么窗口大小属性呢?让我们用以下代码来说明它是如何工作的:
// subjects/replay-subject-window-size.js
const Rx = require("rxjs/Rx");
let replaySubjectWithWindow = new Rx.ReplaySubject(2, 2000);
replaySubjectWithWindow.next(1);
replaySubjectWithWindow.next(2);
replaySubjectWithWindow.next(3);
setTimeout(() => {
replaySubjectWithWindow.subscribe(data =>
console.log("replay with buffer and window size", data));
},
2010);
在这里,我们将窗口大小指定为 2,000 毫秒;这就是值应该在缓冲区中保持多长时间。我们可以在下面看到,我们延迟订阅的创建,使其在 2,010 毫秒后发生。结果是,不会发射任何值,因为缓冲区在订阅发生之前就已经清空了。窗口大小的更高值本可以解决这个问题。
AsyncSubject
AsyncSubject 的容量为 1,这意味着我们可以发射大量的值,但只有最新的一个值会被存储。实际上,它也没有真正丢失,除非你完成流。让我们看看一段代码,它正好说明了这一点:
// subjects/async-subject.js
let asyncSubject = new Rx.AsyncSubject();
asyncSubject.next(1);
asyncSubject.next(2);
asyncSubject.next(3);
asyncSubject.next(4);
asyncSubject.subscribe(data => console.log(data), err => console.error(err));
之前我们发射了四个值,但似乎没有任何东西到达订阅者。在这个时候,我们不知道这是因为它就像一个主题,扔掉了在订阅之前发生的所有发射的值,还是不是这样。因此,让我们调用 complete() 方法,看看结果如何:
// subjects/async-subject-complete.js
let asyncSubject = new Rx.AsyncSubject();
asyncSubject.next(1);
asyncSubject.next(2);
asyncSubject.next(3);
asyncSubject.next(4);
// emits 4
asyncSubject.subscribe(data => console.log(data), err => console.error(err));
asyncSubject.complete();
这将发射一个 4,因为 AsyncSubject 只记得最后一个值,而我们正在调用 complete() 方法,从而发出流完成的信号。
错误处理
错误处理是一个非常大的话题。这是一个容易被低估的领域。通常,在编码时,我们可能会认为我们只需要做某些事情,比如确保我们没有语法错误或运行时错误。对于流来说,我们主要考虑运行时错误。问题是,当发生错误时,我们应该怎么做?我们应该假装下雨,只是扔掉错误吗?我们应该希望在未来尝试相同的代码时得到不同的结果,或者当存在某种类型的错误时,我们可能只是放弃?让我们尝试整理我们的思路,看看 RxJS 中存在的不同错误处理方法。
捕获并继续
总有一天,我们会遇到一个会抛出错误的流。让我们看看它可能是什么样子:
// example of a stream with an error
let stream$ = Rx.Observable.create(observer => {
observer.next(1);
observer.error('an error is thrown');
observer.next(2);
});
stream$.subscribe(
data => console.log(data), // 1
error => console.error(error) // 'error is thrown'
);
在前面的代码中,我们设置了一个场景,首先发射一个值,然后发射一个错误。第一个值被捕获在我们的 subscribe 方法中的第一个回调中。第二个发射的内容,即错误,被我们的错误回调捕获。第三个发射的值没有发送给我们的订阅者,因为我们的流已经被错误中断。我们可以在这里做的是使用 catch() 操作符。让我们将其应用到我们的流中,看看会发生什么:
// error-handling/error-catch.js
const Rx = require("rxjs/Rx");
let stream$ = Rx.Observable.create(observer => {
observer.next(1);
observer.error("an error is thrown");
observer.next(2);
}).catch(err => Rx.Observable.of(err));
stream$.subscribe(
data => console.log(data), // emits 1 and 'error is thrown'
error => console.error(error)
);
在这里,我们使用catch()操作符捕获错误。在catch()操作符中,我们取我们的错误,并使用of()操作符将其作为正常的 Observable 发出。那么我们发出的2会发生什么呢?仍然没有成功。catch()操作符能够将我们的错误转换为一个正常的发出值;而不是错误,我们不会从流中获得所有值。
让我们看看当我们处理多个流时的一个场景:
// example of merging several streams
let merged$ = Rx.Observable.merge(
Rx.Observable.of(1),
Rx.Observable.throw("err"),
Rx.Observable.of(2)
);
merged$.subscribe(data => console.log("merged", data));
在上述场景中,我们合并了三个流。第一个流只发出数字1,没有其他任何东西被发出。这是因为我们的第二个流将所有内容都拆除了,因为它发出了一个错误。让我们尝试应用我们新发现的catch()操作符,看看会发生什么:
// error-handling/error-merge-catch.js
const Rx = require("rxjs/Rx");
let merged$ = Rx.Observable.merge(
Rx.Observable.of(1),
Rx.Observable.throw("err").catch(err => Rx.Observable.of(err)),
Rx.Observable.of(2)
);
merged$.subscribe(data => console.log("merged", data));
我们运行上述代码,并注意到1被发出,错误作为一个正常值被发出,最后甚至2也被发出了。我们的结论是,在流被合并到我们的流之前应用一个catch()操作符是一个好主意。
如前所述,我们也可以得出结论,catch()操作符能够阻止流仅仅因为错误而停止,但错误之后本应发出的其他值实际上已经丢失了。
忽略错误
如前所述,catch()操作符在确保一个发生错误的流在与其他流合并时不会引起任何问题方面做得很好。catch()操作符使我们能够捕获错误,调查它,并创建一个新的 Observable,它将发出一个值,就像什么都没发生一样。然而,有时你甚至不想处理发生错误的流。对于这样的场景,有一个不同的操作符,称为onErrorResumeNext():
// error-handling/error-ignore.js
const Rx = require("rxjs/Rx");
let mergedIgnore$ = Rx.Observable.onErrorResumeNext(
Rx.Observable.of(1),
Rx.Observable.throw("err"),
Rx.Observable.of(2)
);
mergedIgnore$.subscribe(data => console.log("merge ignore", data));
使用onErrorResumeNext()操作符的含义是,第二个流,即发出错误的那个流,被完全忽略,而值1和2被发出。如果你的场景只是关心不发生错误的流,这是一个非常好的操作符。
重试
你可能出于不同的原因想要重试一个流。如果你的流正在处理 AJAX 调用,更容易想象为什么你想要这样做。有时,你所在的本地网络可能不可靠,或者你试图调用的服务可能因为某些原因暂时关闭。无论原因如何,你都会遇到一种情况,即调用该端点有时会回复答案,有时会返回 401 错误。我们在这里描述的是在流中添加重试逻辑的业务案例。让我们看看一个设计来失败的流:
// error-handling/error-retry.js
const Rx = require("rxjs/Rx");
let stream$ = Rx.Observable.create(observer => {
observer.next(1);
observer.error("err");
})
.retry(3);
// emits 1 1 1 1 err
stream$
.subscribe(data => console.log(data));
上述代码的输出是值1被发出四次,然后是我们的错误。发生的情况是我们流的值在错误回调被触发之前被重试了三次。使用retry()操作符延迟了错误实际上被视为错误的时间。然而,前面的例子没有重试的必要,因为错误总是会发生的。因此,让我们看看更好的例子——一个网络连接可能会来也可能去的 AJAX 调用:
// example of using a retry with AJAX
let ajaxStream$ = Rx.Observable.ajax("UK1.json")
.map(r => r.response)
.retry(3);
ajaxStream$.subscribe(
data => console.log("ajax result", data),
err => console.error("ajax error", err)
);
在这里,我们尝试对似乎不存在的文件发起一个 AJAX 请求。查看控制台,我们遇到了以下结果:

在上面的日志中,我们看到有四个失败的 AJAX 请求导致了错误。我们实际上已经将我们的简单流转换成了一个更可靠的 AJAX 请求流,具有相同的行为。如果文件突然开始存在,我们可能会遇到两次失败尝试和一次成功尝试的情况。然而,我们的方法有一个缺陷:我们过于频繁地重试我们的 AJAX 尝试。如果我们实际上在处理间歇性网络连接,我们需要在尝试之间设置某种延迟。尝试之间至少设置 30 秒或更长时间的延迟是合理的。我们可以通过使用一个稍微不同的重试操作符来实现这一点,该操作符接受毫秒数而不是尝试次数作为参数。它看起来如下所示:
// retry with a delay
let ajaxStream$ = Rx.Observable.ajax("UK1.json")
.do(r => console.log("emitted"))
.map(r => r.response)
.retryWhen(err => {
return err.delay(3000);
});
我们在这里使用的是retryWhen()操作符。retryWhen()操作符在其生命周期中的任务是返回一个流。在这个点上,你可以通过附加一个.delay()操作符来操作它返回的流,该操作符接受毫秒数。这样做的结果是它将无限期地重试 AJAX 调用,这可能不是你想要的。
高级重试
我们最可能想要的是将重试尝试之间的延迟与指定我们想要重试流多少次的能力结合起来。让我们看看我们如何实现这一点:
// error-handling/error-retry-advanced.js
const Rx = require("rxjs/Rx");
let ajaxStream$ = Rx.Observable.ajax("UK1.json")
.do(r => console.log("emitted"))
.map(r => r.response)
.retryWhen(err => {
return err
.delay(3000)
.take(3);
});
这里有趣的部分是我们使用了.take()操作符。我们指定了从这个内部可观察对象中想要发出的值的数量。我们现在已经实现了一种很好的方法,使我们能够控制重试次数和重试之间的延迟。这个方法有一个我们没有尝试的方面,即我们希望所有重试何时结束。在前面的代码中,流在经过x次重试并且没有成功结果后只是完成了。然而,我们可能希望流出错。我们可以通过向代码中添加一个操作符来实现这一点,如下所示:
// error-handling/error-retry-advanced-fail.js
let ajaxStream$ = Rx.Observable.ajax("UK1.json")
.do(r => console.log("emitted"))
.map(r => r.response)
.retryWhen(err => {
return err
.delay(3000)
.take(3)
.concat(Rx.Observable.throw("giving up"));
});
在这里,我们添加了一个concat()操作符,它添加了一个只失败的流。因此,我们保证在三次失败尝试后会发生错误。这通常比在x次失败尝试后流默默地完成要好。
虽然这不是一个完美的方法;想象一下,你想调查你得到什么类型的错误。在 AJAX 请求的情况下,我们得到的 HTTP 状态码是 400 多还是 500 多,这很重要。它们意味着不同的事情。对于 500 错误,后端可能出了大问题,我们可能想立即放弃。然而,对于 404 错误,这表明资源不存在,但在间歇性网络连接的情况下,这意味着由于我们的连接离线,资源无法访问。因此,404 错误可能值得重试。要在代码中解决这个问题,我们需要检查发出的值以确定要做什么。我们可以使用 do() 操作符来检查值。
在以下代码中,我们调查响应的 HTTP 状态类型并确定如何处理它:
// error-handling/error-retry-errorcodes.js
const Rx = require("rxjs/Rx");
function isOkError(errorCode) {
return errorCode >= 400 && errorCode < 500;
}
let ajaxStream$ = Rx.Observable.ajax("UK1.json")
.do(r => console.log("emitted"))
.map(r => r.response)
.retryWhen(err => {
return err
.do(val => {
if (!isOkError(val.status) || timesToRetry === 0) {
throw "give up";
}
})
.delay(3000);
});
Marble 测试
测试异步代码可能具有挑战性。一方面,我们有时间因素。我们指定用于我们精心设计的算法的操作符的方式导致算法执行时间从 2 秒到 30 分钟不等。因此,一开始可能会觉得测试它没有意义,因为它无法在合理的时间内完成。尽管如此,我们有一种测试 RxJS 的方法;它被称为 Marble 测试,它允许我们控制时间流逝的速度,以便我们可以在毫秒内执行测试。
我们已经知道了 Marble 的概念。我们可以表示一个或多个流以及操作符对一或多个流产生的影响。我们通过将流绘制成线条,将值绘制成线条上的圆圈来实现这一点。操作符显示在输入流下面的动词。接下来的操作符是一个第三流,即通过将输入流应用操作符得到的结果,也就是所谓的 marble 图。线条代表一个连续的时间线。我们采用这个概念并将其应用于测试。这意味着我们可以将我们的输入值表示为图形表示,并对其应用我们的算法,然后对结果进行断言。
设置
让我们正确设置我们的环境,以便我们可以编写 marble 测试。我们需要以下内容:
-
NPM 库 jasmine-marbles
-
搭建 Angular 应用程序
通过这样,我们搭建了我们的 Angular 项目,如下所示:
ng new MarbleTesting
在项目搭建完成后,是时候添加我们的 NPM 库了,如下所示:
cd MarbleTesting
npm install jasmine-marbles --save
现在我们已经完成了设置,所以是时候编写测试了。
编写你的第一个 marble 测试
让我们创建一个新的文件 marble-testing.spec.ts。它应该看起来像以下这样:
// marble-testing\MarbleTesting\src\app\marble-testing.spec.ts
import { cold } from "jasmine-marbles";
import "rxjs/add/operator/map";
describe("marble tests", () => {
it("map - should increase by 1", () => {
const one$ = cold("x-x|", { x: 1 });
expect(one$.map(x => x + 1)).toBeObservable(cold("x-x|", { x: 2 }));
});
});
在这里正在发生许多有趣的事情。我们从 NPM 库 marble-testing 中导入 cold() 函数。之后,我们通过调用 describe() 来设置测试套件,然后通过调用 it() 来指定测试规范。然后我们调用我们的 cold() 函数并给它提供一个字符串。让我们仔细看看这个函数调用:
const stream$ = cold("x-x|", { x: 1 });
上述代码设置了一个期望发出两个值然后流结束的流。我们如何知道这一点?现在是时候解释 x-x| 的含义了。x 是任何值,横线 - 表示时间已经过去。管道 | 表示我们的流已经结束。在 cold 函数的第二个参数是一个映射对象,它告诉我们 x 的含义。在这种情况下,它已经意味着值 1。
接下来,让我们看看下一行:
expect(stream$.map(x => x + 1)).toBeObservable(cold("x-x|", { x: 2 }));
上述代码应用了 .map() 操作符,并将每个在流中发出的值增加了一个。之后,我们调用 .toBeObservable() 辅助方法,并验证它是否满足预期的条件,
cold("x-x|", { x: 2 })
之前的状态表明我们期望流应该发出两个值,但这两个值现在应该具有数字 2。这很有道理,因为我们的 map() 函数正是这样做的。
通过更多的测试来完善
让我们再写一个测试。这次我们将测试 filter() 操作符。这个操作符很有趣,因为它会过滤掉不满足特定条件的值。我们的测试文件现在应该看起来像下面这样:
import { cold } from "jasmine-marbles";
import "rxjs/add/operator/map";
import "rxjs/add/operator/filter";
describe("marble testing", () => {
it("map - should increase by 1", () => {
const one$ = cold("x-x|", { x: 1 });
expect(one$.map(x => x + 1)).toBeObservable(cold("x-x|", { x: 2 }));
});
it("filter - should remove values", () => {
const stream$ = cold("x-y|", { x: 1, y: 2 });
expect(stream$.filter(x => x > 1)).toBeObservable(cold("--y|", { y: 2 }));
});
});
这个测试的设置基本上和我们的第一个测试一样。这次我们使用 filter() 操作符,但突出的是我们期望的流:
cold("--y|", { y: 2 })
--y 表示我们的第一个值被移除了。根据过滤器条件的定义,我们并不感到惊讶。然而,双横线 - 的原因是因为时间仍在流逝,但取而代之的是横线本身代替了发出的值。
要了解更多关于 Marble 测试的信息,请查看官方文档中的以下链接,github.com/ReactiveX/rxjs/blob/master/doc/writing-marble-tests.md
可连接的操作符
到目前为止,我们还没有过多地提到它,但 RxJS 库在应用中使用时相当重。在当今以移动为先的世界里,当你将库包含到你的应用中时,每个千字节都很重要。这是因为用户可能在使用 3G 连接,如果加载时间过长,用户可能会离开,或者最终可能不喜欢你的应用,因为它感觉加载缓慢,这可能会导致你收到差评或失去用户。到目前为止,我们已经使用了两种不同的方式来导入 RxJS:
-
导入整个库;这在大小方面相当昂贵
-
只导入我们需要的操作符;这确保了包的大小显著减小
不同的选项看起来是这样的,用于导入整个库及其所有操作符:
import Rx from "rxjs/Rx";
或者像这样,只导入我们需要的:
import { Observable } from 'rxjs/Observable';
import "rxjs/add/operator/map";
import "rxjs/add/operator/take";
let stream = Observable.interval(1000)
.map(x => x +1)
.take(2)
这看起来不错,是吗?嗯,是的,但这是一种有缺陷的方法。让我们解释一下当你输入以下内容时会发生什么:
import "rxjs/add/operator/map";
通过输入上述代码,我们向 Observable 的原型中添加了内容。查看 RxJS 的源代码,它看起来像这样:
var Observable_1 = require('../../Observable');
var map_1 = require('../../operator/map');
Observable_1.Observable.prototype.map = map_1.map;
如您从前面的代码中看到的,我们导入了Observable以及相关的操作符,并将操作符添加到原型上,通过将其分配给原型的map属性。这有什么问题呢?您可能会想知道?问题是摇树优化,这是我们用来去除未使用代码的过程。摇树优化在确定您使用和未使用的内容方面有困难。您实际上可能导入了map()操作符,并将其添加到Observable中。随着时间的推移,代码发生变化,您可能不再使用它。您可能会争辩说,在那个时刻您应该移除导入,但是您可能有大量的代码,很容易忽略。如果只有使用的操作符包含在最终的包中会更好。正如我们之前提到的,使用当前方法,摇树优化过程很难知道什么被使用,什么没有被使用。因此,RxJS 进行了一次大规模的重写,添加了所谓的可管道操作符,这有助于我们解决上述问题。修补原型的另一个缺点是,它创建了一个依赖。如果库发生变化,当我们修补它(调用导入)时,操作符不再被添加,那么我们就会遇到问题。我们不会在运行时检测到这个问题。我们更愿意被告知操作符已经通过我们导入并显式使用它,如下所示:
import { operator } from 'some/path';
operator();
使用 let()创建可重用操作符
let()操作符让您拥有整个操作符并对其操作,而不仅仅是像使用map()操作符那样操纵值。使用let()操作符可能看起来像这样:
import Rx from "rxjs/Rx";
let stream = Rx.Observable.of(0,1,2);
let addAndFilter = obs => obs.map( x => x * 10).filter(x => x % 10 === 0);
let sub3 = obs => obs.map(x => x - 3);
stream
.let(addAndFilter)
.let(sub3)
.subscribe(x => console.log('let', x));
在前面的例子中,我们能够定义一组操作符,如addAndFilter和sub3,并使用let()操作符在流上使用它们。这使得我们能够创建可组合和可重用的操作符。正是基于这种知识,我们现在继续探讨可管道操作符的概念。
转向可管道操作符
如我们之前提到的,可管道操作符已经在这里了,你可以通过从rxjs/operators目录导入相应的操作符来找到它们,如下所示:
import { map } from "rxjs/operators/map";
import { filter } from "rxjs/operators/filter";
要使用它,我们现在依赖于作为父操作符的pipe()操作符。因此,使用前面的操作符将看起来像这样:
import { map } from "rxjs/operators/map";
import { filter } from "rxjs/operators";
import { of } from "rxjs/observable/of";
import { Observable } from "rxjs/Observable";
let stream = of(1,2);
stream.pipe(
map(x => x + 1),
filter(x => x > 1)
)
.subscribe(x => console.log("piped", x)); // emits 2 and 3
摘要
本章通过涵盖诸如热、冷和温 Observables 等主题,深入探讨了 RxJS,并讨论了在一般情况下何时订阅流以及它们在特定条件下如何共享生产者。接下来,我们介绍了 Subjects,并指出 Observable 并不是唯一可以订阅的东西。Subjects 还允许我们在任何时候向流中追加值,我们还了解到存在不同类型的 Subjects,这取决于具体情况。
我们深入探讨了一个重要主题——测试,并尝试解释测试异步代码的难度。我们讨论了当前的测试状况以及现在可以用于测试场景的库。最后,我们介绍了可管道操作符,以及我们新推荐的方式来导入和组合操作符,以确保我们最终得到尽可能小的包大小。
在掌握了所有这些 RxJS 知识之后,现在是时候在下一章中接受 Redux 模式及其核心概念了,这样我们就可以在本书的最后一章中处理 NgRx。如果你之前还没有感到兴奋,现在是时候激动起来了。
第八章:Redux
在一个应用中维护和控制状态,当我们的应用比 Todo 应用更大时,这会迅速变得复杂,尤其是如果我们有多个视图、模型以及它们之间的依赖关系。多种状态类型,如缓存数据、服务器响应以及当你与该应用一起工作时仅在本地存在的数据,使得情况更加复杂。由于多个参与者、同步和异步代码可以更改状态,更改状态变得更加复杂。随着应用的不断增长,最终结果是一个非确定性的系统。这样的系统的问题是,你失去了可预测性,这反过来意味着你可能会有难以复现的 bug,并且使得应用及其数据难以推理。我们渴望秩序和可预测性,但我们两者都没有。
为了尝试解决这个问题,我们在前一章中介绍了 Flux 模式。一切都很顺利,对吧?我们不需要另一个模式。或者我们需要吗?好吧,Flux 有问题。其中一个问题是你的数据被分割成几个存储。你可能会想,那有什么问题呢?想象一下你有一个在多个存储中触发的动作。很容易忘记在所有存储中处理一个动作。所以,这个问题更多的是一个管理问题。多个存储的另一个问题是,很难获得一个关于你的状态构成的良好概览。更新是我们与 Flux 的另一个问题。有时你有很多更新;更新状态和顺序很重要。在 Flux 中,这是通过一个称为waitFor的结构来处理的。想法是,你应该能够指定在什么顺序下发生什么。这听起来很好,但想象一下,这被分散在许多模块中;这变得难以跟踪,因此容易出错。
变更和异步行为是两个难以处理的概念。变更意味着我们更改数据。异步意味着某事需要时间来完成;当它完成时,可能会更改状态。想象一下混合同步和异步操作,所有这些操作都在更新状态。我们意识到由于这一点,跟踪代码变得不容易,而且与状态变更混合在一起使得整个情况更加复杂。
这引导我们思考 Redux 能为我们做什么,那就是使我们的变更可预测,但它也给我们一个存储,一个单一的真实来源。
在本章中,你将学习:
-
核心概念
-
数据如何流动
-
如何通过构建自己的 Redux 迷你实现来将你的技能付诸实践
-
在 Redux 的上下文中如何处理 AJAX
-
一些最佳实践
原则
Redux 建立在三个原则之上:
-
单一真实来源:我们有一个地方存放所有数据。
-
状态是只读的:无变更;改变状态只有一种方式,那就是通过一个动作。
-
变更通过纯函数进行:通过应用变更并产生新状态来生成新状态;旧状态永远不会被更改。
让我们逐一点探索这些要点。
单一事实来源
数据生活在 Redux 的单个存储中,而不是像 Flux 那样的多个存储。数据由一个对象树表示。这带来了很多好处,例如:
-
在任何给定时刻更容易看到你的应用程序知道什么,因此它很容易进行序列化或反序列化。
-
在开发中更容易处理,更容易调试和检查。
-
如果所有应用的动作都产生一个新的状态,那么执行撤销/重做等操作会更简单。
一个单存储的例子可能如下所示:
// principles/store.js
class Store {
getState() {
return {
jedis: [
{ name: "Yoda", id: 1 },
{ name: "Palpatine", id: 2 },
{ name: "Darth Vader", id: 3 }
],
selectedJedi: {
name: "Yoda",
id: 1
}
};
}
}
const store = new Store();
console.log(store.getState());
/*
{
jedis: [
{ name: 'Yoda', id: 1 },
{ name: 'Palpatine', id: 2 },
{ name: 'Darth Vader', id: 3 }
],
selectedJedi: {
name: 'Yoda', id: 1
}
}
*/
如您所见,这只是一个对象。
只读状态
我们希望确保只有一种方式可以改变状态,那就是通过称为动作的中介。一个动作应该描述动作的意图以及应该应用于当前状态的数据。我们通过store.dispatch(action)来分发动作。动作本身应该看起来像以下这样:
// principles/action.js
// the action
let action = {
// expresses intent, loading jedis
type: "LOAD_JEDIS",
payload:[
{ name: "Yoda", id: 1 },
{ name: "Palpatine", id: 2 },
{ name: "Darth Vader", id: 3 }
]
};
在这个阶段,让我们尝试实现一个存储可能的样子以及它最初包含的内容:
// principles/storeII.js
class Store {
constructor() {
this.state = {
jedis: [],
selectedJedi: null
}
}
getState() {
return this.state;
}
}
const store = new Store();
console.log(store.getState());
// state should now be
/*
{
jedis : [],
selectedJedi: null
}
*/
我们可以看到它是一个由两个属性组成的对象,jedis是一个数组,selectedJedi是一个包含我们选择的对象的对象。在这个时候,我们想要分发一个动作,这意味着我们将使用前面代码中显示的旧状态,并产生一个新的状态。我们之前描述的动作应该改变jedis数组,并用传入的数组替换空数组。但是,请记住,我们并没有修改现有的存储对象;我们只是取它,应用我们的更改,并产生一个新的对象。让我们分发我们的动作并查看最终结果:
// principles/storeII-with-dispatch.js
class Store {
constructor() {
this.state = {
jedis: [],
selectedJedi: null
}
}
getState() {
return this.state;
}
dispatch(action) {
// to be implemented in later sections
}
}
// the action
let action = {
type: 'LOAD_JEDIS',
payload:[
{ name: 'Yoda', id: 1 },
{ name: 'Palpatine', id: 2 },
{ name: 'Darth Vader', id: 3 }
]
}
// dispatching the action, producing a new state
store.dispatch(action);
console.log(store.getState());
// state should now be
/*
{
jedis : [
{ name: 'Yoda', id: 1 },
{ name: 'Palpatine', id: 2 },
{ name: 'Darth Vader', id: 3 }
],
selectedJedi: null
}
*/
前面的代码是伪代码,因为它实际上还没有产生预期的结果。我们将在后面的章节中学习如何实现存储。好的,现在我们的状态已经改变,传入的数组已经替换了我们之前使用的空数组。我们再次强调,我们没有修改现有的状态,而是根据旧状态和我们的动作产生了新的状态。让我们看看下一个关于纯函数的部分,并进一步解释我们的意思。
使用纯函数改变状态
在上一个部分,我们介绍了动作的概念以及它是我们允许改变状态的媒介。然而,我们并没有在正常意义上改变状态,而是取了旧状态,应用了动作,并产生了新状态。为了完成这个任务,我们需要使用一个纯函数。在 Redux 的上下文中,这些被称为 reducers。让我们自己写一个reducer:
// principles/first-reducer.js
module.exports = function reducer(state = {}, action) {
switch(action.type) {
case "SELECT_JEDI":
return Object.assign({}, action.payload);
default:
return state;
}
}
我们强调前面reducer的纯特性。它从action.payload中获取我们的selectedJedi,使用Object.assign()进行复制,分配它,并返回新状态。
我们所写的是一个reducer,它根据我们尝试执行的动作进行切换,并执行更改。让我们将这个纯函数投入使用:
const reducer = require("./first-reducer");
let initialState = {};
let action = { type: "SELECT_JEDI", payload: { id: 1, name: "Jedi" } };
let state = reducer(initialState, action);
console.log(state);
/* this produces the following:
{ id: 1, name: 'Yoda' }
*/
核心概念
在 React 中,我们正在处理三个核心概念,我们已经介绍了状态、动作和 reducer。现在,让我们深入了解,真正理解它们是如何结合在一起以及它们是如何工作的。
不可变模式
状态的全部意义在于接受一个现有的状态,对其应用一个动作,并产生一个新的状态。它可以写成这样:
old state + action = new state
假设你正在进行基本的计算,那么你将开始这样写:
// sum is 0
let sum = 0;
// sum is now 3
sum +=3;
然而,Redux 的方式是将前面的操作改为:
let sum = 0;
let sumWith3 = sum + 3;
let sumWith6 = sumWith3 + 3;
我们没有做任何修改,而是为我们所做的每一件事都产生一个新的状态。让我们看看不同的构造,以及在实际中不修改意味着什么。
修改列表
我们可以在列表上执行两种操作:
-
向列表中添加项目
-
从列表中移除项目
让我们拿第一个要点,以旧的方式做出这个改变,然后以 Redux 的方式做出这个改变:
// core-concepts/list.js
// old way
let list = [1, 2, 3];
list.push(4);
// redux way
let immutablelist = [1, 2, 3];
let newList = [...immutablelist, 4];
console.log("new list", newList);
/*
[1, 2, 3, 4]
*/
前面的代码取旧列表及其项目,创建一个新的列表,包含旧列表加上我们的新成员。
对于我们的下一个要点,要移除一个项目,我们这样做:
// core-concepts/list-remove.js
// old way
let list = [1, 2, 3];
let index = list.indexOf(1);
list.splice(index, 1);
// redux way
let immutableList = [1, 2, 3];
let newList = immutableList.filter(item => item !== 1);
如您所见,我们产生了一个不包含我们的项目的列表。
修改对象
修改对象涉及到在它上面更改属性以及向它添加属性。首先,让我们看看如何更改现有值:
// core-concepts/object.js
// the old way
let anakin = { name: "anakin" };
anakin.name = "darth";
console.log(anakin);
// the Redux way
let anakinRedux = { name: "anakin" };
let darth = Object.assign({}, anakinRedux, { name: "darth" });
console.log(anakinRedux);
console.log(darth);
这就涵盖了现有情况。那么,添加新属性怎么办?我们可以这样做:
// core-concepts/object-add.js
// the old way
let anakin = { name: "anakin" };
console.log("anakin", anakin);
anakin["age"] = "17";
console.log("anakin with age", anakin);
// the Redux way
let anakinImmutable = { name: "anakin" };
let anakinImmutableWithAge = Object.assign({}, anakinImmutable, { age: 17 });
console.log("anakin redux", anakinImmutable);
console.log("anakin redux with age", anakinImmutableWithAge);
使用 reducer
在上一节中,我们介绍了如何以旧的方式更改状态以及如何以新的 Redux 方式执行。reducer 不过是纯函数;纯的意思是它们不改变,而是产生一个新的状态。但是,reducer 需要一个动作来工作。让我们深化我们对 reducer 和动作的了解。让我们创建一个动作,用于向列表添加项目,以及与之对应的 reducer:
// core-concepts/jedilist-reducer.js
let actionLuke = { type: "ADD_ITEM", payload: { name: "Luke" } };
let actionVader = { type: "ADD_ITEM", payload: "Vader" };
function jediListReducer(state = [], action) {
switch(action.type) {
case "ADD_ITEM":
return [... state, action.payload];
default:
return state;
}
}
let state = jediListReducer([], actionLuke);
console.log(state);
/*
[{ name: 'Luke '}]
*/
state = jediListReducer(state, actionVader);
console.log(state);
/*
[{ name: 'Luke' }, { name: 'Vader' }]
*/
module.exports = jediListReducer;
好的,现在我们知道如何处理列表了;那么对象呢?我们再次需要定义一个动作和一个 reducer:
// core-concepts/selectjedi-reducer.js
let actionPerson = { type: "SELECT_JEDI", payload: { id: 1, name: "Luke" } };
let actionVader = { type: "SELECT_JEDI", payload: { id: 2, name: "Vader" } };
function selectJediReducer({}, action) {
switch (action.type) {
case "SELECT_JEDI":
return Object.assign({}, action.payload);
default:
return state;
}
}
state = selectJediReducer({}, actionPerson);
console.log(state);
/*
{ name: 'Luke' }
*/
state = selectJediReducer(state, actionVader);
console.log(state);
/*
{ name: 'Vader' }
*/
module.exports = selectJediReducer;
我们在这里看到的是如何通过调用SELECT_JEDI使一个对象完全替换另一个对象的内容。我们还看到我们如何使用Object.assign()来确保我们只复制传入对象中的值。
合并所有 reducer
好的,现在我们已经有了一个处理jedis列表的 reducer,以及一个专门处理特定jedis选择的 reducer。我们之前提到,在 Redux 中,我们有一个单一的存储,所有我们的数据都存储在那里。现在是我们创建这个单一存储的时候了。这可以通过创建以下函数store()轻松实现:
// core-concepts/merged-reducers.js
function store(state = { jedis: [], selectedJedi: null }, action) {
return {
jedis: jediListReducer(state.jedis, action),
selectedJedi: selectJediReducer(state.selectedJedi, action)
};
}
let newJediActionYoda = { type: "ADD_ITEM", payload: { name: "Yoda"} };
let newJediActionVader = { type: "ADD_ITEM", payload: { name: "Vader"} };
let newJediSelection = { type: "SELECT_JEDI", payload: { name: "Yoda"} };
let initialState = { jedis: [], selectedJedi: {} };
let state = store(initialState, newJediActionYoda);
console.log("Merged reducers", state);
/*
{
jedis: [{ name: 'Yoda' }],
selectedJedi: {}
}
*/
state = store(state, newJediActionVader);
console.log("Merged reducers", state);
/*
{
jedis: [{ name 'Yoda' }, {name: 'Vader'}],
selectedJedi: {}
}
*/
state = store(state, newJediSelection);
console.log("Merged reducers", state);
console.log(state);
/*
{
jedis: [{ name: 'Yoda' }, { name: 'Vader'}],
selectedJedi: { name: 'Yoda' }
}
*/
从我们在这里看到的情况来看,我们的store()函数所做的不过是返回一个对象。返回的对象是我们的当前状态。我们选择如何称呼状态对象的属性,就是我们想要在显示存储内容时引用的内容。如果我们想要改变存储的状态,我们需要重新调用store()函数,并给它提供一个表示我们改变意图的动作。
数据流
好的,所以我们知道了动作、reducer 和以纯方式操作状态。那么,如何在实际应用中将所有这些结合起来呢?我们该如何做呢?让我们尝试模拟我们应用程序的数据流。想象一下,我们有一个视图处理向列表添加项目,还有一个视图处理显示列表。然后,我们的数据流可能看起来像以下这样:

在创建项目视图的情况下,我们输入创建项目所需的数据,然后我们派发一个动作,即 create-item,这最终会将项目添加到存储中。在我们的其他数据流中,我们只有一个列表视图,它从存储中选择项目,这导致列表视图被填充。我们意识到在实际应用中可能有以下步骤:
-
用户交互
-
创建表示我们意图的动作
-
派发一个动作,这导致我们的状态改变其状态
上述步骤适用于我们的创建项目视图。对于我们的列表视图,我们只想从存储中读取并显示数据。让我们尝试使这一点更具体,并将至少 Redux 部分转换为实际代码。
创建动作
我们将首先创建一个动作创建器,一个辅助函数,帮助我们创建动作:
// dataflow/actions.js
export function createItem(title){
return { type: "CREATE_ITEM", payload: { title: title } };
}
创建控制器类 – create-view.js
现在想象一下,我们处于处理创建项目的视图代码中;它可能看起来像这样:
// dataflow/create-view.js
import { createItem } from "./actions";
import { dispatch, select } from "./redux";
console.log("create item view has loaded");
class CreateItemView {
saveItem() {
const elem = document.getElementById("input");
dispatch(createItem(elem.value));
const items = select("items");
console.log(items);
}
}
const button = document.getElementById("saveButton");
const createItemWiew = new CreateItemView();
button.addEventListener("click", createItemWiew.saveItem);
export default createItemWiew;
好的,所以,在我们的 create-view.js 文件中,我们创建了一个 CreateItemView 类,它上面有一个 saveItem() 方法。saveItem() 方法是响应 ID 为 saveButton 的按钮点击事件的第一响应者。当按钮被点击时,我们的 saveItem() 方法被调用,这最终会调用我们的 dispatch 函数,使用 createItem() 动作方法,该方法反过来使用输入元素值作为输入,如下所示:
dispatch(createItem(elem.value));
创建存储实现
我们还没有创建 dispatch() 方法,所以我们将接下来做这件事:
// dataflow/redux.js
export function dispatch(action) {
// implement this
}
从前面的代码中我们可以看到,我们有一个 dispatch() 函数,这是我们从这个文件导出的东西之一。让我们尝试填写实现:
// dataflow/redux-stepI.js
// 1)
function itemsReducer(state = [], action) {
switch(action.type) {
case "CREATE_ITEM":
return [...state, Object.assign(action.payload) ];
default:
return state;
}
}
// 2)
let state = {
items: []
};
// 3
function store(state = { items: [] }, action) {
return {
items: itemsReducer(state.items, action)
};
}
// 4)
export function getState() {
return state;
}
// 5)
export function dispatch(action) {
state = store(state, action);
}
让我们解释一下我们从顶部做了什么。我们首先定义了一个名为 itemsReducer 的 reducer 1),它可以根据新项目生成新状态。之后,我们创建了一个状态变量,即我们的状态 2)。这之后是 store() 函数 3),这是一个设置哪个属性与哪个 reducer 配对的函数。之后,我们定义了一个名为 getState() 的函数 4),它返回我们的当前状态。最后,我们有我们的 dispatch() 函数 5),它只是调用 store() 函数并传递给它我们提供的动作。
测试我们的存储
现在是时候使用我们的代码了;首先,我们将创建一个 redux-demo.js 文件来测试我们的 Redux 实现,然后我们将对其进行一些润色,最后我们将将其用于我们之前创建的视图中:
// dataflow/redux-demo.js
import { dispatch, getState, select, subscribe } from "./redux";
const { addItem } = require("./actions");
subscribe(() => {
console.log("store changed");
});
console.log("initial state", getState());
dispatch(addItem("A book"));
dispatch(addItem("A second book"));
console.log("after dispatch", getState());
console.log("items", select("items"));
/*
this will print the following
state before: { items: [] }
state after: { items: [{ title: 'a new book'}] }
*/
清理实现
好的,所以我们的 Redux 实现看起来似乎正在工作。现在是时候对其进行一些清理了。我们需要将 reducer 移动到它自己的文件中,如下所示:
// dataflow/reducer.js
function itemsReducer(state = [], action) {
switch(action.type) {
case "CREATE_ITEM":
return [...state, Object.assign(action.payload) ];
default:
return state;
}
}
也是一个好主意,向存储中添加一个 select() 函数,因为我们有时不想移动整个状态,而只想移动其中的一部分。我们的列表视图将受益于 select() 函数的使用。让我们添加这个函数:
// dataflow/redux-stepII.js
// this now refers to the reducers.js file we broke out
import { itemsReducer } from "./reducers";
let state = {
items: []
};
function store(state = { items: [] }, action) {
return {
items: itemsReducer(state.items, action)
};
}
export function getState() {
return state;
}
export function dispatch(action) {
state = store(state, action);
}
export function select(slice) {
return state[slice];
}
创建第二个控制器类 – list-view.js
让我们现在将注意力转移到我们尚未创建的 list-view.js 文件上:
// dataflow/list-view.js
import { createItem } from "./actions";
import { select, subscribe } from "./redux";
console.log("list item view has loaded");
class ListItemsView {
constructor() {
this.render();
subscribe(this.render);
}
render() {
const items = select("items");
const elem = document.getElementById("list");
elem.innerHTML = "";
items.forEach(item => {
const li = document.createElement("li");
li.innerHTML = item.title;
elem.appendChild(li);
});
}
}
const listItemsView = new ListItemsView();
export default listItemsView;
好的,所以我们利用 select() 方法从我们创建的 redux.js 文件中的状态中获取状态的一部分。然后我们渲染响应。只要这些视图在不同的页面上,我们总是会从我们的状态中获得 items 数组的最新版本。然而,如果这些视图同时可见,那么我们就有一个问题。
为我们的存储添加订阅功能
某种程度上,列表视图需要监听存储中的变化,以便在发生变化时重新渲染。实现这一点的办法当然是设置某种类型的监听器,当发生变化时触发事件。如果我们作为视图订阅这些变化,那么我们可以相应地采取行动并重新渲染我们的视图。有几种不同的方法可以实现这一点:我们可以实现一个可观察的模式,或者使用一个库,例如 EventEmitter。让我们更新我们的 redux.js 文件来实现这一点:
// dataflow/redux.js
import { itemsReducer } from "./reducer";
import EventEmitter from "events";
const emitter = new EventEmitter();
let state = {
items: []
};
function store(state = { items: [] }, action) {
return {
items: itemsReducer(state.items, action)
};
}
export function getState() {
return state;
}
export function dispatch(action) {
const oldState = state;
state = store(state, action);
emitter.emit("changed");
}
export function select(slice) {
return state[slice];
}
export function subscribe(cb) {
emitter.on("changed", cb);
}
创建一个程序
到目前为止,我们已经创建了一系列文件,具体如下:
-
redux.js:我们的存储实现。 -
create-view.js:一个控制器,它监听输入和按钮点击。控制器将在按钮点击时读取输入,并派发输入的值以便将其保存在存储中。 -
list-view.js:我们的第二个控制器,负责显示存储的内容。 -
todo-app.js:创建我们整个应用的启动文件(我们尚未创建此文件)。 -
index.html:我们应用的 UI(我们尚未创建此文件)。
设置我们的环境。
也许你已经注意到我们正在使用用于 ES6 模块的导入语句?有许多方法可以使它工作,但我们选择了一个现代选项,即利用 webpack。为了成功设置 webpack,我们需要做以下事情:
-
安装 npm 库
webpack和webpack-cli -
创建一个
webpack.config.js文件并指定应用的入口点。 -
在
package.json文件中添加一个条目,以便我们可以通过简单的npm start来构建和运行我们的应用。 -
添加一个 HTTP 服务器,以便我们可以展示应用。
我们可以通过输入以下命令来安装所需的库:
npm install webpack webpack-cli --save-dev
此后,我们需要创建我们的 config 文件,webpack.config.js,如下所示:
// dataflow/webpack.config.js
module.exports = {
entry: "./todo-app.js",
output: {
filename: "bundle.js"
},
watch: true
};
在前面的代码中,我们声明入口点应该是 todo-app.js,并且输出文件应该命名为 bundle.js。我们还通过将 watch 设置为 true 来确保我们的包将被重新构建。让我们通过在 script 标签中添加以下内容来将所需的入口添加到 package.json 文件中:
// dataflow/package.json excerpt
"scripts": {
"start" : "webpack -d"
}
在这里,我们定义了一个启动命令,它使用 webpack 的-d标志调用 webpack,这意味着它将生成源映射,从而提供良好的调试体验。
对于我们的最后一步设置,我们需要一个 HTTP 服务器来显示我们的应用程序。Webpack 本身有一个叫做webpack-dev-server的,或者我们可以使用http-server,这是一个 NPM 包。这是一个相当简单的应用程序,所以两者都可以。
创建缺失的文件并运行我们的程序
我们的应用程序需要一个 UI,让我们创建它:
// dataflow/dist/index.html
<html>
<body>
<div>
<input type="text" id="input">
<button id="saveButton">Save</button>
</div>
<div>
<ul id="list"></ul>
</div>
<button id="saveButton">Save</button>
<script src="img/bundle.js"></script>
</body>
</html>
因此,这里我们有一个输入元素和一个按钮,我们可以按下它来保存一个新项目。接下来是一个列表,我们的内容将会在这里渲染。
接下来,让我们创建todo-app.js。它应该看起来像以下这样:
// dataflow/todo-app.js
// import create view
import createView from "./create-view";
// import list view
import listView from "./list-view";
在这里,我们正在引入两个控制器,这样我们就可以收集输入以及显示存储内容。让我们通过在终端窗口中输入npm start来尝试我们的应用程序。这将在 dist 文件夹中创建bundle.js文件。为了显示应用程序,我们需要打开另一个终端窗口并定位到dist文件夹。你的 dist 文件夹应该包含以下文件:
-
index.html -
bundle.js
现在我们已经准备好通过输入http-server -p 5000来启动应用程序。你可以在浏览器中的http://localhost:5000找到你的应用程序:

我们看到我们期望的应用程序,有一个输入元素和一个按钮,我们还看到右侧的控制台显示我们的两个控制器都已加载。此外,我们还看到存储对象 items 属性的内容,它指向一个空数组。这是预期的,因为我们还没有向其中添加任何项目。让我们通过向我们的输入元素添加一个值并按下保存按钮来向我们的存储添加一个项目:

在右侧,我们可以看到我们的存储现在包含了一个项目,但我们的 UI 没有更新。原因是我们没有实际订阅这些变化。我们可以通过向我们的 list-view.js 控制器文件中添加以下代码片段来改变这一点:
// dataflow/list-view.js
import { createItem } from "./actions";
import { select, subscribe } from "./redux";
console.log("list item view has loaded");
class ListItemsView {
constructor() {
this.render();
subscribe(this.render);
}
render() {
const items = select("items");
const elem = document.getElementById("list");
elem.innerHTML = "";
console.log("items", items);
items.forEach(item => {
const li = document.createElement("li");
li.innerHTML = item.title;
elem.appendChild(li);
});
}
}
const listItemsView = new ListItemsView();
export default listItemsView;
现在我们的应用程序应该按预期渲染,并且看起来应该像这样,前提是你添加了一些项目:

处理异步调用
分发动作始终是同步完成的。数据通过 AJAX 异步获取,那么我们如何让异步与 Redux 良好地协同工作呢?
当设置异步调用时,你应该以下述方式定义你的 Redux 状态:
-
加载:在这里,我们有显示旋转器、不渲染 UI 的一部分,或者以其他方式向用户传达 UI 正在等待某物的机会
-
数据成功获取:你应该为获取的数据设置一个状态
-
发生错误:你应该以某种方式记录错误,这样你就能告诉用户发生了错误
根据惯例,您使用单词 fetch 来表示您正在获取数据。让我们看看这可能会是什么样子。首先,让我们定义我们需要采取的步骤:
-
创建一个 reducer。这个 reducer 应该能够根据我们是在等待响应、已收到响应还是发生了错误来设置不同的状态。
-
创建动作。我们需要一个文件的动作来支持我们之前提到的状态;创建这个文件更多的是关于便利性。
-
更新我们的
redux.js文件以使用我们新的 reducer。 -
测试我们的创建。
假设我们正在从 API 获取一本书。我们应该有一个看起来像以下的 reducer:
// async/book-reducer.js
let initialState = {
loading: false,
data: void 0,
error: void 0
};
const bookReducer = (state = initialState, action) => {
switch(action.type) {
case 'FETCH_BOOK_LOADING':
return {...state, loading: true };
case 'FETCH_BOOK_SUCCESS':
return {...state, data: action.payload.map(book => ({ ... book })) };
case 'FETCH_BOOK_ERROR':
return {...state, error: { ...action.payload }, loading: false };
}
}
module.exports = bookReducer;
现在我们已经涵盖了 reducer 部分,让我们继续创建动作。它看起来如下:
// async/book-actions.js
const fetchBookLoading = () => ({ type: 'FETCH_BOOK_LOADING' });
const fetchBookSuccess = (data) => ({ type: 'FETCH_BOOK_SUCCESS', payload: data });
const fetchBookError = (error) => ({ type: 'FETCH_BOOK_ERROR', payload: error });
module.exports = {
fetchBookLoading,
fetchBookSuccess,
fetchBookError
};
现在我们需要转向我们的 store 文件并更新它:
// async/redux.js
const bookReducer = require('./book-reducer');
const EventEmitter = require('events');
const emitter = new EventEmitter();
let state = {
book: {}
};
function store(state = {}, action) {
return {
book: bookReducer(state.book, action)
};
}
function getState() {
return state;
}
function dispatch(action) {
const oldState = state;
state = store(state, action);
emitter.emit('changed');
}
function select(slice) {
return state[slice];
}
function subscribe(cb) {
emitter.on('changed', cb);
}
module.exports = {
getState, dispatch, select, subscribe
}
使用 Redux 和异步创建一个演示
现在是时候测试一切了。我们在这里感兴趣的是确保我们的存储状态按预期工作。我们希望存储反映我们正在加载数据、接收数据,以及如果发生错误,这也应该得到反映。让我们先模拟一个 AJAX 调用:
const { fetchBookLoading, fetchBookSuccess, fetchBookError } = require('./book-actions');
const { dispatch, getState } = require('./redux');
function fetchBook() {
return new Promise(resolve => {
setTimeout(() => {
resolve({ title: 'A new hope - the book' });
}, 1000);
})
}
作为我们接下来的业务,让我们为状态设置一些日志记录,并分发我们的第一个动作 fetchBookLoading,这表示一个 AJAX 请求正在进行中。理想情况下,我们希望在这个状态下反映 UI 并显示一个旋转器或类似的东西:
console.log(getState());
// { book: {} }
dispatch(fetchBookLoading());
console.log(getState());
// { book: { loading: true } }
最后一步是调用我们的 fetchBook() 方法并适当地设置存储状态:
async function main() {
try {
const book = await fetchBook();
dispatch(fetchBookSuccess(book));
console.log(getState());
// { book: { data: { title: 'A new hope - the book'}, loading: false } }
} catch(err) {
dispatch(fetchBookError(err));
console.log(getState());
// { book: { data: undefined, error: { title: 'some error message' } } }
}
}
main();
到目前为止,我们已经从上到下分步骤描述了我们的演示。完整的代码应该像这样:
// async/demo.js
const { fetchBookLoading, fetchBookSuccess, fetchBookError } = require('./book-actions');
const { dispatch, getState } = require('./redux');
function fetchBook() {
return new Promise(resolve => {
setTimeout(() => {
resolve({ title: 'A new hope - the book' });
}, 1000);
})
}
console.log(getState());
dispatch(fetchBookLoading());
console.log(getState());
async function main() {
try {
const book = await fetchBook();
dispatch(fetchBookSuccess(book));
console.log(getState());
} catch(err) {
dispatch(fetchBookError(err));
console.log(getState());
}
}
main();
如您所见,处理异步操作实际上并没有太多复杂的地方,你只需要在异步操作完成其流程后,分配合适的状态即可。尽管如此,处理异步操作还是有相应的库。如果您是 React 用户,那么研究 Sagas 可能是值得的;如果您喜欢 Angular,那么 NgRx 和 effects 就是您的首选。存在这些独立库的原因在于,异步交互,尤其是 AJAX 交互,被视为副作用,因此它们位于 正常 流程之外。最终,是否需要这样的库取决于您的个人判断。
最佳实践
到目前为止,我们已经走得很远了。我们已经涵盖了原则、核心概念,甚至自己构建了 Redux 实现。在这个时候,我们应该非常自豪。尽管如此,我们还有一些内容尚未涉及,那就是如何以最佳方式使用 Redux。有一些关键规则我们可以遵循。
优化文件系统。在构建应用程序时,您不应该只有几个文件,而应该有很多,通常按功能组织。这导致了一个功能以下面的文件设置:
-
Reducer:我们应该为每个 reducer 有一个文件
-
Actions:我们应该有一个文件来描述我们可能需要分发的所有动作
-
视图/组件文件:这与 Redux 无关,但无论我们选择哪个框架,我们通常都有一个文件来描述我们试图构建的组件
还有另一个值得做的事情,那就是优化我们 store 的设置过程。store 通常需要用多个 reducer 进行初始化。我们可以编写一些类似这样的代码:
const booksReducer = require("./books/reducer");
const personReducer = require("./reducer");
function combineReducers(config) {
const states = Object.keys(config);
let stateObject = {};
states.forEach(state => {
stateObject[state] = config[state];
});
return stateObject;
}
const rootReducer = combineReducers({
books: booksReducer,
person: personReducer
});
const store = createStore(rootReducer);
store.reduce({ type: "SOME_ACTION", payload: "some data" });
这里的设置没有问题,但是如果你有很多功能,每个功能都有一个 reducer,最终你会有很多导入,你的 combineReducers() 调用会越来越长。解决这个问题的方法是在每个 reducer 中注册它自己到 rootReducer。这样,我们可以切换以下调用:
const rootReducer = combineReducers({
books: booksReducer,
person: personReducer
});
const store = createStore(rootReducer);
它将被替换为以下内容:
const store = createStore(getRootReducer());
这迫使我们创建一个新的 root-reducer.js 文件,其结构如下:
// best-practices/root-reducer.js
function combineReducers(config) {
const states = Object.keys(config);
let stateObject = {};
states.forEach(state => {
stateObject[state] = config[state];
});
return stateObject;
}
let rootReducer = {};
function registerReducer(reducer) {
const entry = combineReducers(reducer);
rootReducer = { ...rootReducer, ...entry };
}
function getRootReducer() {
return rootReducer;
}
module.exports = {
registerReducer,
getRootReducer
};
我们在这里突出了重要部分,即 registerReducer() 方法,reducer 现在可以使用它来注册自己到 rootReducer。在这个时候,回到我们的 reducer 并更新它以使用 registerReducer() 方法是值得的:
// best-practies/books/reducer.js
const { registerReducer } = require('../root-reducer');
let initialState = [];
function bookReducer(state = initialState, action) {
switch(action.type) {
case 'LIST_BOOKS':
return state;
case 'ADD_BOOK':
return [...state, {...action.payload}];
}
}
registerReducer({ books: bookReducer });
摘要
本章内容丰富多彩,从描述原则到核心概念,再到能够理解和甚至构建自己的 Redux。我们花费时间研究如何处理 AJAX 调用和适合该状态的模式。我们了解到这实际上并没有什么复杂。我们通过查看最佳实践来结束本章。到目前为止,我们能够更好地理解和欣赏 NgRx,因为我们知道了其底层模式和存在的理由。我们可以知道,在书的最后一章我们将学习 NgRx。目标是涵盖其原则和概念,如何在实践中使用它,以及涵盖一些必要的工具,以确保我们真正成功。
第九章:NgRx – Reduxing that Angular App
我们已经到达了这本书的最后一章。现在是时候理解 NgRx 库了。到目前为止,已经涵盖了不同的主题,使您作为读者更习惯于思考诸如不可变数据结构和响应式编程等问题。我们这样做是为了使您更容易消化本章中将要介绍的内容。NgRx 是为 Angular 制作的 Redux 实现,因此诸如存储、动作创建器、动作、选择器和还原器等概念被广泛使用。您通过阅读前面的章节可能已经了解到了 Redux 的工作原理。通过阅读上一章,您将发现您所学的 Redux 知识如何转化为 NgRx 以及其代码组织原则。本章旨在描述核心库 @ngrx-store,如何使用 @ngrx-effects 处理副作用,以及如何像专业人士一样使用 @ngrx/store-devtools 进行调试,以及其他内容。
在本章中,我们将学习:
-
使用
@ngrx/store进行状态管理 -
使用
@ngrx/effects处理副作用 -
如何使用
@ngrx/store-devtools进行调试 -
如何使用
@ngrx/router-store捕获和转换路由状态
NgRx 概述
NgRx 由以下部分组成:
-
@ngrx/store:这是包含我们维护状态和分发动作方式的核心。 -
@ngrx/effects:这将处理副作用,例如,例如 AJAX 请求。 -
@ngrx/router-store:这确保我们可以将 NgRx 与 Angular 路由集成。 -
@ngrx/store-devtools:这将安装一个工具,例如,通过提供时间旅行调试功能,给我们调试 NgRx 的机会。 -
@ngrx/entity:这是一个帮助我们管理记录集合的库。 -
@ngrx/schematics:这是一个脚手架库,在您使用 NgRx 时提供帮助。
关于状态管理的一些话
一些组件必须具有状态。当有其他组件需要了解那个非常相同的状态时,第一个组件需要找到一种方法将这个状态传达给其他组件。有许多实现这一目标的方法。一种方法是通过确保所有应该共享的状态都生活在中央存储中。将这个存储视为一个单一的真实来源,所有组件都可以从中读取。并不是每个状态都一定需要最终进入中央存储,因为状态可能只关注特定的组件。在 NgRx 和 Redux 之前,解决这一问题的方法之一是将所有内容放入一个全局可访问的对象或服务中。正如我们提到的,存储就是这样。它在全局上是可访问的,因为它可以被注入到可能需要它的任何组件中。一个警告:尽管将所有状态放入我们的存储中很有诱惑力,但我们真的不应该这样做。需要在不同组件之间共享的状态值得放入那里。
从拥有集中式存储中我们得到的另一个好处是,保存应用程序的状态以便稍后恢复非常容易。如果状态只存在于一个地方,比如用户或系统,那么用户可以轻松地将该状态持久化到后端,这样下次,如果他们想要从上次离开的地方继续,他们可以通过查询后端的状态来轻松地做到这一点。所以,除了想要在许多组件之间共享数据之外,还存在另一个想要集中存储的原因。
@ngrx/store – 状态管理
本节中的所有文件都指向Chapter9/State项目。
这是我们一直等待的时刻。我们实际上该如何开始呢?这真的很简单。首先,让我们确保我们已经安装了 Angular CLI。我们通过在终端中输入以下内容来完成此操作:
npm install -g @angular/cli
在这一点上,我们需要一个 Angular 项目。我们使用 Angular CLI 来做这件事,并使用以下命令搭建一个新项目:
ng new <my new project>
一旦搭建过程完成,我们使用简单的cd <项目目录>命令导航到我们新创建的项目目录。我们想要使用@ngrx/store库提供的核心功能,因此我们通过输入以下内容来安装它:
npm install @ngrx/store --save
现在我们打开我们搭建的项目中的app.module.ts文件。是时候将 NgRx 导入并注册到AppModule中了:
// app.module.ts
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { StoreModule } from "@ngrx/store";
import { AppComponent } from "./app.component";
import { counterReducer } from "./reducer";
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({ counter: counterReducer }),
],
bootstrap: [AppComponent]
})
export class AppModule {}
在前面的代码中,我们突出显示了重要部分,即导入StoreModule并通过输入将其与AppModule注册:
StoreModule.forRoot({ counter: counterReducer })
在这里,我们告诉存储应该存在什么状态,即counter,以及counterReducer是负责该状态片段的 reducer。正如你所见,代码还没有完全工作,因为我们还没有创建counterReducer,让我们接下来创建它:
// reducer.ts
export function counterReducer(state = 0, action) {
switch(action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state -1;
default:
return state;
}
}
希望你已经阅读了第八章,Redux,并理解为什么我们以这种方式编写 reducer 文件。让我们回顾一下,并声明 reducer 只是一个函数,它接受一个状态并根据一个动作产生一个新的状态。同样重要的是强调,reducer 被称为纯函数,它不会改变状态,而是根据旧状态加上传入的动作产生一个新的状态。让我们在这里展示如果我们想在 Redux 之外使用 reducer 时会如何理论性地使用它。我们这样做只是为了演示 reducer 是如何工作的:
let state = counterReducer(0, { type: 'INCREMENT' });
// state is 1
state = counterReducer(state, { type: 'INCREMENT' });
// state is 2
如我们所见,我们从初始值0开始,并计算出一个新值,结果为1。在函数的第二次执行中,我们向它提供现有的状态,其值为0。这导致我们的状态现在变为2。这看起来可能很简单,但这几乎是一个 reducer 可能达到的复杂程度。通常,你不会自己执行 reducer 函数,而是将其注册到 store 中,并向 store 发送动作。这将导致 reducer 被调用。那么,我们如何告诉 store 发送动作呢?很简单,我们使用 store 上的dispatch()函数。对于这段代码,让我们转到app.component.ts文件。我们还需要创建一个名为app-state.ts的文件,它是一个接口,是我们 store 的类型化表示:
// app-state.ts
export interface AppState {
counter: number;
}
// app.component.ts
import { Component } from "@angular/core";
import { Store } from "@ngrx/store";
import { Observable } from "rxjs/Observable";
import { AppState } from "./app-state";
@Component({
selector: "app-root",
template: `
{{ counter$ | async }}
`
})
export class AppComponent {
counter$;
constructor(private store: Store<AppState>) {
this.counter$ = store.select("counter");
}
}
从前面的代码中,我们可以看到我们如何将 store 服务注入到构造函数中,如下所示:
constructor(private store: Store<AppState>) {
this.counter$ = store.select("counter");
}
此后,我们调用store.select("count"),这意味着我们正在向 store 请求其状态的count属性部分,因为这就是这个组件所关心的。store.select()的调用返回一个Observable,当解析时包含一个值。我们可以通过将其添加到模板标记中轻松显示此值,如下所示:
{{ counter$ | async }}
这样就处理了获取和显示状态。那么,发送动作怎么办?store 实例上有一个名为dispatch()的方法,它接受一个包含属性类型的对象。所以以下是一个完美的输入:
// example input to a store
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT', payload: 1 });
store.dispatch({})
// will throw an error, as it is missing the type property
现在,让我们构建我们的组件,并创建一些方法和标记,以便我们可以发送动作并看到这样做的结果:
// app.component.ts
import { Component } from "@angular/core";
import { Store } from "@ngrx/store";
import { AppState } from "./app-state";
@Component({
selector: "app-root",
template: `
{{ counter$ | async }}
<button (click)="increment()" >Increment</button>
<button (click)="decrement()" >Decrement</button>
`
})
export class AppComponent {
counter$;
constructor(private store: Store<AppState>) {
this.counter$ = store.select("counter");
}
increment() {
this.store.dispatch({ type: 'INCREMENT' });
}
decrement() {
this.store.dispatch({ type: 'DECREMENT' });
}
}
我们在类体中添加了increment()和decrement()方法,并在标记中添加了两个按钮,这些按钮调用这些函数。尝试这样做,我们可以看到我们的 UI 在每次按钮按下时都会更新。当然,这是因为每个发送的动作都会隐式调用我们的counterReducer,也因为我们在counter$变量的形式中持有对状态的引用。由于这是一个Observable,这意味着当发生变化时它会被更新。当发送动作时,变化会被推送到我们的counter$变量。这很简单,但很强大。
一个更复杂的例子——一个列表
到目前为止,我们已经学习了如何通过导入和注册其模块来设置 NgRx。我们还学习了select()函数,它给我们一个状态切片,以及允许我们发送动作的dispatch()函数。这些都是基础知识,我们将使用这些非常相同的基础知识来创建一个新的 reducer,以巩固我们已知的知识,同时引入负载的概念。
我们需要做以下事情:
-
告诉 store 我们有一个新的状态,
jedis -
创建一个
jediListReducer并将其注册到 store 中 -
创建一个组件,它不仅支持显示我们的
jediList,还能够发送改变我们状态切片jedis的动作。
让我们开始定义我们的 reducer,jediListReducer:
// jedi-list.reducer.ts
export function jediListReducer(state = [], action) {
switch(action.type) {
case 'ADD_JEDI':
return [ ...state, { ...action.payload }];
case 'REMOVE_JEDI':
return state.filter(jedi => jedi.id !== action.payload.id);
case 'LOAD_JEDIS':
return action.payload.map(jedi => ({...jedi}));
default:
return state;
}
}
让我们解释一下这里的每个 case 发生了什么。首先,我们有ADD_JEDI。我们取我们的action.payload并将其添加到列表中。或者技术上,我们取我们的现有列表并根据旧列表构建一个新列表,加上我们在action.payload中找到的新列表项。其次,我们有REMOVE_JEDI,它使用filter()函数来移除我们不希望看到的列表项。最后,我们有LOAD_JEDIS,它接受一个现有列表并替换我们的状态。现在,让我们通过在这里调用它来演示这个 reducer:
let state = jediListReducer([], { type: 'ADD_JEDI', payload : { id: 1, name: 'Yoda' });
// now contains [{ id: 1, name: 'Yoda' }]
state = jediListReducer(state, { type: 'ADD_JEDI', payload: { id: 2, name: 'Darth Vader'} });
// now contains [{ id: 1, name: 'Yoda' }, { id: 2, name: 'Darth Vader'}];
state = jediListReducer(state, { type: 'REMOVE JEDI', payload: { id: 1 } });
// now contains [{ id: 2, name: 'Darth Vader'}];
state = jediListReducer(state, { type: 'LOAD_JEDIS', payload: [] });
// now contains []
现在,让我们将这个 reducer 注册到 store 中。因此,我们将返回到app.module.ts:
// app.module.ts
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { StoreModule } from "@ngrx/store";
import { AppComponent } from "./app.component";
import { counterReducer } from "./reducer";
import { jediListReducer } from "./jedi-list-reducer";
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({
count: counterReducer,
jediList: jediListReducer }),
],
bootstrap: [AppComponent]
})
export class AppModule {}
由于我们刚刚向我们的 store 添加了一个新的状态,我们应该让app-state.ts文件知道它,我们还应该创建一个Jedi模型,这样我们就可以在组件中稍后使用它:
// jedi.model.ts
export interface Jedi {
id: number;
name: string;
}
// app-state.ts
import { Jedi } from "./jedi.model";
export interface AppState {
counter: number;
jediList: Array<Jedi>;
}
从前面的代码中,我们可以看到jediListReducer以及状态jediList被添加到作为StoreModule.forRoot()函数输入的对象中。这意味着 NgRx 知道这个状态,并将允许我们检索它并向它分发动作。为了做到这一点,让我们构建一个只包含这个功能的组件。我们需要创建jedi-list.component.ts文件:
// jedi-list.component.ts
import { Component } from "@angular/core";
import { Store } from "@ngrx/store";
import { AppState } from "../app-state";
import { Jedi } from "./jedi.model";
@Component({
selector: "jedi-list",
template: `
<div *ngFor="let jedi of list$ | async">
{{ jedi.name }}<button (click)="remove(jedi.id)" >Remove</button>
</div>
<input [(ngModel)]="newJedi" placeholder="" />
<button (click)="add()">Add</button>
<button (click)="clear()" >Clear</button>
`
})
export class JediListComponent {
list$: Observable<Array<Jedi>>;
counter = 0;
newJedi = "";
constructor(private store: Store<AppState>) {
this.list$ = store.select("jediList");
}
add() {
this.store.dispatch({
type: 'ADD_JEDI',
payload: { id: this.counter++, name: this.newJedi }
});
this.newJedi = '';
}
remove(id) {
this.store.dispatch({ type: 'REMOVE_JEDI', payload: { id } });
}
clear() {
this.store.dispatch({ type: 'LOAD_JEDIS', payload: [] });
this.counter = 0;
}
}
我们最后需要做的是将这个组件注册到我们的模块中,我们应该有一个可工作的应用程序:
// app.module.ts
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { StoreModule } from "@ngrx/store";
import { AppComponent } from "./app.component";
import { counterReducer } from "./reducer";
import { jediListReducer } from "./jedi-list.reducer";
import { JediListComponent } from './jedi-list.component';
@NgModule({
declarations: [AppComponent, JediListComponent ],
imports: [
BrowserModule,
StoreModule.forRoot({ count: counterReducer, jediList: JediListReducer }),
],
bootstrap: [AppComponent]
})
export class AppModule {}
最佳实践
以下文件指向演示项目Chapter9/BestPractices。
到目前为止,我们已经创建了一些可工作的代码,但它可以看起来好得多,并且错误的可能性也更小。我们可以采取一些步骤来改进代码,那些是:
-
摆脱所谓的魔法字符串并依赖常量
-
在你的 reducer 中添加一个默认状态
-
创建所谓的动作创建者
-
将所有内容移动到一个专用模块中,并将其拆分为几个组件
让我们看看我们的第一个项目符号。根据我们在jediList上执行的动作类型,我们可以为它们创建一个constants.ts文件,如下所示:
// jedi.constants.ts
export const ADD_JEDI = 'ADD_JEDI';
export const REMOVE_JEDI = "REMOVE_JEDI";
export const LOAD_JEDIS ="LOAD_JEDIS";
现在,当我们引用这些动作时,我们可以改用导入这个文件并使用这些常量,从而降低我们输入错误的几率。
我们可以做的第二件事是通过创建所谓的动作创建者来简化动作的创建。到目前为止,我们已经习惯了输入以下内容来创建一个动作:
const action = { type: 'ADD_JEDI', payload: { id: 1, name: 'Yoda' } };
在这里,一个更好的习惯是创建一个为我们做这件事的函数。对于列表 reducer 的情况,有三种可能发生的情况,所以让我们把这些都放在一个actions.ts文件中:
// jedi.actions.ts
import {
ADD_JEDI,
REMOVE_JEDI,
LOAD_JEDIS
} from "./jedi.constants";
export const addJedi = (id, name) => ({ type: ADD_JEDI, payload: { id, name } });
export const removeJedi = (id) => ({ type: REMOVE_JEDI, payload:{ id } });
export const loadJedis = (jedis) => ({ type: LOAD_JEDIS, payload: jedis });
创建actions.ts文件的目的在于,当我们分发动作时,我们不需要写太多的代码。而不是写以下内容:
store.dispatch({ type: 'ADD_JEDI', payload: { id: 3, name: 'Luke' } });
我们现在可以写成这样:
// example of how we can dispatch to store using an actions method
import { addJedi } from './jedi.actions';
store.dispatch(addJedi(3, 'Luke'));
一个清理示例
以下场景可以在代码仓库的Chapter9/BestPractices文件夹中找到。
让我们解释一下我们是从哪里来的,以及为什么可能需要清理你的代码。如果你从一个非常简单的应用开始,你可能会在项目的根模块中添加 reducer、actions 和组件。一旦你想添加另一个组件,这可能会造成混乱。让我们在开始清理之前展示一下我们的文件结构可能的样子:
app.component.ts
app.module.ts
jedi-list-reducer.ts
jedi-constants.ts
jedi-list-actions.ts
jedi-list-component.ts
从这个角度来看,很明显,如果我们的应用只包含那个一个组件,这只会持续下去。一旦我们添加了更多组件,事情就会开始变得混乱。
让我们列出我们需要做什么来创建一个更好的文件结构,同时尽可能好地利用动作创建者、常量和 reducers:
-
创建一个专门的功能模块和目录
-
创建 reducer 和动作文件可以使用的动作常量
-
创建一个包含所有我们打算执行的动作的动作创建者文件
-
创建一个处理派发的 reducer
-
创建一个能够处理我们打算使用的所有动作的
JediList组件 -
将我们的 reducer 和状态注册到 store 中
创建一个专门的目录和功能模块
由于这个原因,我们希望将所有东西都放在一个专门的目录jedi中。最容易的方法是使用 Angular CLI 并运行以下命令:
ng g module jedi
上述代码将生成以下文件:
jedi/
jedi.module.ts
将自己置于新创建的jedi目录中,并输入以下内容:
ng g component jedi-list
这将在你的jedi目录中添加以下结构:
jedi/
jedi.module.ts
jedi-list/
jedi-list.component.html
jedi-list.component.ts
jedi-list.component.css
jedi-list.component.spec.ts
然而,我们在前面的部分中已经创建了jedi-list.component及其相关文件,所以现在我们将移除这些生成的文件,并将已经创建的文件移动到jedi-list目录下。所以,你的目录应该看起来像这样:
jedi/
jedi.module.ts
jedi-list/
添加 reducer 和常量
让我们创建我们的 reducer,如下所示:
// jedi/jedi-list/jedi-list.reducer.ts
import {
ADD_JEDI,
REMOVE_JEDI,
LOAD_JEDIS
} from './jedi-list.constants.ts'
const initialState = [];
export function jediListReducer(state = initialState, action) {
switch(action.type) {
case ADD_JEDI:
return [ ...state, { ...action.payload }];
case REMOVE_JEDI:
return state.filter(jedi => jedi.id !== action.payload.id);
case LOAD_JEDIS:
return action.payload.map(jedi => ({ ...jedi}));
default:
return state;
}
}
我们下一个任务是我们的常量文件,它已经被创建,只需要移动,如下所示:
// jedi/jedi-list/jedi-list-constants.ts
export const ADD_JEDI = 'ADD_JEDI';
export const REMOVE_JEDI = "REMOVE_JEDI";
export const LOAD_JEDIS ="LOAD_JEDIS";
一个一般的建议是,如果你发现组件和文件的数量在增长,考虑为它们创建一个专门的目录。
接下来是我们也已经创建并需要移动到我们的jedi目录的动作创建者文件,如下所示:
// jedi/jedi-list/jedi-list-actions.ts
import { ADD_JEDI, REMOVE_JEDI, LOAD_JEDIS } from "./jedi-list-constants";
let counter = 0;
export const addJedi = (name) => ({ type: ADD_JEDI, payload: { id: counter++, name }});
export const removeJedi = (id) => ({ type: REMOVE_JEDI, payload: { id } });
export const loadJedis = (jedis) => ({ type: LOAD_JEDIS, payload: jedis });
我们的目录现在应该看起来像这样:
jedi/
jedi.module.ts
jedi-list/ jedi-list.reducer.ts
jedi-list.actions.ts
将组件移动到我们的 jedi 目录
下一点是关于将我们的JediListComponent移动到我们的jedi目录,如下所示:
// jedi/jedi-list/jedi-list.component.ts
import { Component } from "@angular/core";
import { Store } from "@ngrx/store";
import { Observable } from "rxjs/Observable";
import { AppState } from "../app-state";
import {
addJedi,
removeJedi,
loadJedis
} from './jedi-list-actions';
@Component({
selector: "jedi-list",
template: `
<div *ngFor="let jedi of list$ | async">
{{ jedi.name }}<button (click)="remove(jedi.id)" >Remove</button>
</div>
<input [(ngModel)]="newJedi" placeholder="" />
<button (click)="add()">Add</button>
<button (click)="clear()" >Clear</button>
`
})
export class JediListComponent {
list$: Observable<number>;
counter = 0;
newJedi = "";
constructor(private store: Store<AppState>) {
this.list$ = store.select("jediList");
}
add() {
this.store.dispatch(addJedi(this.newJedi));
this.newJedi = '';
}
remove(id) {
this.store.dispatch(removeJedi(id));
}
clear() {
this.store.dispatch(loadJedis([]));
this.counter = 0;
}
}
在我们将jedi-list组件移动之后,我们的目录现在应该看起来如下所示:
jedi/
jedi.module.ts
jedi-list/ jedi-list.reducer.ts
jedi-list.actions.ts jedi-list.component.ts
在 store 中注册我们的 reducer
最后,我们只需要对app.module.ts文件进行轻微的更新,使其正确指向我们的JediListReducer,如下所示:
// app.module.ts
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { StoreModule } from "@ngrx/store";
import { AppComponent } from "./app.component";
import { counterReducer } from "./reducer";
import { JediModule } from './jedi/jedi.module';
import { jediListReducer } from "./jedi/jedi-list/jedi-list.reducer";
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({
counter: counterReducer,
jediList: JediListReducer
}),
JediModule
],
bootstrap: [AppComponent]
})
export class AppModule {}
利用类型和功能模块
以下文件指向的是演示项目Chapter9/FeatureModules。
好的,我们可以肯定改进的一点是,我们如何告诉StoreModule我们的应用中存在哪些状态和 reducers。让我们快速回顾一下,并看看它的当前状态:
// from app.module.ts
StoreModule.forRoot({ count: counterReducer, jediList: JediListReducer })
因此,我们实际上是在向forRoot()方法传递一个对象。这有什么问题吗?好吧,想象一下你有十个不同的功能模块,每个功能模块可能有三到四个状态,那么传递给forRoot()的对象将增大,你需要在app.module.ts中进行的导入数量也将增加。它看起来可能像这样:
StoreModule.forRoot({
featureModuleState1: featureModuleState1Reducer,
featureModuleState2 : featureModuleState2Reducer
.
.
.
.
.
.
.
.
})
从 forRoot()到 forFeature()
要解决我们在app.module.ts中造成的混乱,我们现在将使用在StoreModule上的forFeature()方法,这将允许我们为每个功能模块设置所需的各个状态。让我们从现有的设置开始,进行重构:
// app.module.ts
StoreModule.forRoot({ }) // this would be empty
我们将两个还原器条目移动到它们各自的功能模块中,counter.module.ts和jedi.module.ts。现在它看起来可能像这样:
// counter.module.ts
@NgModule({
imports: [StoreModule.forFeature(
// add reducer object here
)]
})
// jedi.module.ts
@NgModule({
imports : [StoreModule.forFeature(
// add reducer here
)]
})
我们故意省略了这里的实现,因为我们需要退后一步。记得当我们调用StoreModule.forRoot()时,我们可以直接传递一个对象。使用forFeature()时看起来并不完全一样。有一点不同,所以让我们尝试解释一下这个差异。我们习惯于通过传递一个对象来设置我们的存储,这个对象看起来像这样:
{
sliceOfState : reducerFunction,
anotherSliceOfState: anotherReducerFunction
}
将 forFeature()从字符串转换为选择函数
我们可以以几乎相同的方式设置它,但我们需要传递一个功能模块的名称。让我们看看我们的counter.module.ts,并给它添加一些代码:
// counter.module.ts
@NgModule({
imports: [
StoreModule.forFeature('counter',{
data: counterReducer
})
]
})
这将改变我们选择状态的方式。想象一下我们处于counter.component.ts内部,当前的实现看起来如下:
// counter.component.ts
@Component({
selector: 'counter',
template: `{{ counter$ | async }}`
})
export class CounterComponent {
counter$;
constructor(private store: Store<AppState>) {
// this needs to change..
this.counter$ = this.store.select('counter');
}
}
因为我们在counter.module.ts中改变了状态的外观,我们现在需要在counter.component.ts中反映这一点,如下所示:
// counter.component.ts
@Component({
selector: 'counter',
template: `{{ counter$ | async }}`
})
export class CounterComponent {
counter$;
constructor(private store: Store<AppState>) {
this.counter$ = this.store.select((state) => {
return state.counter.data;
});
}
}
介绍用于设置状态的 NgRx 类型
到目前为止,我们已经学习了如何将存储状态声明从app.module.ts移动并注册到每个功能模块中。这将给我们带来更多的秩序。让我们仔细看看用于注册状态的类型。ActionReducerMap是我们迄今为止隐式使用的一个类型。每次我们调用StoreModule.forRoot()或StoreModule.forFeature()时,我们都在使用它。我们在使用它的意义上,传递包含状态及其还原器的对象由这种类型组成。让我们通过转向我们的counter.module.ts来证明这一点:
// counter.module.ts
@NgModule({
imports: [
StoreModule.forFeature('counter',{
data: counterReducer
})
]
})
让我们稍作改变,变成这样:
// counter.reducer.ts
export interface CounterState = {
data: number
};
export reducer: ActionReducerMap<CounterState> = {
data: counterReducer
}
// counter.module.ts
@NgModule({
imports: [
StoreModule.forFeature('counter', reducer)
]
})
现在,我们可以看到我们正在利用ActionReducerMap,这是一个泛型,它强制我们提供给它一个类型。在这种情况下,类型是CounterState。运行这段代码应该可以正常工作。那么,为什么要显式地使用ActionReducerMap呢?
给 forFeature()一个类型
好吧,forFeature()方法也是一个泛型,我们可以像这样显式指定它:
// counter.module.ts
const CounterState = {
data: number
};
const reducers: ActionReducerMap<CounterState> = {
data: counterReducer
}
@NgModule({
imports: [
StoreModule.forFeature<CounterState, Action>('counter', reducers)
]
})
这保护我们不会向forFeature()方法添加它不期望的状态映射对象。例如,以下将引发错误:
// example of what NOT to do interface State {
test: string;
}
function testReducer(state ="", action: Action) {
switch(action.type) {
default:
return state;
}
}
const reducers: ActionReducerMap<State> = {
test: testReducer
};
@NgModule({
imports: [
BrowserModule,
StoreModule.forFeature<CounterState, Action>('counter', reducers)
],
exports: [CounterComponent, CounterListComponent],
declarations: [CounterComponent, CounterListComponent],
providers: [],
})
export class CounterModule { }
原因在于我们向forFeature()方法提供了错误类型。它期望 reducer 参数是ActionReducerMap<CounterState>类型,这显然不是,因为我们发送的是ActionReducerMap<State>。
同一特征模块中的多个状态
以下场景可以在代码仓库的Chapter9/TypesDemo文件夹中找到。
好的,现在我们知道了ActionReducerMap类型,我们也知道可以向forFeature()方法提供一个类型,使其使用更安全。如果我们特征模块中有多个状态,会发生什么?答案是相当简单的,但让我们首先更仔细地看看我们所说的“多个状态”究竟是什么意思。我们的计数器模块包含counter.value状态。这在我们counter.component.ts中显示。如果我们想添加一个counter.list状态,我们需要添加支持常量、reducer、actions 和一个组件文件,以便我们能够正确地显示它。因此,我们的文件结构应该如下所示:
/counter
counter.reducer.ts
counter.component.ts
counter.constants.ts
counter.actions.ts
/counter-list
counter-list.reducer.ts
counter-list.component.ts
counter-list.constants.ts
counter-list.action.ts counter.model.ts
counter.module.ts
我们需要为所有这些加粗的文件添加实现。
添加计数器列表的 reducer
让我们从 reducer 开始:
// counter/counter-list/counter-list.reducer.ts
import {
ADD_COUNTER_ITEM,
REMOVE_COUNTER_ITEM
} from "./counter-list.constants";
import { ActionPayload } from "../../action-payload";
import { Counter } from "./counter.model";
export function counterListReducer(state = [], action: ActionPayload<Counter>) {
switch (action.type) {
case ADD_COUNTER_ITEM:
return [...state, Object.assign(action.payload)];
case REMOVE_COUNTER_ITEM:
return state.filter(item => item.id !== action.payload.id);
default:
return state;
}
}
这个 reducer 支持两种类型,ADD_COUNTER_ITEM和REMOVE_COUNTER_ITEM,这将使我们能够向列表中添加和移除项目。
添加组件
这个部分分为两部分,HTML 模板和类文件。让我们先从类文件开始:
// counter/counter-list/counter-list.component.ts
import { Component, OnInit } from "@angular/core";
import { AppState } from "../../app-state";
import { Store } from "@ngrx/store";
import { addItem, removeItem } from "./counter-list.actions";
@Component({
selector: "app-counter-list",
templateUrl: "./counter-list.component.html",
styleUrls: ["./counter-list.component.css"]
})
export class CounterListComponent implements OnInit {
list$;
newItem: string;
counter: number;
constructor(private store: Store<AppState>) {
this.counter = 0;
this.list$ = this.store.select(state => state.counter.list);
}
ngOnInit() {}
add() {
this.store.dispatch(addItem(this.newItem, this.counter++));
this.newItem = "";
}
remove(id) {
this.store.dispatch(removeItem(id));
}
}
HTML 模板文件相当简单,看起来像这样:
// counter/counter-list/counter-list.component.html
<div>
<input type="text" [(ngModel)]="newItem">
<button (click)="add()">Add</button>
</div>
<div *ngFor="let item of list$ | async">
{{item.title}}
<button (click)="remove(item.id)">Remove</button>
</div>
在前面的代码中,我们支持以下内容:
-
显示计数器对象的列表
-
将项目添加到列表中
-
从列表中移除项目
添加常量
接下来是添加常量。常量是件好事;它们可以保护我们免受在处理 action creators 和 reducers 时因误输入而犯错的困扰:
// counter/counter-list/counter-list.constants.ts
export const ADD_COUNTER_ITEM = "add counter item";
export const REMOVE_COUNTER_ITEM = "remove counter item";
添加动作方法
我们还必须定义动作方法。这些只是帮助我们创建动作的函数,所以我们需要输入的更少:
// counter/counter-list/counter-list.actions.ts
import {
ADD_COUNTER_ITEM,
REMOVE_COUNTER_ITEM
} from "./counter-list.constants";
export const addItem = (title, id) => ({
type: ADD_COUNTER_ITEM,
payload: { id, title }
});
export const removeItem = id => ({
type: REMOVE_COUNTER_ITEM,
payload: { id }
});
添加模型
我们需要为我们的计数器列表指定类型。为此,我们需要创建一个模型:
// counter/counter-list/counter.model.ts
export interface Counter {
title: string;
id: number;
}
注册我们的 reducer
我们确实需要添加和实现所有加粗的文件,但我们也需要更新counter.module.ts文件,以便我们能够处理添加的状态:
// counter/counter.module.ts
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { CounterComponent } from "./counter.component";
import { StoreModule, ActionReducerMap } from "@ngrx/store";
import { counterReducer } from "./counter.reducer";
import { CounterListComponent } from "./counter-list/counter-list.component";
import { Counter } from "./counter-list/counter.model";
import { counterListReducer } from "./counter-list/counter-list.reducer";
import { FormsModule } from "@angular/forms";
export interface CounterState {
data: number;
list: Array<Counter>;
}
const combinedReducers: ActionReducerMap<CounterState> = {
data: counterReducer,
list: counterListReducer
};
@NgModule({
imports: [
CommonModule,
StoreModule.forFeature("counter", combinedReducers),
FormsModule
],
declarations: [CounterComponent, CounterListComponent],
exports: [CounterComponent, CounterListComponent]
})
export class CounterModule {}
我们需要添加一个CombinedState接口,它代表所有我们的 reducer 及其状态。最后,我们更改对StoreModule.forFeature()的调用。这就完成了我们在同一模块内处理多个状态和 reducer 的方法。
组件架构
有不同种类的组件。在 NgRx 的上下文中,有两种类型的组件值得关注:智能组件和哑组件。
智能组件也被称为容器组件。它们应该在应用程序的最高级别,并处理路由。例如,如果ProductsComponent处理route/products,它应该是一个容器组件。它还应该了解存储。
纯组件的定义是它没有关于存储的知识,并且完全依赖于 @Input 和 @Output 属性——它完全是关于展示的,这也是为什么它也被称为展示组件。因此,在这个上下文中,一个展示组件可以是 ProductListComponent 或 ProductCreateComponent。一个功能模块的快速概述可能看起来像这样:
ProductsComponent // container component
ProductsListComponent // presentational component
ProductsCreateComponent // presentational component
让我们看看一个小代码示例,以便你理解这个概念:
// products.component.ts - container component
@Component({
template: `
<products-list [products]="products$ | async">
`
})
export class ProductsComponent {
products$: Observable<Product>;
constructor(private store: Store<AppState>) {
this.products$ = this.store.select('products');
}
}
// products-list.component.ts - dumb component
@Component({
selector: 'products-list',
template : `
<div *ngFor="let product of products">
{{ products.name }}
</div>
`
})
export class ProductsListComponent {
@Input() products;
}
我们的 ProductsComponent 负责处理 /products 路由。ProductsListComponent 是一个纯组件,它只被分配了一个列表,并且非常乐意将其渲染到屏幕上。
@ngrx/store-devtools – 调试
以下场景可以在代码仓库的 Chapter9/DevTools 目录下找到。
要使 DevTools 工作正常,我们需要做三件事:
-
安装 NPM 包:
npm install @ngrx/store-devtools --save。 -
安装 Chrome 扩展程序:
http://extension.remotedev.io/。这个扩展程序被称为 Redux DevTools 扩展程序。 -
在你的 Angular 模块中设置它:这需要我们将 DevTools 导入到我们的 Angular 项目中。
假设我们已经完成了前两个步骤,那么我们只剩下设置阶段了,所以我们需要打开 app.module.ts 文件:
import { BrowserModule } from "@angular/platform-browser";
import { NgModule } from "@angular/core";
import { StoreModule } from "@ngrx/store";
import { AppComponent } from "./app.component";
import { counterReducer } from "./reducer";
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({
counter: counterReducer,
StoreDevtoolsModule.instrument({
maxAge: 25 // Retains last 25 states
})
],
bootstrap: [AppComponent]
})
export class AppModule {}
好的,现在一切都已经设置好了,我们准备运行我们的应用程序并看看我们的调试工具能告诉我们什么。让我们使用 ng serve 启动我们的应用程序,并访问 http://localhost:4200/。我们首先想要做的是在 Chrome 中打开开发者工具,并点击一个名为 Redux 的标签页。你应该会看到如下内容:

Redux 标签
在左侧,我们有我们的应用程序 UI,在右侧,我们有 Redux 插件。在这个时候,除了存储的初始化之外,没有执行任何动作,这可以在插件的“检查器”部分看到。只有一个日志条目,@ngrx/store/init。让我们通过点击增量按钮与 UI 交互,看看我们的存储会发生什么:

增量按钮
如你所见,我们有一个新的条目叫做 INCREMENT。从调试的角度来看,现在有两个事情值得关注:
-
派发了哪些动作?
-
这些动作对存储有什么影响?
我们通过与插件右侧的标签按钮交互来了解这两个问题的答案。名为 Action 的按钮会告诉我们派发了什么动作以及它是否有任何负载:

动作按钮
在这里,清楚地说明了派发了一个类型值为 Increment 的动作。现在,关于我们的第二个问题;这些动作对存储有什么影响?为了找出答案,我们只需点击状态按钮:

状态按钮
我们的状态告诉我们它由三个属性组成,count、todos和jediList。我们的count属性值为 1,是我们点击增加按钮所影响的。让我们再点击几次增加按钮来看看这是否真的如此:

增加按钮
我们现在看到我们的count属性值为3,并且有三个增加动作条目。
现在,让我们谈谈一个真正酷的功能,时间旅行调试。是的,您没有看错,我们可以通过重放派发动作来控制我们存储中的时间,甚至通过删除派发动作来改变历史,所有这些都是在调试的名义下。Redux 插件为我们提供了几种实现这一点的途径:
-
在左侧点击特定的派发动作,并选择跳过派发它
-
使用滑块来控制和重放所有事件,并根据您的需要来回穿梭时间
让我们调查第一种方法——点击特定的动作:

点击特定的动作
在这里,我们点击了跳过按钮以跳过一个派发动作,最终结果是这个派发动作被移除,这通过动作被划掉来表示。我们还可以看到,我们的count属性现在值为2,因为动作从未发生。如果我们想恢复它,可以再次点击跳过。
我们提到了另一种控制已派发动作流的方法,即使用滑块。有一个名为“滑块”的按钮可以切换滑块。点击它会导致我们看到一个带有播放按钮的滑块控制,如下所示:

播放按钮
如果您按下播放按钮,它将简单地播放所有派发动作。然而,如果您选择与滑块上的光标进行交互,您可以将它向左拉,以回到过去,或者向右拉,以进入未来。
如您所见,Redux 插件是一个真正强大的工具,可以帮助我们快速了解以下方面:
-
在特定时间点您的应用状态是什么
-
UI 的哪个部分会导致存储中的哪些副作用
@ngrx/effects – 处理副作用
到目前为止,我们对 NgRx 有一个基本的了解。我们知道如何设置我们的状态并创建所有相关的工件,如动作、动作创建者和减少器。此外,我们还熟悉了 Chrome 的 Redux 插件,并理解它可以帮助我们快速了解应用的状态,最重要的是,它可以帮助我们调试与 NgRx 相关的任何问题。
现在,是时候讨论一些不太适合我们有序和同步的 reducer 和 actions 世界的东西了。我正在谈论的是叫做副作用的东西。副作用是诸如访问文件或网络资源之类的操作,尽管它们可能包含我们想要的数据的容器,或者是我们持久化数据的地方,但它们实际上与我们应用程序的状态并没有真正关系。正如我们刚才说的,派发的动作是以同步方式派发的,我们的状态变化是立即发生的。副作用是可能需要时间的事情。想象一下,我们访问一个大型文件或使用 AJAX 在网络上请求资源。这个请求将在未来某个时候完成,完成后可能会影响我们的状态。我们如何让这些耗时和异步的操作与我们的同步和瞬时的世界相适应?在 NgRx 中的答案是名为 @ngrx/effects 的库。
安装和设置
安装它就像在终端执行以下命令一样简单:
npm install @ngrx/effects --save
下一步是设置它。设置可以看作是两个步骤:
-
创建我们的效果
-
将效果注册到
EffectsModule
一个效果只是一个可注入的服务,它监听特定的动作。一旦效果被关注,它可以在离开控制之前执行一系列操作和转换。它通过派发一个动作来放弃控制。
创建我们的第一个效果 – 一个真实场景
以下场景可以在代码库的 Chapter9/DemoEffects 下找到。
这听起来有点晦涩,所以让我们用一个真实场景来说明。你想要从一个端点使用 AJAX 获取产品。如果你考虑以下步骤中你将要做什么:
-
派发一个
FETCHING_PRODUCTS,这设置我们的状态,这样我们就可以看到 AJAX 请求正在进行中,我们可以利用这一点来显示一个旋转器,直到 AJAX 请求完成等待。 -
执行 AJAX 调用并检索你的产品。
-
如果成功检索到产品,则派发
FETCHING_PRODUCTS_SUCCESSFULLY。 -
如果出现错误,则派发
FETCHING_PRODUCTS_ERROR。
让我们以下面的步骤来解决这个问题:
-
为它创建一个 reducer。
-
创建动作和动作创建者。
-
创建一个效果。
-
将前面的效果注册到我们的效果模块中。
为了执行所有这些,我们将创建一个功能模块。为此,我们创建一个 product/ 目录,包含以下文件:
-
product.component.ts -
product.actions.ts -
product.constants.ts -
product.reducer.ts -
product.selectors.ts -
product.module.ts -
product.effect.ts
我们都知道这些文件,除了 product.effect.ts。
创建我们的常量
让我们从我们的常量文件开始。我们需要的是支持我们发起 AJAX 请求的常量。我们还需要一个常量来表示我们成功获取数据,但我们还需要应对可能发生的任何错误。这意味着我们需要以下三个常量:
// product/product.constants.ts
export const FETCHING_PRODUCTS = "FETCHING_PRODUCTS";
export const FETCHING_PRODUCTS_SUCCESSFULLY = "FETCHING_PRODUCTS_SUCCESSFULLY";
export const FETCHING_PRODUCTS_ERROR = "FETCHING_PRODUCTS_ERROR";
动作创建者
我们需要公开一系列函数,这些函数可以为我们构建包含类型和有效载荷属性的对象。根据我们调用的函数不同,我们将赋予它不同的常量,当然,如果使用一个,也会赋予不同的有效载荷。动作创建者 fetchProducts() 将创建一个只设置类型的对象。接着是一个 fetchSuccessfully() 动作创建者,它将在数据从端点返回时被调用。最后,我们有 fetchError() 动作创建者,如果发生错误,我们将调用它:
// product/product.actions.ts
import {
FETCHING_PRODUCTS_SUCCESSFULLY,
FETCHING_PRODUCTS_ERROR,
FETCHING_PRODUCTS
} from "./product.constants";
export const fetchSuccessfully = (products) => ({
type: FETCHING_PRODUCTS_SUCCESSFULLY,
payload: products
});
export const fetchError = (error) => ({
type: FETCHING_PRODUCTS_ERROR,
payload: error
});
export const fetchProductsSuccessfully = (products) => ({
type: FETCHING_PRODUCTS_SUCCESSFULLY,
payload: products
});
export const fetchProducts =() => ({ type: FETCHING_PRODUCTS });
带有新类型默认状态的 reducer
初看,下面的 reducer 就像你之前写的任何 reducer 一样。它是一个接受参数状态和动作的函数,并包含一个 switch 构造,用于在不同动作之间切换。到目前为止,一切都是熟悉的。不过,initialState 变量是不同的。它包含 loading、list 和 error 属性。loading 是一个简单的布尔值,表示我们的 AJAX 请求是否仍在挂起。list 是我们的数据属性,一旦返回,它将包含我们的产品列表。error 属性是一个简单的属性,如果 AJAX 请求返回错误,它将包含错误:
// product/product.reducer.ts
import {
FETCHING_PRODUCTS_SUCCESSFULLY,
FETCHING_PRODUCTS_ERROR,
FETCHING_PRODUCTS
} from "./product.constants";
import { Product } from "./product.model";
import { ActionReducerMap } from "@ngrx/store/src/models";
const initialState = {
loading: false,
list: [{ name: "init" }],
error: void 0
};
export interface ProductState {
loading: boolean;
list: Array<Product>;
error: string;
}
export interface FeatureProducts {
products: ProductState;
}
export const ProductReducers: ActionReducerMap<FeatureProducts> = {
products: productReducer
};
export function productReducer(state = initialState, action) {
switch (action.type) {
case FETCHING_PRODUCTS_SUCCESSFULLY:
return { ...state, list: action.payload, loading: false };
case FETCHING_PRODUCTS_ERROR:
return { ...state, error: action.payload, loading: false };
case FETCHING_PRODUCTS:
return { ...state, loading: true };
default:
return state;
}
}
效果 – 监听特定的分发的动作
因此,我们来到了效果。我们的效果像一个监听分发的动作的监听器。这给了我们执行一个工作单元的机会,一旦这项工作完成,我们还可以分发一个动作。
我们已经创建了所有我们习惯的组件,所以现在是我们创建处理整个工作流程的效果的时候了:
// product/product.effect.ts
import { Actions, Effect } from "@ngrx/effects";
@Injectable()
export class ProductEffects {
@Effect() products$: Observable<Action>;
constructor(
private actions$: Actions<Action>>
) {}
}
该效果只是一个用 @Injectable 装饰器装饰的类。它还包含两个成员:一个是 Actions 类型的成员,另一个是 Observable<Action> 类型的成员。动作来自 @ngrx/effects 模块,并且不过是一个带有 ofType() 方法的特殊 Observable。ofType() 是一个接受字符串常量的方法,这是我们正在监听的事件。在上面的代码中,products$ 是我们用 @Effect 装饰器装饰的 Observable。我们的下一步是将 products$ 与 actions$ 连接起来,并定义我们的效果应该如何工作。我们用以下代码来完成:
// product/product.effect.ts, starting out..
import { Actions, Effect, ofType } from "@ngrx/effects";
import { switchMap } from "rxjs/operators";
import { Observable } from "rxjs/Observable";
import { Injectable } from "@angular/core";
@Injectable()
export class ProductEffects {
@Effect() products$: Observable<Action> = this.actions$.pipe(
ofType(FETCHING_PRODUCTS),
switchMap(action => {
// do something completely else that returns an Observable
})
);
constructor(
private actions$: Actions<Action>>
) {}
}
好的,所以我们已经稍微设置好了我们的效果。对 ofType() 的调用确保我们为自己设置了监听特定分发的动作。对 switchMap() 的调用确保我们能够将我们目前所在的当前 Observable 转换为完全不同的东西,比如调用 AJAX 服务。
现在让我们回到我们的例子,看看我们如何在其中加入一些与产品相关的逻辑:
// product/product.effect.ts
import { Actions, Effect, ofType } from "@ngrx/effects";
import { HttpClient } from "@angular/common/http";
import { FETCHING_PRODUCTS } from "./product.constants";
import { Injectable } from "@angular/core";
import { Observable } from "rxjs/Observable";
import { delay, map, catchError, switchMap } from "rxjs/operators";
import { fetchProductsSuccessfully, fetchError } from "./product.actions";
import { Action } from "@ngrx/store";
@Injectable()
export class ProductEffects {
@Effect()
products$ = this.actions$.pipe(
ofType(FETCHING_PRODUCTS),
switchMap(action =>
this.http
.get("data/products.json")
.pipe(
delay(3000),
map(fetchProductsSuccessfully),
catchError(err => of(fetchError(err)))
)
)
);
constructor(private actions$: Actions<Action>, private http: HttpClient) {}
}
在前面的代码中,我们监听 FETCHING_PRODUCTS 动作,并对 AJAX 服务进行调用。我们添加了对 delay() 操作符的调用,以模拟我们的 AJAX 调用需要一些时间来完成。这将给我们一个机会来显示一个加载旋转器。map() 操作符确保我们在收到 AJAX 响应时分发一个动作。我们可以看到我们调用了动作创建器 fetchProductsSuccessfully(),它隐式地调用 reducer 并在产品属性上设置新的状态。
在这一点上,我们需要在继续之前注册效果。我们可以在根模块或功能模块中这样做。这是一个非常相似的调用,所以让我们描述两种方法:
// app.module.ts - registering our effect in the root module, alternative I
/* omitting the other imports for brevity */
import { EffectsModule } from "@ngrx/effects";
import { ProductEffects } from "./products/product.effect";
@NgModule({
declarations: [AppComponent],
imports: [
BrowserModule,
StoreModule.forRoot({}),
ProductsModule,
StoreDevtoolsModule.instrument({
maxAge: 25 // Retains last 25 states
}),
EffectsModule.forRoot([ ProductEffects ])
],
bootstrap: [AppComponent]
})
export class AppModule {}
另一方面,如果我们有一个功能模块,我们可以在 EffectsModule 上使用 forFeature() 方法,并在我们的功能模块中这样调用:
// product/product.module.ts, registering in the feature module, alternative II
import { NgModule } from "@angular/core";
import { ProductComponent } from "./product.component";
import { BrowserModule } from "@angular/platform-browser";
import { ProductEffects } from "./product.effect";
import { EffectsModule } from "@ngrx/effects";
import { StoreModule, Action } from "@ngrx/store";
import { ProductReducers } from "./product.reducer";
import { HttpClientModule } from "@angular/common/http";
import { ActionReducerMap } from "@ngrx/store/src/models";
@NgModule({
imports: [
BrowserModule,
StoreModule.forFeature("featureProducts", ProductReducers),
EffectsModule.forFeature([ProductEffects]),
HttpClientModule
],
exports: [ProductComponent],
declarations: [ProductComponent],
providers: []
})
export class ProductModule {}
添加组件 - 介绍选择器
就这样,这就是创建效果所需的所有内容。不过,我们还没有完成,我们需要一个组件来显示我们的数据,以及一个旋转器,在我们等待 AJAX 请求完成时。
好吧,首先的事情是:我们对应该使用 NgRx 的组件了解多少?明显的答案是它们应该注入 store,这样我们就可以监听 store 中的状态片段。我们监听状态片段的方式是通过调用 stores 的 select() 函数。这将返回一个 Observable。我们知道我们可以通过使用异步管道轻松地在模板中显示 Observables。所以让我们开始绘制我们的组件:
// product/product.component.ts
import { Component, OnInit } from "@angular/core";
import { AppState } from "../app-state";
import { Store } from "@ngrx/store";
@Component({
selector: "products",
template: `
<div *ngFor="let product of products$ | async">
Product: {{ product.name }}
</div>
</div>
`
})
export class ProductsComponent {
products$;
loading$;
constructor(private store: Store<AppState>) {
this.products$ = this.store.select((state) => {
return state.products.list;
});
}
}
我们组件的这一部分不应该让人感到太意外;我们在构造函数中注入 store,调用 select(),并返回一个 Observable。但是,这里有一个“但是”,我们调用 select() 方法的方式不同。我们过去传递一个字符串到 select() 函数,而现在我们传递一个函数。为什么是这样?嗯,因为我们改变了我们的状态看起来。让我们再次展示我们的新状态,以保持清晰:
const initialState = {
loading: false,
list: [],
error: void 0
}
上述代码显示我们不能简单地做 store.select("products"),因为这会返回整个对象。所以我们需要一种方法来深入到前面的对象中,以便抓住应该包含我们的产品列表属性。要做到这一点,我们可以使用 select 方法的变体,它接受一个函数。我们就是这样做的,以下代码所示:
this.products$ = this.store.select((state) => {
return state.products.list;
});
好吧,但这真的会是类型安全的吗?AppState 接口不会抱怨吗?它知道我们改变的状态结构吗?嗯,我们可以告诉它它知道,但我们需要确保我们的 reducer 导出一个代表我们新状态结构的接口。因此,我们将 reducer 改成如下所示:
// product/products-reducer.ts
import {
FETCHING_PRODUCTS_SUCCESSFULLY,
FETCHING_PRODUCTS_ERROR,
FETCHING_PRODUCTS
} from "./product-constants";
export interface ProductsState {
loading: boolean;
list: Array<Product>;
error: string;
}
const initialState: ProductsState = {
loading: false,
list: [],
error: void 0
}
export function productReducer(state = initialState, action) {
switch(action.type) {
case FETCHING_PRODUCTS_SUCCESSFULLY:
return { ...state, list: action.payload, loading: false };
case FETCHING_PRODUCTS_ERROR:
return { ...state, error: action.payload, loading: false };
case FETCHING_PRODUCTS:
return { ...state, loading: true };
default:
return state;
}
}
当然,我们需要更新 AppState 接口,使其看起来像这样:
// app-state.ts
import { FeatureProducts } from "./product/product.reducer";
export interface AppState {
featureProducts: FeatureProducts;
}
好的,这使得我们的AppState知道我们的products属性实际上是什么样的怪物,因此使得store.select(<Fn>)调用成为可能。我们提供给select方法的函数被称为选择器,实际上它不必存在于组件内部。原因是我们可能希望在别处访问那个状态片段。因此,让我们创建一个product.selectors.ts文件。我们将随着继续支持 CRUD 而向其中添加内容:
// product/product.selectors.ts
import { AppState } from "../app-state";
export const getList = (state:AppState) => state.featureProducts.products.list;
export const getError = (state:AppState) => state.featureProducts.products.error;
export const isLoading = (state:AppState) => state.featureProducts.products.loading;
好的,所以现在我们已经创建了我们的选择器文件,我们就可以立即开始改进我们的组件代码,并在继续添加内容之前对其进行一些清理:
// product/product.component.ts
import { Component, OnInit } from "@angular/core";
import { AppState } from "../app-state";
import { Store } from "@ngrx/store";import { getList } from './product.selectors';
@Component({
selector: "products",
template: `
<div *ngFor="let product of products$ | async">
Product: {{ product.name }}
</div>
`
})
export class ProductsComponent {
products$;
constructor(private store: Store<AppState>) {
this.products$ = this.store.select(getList);
}
}
我们代码看起来好多了。现在是时候开始关注这个的其他方面了;如果我们的 HTTP 服务需要几秒钟,甚至一秒钟来返回,会怎样?这是一个真正的担忧,尤其是当我们的用户可能处于 3G 连接时。为了解决这个问题,我们从产品状态中获取loading属性,并将其用作模板中的条件。我们基本上会说,如果 HTTP 调用仍在挂起,显示一些文本或图像来向用户指示正在加载。让我们将这个功能添加到组件中:
import { Component, OnInit } from "@angular/core";
import { AppState } from "../app-state";
import { Store } from "@ngrx/store";import { getList, isLoading } from "./products.selectors";
@Component({
selector: "products",
template: `
<div *ngFor="let product of products$ | async">
Product: {{ product.name }}
</div>
<div *ngIf="loading$ | async; let loading">
<div *ngIf="loading">
loading...
</div>
</div>
`
})
export class ProductsComponent {
products$;
loading$;
constructor(private store: Store<AppState>) {
this.products$ = this.store.select(getList);
this.loading$ = this.store.select(isLoading);
}
}
让我们再确保通过订阅products.error来显示任何错误。我们只需用以下更改更新组件:
import { Component, OnInit } from '@angular/core';
import { AppState } from "../app-state";
import { Store } from "@ngrx/store";import { getList, isLoading, getError } from "./products.selectors";
@Component({
selector: "products",
template: `
<div *ngFor="let product of products$ | async">
Product: {{ product.name }}
</div>
<div *ngIf="loading$ | async; let loading">
<div *ngIf="loading">
loading...
</div>
</div>
<div *ngIf="error$ | async; let error" >
<div *ngIf="error">{{ error }}</div>
</div>
`
})
export class ProductsComponent {
products$;
loading$;
error$;
constructor(private store: Store<AppState>) {
this.products$ = this.store.select(getList);
this.loading$ = this.store.select(isLoading);
this.error$ = this.store.select(getError);
}
}
好的,我们现在启动应用程序。这里有一个非常小的问题;我们根本看不到任何产品。为什么是这样?解释很简单。我们实际上没有派发一个会导致 AJAX 调用执行的动作。让我们通过向我们的组件添加以下代码来修复这个问题:
import { Component, OnInit } from '@angular/core';
import { AppState } from "../app-state";
import { Store } from "@ngrx/store";import { getList, isLoading, getError } from "./products.selectors";
import { fetchProducts } from "./products.actions";
@Component({
selector: "products",
template: `
<div *ngFor="let product of products$ | async">
Product: {{ product.name }}
</div>
<div *ngIf="loading$ | async; let loading">
<div *ngIf="loading">
loading...
</div>
</div>
<div *ngIf="error$ | async; let error" >
<div *ngIf="error">{{ error }}</div>
</div> `
})
export class ProductsComponent implements OnInit {
products$;
loading$;
error$;
constructor(private store: Store<AppState>) {
this.products$ = this.store.select(getList);
this.loading$ this.store.select(isLoading);
this.error$ = this.store.select(getError); }
ngOnInit() {
this.store.dispatch(fetchProducts);
}
}
这当然会触发我们的效果,这将导致我们的 HTTP 调用,这将导致调用fetchProductsSuccessfully(),从而我们的状态将被更新,products.list将不再是一个空数组,这意味着我们的 UI 将显示产品列表。成功了!
在我们的示例中扩展创建效果
到目前为止,我们已经走过了添加效果、构建组件和通过选择器改进代码的全过程。为了确保我们真正理解如何使用效果以及应用程序如何随着它而扩展,让我们添加另一个效果,这次让我们添加一个支持 HTTP POST 调用的效果。从应用程序的角度来看,我们想要做的是向列表中添加另一个产品。这应该更新 UI 并显示我们添加的产品。数据方面发生的事情是,我们的存储应该反映这种变化,并且作为副作用,执行一个 HTTP POST。我们需要以下内容来完成此操作:
-
一个支持向产品列表添加产品的 reducer
-
一个监听产品添加动作并执行 HTTP POST 的效果
-
我们还需要注册创建的效果
更新常量文件
就像获取产品一样,我们需要支持一个触发所有操作的动作。我们需要另一个动作来处理 HTTP 请求成功的情况,以及一个最后的动作来支持错误处理:
// product.constants.ts
export const FETCHING_PRODUCTS = "FETCHING_PRODUCTS";
export const FETCHING_PRODUCTS_SUCCESSFULLY = "FETCHING_PRODUCTS_SUCCESSFULLY";
export const FETCHING_PRODUCTS_ERROR = "FETCHING_PRODUCTS_ERROR";
export const ADD_PRODUCT = "ADD_PRODUCT";
export const ADD_PRODUCT_SUCCESSFULLY = "ADD_PRODUCT_SUCCESSFULLY";
export const ADD_PRODUCT_ERROR ="ADD_PRODUCT_ERROR";
更新 reducer
在这一点上,我们取我们的现有reducer.ts文件并添加我们需要支持添加产品的内容:
// products.reducer.ts
import {
FETCHING_PRODUCTS_SUCCESSFULLY,
FETCHING_PRODUCTS_ERROR,
FETCHING_PRODUCTS,
ADD_PRODUCT,
ADD_PRODUCT_SUCCESSFULLY,
ADD_PRODUCT_ERROR
} from "./product.constants";
import { Product } from "./product.model";
const initialState = {
loading: false,
list: [],
error: void 0
}
export interface ProductsState {
loading: boolean;
list: Array<Product>,
error: string;
}
function addProduct(list, product) {
return [ ...list, product];
}
export function productsReducer(state = initialState, action) {
switch(action.type) {
case FETCHING_PRODUCTS_SUCCESSFULLY:
return { ...state, list: action.payload, loading: false };
case FETCHING_PRODUCTS_ERROR:
case ADD_PRODUCT_ERROR:
return { ...state, error: action.payload, loading: false };
case FETCHING_PRODUCTS:
case ADD_PRODUCT:
return { ...state, loading: true };
case ADD_PRODUCT_SUCCESSFULLY:
return { ...state, list: addProduct(state.list, action.payload) };
default:
return state;
}
}
值得注意的是我们如何创建帮助函数addProduct(),它允许我们创建一个包含旧内容和新产品的新的列表。同样值得注意的还有,我们可以将FETCHING_PRODUCTS_ERROR和ADD_PRODUCT_ERROR动作分组,以及ADD_PRODUCT和ADD_PRODUCT_SUCCESSFULLY。
额外的动作
接下来的任务是更新我们的products.actions.ts文件,添加我们需要支持前面代码的新方法:
// products.actions.ts
import {
FETCHING_PRODUCTS_SUCCESSFULLY,
FETCHING_PRODUCTS_ERROR,
FETCHING_PRODUCTS,
ADD_PRODUCT,
ADD_PRODUCT_SUCCESSFULLY,
ADD_PRODUCT_ERROR
} from "./product.constants";
export const fetchProductsSuccessfully = (products) => ({
type: FETCHING_PRODUCTS_SUCCESSFULLY,
payload: products
});
export const fetchError = (error) => ({
type: FETCHING_PRODUCTS_ERROR,
payload: error
});
export const fetchProductsLoading = () => ({ type: FETCHING_PRODUCTS });
export const fetchProducts = () => ({ type: FETCHING_PRODUCTS });
export const addProductSuccessfully (product) => ({
type: ADD_PRODUCT_SUCCESSFULLY },
payload: product
);
export const addProduct = (product) => ({
type: ADD_PRODUCT,
payload: product
});
export const addProductError = (error) => ({
type: ADD_PRODUCT_ERROR,
payload: error
});
值得注意的是,创建的动作中addProduct()方法接受一个产品作为参数。这样做的原因是我们希望副作用使用它作为即将到来的 HTTP POST 请求的正文数据。
添加另一个效果
现在我们终于准备好构建我们的效果了。它将非常类似于现有的一个:
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { Action } from "@ngrx/store";
import { Actions, Effect, ofType } from "@ngrx/effects";
import { Observable } from "rxjs/Observable";
import { of } from "rxjs/observable/of";
import "rxjs/add/observable/of";
import {
catchError,
map,
mergeMap,
delay,
tap,
switchMap
} from "rxjs/operators";
import { FETCHING_PRODUCTS, ADD_PRODUCT } from "./product.constants";
import {
fetchProductsSuccessfully,
fetchError,
addProductSuccessfully,
addProductError
} from "./product.actions";
import { Product } from "./product.model";
import { ActionPayload } from "../interfaces";
@Injectable()
export class ProductEffects { @Effect() productsAdd$: Observable<Action> = this.actions$.pipe(
ofType(ADD_PRODUCT),
switchMap(action =>
this.http.post("products/", action.payload).pipe(
map(addProductSuccessfully),
catchError((err) => of(addProductError(err)))</strong>
**)**
**)**
**);** @Effect() productsGet$: Observable<Action> = this.actions$.pipe(
ofType(FETCHING_PRODUCTS),
switchMap(action =>
this.http.get("data/products.json").pipe(
delay(3000),
map(fetchProductsSuccessfully),
catchError((err) => of(fetchError(err)))
)
)
);
constructor(
private http: HttpClient,
private actions$: Actions<ActionPayload<Product>>
) {}
}
在这里我们首先重用我们的ProductEffects类,并给它添加一个新成员productsAdd$。同时,我们将products$重命名为productsGet$。只要我们处理产品,我们就可以继续添加到这个类中。
我们看到与现有效果相似之处在于我们设置了ofType()操作符来监听我们选择的已分发动作。之后,我们继续进行副作用,即调用HttpClient服务,最终成为一个 HTTP POST 调用。
在我们的组件中支持效果
在我们的组件中我们不需要做太多。当然,在模板中我们需要添加一些内容来支持添加产品。在 NgRx 方面,我们只需要分发ADD_PRODUCT动作。让我们看看代码:
import { Component, OnInit } from "@angular/core";
import { AppState } from "../app-state";
import { Store } from "@ngrx/store";
import { fetchProducts, addProduct } from "./product.actions";
import { getList, isLoading, getError } from "./products.selectors";
@Component({
selector: "products",
template: `
<div>
<input [(ngModel)]="newProduct" placeholder="new product..." />
<button (click)="addNewProduct()"></button>
</div>
<div *ngFor="let product of products$ | async">
Product: {{ product.name }}
</div>
<div *ngIf="loading$ | async; let loading">
<div *ngIf="loading">
loading...
</div>
</div>
<div *ngIf="error$ | async; let error">
{{ error }}
</div>
`
})
export class ProductsComponent implements OnInit {
products$;
loading$;
error$;
newProduct: string;
constructor(private store: Store<AppState>) {
this.products$ = this.store.select(getList);
this.loading$ = store.select(isLoading);
this.error$ = store.select(getError);
}
ngOnInit() {
this.store.dispatch(fetchProducts());
}
addNewProduct() {
this.store.dispatch(addProduct(this.newProduct));
this.newProduct = "";
}
}
好的,从这段代码中,我们设置了一个输入控件和一个按钮来处理用户输入新产品。对于类,我们添加了newProduct字段,并且还添加了addNewProduct()方法,在其主体中调用addProduct()方法,从而传递一个ADD_PRODUCT动作。我们实际上不需要做更多。我们的产品添加在执行 HTTP 调用之前设置加载状态,因此如果我们想的话,可以显示一个加载指示器,我们的错误状态会捕捉到可能发生的任何错误并在 UI 中展示它们。最后,别忘了将FormsModule添加到product.module.ts中的import属性。
运行应用的演示
要尝试我们的应用,我们可以在终端中简单地运行ng serve命令。我们期望看到的是屏幕上显示加载状态三秒钟,随后被获取到的数据所替代。这将展示加载状态的分发,以及我们在数据到达后将其派发到存储中的过程。以下是我们数据尚未到达时的初始屏幕。我们触发FETCHING_PRODUCTS动作,这使得加载文本显示出来:

下一个屏幕是我们数据到达时的情景。随后,我们触发ADD_PRODUCT_SUCCESSFULLY以确保获取到的数据被放置在存储中:

摘要
在本章中,我们经历了很多。其中涉及的内容包括安装和使用存储。在此基础上,我们增加了一些最佳实践来组织你的代码。重要的是要注意一致性。有许多组织代码的方式,只要选择的方式在整个应用中保持一致,这就是最重要的因素。因此,按照领域组织代码是 Angular 推荐的做法。至于 NgRx 是否也是如此,取决于你,亲爱的读者。将最佳实践视为指南而不是规则。此外,我们还涵盖了副作用以及如何使用@ngrx/effects来处理这些副作用。@store-devtools也是我们讨论的内容之一,它允许我们使用浏览器轻松地调试我们的存储。在下一章,也就是最后一章,我们将涵盖@ngrx/schematics和@ngrx/entity,这样我们就能涵盖 NgRx 提供的所有内容。此外,我们将展示如何自己构建 NgRx,以进一步了解底层发生了什么。如果你对底层发生的事情不感兴趣,那么你可能选择了错误的专业!一切都被设置为使最后一章非常有趣。
第十章:NgRx – 深入了解
本章将深入探讨更高级的 NgRx 主题。当您使用 NgRx 实现第一个应用程序时,您会注意到这意味着创建大量的样板代码,您可能不会感觉像不使用 NgRx 时那样快。因此,实体库存在是为了帮助减轻一些样板代码的创建——关于这一点,本章后面会详细介绍。
路由及其状态是另一个可能值得跟踪的东西。您当前所在的 URL、路由参数以及查询参数都是可能很有用的信息。如果您处于可能需要重新启动应用程序的情况,这些信息可能很有用,也称为恢复。
接下来,我们将深入了解如何使用 RxJS 构建您自己的 NgRx 微实现,这样您就可以了解正在发生的事情。希望这将是一个真正令人耳目一新的时刻,有助于理解使 NgRx 和 Redux 运转的底层理念。
为了结束本章和本书,我们将解释什么是 schematics 以及它将如何帮助您快速搭建您需要的各个部分,从而成为 NgRx 的真正高效用户。
在本章中,您将学习如何:
-
利用实体库以及它如何使我们的生活更轻松使用 NgRx
-
通过编写我们自己的定制代码来捕获路由状态以及自定义要保存的内容
-
构建 NgRx 的微实现
-
揭示 schematics 的神秘面纱,看看它如何使我们成为更快、更高效的 NgRx 用户。
@ngrx/entity
本节的示例代码可以在本书的代码仓库中的Chapter10/Entity下找到。
实体库在这里帮助我们管理集合,基本上,这意味着到目前为止,我们在创建 reducer 和 selectors 时编写了大量的代码,当我们利用实体库的全部功能时,我们根本不需要做这些。
设置它
我们首先下载实体库。为此,我们需要运行以下命令:
npm install @ngrx/entity
然后,我们需要执行以下步骤:
-
创建一个模型。
-
根据模型创建一个实体状态。
-
创建一个实体适配器。
-
创建初始状态。
-
创建 reducer 并在
StoreModule中设置状态。
让我们从创建我们的模型开始:
// user.model.ts
export interface User {
id: number;
name: string;
}
上述代码只是一个具有id和name字段的简单模型。然后我们创建我们的实体状态,如下所示:
// excerpt from app.module.ts
import {
EntityState,
createEntityAdapter,
EntityAdapter
} from "@ngrx/entity";
export interface State extends EntityState<User> {
selectedUserId: number | null;
}
这将是我们的 reducer 需要遵守的返回类型。类型EntityState看起来如下,如果您查看 NgRx 源代码的话:
// from NGRX source code
export interface EntityState<T> {
ids: string[] | number[];
entities: Dictionary<T>;
}
通过扩展前面的接口EntityState,当我们创建类型State时,我们也将获得ids和entities属性。我们将在本节稍后看到这些属性是如何被填充的,一旦我们开始使用实体库提供的实用方法。
下一步是创建我们的适配器。适配器实例提供了一系列方法,使我们能够编写更少的代码。我们用以下代码创建适配器:
// excerpt from app.module.ts
import {
EntityState,
createEntityAdapter,
EntityAdapter
} from "@ngrx/entity";
const userAdapter: EntityAdapter<User> = createEntityAdapter<User>();
到目前为止,我们几乎准备好了;我们只需要从适配器获取初始状态并将其提供给我们的 reducer。
要获取初始状态,我们需要与我们的适配器交谈,如下所示:
// excerpt from app.module.ts
const initialState: State = {
ids: [],
entities: {},
selectedUserId: null
};
const initial = userAdapter.getInitialState(initialState);
这里发生的事情是我们需要创建一个表示初始状态的对象。它需要是State类型,因此它需要定义ids、entities和selectedUserId属性。然后,我们在适配器上调用getInitialState()来生成我们的初始状态。那么,我们需要初始状态做什么呢?我们需要将其设置为 reducer 状态的默认值。
接下来,我们创建我们的 reducer 并将其默认状态设置为之前创建的初始状态实例:
// interfaces.ts
import { Action } from "@ngrx/store";
export interface ActionPayload<T> extends Action {
payload: T;
}
// excerpt from app.module.ts
function userReducer(state = initial, action: ActionPayload<User>): State {
switch (action.type) {
case "ADD_USER":
return userAdapter.addOne(action.payload, state);
default:
return state;
}
}
}
注意这里我们是如何调用我们的userAdapter并调用addOne()方法的;这意味着我们不需要编写像这样的代码:
// example of what a reducer could look like that is NOT using @ngrx/entity
function reducer(state = [], action: ActionPayload<User>) {
switch (action.type) {
case "ADD_USER":
return [
...state.users
Object.assign({}, action.payload)
];
default:
return state;
}
}
}
设置一切的最后一步是将状态添加到StoreModule中,这样 NgRx 就会知道它:
// excerpt from app.module.ts
@NgModule({
declarations: [AppComponent, EditUserComponent],
imports: [
BrowserModule,
FormsModule,
StoreModule.forRoot({
users: userReducer
})
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
这就完成了所需的设置。在接下来的几节中,我们想要采取的下一步是展示如何在组件中显示数据,以及如何执行完整的 CRUD 操作,从而充分利用EntityAdapter的功能。
选择我们的数据
到现在为止,我们已经了解到,在 NgRx 中选择数据,我们需要注入 store 服务并调用它的select方法,要么传递一个字符串作为参数,要么传递一个函数。让我们注入 store 服务并查看返回的状态:
// app.component.ts - a first draft
import { Component } from "@angular/core";
import { AppState } from "./app-state";
import { Store } from "@ngrx/store";
@Component({
selector: "app-root",
template: `
User list view
`
})
export class AppComponent {
title = "app";
users$;
constructor(private store: Store<AppState>) {
this.users$ = this.store
.select(state => state);
this.users$
.subscribe(data => console.log("users", data));
}
}
前面的组件还不会以整洁的列表形式显示用户;我们稍后会解释原因。同时,我们将专注于控制台上的日志输出:

我们在这里看到的是我们商店的状态,它包含用户属性的最高级别,它有entities、ids和selectedUserId作为属性。到目前为止这是可以预料的。但让我们有点惊讶的是,实体字典是一个对象而不是一个列表。我们如何使用*ngFor以列表形式输出它呢?嗯,我们可以很容易地用map()操作符解决这个问题,如下所示:
// app.component.ts adding more UI and selecting the correct slice of state
import { Component } from "@angular/core";
import { AppState } from "./app-state";
import { Store } from "@ngrx/store";
import { map } from "rxjs/operators";
@Component({
selector: "app-root",
template: `
<div style="border: solid 1px black; padding: 10px;"
*ngFor="let user of users$ | async">
{{ user.name }}
</div>
`
})
export class AppComponent {
title = "app";
users$;
constructor(private store: Store<AppState>) {
this.users$ = this.store
.select(state => state.users.entities)
.pipe(
map(this.toArray)
);
this.users$.subscribe(data => console.log("users", data));
}
toArray(obj) {
const keys = Object.keys(obj);
return keys.map(key => obj[key]);
}
}
好的,现在我们深入到state.users.entities以获取我们的用户,但我们需要添加map()操作来将我们的实体字典转换为列表。所以,控制台现在显示users$的初始值为一个空数组,这是完全合理的。UI 仍然是空的,因为我们有一个空数组,因此没有东西可以显示。在下一节中,我们将介绍如何使用EntityAdapter添加、删除和更新状态。
添加完整的 CRUD
我们所说的 CRUD 是指从存储中添加、编辑、读取和删除数据的能力。使用实体库的目的就是让它做大部分繁重的工作。现在是时候回顾我们的 reducer 了:
// excerpt from app.module.ts
function userReducer(
state = initial,
action: ActionPayload<User>): State {
switch (action.type) {
case "ADD_USER":
return userAdapter.addOne(action.payload, state);
default:
return state;
}
}
在这里,我们使用userAdapter实例来执行向存储中添加一个项目的操作。尽管适配器还能为我们做更多的事情——以下是其全部功能列表:
// description of the interface for EntityStateAdapter,
// the interface our userAdapter implements
export interface EntityStateAdapter<T> {
addOne<S extends EntityState<T>>(entity: T, state: S): S;
addMany<S extends EntityState<T>>(entities: T[], state: S): S;
addAll<S extends EntityState<T>>(entities: T[], state: S): S;
removeOne<S extends EntityState<T>>(key: string, state: S): S;
removeOne<S extends EntityState<T>>(key: number, state: S): S;
removeMany<S extends EntityState<T>>(keys: string[], state: S): S;
removeMany<S extends EntityState<T>>(keys: number[], state: S): S;
removeAll<S extends EntityState<T>>(state: S): S;
updateOne<S extends EntityState<T>>(update: Update<T>, state: S): S;
updateMany<S extends EntityState<T>>(updates: Update<T>[], state: S): S;
}
创建用户
如我们所见,EntityStateAdapter提供了完整的 CRUD 方法。让我们看看如何为我们的组件添加添加用户的能力。我们需要对我们的组件做以下添加:
-
添加一个输入字段
-
使用我们的新用户作为有效负载派发
ADD_USER动作
必要的代码更改以粗体显示,如下所示:
// app.component.ts - adding the capability to add users
import { Component } from "@angular/core";
import { AppState } from "./app-state";
import { Store } from "@ngrx/store";
import { map } from "rxjs/operators";
@Component({
selector: "app-root",
template: `
<div style="border: solid 1px black; padding: 10px;"
*ngFor="let user of users$ | async">
{{ user.name }}
</div>
<div>
<input [(ngModel)]="user" /> <button (click)="add()">Add</button>
</div>
`
})
export class AppComponent {
title = "app";
users$;
user;
id = 1;
constructor(private store: Store<AppState>) {
this.users$ = this.store
.select(state => state.users.entities)
.pipe(map(this.toArray));
this.users$.subscribe(data => console.log("users", data));
}
toArray(obj) {
const keys = Object.keys(obj);
return keys.map(key => obj[key]);
}
add() {
const newUser = { id: this.id++, name: this.user };
this.store.dispatch({
type: "ADD_USER",
payload: newUser
});
}
}
这段代码演示了如何添加一个输入元素并将其通过ngModel连接到我们的类中的user字段。我们还添加了add()方法,该方法将用户派发到我们的 reducer。现在在 UI 中添加用户应该看起来如下所示:

更新用户
为了支持更新用户,我们需要做两件事:
-
添加一个支持更新的组件
-
在我们的 reducer 中添加一个 CASE 来监听动作并调用适当的
adapter方法
让我们从我们的组件开始:
// edit-user.component.ts
import {
Component,
OnInit,
Output,
Input,
EventEmitter
} from "@angular/core";
@Component({
selector: "edit-user",
template: `
<div>
<input [(ngModel)]="user.name" />
<button (click)="save.emit(user)" >Save</button>
</div>
`
})
export class EditUserComponent implements OnInit {
private _user;
@Input()
get user() {
return this._user;
}
set user(val) {
this._user = Object.assign({}, val);
}
@Output() save = new EventEmitter();
constructor() {}
ngOnInit() {}
}
在这里,我们有一个组件,它接受一个user作为输入,并能够通过输出save调用父组件。简而言之,这个组件允许我们编辑用户。
现在,我们需要将此组件添加到app.module.ts中,以便本模块内的其他组件可以使用它:
// excerpt from app.module.ts
@NgModule({
declarations: [AppComponent, EditUserComponent],
imports: [
BrowserModule,
FormsModule,
StoreModule.forRoot({
users: userReducer
})],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule {}
现在,我们准备将组件添加到父组件的模板中,如下所示:
// app.component.ts - adding EditUserComponent to the markup
import { Component } from "@angular/core";
import { AppState } from "./app-state";
import { Store } from "@ngrx/store";
import { map } from "rxjs/operators";
@Component({
selector: "app-root",
template: `
<div style="border: solid 1px black; padding: 10px;"
*ngFor="let user of users$ | async">
{{ user.name }}
<edit-user [user]="user" (save)="update($event)" ></edit-user>
</div>
<div>
<input [(ngModel)]="user" /> <button (click)="add()">Add</button>
</div>
`
})
export class AppComponent {
title = "app";
users$;
user;
id = 1;
constructor(private store: Store<AppState>) {
this.users$ = this.store
.select(state => state.users.entities)
.pipe(map(this.toArray));
this.users$.subscribe(data => console.log("users", data));
}
toArray(obj) {
const keys = Object.keys(obj);
return keys.map(key => obj[key]);
}
add() {
const newUser = { id: this.id++, name: this.user };
this.store.dispatch({
type: "ADD_USER",
payload: newUser
});
}
update(user) {
console.log("updating", user);
this.store.dispatch({ type: "UPDATE_USER", payload: user });
}
}
这段代码展示了我们如何将EditUserComponent添加到标记中,以及我们添加的update()方法,当调用该方法时,会触发UPDATE_USER动作。这将导致我们的 reducer 被调用,进而引导我们到达拼图的最后一部分,即我们需要对 reducer 所做的必要更改:
// excerpt from app.module.ts
function userReducer(state = initial, action: ActionPayload<User>): State {
switch (action.type) {
case "ADD_USER":
return userAdapter.addOne(action.payload, state);
case "UPDATE_USER":
return userAdapter.updateOne({
id: action.payload.id,
changes: action.payload
},
state
);
default:
return state;
}
}
我们现在支持更新用户列表。
删除用户
支持 CRUD 的最后一部分是能够从列表中删除用户。这种情况与其他所有情况非常相似:
-
我们需要将其添加到
app.component.ts中 -
我们需要更新 reducer,并且 reducer 需要调用适当的适配器方法
让我们从组件开始,并在标记中添加支持,以及添加一个remove()方法到组件类中,如下所示:
// app.component.ts - adding remove capability
import { Component } from "@angular/core";
import { AppState } from "./app-state";
import { Store } from "@ngrx/store";
import { map } from "rxjs/operators";
@Component({
selector: "app-root",
template: `
<div style="border: solid 1px black; padding: 10px;"
*ngFor="let user of users$ | async">
{{ user.name }}
<button (click)="remove(user.id)" >Remove</button>
<edit-user [user]="user" (save)="update($event)" ></edit-user>
</div>
<div>
<input [(ngModel)]="user" /> <button (click)="add()">Add</button>
</div>
`
})
export class AppComponent {
title = "app";
users$;
user;
id = 1;
constructor(private store: Store<AppState>) {
this.users$ = this.store
.select(state => state.users.entities)
.pipe(map(this.toArray));
this.users$.subscribe(data => console.log("users", data));
}
toArray(obj) {
const keys = Object.keys(obj);
return keys.map(key => obj[key]);
}
add() {
const newUser = { id: this.id++, name: this.user };
this.store.dispatch({
type: "ADD_USER",
payload: newUser
});
}
remove(id) {
console.log("removing", id);
this.store.dispatch({ type: "REMOVE_USER", payload: { id } });
}
update(user) {
console.log("updating", user);
this.store.dispatch({ type: "UPDATE_USER", payload: user });
}
}
剩余的部分是更新我们的 reducer,使其如下所示:
// excerpt from app.module.ts
function userReducer(state = initial, action: ActionPayload<User>): State {
switch (action.type) {
case "ADD_USER":
return userAdapter.addOne(action.payload, state);
case "REMOVE_USER":
return userAdapter.removeOne(action.payload.id, state);
case "UPDATE_USER":
return userAdapter.updateOne(
{
id: action.payload.id,
changes: action.payload
},
state
);
default:
return state;\
}
}
@ngrx/router-store
我们希望能够追踪我们在应用程序中的位置——位置由我们的路由、路由参数以及查询参数表示。通过将我们的位置保存到我们的 store 中,我们能够轻松地将 store 的信息序列化到存储中,以便稍后检索和反序列化,这意味着我们可以不仅恢复应用程序的状态,还可以恢复我们的页面位置。
安装和设置
路由存储在一个 NPM 包中,因此我们可以使用以下命令来安装它:
npm install @ngrx/router-store --save
下一步,我们需要导入正确的模块,并在根模块的import属性中设置它们,如下所示:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, Injectable } from '@angular/core';
import { StoreModule, Action } from '@ngrx/store';
import { AppComponent } from './app.component';
import { counterReducer } from './reducer';
import { TodoModule } from './todo/todo.module';
import { todosReducer } from './todo/reducer';
import { JediModule } from './jedi/jedi.module';
import { jediListReducer } from './jedi/list.reducer';
import { productsReducer } from './products/products.reducer';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { ProductsModule } from './products/products.module';
import { StoreRouterConnectingModule, routerReducer } from '@ngrx/router-store';
import { RouterModule } from '@angular/router';
import { TestingComponent } from './testing.component';
import { Effect, ofType, Actions } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';
import { switchMap } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';
import { EffectsModule } from '@ngrx/effects';
@NgModule({
declarations: [AppComponent, TestingComponent],
imports: [
BrowserModule,
StoreModule.forRoot({
count: counterReducer,
todos: todosReducer,
jediList: jediListReducer,
products: productsReducer,
router: routerReducer
}),
EffectsModule.forRoot([]),
RouterModule.forRoot([{ path: 'testing', component: TestingComponent }]),
StoreRouterConnectingModule.forRoot({
stateKey: 'router' // name of reducer key
}),
StoreDevtoolsModule.instrument({
maxAge: 25 // Retains last 25 states
}),
TodoModule,
JediModule,
ProductsModule
],
bootstrap: [AppComponent]
})
export class AppModule {}
我们在这里没有做太多。我们调用 StoreRouterConnectingModule 上的 forRoot() 方法,并且我们还添加了一个新的路由形式的 reducer 条目,指向 routerReducer 作为将处理 router 属性任何变化的 reducer。
调查路由状态
我们刚刚设置了路由存储。这意味着每次我们导航时,我们都会自动将 router 属性写入我们的存储。我们可以通过编辑 app.component.ts 来订阅这个状态片段来证明这一点:
import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { Increment, Decrement } from './actions';
import { AppState } from './app-state';
@Component({
selector: 'app-root',
template: `
{{ count$ | async }}
<button (click)="increment()">Increment</button>
<button (click)="decrement()">Decrement</button>
<app-todos></app-todos>
<jedi-list></jedi-list>
<div>
<a routerLink="/testing" routerLinkActive="active">Testing</a>
</div>
<router-outlet></router-outlet>`,
styleUrls: ['./app.component.css']
})
export class AppComponent {
count$: Observable<number>;
constructor(private store: Store<AppState>) {
this.count$ = store.select('count');
store
.select(state => state.router)
.subscribe(route => console.log('router obj', route));
}
increment() {
this.store.dispatch(Increment());
}
decrement() {
this.store.dispatch(Decrement());
}
}
在这里,我们订阅了状态路由器,因此每次路由变化时都会监听到。我们记录了所说的对象,它看起来是这样的:

这张截图显示了我们的路由状态现在包含的对象。我们可以看到 url 属性指向 /,这意味着我们的默认路由已经被加载。我们还可以看到这个对象在 root 属性中包含了路由参数和查询参数。所以,这里有一些有趣的信息。
让我们看看当我们路由到像 /testing 这样的地方时会发生什么:

我们的路由状态已经更新,我们可以看到我们的 url 属性指向 /testing。
到目前为止,我们已经订阅了路由状态,并监听了路由变化时的情况。还有第二种方式。我们可以监听特定动作的派发。用于路由的派发动作是字符串 ROUTER_NAVIGATION。因此,我们可以轻松地构建一个效果,以便在路由变化时执行副作用。我们可能想要执行 AJAX 请求或存储本地缓存中的内容。只有你知道你想要做什么。让我们构建这个效果。我们将返回到现有的文件 routing.effects.ts 并扩展它:
import { Injectable } from '@angular/core';
import { Effect, Actions, ofType } from '@ngrx/effects';
import { Router } from '@angular/router';
import { map, tap, switchMap } from 'rxjs/operators';
import { Action } from '@ngrx/store';
import { PRODUCTS, RoutingAction } from './routing.constants';
import { Observable } from 'rxjs/Observable';
@Injectable()
export class RoutingEffects {
@Effect({ dispatch: false })
gotoProducts$ = this.actions$.ofType(PRODUCTS).pipe(
tap(action => {
this.router.navigate([action.payload.url]);
})
);
@Effect({ dispatch: false })
locationUpdate$: Observable<Action> =
this.actions$.ofType('ROUTER_NAVIGATION').pipe(
tap((action: any) => {
console.log('router navigation effect', action);
})
);
constructor(
private router: Router,
private actions$: Actions<RoutingAction>) {}
}
自定义序列化
被存储的对象有点冗长。它包含了很多信息,而我们可能只对其中的一部分感兴趣。实际上,我们可以通过构建自己的自定义序列化器来解决这个问题。为了实现这一点,我们需要做以下几步:
-
创建一个实现接口
RouterStateSerializer的类,并决定我们想要返回什么 -
将路由键
RouterStateSerializer替换为我们的自定义实现
让我们开始吧。我们首先创建一个类,如下所示:
// my-serializer.ts
import { RouterStateSerializer } from '@ngrx/router-store';
import { RouterStateSnapshot } from '@angular/router';
interface MyState {
url: string;
}
export class MySerializer implements RouterStateSerializer<MyState> {
serialize(routerState: RouterStateSnapshot): MyState {
return <MyState>{};
// todo: implement
}
}
RouterStateSeralizer接口强制我们指定一个type T,它可以是任何东西。T是我们从路由对象中想要返回的内容。记住我们这样做的原因是为了从路由对象中获取有趣信息的一个子集。完整的路由信息包含在我们的输入参数routerState中,它是一个RouterStateSnapshot类型的对象。不过有一个评论是,MyState有点贫血,因为它只包含一个属性,url。当然,你可以根据你应用程序的需求来扩展它。你很可能想要获取router和query参数。我们将在本节完成之前获取这些参数,但让我们先展示它是如何工作的。下一步是从routerState参数中获取数据。现在,我们挖掘出url——让我们更新代码以反映这一点:
// my-serializer.ts
import { RouterStateSerializer } from '@ngrx/router-store';
import { RouterStateSnapshot } from '@angular/router';
interface MyState {
url: string;
}
export class MySerializer implements RouterStateSerializer<MyState> {
serialize(routerState: RouterStateSnapshot): MyState {
const { url } = routerState;
return { url };
}
}
现在我们要告诉提供者使用我们的实现。我们需要进入app.module.ts文件:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule, Injectable } from '@angular/core';
import { StoreModule, Action } from '@ngrx/store';
import { AppComponent } from './app.component';
import { counterReducer } from './reducer';
import { TodoModule } from './todo/todo.module';
import { todosReducer } from './todo/reducer';
import { JediModule } from './jedi/jedi.module';
import { jediListReducer } from './jedi/list.reducer';
import { productsReducer } from './products/products.reducer';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { ProductsModule } from './products/products.module';
import { StoreRouterConnectingModule, routerReducer, RouterStateSerializer } from '@ngrx/router-store';
import { RouterModule } from '@angular/router';
import { TestingComponent } from './testing.component';
import { Effect, ofType, Actions } from '@ngrx/effects';
import { Observable } from 'rxjs/Observable';
import { switchMap } from 'rxjs/operators';
import { of } from 'rxjs/observable/of';
import { EffectsModule } from '@ngrx/effects';
import { RoutingEffects } from './routing.effects';
import { ProductsTestComponent } from './productstest.component';
import { MySerializer } from './my-serializer';
@NgModule({
declarations: [AppComponent, TestingComponent, ProductsTestComponent],
imports: [
BrowserModule,
StoreModule.forRoot({
count: counterReducer,
todos: todosReducer,
jediList: jediListReducer,
products: productsReducer,
router: routerReducer}),
EffectsModule.forRoot([RoutingEffects]),
RouterModule.forRoot([
{ path: 'testing', component: TestingComponent },
{ path: 'products', component: ProductsTestComponent }
]),
StoreRouterConnectingModule.forRoot({
stateKey: 'router' // name of reducer key
}),
StoreDevtoolsModule.instrument({
maxAge: 25 // Retains last 25 states
}),
TodoModule,
JediModule,
ProductsModule
],
providers: [{ provide: RouterStateSerializer, useClass: MySerializer }],
bootstrap: [AppComponent]
})
export class AppModule {}
我们现在已经导入了MySerializer类和RouterStateSeralizer接口,并且正在使用以下行替换提供者键:
providers: [{ provide: RouterStateSerializer, useClass: MySerializer }]
现在是时候试一试了。所以,我们启动应用程序并看看在应用程序中导航会发生什么。这里有一个快速提醒,看看我们的应用程序现在是什么样子:

点击测试或产品链接将分别带我们到/testing或/products。让我们这样做并看看它会是什么样子。我们查看控制台,哇!我们的路由对象小得多:

我们的对象现在几乎只包含url属性。这是存储在我们应用程序状态中的内容。如果我们想存储比这更多的事情,我们可以很容易地扩展MySerializer类——建议的添加是路由和查询参数。让我们对MySerializer类做出以下更改:
// my-serializer.ts
import { RouterStateSerializer } from '@ngrx/router-store';
import { RouterStateSnapshot } from '@angular/router';
interface MyState {
url: string;
queryParams;
}
export class MySerializer implements RouterStateSerializer<MyState> {
serialize(routerState: RouterStateSnapshot): MyState {
const { url, root: { queryParams } } = routerState;
return { url, queryParams };
}
}
导航到http://localhost:4200/products?page=1现在将在控制台产生以下内容:

现在的不同之处在于我们有一个queryParams属性,它指向一个包含内容{ page: 1 }的对象。这正是我们预期的。挖掘路由参数同样简单。但为了使我们一开始就有填充的路由参数,我们需要有一个带有路由参数的路由。我们不需要/products,而是需要像products/:id这样的东西。让我们首先将其添加到我们的路由列表中:
// products/products.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { EffectsModule } from '@ngrx/effects';
import { ProductEffects } from './products.effect';
import { HttpClientModule } from '@angular/common/http';
import { ProductsComponent } from './products.component';
import { FormsModule } from '@angular/forms';
import { ProductsHttpActions } from './products-http.actions';
import { RouterModule } from '@angular/router';
import { ProductsDetailComponent } from './products-detail.component';
@NgModule({
imports: [
BrowserModule,
HttpClientModule,
FormsModule,
EffectsModule.forFeature([ProductEffects]),
RouterModule.forChild([{
path: 'products',
component: ProductsComponent
}, {
path: 'products/:id',
component: ProductsDetailComponent
}])
],
exports: [ProductsComponent],
declarations: [ProductsComponent, ProductsDetailComponent],
providers: [ProductsHttpActions]
})
export class ProductsModule {}
当然,我们还需要添加一个组件。它除了用于我们的演示目的外没有做任何特别的事情。记住,重点是理解序列化过程:
// products-detail.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-products-detail',
template: `
products detail
`
})
export class ProductsDetailComponent{
constructor() {}
}
到目前为止,是时候回到我们的浏览器中输入url,products/1?page=1了。现在让我们看看控制台:

在这里,我们看到我们的params属性是如何添加到我们的自定义对象中的。
通过分发进行导航
现在我们已经正确设置了路由存储库,我们实际上可以开始考虑分发动作,甚至对于路由。好吧,那是什么意思呢?嗯,想象一下我们越来越多地考虑分发动作;这使我们的世界变得更简单。当你分发一个动作时,会发生 HTTP 调用,并且在分发动作时,应用程序将你路由到你想要去的地方。
这并不是路由存储库的真正功能,而是一种你可以看待它的方式。实现这一点的办法是编写自己的效果,以响应路由动作,然后,作为结果,你调用路由服务并执行导航。让我们用项目符号列表总结我们刚才说的内容,并执行相应的步骤:
-
设置一些路由常量。
-
设置一些路由动作。
-
编写一个效果,使其监听路由动作并在效果内部执行路由。
这里是第一步:
// routing-constants.ts
export const PRODUCTS = 'Navigation products';
export const TODOS = 'Navigation todos';
export interface RoutingAction implements Action {
type: string;
payload: { url: string, query: { page: number } ;}
}
好的,我们的下一步是定义一组动作创建器,这样我们就可以在特定动作发生时触发某种行为:
// router-actions.ts
import { PRODUCTS, TODOS } from './routing-constants';
export const gotoProducts = (pageNo) => ({
type: PRODUCTS,
payload: { url: '/products', query: { page: pageNo } }
});
export const gotoTodo = (pageNo) => ({
type: TODOS,
payload: { url: '/todos', query: { page: pageNo } }
})
我们的下一步是我们的效果,它现在将能够响应前面的动作:
// routing-effects.ts
import { PRODUCTS, TODOS } from './routing-constants';
import { gotoProducts, gotoTodo }
export class RoutingEffects {
@Effect({ dispatch: false }) routingProducts$ = this.actions$
.ofType(PRODUCTS)
.tap(action => {
this.router.navigate('/products')
})
@Effect({ dispatch: false }) routingTodos$ = this.actions$
.ofType(TODOS)
.tap(action => {
this.router.navigate('/todos');
})
constructor(
private router: Router,
private actions$: Actions) {
}
}
理解 NgRx – 构建我们自己的微实现
我们之前在第八章,Redux中做过这个实验。目的是更深入地了解幕后发生了什么。实现 Redux 和实现 NgRx 的区别在于使用一个用于发布/订阅的库,这是你选择传达给监听器发生更改的方式。在我们的第八章,Redux实现中,我们给了你选择在不使用库的情况下实现四人帮发布/订阅模式,或者使用EventEmitter来实现相同功能的机会。在 NgRx 中,该组件是 RxJS。所以,让我们开始实现。在这样做之前,让我们描述我们想要实现的内容:
-
我们的目标是实现一个存储状态
-
应该能够向该存储库分发一个动作,以便其内部状态发生变化
-
存储库的任何更改都应该通过还原器进行
-
我们将学习如何处理副作用
添加存储库
核心来说,存储库只是一个封装状态的类。存储库需要能够处理更改;更改应通过方法分发。以下伪代码表示存储库可能的样子:
// NGRX-light/storeI.js
class Store {
constructor() {
this.state = {};
}
dispatch() {
// calculate the new state and replace the old one
}
}
在本节的开头,我们提到 NgRx 在其核心使用 RxJS。我们提到这是为了让存储库能够将其更改传达给其监听器。让我们提及可能适合先前问题描述的 RxJS 的核心概念。在 RxJS 中,我们有:
-
可观察者:它能够发出值,并且你可以将其订阅者附加到它上面
-
观察者:这是被调用的对象,以便我们最终以订阅者的形式获取值
-
订阅者:这是一个 Observable 和 Observer 的组合,它可以在订阅之后添加值。
仔细思考一下 store,我们意识到我们需要能够在任何时刻向其中添加值,并且我们需要能够订阅它。这似乎符合 Subject 的行为。让我们继续我们的 Store 的伪代码编写,但现在让 Subject 成为它的一部分:
// NGRX-light/storeII.js
class Store {
constructor() {
this.state = {};
}
dispatch(newState) {
this.state = newState;
}
}
我们使用以下代码实现了 dispatch() 方法:
this.innerSubject.next(newState);
现在,让我们关注实现订阅功能。让我们想象 store 将以以下方式使用:
const store = new Store();
store.subscribe(data => {
console.log('data', data);
})
为了实现这一点,我们可以在我们的 store 中添加 subscribe() 方法。如果我们自己这样做,我们必须注意一个监听器列表,并确保监听器在状态发生变化时被告知。更好的选择是让我们的 store 继承自 Subject。这将处理订阅部分。让我们看看它可能的样子:
// NGRX-light/storeIII.js
const Rx = require('rxjs');
class Store extends Rx.Subject {
constructor() {
super();
this.state = {};
this.subscribe(data => this.state = data);
}
dispatch(newState) {
this.next(newState);
}
}
const store = new Store();
store.subscribe(data => console.log('store', data));
store.dispatch({});
store.dispatch({ user: 'chris' });
// store {}
// store { user: 'chris' }
前面的代码重新实现了 dispatch() 方法,我们还在构造函数中设置了一个订阅,以确保我们的最新状态得到更新。这里有一个需要改进的地方,那就是我们如何向我们的 store 添加状态。在 Redux 中,传入的状态变化应该被还原到旧状态,如下所示:
const store = new Store();
store.subscribe(data => console.log('store', data));
// desired behavior: store { name: 'chris' }
// desired behavior: store { name: 'chris', address: 'London' }
store.dispatch({ name : 'chris' });
store.dispatch({ address : 'London' });
实现这一点的方法是稍微重构我们的代码,并创建另一个 Subject,它将成为 dispatch 调用的目标,如下所示:
// NGRX-light/storeIV.js
const Rx = require('rxjs');
class Store extends Rx.Subject {
constructor() {
super();
this.dispatcher = new Rx.Subject();
this.state = {};
this.dispatcher.subscribe(data => {
this.state = Object.assign({}, this.state, data);
this.next(this.state);
});
}
dispatch(newState) {
this.dispatcher.next(newState);
}
}
const store = new Store();
store.subscribe(data => console.log('store', data));
// store { name: 'chris' }
// store { address: 'London' }
store.dispatch({ name: 'chris' });
store.dispatch({ address: 'London' });
更好地合并状态
在前面的代码中,我们使用了 Object.assign() 来合并旧状态和新状态。我们可以通过在我们的 dispatcher 成员上使用 scan() 操作符来做得更好,如下所示:
// NGRX-light/storeV.js
const Rx = require('rxjs');
class Store extends Rx.Subject {
constructor() {
super();
this.dispatcher = new Rx.Subject();
this.dispatcher
.scan((acc, curr) => ({ ...acc, ...curr }))
.subscribe(data => this.next(data));
}
dispatch(newState) {
this.dispatcher.next(newState);
}
}
const store = new Store();
store.subscribe(data => console.log('store', data));
store.dispatch({ name: 'chris' });
store.dispatch({ address: 'London' });
在前面的代码中需要注意的一个重要问题是,我们从 store 中移除了状态成员。这根本不是必需的,因为我们只关心正在发出的最新值。
实现一个 reducer 并将其与 store 集成
Redux 的重要概念之一是保护谁和什么可以影响你的 store。谁 是 reducers。通过只允许 reducers 影响你的 store,我们可以更好地控制发生的事情。一个简单的 reducer 只是一个函数,它接受状态和动作作为参数,并能够根据旧状态和现有状态产生一个新的状态,如下所示:
// example reducer
function countReducer(state = 0, action) {
switch(action.type) {
case "INCREMENT":
return state + 1;
default:
return state;
}
}
let state = countReducer(0, { type: "INCREMENT" });
// 1
state = countReducer(state, { type: "INCREMENT" });
// 2
那么,在 Redux 中,reducer 是如何进入画面的呢?嗯,store 的状态由一个对象组成,如下所示:
{
counter: 1
products : []
}
Store 计算下一个状态的方式是创建一个看起来像这样的函数:
// calculate state
function calcState(state, action) {
return {
counter: counterReducer(state.counter, action),
products: productsReducer(state.products, action)
}
}
使用前面的代码,我们能够让不同的 reducer 函数处理我们状态的不同部分。让我们在我们的 store 中添加这样一个函数以及一些 reducers:
// NGRX-light/storeVI.js
const Rx = require('rxjs');
function counterReducer(state = 0, action) {
switch(action.type) {
case "INCREMENT":
return state + 1;
default:
return state;
}
}
function productsReducer(state = [], action) {
switch(action.type) {
case 'ADD_PRODUCT':
return [ ...state, Object.assign({}, action.payload) ]
default:
return state;
}
}
class Store extends Rx.BehaviorSubject {
constructor() {
super({ counter: 0, products: [] });
this.dispatcher = new Rx.Subject();
this.state = {};
this.dispatcher
.scan((acc, curr) => ({ ...acc, ...curr }))
.subscribe(data => this.next(data));
}
calcState(state, action) {
return {
counter: counterReducer(state.counter, action),
products: productsReducer(state.products, action)
}
}
dispatch(action) {
const newState = this.calcState(this.value, action);
this.dispatcher.next(newState);
}
}
const store = new Store();
store.subscribe(data => console.log('store', data));
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'ADD_PRODUCT', payload: { id: 1, name: 'Yoda' } });
到目前为止,我们已经对我们的 store 做了一些修改:
-
我们添加了两个 reducers
-
现在我们从
BehaviorSubject继承;这样做是为了记住旧的状态,当我们调用calcState()时,我们能够根据旧状态 + 动作生成一个新的状态 -
我们添加了
calcState()方法,该方法接受旧状态和一个动作 -
现在分发器接受一个动作而不是状态
-
构造函数中的
super()现在接受一个初始值
我们已经为下一步做好了充分的准备,即如何获取状态的一部分。
处理状态切片
只想获取部分状态的原因是,我们将在一个有许多组件只关心渲染应用程序完整状态的一小部分的上下文中使用 NgRx。例如,我们可能有一个产品列表组件、产品详情组件等。因此,我们需要实现获取状态切片的支持。由于我们的存储从 BehaviorSubject 继承,实现状态切片变得轻而易举:
// NGRX-light/storeVII.js
const Rx = require('rxjs');
function counterReducer(state = 0, action) {
switch(action.type) {
case "INCREMENT":
return state + 1;
default:
return state;
}
}
function productsReducer(state = [], action) {
switch(action.type) {
case 'ADD_PRODUCT':
return [ ...state, Object.assign({}, action.payload) ]
default:
return state;
}
}
class Store extends Rx.BehaviorSubject {
constructor() {
super({ counter: 0, products: [] });
this.dispatcher = new Rx.Subject();
this.state = {};
this.dispatcher
.scan((acc, curr) => ({ ...acc, ...curr }))
.subscribe(data => this.next(data));
}
calcState(state, action) {
return {
counter: counterReducer(state.counter, action),
products: productsReducer(state.products, action)
}
}
dispatch(action) {
const newState = this.calcState(this.value, action);
this.dispatcher.next(newState);
}
select(slice) {
return this.map(state => state[slice]);
}
}
const store = new Store();
store
.select('products')
.subscribe(data => console.log('store using select', data));
// store using select, []
// store using select, [{ id: 1, name: 'Yoda' }]
store.subscribe(data => console.log('store', data));
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' });
// store 0
// store 1
store.dispatch({ type: 'ADD_PRODUCT', payload: { id: 1, name: 'Yoda' } });
如果我们想要一个更高级的 select 方法,我们可以让它接受一个函数,如下所示:
// excerpt from the Store class
select(fn) {
return this.map(fn);
}
// usage - if there were such a state as 'state.products.list'
store
.select(state => state.products.list);
处理副作用
什么是副作用?副作用是指不属于正常代码流程的一部分,但它访问外部资源,例如文件系统或其他网络上的资源。在 Redux 的上下文中,副作用最常用于执行 AJAX 调用。一旦调用返回,我们很可能需要更新存储的状态,因为某些东西已经改变。我们如何实现这样的函数?一种方法是添加一个 effect() 方法,该方法接受一个函数。该函数将接受 dispatch 方法作为参数,以便参数函数在副作用运行完毕后,如果需要,可以执行 dispatch。让我们想象它将被这样使用:
// pseudo code
const store = new Store();
store.effect( async(dispatch) => {
const products = await getProducts();
dispatch({ type: 'LOAD_PRODUCTS', payload: products });
})
上述代码显示了我们在副作用中想要执行 AJAX 调用并获取我们的产品的方式。一旦我们完成获取,我们希望分发获取到的产品,使它们成为存储状态的一部分。让我们尝试实现前面的 effect() 函数:
// NGRX-light/storeVIII.js
const Rx = require('rxjs');
function counterReducer(state = 0, action) {
switch(action.type) {
case "INCREMENT":
return state + 1;
default:
return state;
}
}
function productsReducer(state = [], action) {
switch(action.type) {
case 'ADD_PRODUCT':
return [ ...state, Object.assign({}, action.payload) ];
case 'LOAD_PRODUCTS':
return action.payload.map(p => Object.assign({}, p));
default:
return state;
}
}
class Store extends Rx.BehaviorSubject {
constructor() {
super({ counter: 0, products: [] });
this.dispatcher = new Rx.Subject();
this.state = {};
this.dispatcher
.scan((acc, curr) => ({ ...acc, ...curr }))
.subscribe(data => this.next(data));
}
calcState(state, action) {
return {
counter: counterReducer(state.counter, action),
products: productsReducer(state.products, action)
}
}
dispatch(action) {
const newState = this.calcState(this.value, action);
this.dispatcher.next(newState);
}
select(slice) {
return this.map(state => state[slice]);
}
effect(fn) {
fn(this.dispatch.bind(this));
}
}
const store = new Store();
store
.select('products')
.subscribe(data => console.log('store using select', data));
store.subscribe(data => console.log('store', data));
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'INCREMENT' });
store.dispatch({ type: 'ADD_PRODUCT', payload: { id: 1, name: 'Yoda' } });
const getProducts = () => {
return new Promise(resolve => {
setTimeout(() => {
resolve([{ id: 1, name: "Vader" }]);
}, 3000);
});
}
store.effect(async(dispatch) => {
const products = await getProducts();
dispatch({ type: 'LOAD_PRODUCTS', payload: products });
});
上述代码执行以下操作:
-
将新的情况
LOAD_PRODUCTS添加到productsReducer -
在
Store类上实现effect()方法 -
定义一个
getProducts()方法来模拟 AJAX 调用 -
通过执行对
getProducts的调用来演示效果方法的使用,并将获取到的产品发送到存储中
我们现在已经完全实现了 NgRx 的存储和效果库——我们应该为此感到自豪。
@ngrx/schematics
Schematics 依赖于所有可能的 NgRx 库;因此,在我们做其他任何事情之前安装这些库是个好主意。只需输入以下内容:
npm install @ngrx/effects --save
npm install @ngrx/entity --save
npm install @ngrx/store --save
npm install @ngrx/store-devtools
Schematics 本身是一个库,Angular-CLI 使用它来生成 Angular 开发所需的不同结构,例如组件、服务、过滤器等等。@ngrx/schematics 为 schematics 提供蓝图,以便在处理 NgRx 时获取帮助生成所需的构建结构,换句话说,它使开发速度大大加快。您可以获取以下内容的帮助:
-
动作
-
容器
-
Effect
-
实体
-
功能
-
Reducer
-
Store
设置
@ngrx/schematics 是一个 NPM 库,因此可以通过输入以下命令轻松安装:
npm install @ngrx/schematics --save-dev
就这样。这就是设置所需的所有内容。要使用它,您只需要一个终端窗口并输入适当的命令。我们将在下一节中查看。
生成结构
生成所需的内容就像输入以下内容一样简单:
ng generate <what> <name>
这将在适当的位置创建文件。这是一个节省时间的方法,所以学习如何使用它。几乎所有的命令都附带了很多选项,因此值得查看官方文档中它们如何配置,官方文档可以在以下位置找到 github.com/ngrx/platform/tree/master/docs/schematics.
生成动作
通过输入以下内容来完成此操作:
ng generate action jedis
它将为我们生成一个名为 jedi.actions.ts 的动作文件,内容如下:
// jedis.actions.ts
import { Action } from '@ngrx/store';
export enum JedisActionTypes {
JedisAction = '[Jedis] Action'
}
export class Jedis implements Action {
readonly type = JediActionTypes.JediAction;
}
export type JediActions = Jedi;
上述代码为我们提供了带有一些默认设置的精美脚手架文件,并创建了一个可以与 reducer 和选择器一起使用的 enum 类型。查看上述代码,我们意识到如果我们想要像 ADD、CREATE 以及其他 CRUD 操作这样的功能,我们需要扩展 JedisActionTypes。
生成容器
这将在您的组件中注入 store 并创建组件本身——调用此方法的典型方式是输入:
ng generate container jedis
这将创建以下文件:
-
jedis.component.ts -
jedis.component.html -
jedis.component.css -
jedis.component.spec.ts
并且在 jedis.component.ts 中,store 将在构造函数中注入,如下所示:
// jedis.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-jedis',
templateUrl: './jedis.component.html',
styleUrls: ['./jedis.component.css']
})
export class JedisComponent implements OnInit {
constructor(private store: Store<any>) { } }
ngOnInit() {}
}
生成效果
您可以通过输入以下内容来生成一个效果:
ng generate effect jedis
这将生成以下文件:
-
jedis.effect.ts -
jedis.effect.spec.ts
effects 文件看起来如下:
import { Injectable } from '@angular/core';
import { Actions, Effect } from '@ngrx/effects';
@Injectable()
export class JedisEffects {
constructor(private actions$: Actions) {}
}
生成实体
这将生成一大堆可以用来处理实体的文件。要运行命令,请输入:
ng generate entity product
生成的以下文件:
-
product.actions.ts -
product.model.ts -
product.reducer.ts -
product.reducer.spec.ts
值得注意的是,product.reducer.ts 文件不仅生成完整的 reducer 函数,还创建并初始化了 EntityAdapter。这省去了您需要编写的很多样板代码。您还将获得所有动作和所有选择器——这是一个真正强大的命令。
生成功能
生成功能会为您生成很多文件。让我们看看命令的样子:
ng generate feature category
这将生成以下文件:
-
category.actions.ts -
category.reducer.ts -
category.reducer.spec.ts -
category.effects.ts -
category.effects.spec.ts
这又是一堆您不必手动编写的文件。
生成还原器
这将生成一个还原器和测试文件。如果您只想有一个还原器,那么这个命令就是为您准备的。要使用它,请输入以下内容:
ng generate reducer travel
这将生成以下文件:
-
travel.reducer.ts -
travel.reducer.spec.ts
生成存储库
此命令将您完全设置为使用@ngrx/store。它还允许您设置功能存储库。因此,通过输入以下两个命令,您可以生成大量的文件:
ng generate module country
ng generate store country
上述代码将生成一个模块,并添加一个功能状态。运行以下命令将添加与存储库一起工作的设置,以及设置随 NgRx 一起提供的 devtools:
ng generate store State --root --module app.module.ts
摘要
在本书的最后一章,我们探讨了如何真正掌握 NgRx 及其所有辅助库。我们还通过构建自己的 NgRx 微实现来验证我们确实了解幕后发生的事情。我们通过查看实体库和图库,分别探讨了各种提高我们速度和生产力的方法。
作为读者的您,在本书的过程中经历了一段关于 Flux 和 Redux 模式的漫长旅程。此外,函数式编程、响应式编程和深入 RxJS 知识也被添加到您的工具箱中。这最终形成了两章完整的章节,涵盖了 NgRx 所能提供的一切。本书的目的是为您提供足够广泛和深入的背景知识,了解 NgRx 及其库背后的思想和范式。希望阅读本书后,您将充满信心,知道如何使用 Angular 和 NgRx 应对现有和未来的项目。
感谢您抽出时间阅读这本书,并且不要犹豫以任何方式提出疑问。


浙公网安备 33010602011771号