Angular 应用架构指南(三)
原文:
zh.annas-archive.org/md5/e32b0f23109b0daf0acc38b5411ab61e译者:飞龙
第七章: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,以进一步了解底层发生了什么。如果你对底层发生的事情不感兴趣,那么你可能选择了错误的专业!一切都被设置为使最后一章非常有趣。