原文连接: blog.thoughtram.io/angular/201…html
本文为 RxJS 中文社区 翻译文章,如需转载,请注明出处,谢谢合做!git
若是你也想和咱们一块儿,翻译更多优质的 RxJS 文章以奉献给你们,请点击【这里】github
舒适提示: 文章较长,原文中写的是40分钟阅读,建议你们午后有大把空闲时间再慢慢读来编程
开发 Web 应用时,性能始终都是重中之重。要想提高 Angular 应用的速度,咱们能够作一些工做,好比要摇树优化 (tree-shaking)、AoT (ahead-of-time)、模块的懒加载以及缓存。想要对 Angular 应用的性能提高的实战技巧有一个全面了解的话,咱们强烈推荐你参考由 Minko Gechev 撰写的 Angular 性能检测表。在本文中,咱们将专一于缓存。api
实际上,缓存是提高网站用户体验的最有效的方式之一,尤为是当用户使用宽带受限的设备或网络环境较差。浏览器
缓存数据或资源的方式有不少种。静态资源一般都是由标准的浏览器缓存或 Service Workers 来进行缓存。虽然 Service Workers 也能够缓存 API 请求,可是对于图像、HTML、JS 或 CSS 文件等资源的缓存,它们一般更为有用。咱们一般使用自定义机制来缓存应用的数据。缓存
不管咱们使用的是什么机制,缓存一般都是提高应用的响应能力、减小网络花销,并具备内容在网络中断时可用的优点。换句话说,当内容被缓存的更接近消费者时,好比在客户端,请求将不会致使额外的网络活动,而且能够更快速地检索缓存数据,从而节省了网络往返的整个过程。安全
在本文中,咱们将使用 RxJS 和 Angular 提供的工具来开发一个高级缓存机制。服务器
不时地就会有人问,如何在大量使用 Observables 的 Angular 应用中缓存数据?大多数人对于如何使用 Promises 来缓存数据有不错的理解,但当切换至响应式编程时,便会由于它的复杂度 (庞大的 API)、思惟转化 (从命令式到声明式) 和众多概念而感到不知所措。所以,很难将一个基于 Promises 的现有缓存机制转换成基于 Observables 的,当你想要缓存机制变得更高级点时更是如此。网络
在 Angular 应用中一般使用 HttpClientModule
中的 HttpClient
来执行 HTTP 请求。HttpClient
的全部 API 都是基于 Observable 的,也就是说像 get
、post
、put
或 delete
等方法返回的都是 Observable 。由于 Observables 天生是惰性的,因此只有当咱们调用 subscribe
时才会真正发起请求。可是,对同一个 Observable 调用屡次 subscribe
会致使源 Observable 一遍又一遍地从新建立,每一个订阅 (subscription) 上执行一个请求。咱们称之为冷的 Observables 。
若是你对此彻底没有概念的话,咱们以前写过一篇此主题的文章: 冷的 vs 热的 Observables 。(译者注: 想了解冷的 vs 热的 Observables,还能够推荐阅读这篇文章)
这种行为将致使使用 Observables 来实现缓存机制变得很棘手。简单的方法每每就须要至关数量的样板代码, 咱们可能会选择绕过 RxJS, 这也是可行的,但若是咱们想要最终驾驭 Observables 的强大力量时,这种方式是不推荐的。说白了就是咱们不想开配备小型摩托车引擎的法拉利,对吧?
在深刻代码以前,咱们先来为要实现的高级缓存机制制定需求。
咱们想要开发的应用名为笑话世界。这是一个简单的应用,它只是根据制定的分类来随机展现笑话。为了让应用更简单、更专一,咱们只设定一个分类。
应用有三个组件: AppComponent
、 DashboardComponent
和 JokeListComponent
。
AppComponent
组件是应用的入口,它渲染工具栏和 <router-outlet>
,后者会根据当前路由器状态来填充内容。
DashboardComponent
组件只展现分类的列表。在这能够导航至 JokeListComponent
组件,它负责将笑话列表渲染到屏幕中。
笑话是使用 Angular 的 HttpClient
服务从服务器拉取的。要保持组件的职责单一和概念分离,咱们想建立一个 JokeService
来负责请求数据。而后组件只需经过注入此服务即可以经过它的公有 API 来访问数据。
以上就是咱们这个应用的架构,目前尚未涉及到缓存。
当从分类列表页导航至笑话列表页时,咱们更倾向于请求缓存中的最新数据,而不是每次都向服务器发起请求。而缓存的底层数据会每10秒钟自动更新。
固然,对于生产级应用来讲,每隔10秒轮询新数据并不是是个好选择,通常来讲会使用一种更成熟的方式来更新缓存 (例如 Web Socket 推送更新)。但在这里咱们将保持简单性,以便于专一于缓存自己。
咱们将会以某种形式来接收更新通知。对于这个应用来讲,当缓存更新时,咱们不想 UI (JokeListComponent
) 中的数据自动更新,而是等待用户来执行 UI 的更新。为何要这样作?想象一下,用户可能正在读某条笑话,而后忽然间由于数据的自动更新这条笑话就消失了。这样的结果就是因为这种较差的用户体验,让用户很生气。所以,咱们的作法是每当有新数据时提示用户更新。
为了更好玩一些,咱们还想要用户可以强制缓存更新。这与仅更新 UI 不一样,由于强制更新意味着从服务器请求最新数据、更新缓存,而后相应地更新 UI 。
来总结下咱们将要开发的内容点:
下面是应用的预览图:
咱们先从简单的开始,而后一步步地打造出最终成熟的解决方案。
第一步是建立一个新的服务。
接下来,咱们来添加两个接口,一个是描述 Joke
的数据结构,另外一个是用来强化 HTTP 请求响应的类型。这会让 TypeScript 很开心,但最重要的是开发人员使用起来也更加方便和清晰。
export interface Joke {
id: number;
joke: string;
categories: Array<string>;
}
export interface JokeResponse {
type: string;
value: Array<Joke>;
}
复制代码
如今咱们来实现 JokeService
。对于数据是来自缓存仍是服务器,咱们并不想暴露实现的细节,所以,咱们只暴露一个 jokes
的属性,它返回的是包含笑话列表的 Observable 。
为了发起 HTTP 请求,咱们须要确保在服务的构造函数中注入 HttpClien
服务。
下面是 JokeService
的框架:
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Injectable()
export class JokeService {
constructor(private http: HttpClient) { }
get jokes() {
...
}
}
复制代码
接下来,咱们将实现一个私有方法 requestJokes()
,它会使用 HttpClient
来发起 GET
请求以获取笑话列表。
import { map } from 'rxjs/operators';
@Injectable()
export class JokeService {
constructor(private http: HttpClient) { }
get jokes() {
...
}
private requestJokes() {
return this.http.get<JokeResponse>(API_ENDPOINT).pipe(
map(response => response.value)
);
}
}
复制代码
完成这一步后,咱们只剩 jokes
的 getter 方法没有完成了。
一个简单的方法就是直接返回 this.requestJokes()
,但这样并不会生效。从文章开头中咱们已经得知 HttpClient
暴露出的全部方法,例如 get
返回的是冷的 Observables 。这意味着为每次订户都会从新发出整个数据流,从而致使屡次的 HTTP 请求。毕竟,缓存的理念是提高应用的加载速度并将网络请求的数量限制到最小。
相反的,咱们想让流变成热的。不只如此,咱们还想让每一个新订阅者都接收到最新的缓存数据。有一个很是方便的操做符叫作 shareReplay
。它返回的 Observable 会共享底层数据源的单个订阅,在这里也就是 this.requestJokes()
所返回的 Observable 。
除此以外,shareReplay
还接收一个可选参数 bufferSize
,对于咱们这个案例它是至关便利的。bufferSize
决定了重放缓冲区的最大元素数量,也就是缓存和为每一个新订阅者重放的元素数量。对于咱们这个场景来讲,咱们只想要重放最新的一个至,因此 bufferSize
将设定为 1 。
咱们来看下代码,并使用刚刚所学习到的知识:
import { Observable } from 'rxjs/Observable';
import { shareReplay, map } from 'rxjs/operators';
const API_ENDPOINT = 'https://api.icndb.com/jokes/random/5?limitTo=[nerdy]';
const CACHE_SIZE = 1;
@Injectable()
export class JokeService {
private cache$: Observable<Array<Joke>>;
constructor(private http: HttpClient) { }
get jokes() {
if (!this.cache$) {
this.cache$ = this.requestJokes().pipe(
shareReplay(CACHE_SIZE)
);
}
return this.cache$;
}
private requestJokes() {
return this.http.get<JokeResponse>(API_ENDPOINT).pipe(
map(response => response.value)
);
}
}
复制代码
Ok,上面代码中的大部分咱们都已经讨论过了。可是等下,那个私有属性 cache$
和 getter 方法中的 if
语句是作什么的?答案很简单。若是直接返回 this.requestJokes().pipe(shareReplay(CACHE_SIZE))
的话,那么每次订阅都将建立一个缓存实例。但咱们想要的是全部订阅者都共享同一个实例。所以,咱们将这个共享的实例保存在私有属性 cache$
中,并在首次调用 getter 方法时对其进行初始化。后续的全部消费者都将共享此实例而无需每次都从新建立缓存。
经过下面的图来更直观地看下咱们刚刚实现的内容:
在上图中,咱们能够看到描述咱们场景中所涉及到的对象的序列图,即请求笑话列表和在对象之间交换消息的队列。咱们分解来看,以便更好地了解咱们正在作什么。
咱们从 DashboardComponent
导航至 JokeListComponent
开始提及。
组件初始化后 Angular 会调用 ngOnInit
生命周期钩子,这里咱们将调用 JokeService
暴露的 jokes
的 getter 方法来请求笑话列表。由于这是首次请求数据,因此缓存自己还未初始化,也就是说 JokeService.cache$
是 undefined
。在内部咱们会调用 requestJokes()
,它会返回一个将会发出服务端数据的 Observable 。同时咱们还应用了 shareReplay
操做符来获取预期效果。
shareReplay
操做符会自动在原始数据源和全部后来的订阅者之间建立一个 ReplaySubject
。一旦订阅者的数量从 0 增长至 1,就会将 Subject
与底层源 Observable 进行链接,而后广播出它的全部重放值。后续的全部订阅者都将与中间人 Subject
进行链接,所以底层的冷的 Observable 只有一个订阅。这就是多播,它是咱们这个简单缓存机制的基础。(译者注: 想深刻了解多播,推荐这篇文章)
一旦服务端返回数据,数据就会被缓存。
注意,在序列图中 Cache
是一个独立的对象,它被表示成一个 ReplaySubject
,它位于消费者 (订阅者) 和底层数据源 (HTTP 请求) 之间。
当再次为 JokeListComponent
组件请求数据时,缓存将会重放最新值并将其发送给消费者。这样就不会再发起额外的 HTTP 请求。
很简单,是吧?
要想了解更多细节,咱们还需更进一步,来看看在 Observable 级别缓存是如何工做的。所以,咱们将使用弹珠图 (marble diagram) 来对流的工做原理进行可视化展现:
弹珠图看上去十分清晰,底层的 Observable 确实只有一个订阅,全部消费者订阅的都是这个共享 Observable,即 ReplaySubject
。咱们还能够看到只有第一个订阅者触发了 HTTP 请求,而其余订阅者得到的只是缓存重放的最新值。
最后,咱们来看看 JokeListComponent
以及如何展示数据。首先是注入 JokeService
。而后在 ngOnInit
生命周期中对 jokes$
属性进行初始化,初始值为由服务所暴露的 getter 方法所返回的 Observable, Observable 的类型为 Array<Joke>
,这正是咱们想要的数据。
@Component({
...
})
export class JokeListComponent implements OnInit {
jokes$: Observable<Array<Joke>>;
constructor(private jokeService: JokeService) { }
ngOnInit() {
this.jokes$ = this.jokeService.jokes;
}
...
}
复制代码
注意,咱们并无命令式地去订阅 jokes$
,而是在模板中使用 async
管道,这样作是由于这个管道让人爱不释手。很好奇?能够参考这篇文章: 关于 AsyncPipe 你须要知道的三件事
<mat-card *ngFor="let joke of jokes$ | async">...</mat-card>
复制代码
酷!这就是咱们的简单缓存了。想要验证请求是否只发起一次,能够打开 Chrome 的开发者工具,而后点击 Network 标签页并选择 XHR 。从分类列表页开始,导航至笑话列表页,而后再返回分类列表页,反反复复几回。
第 1 阶段在线 Demo: 点击查看 。
到目前为止,咱们已经经过了少许的代码开发出了一个简单的缓存机制,大部分的脏活都是由 shareReplay
操做符完成的,它负责缓存和重放最新值。
目前彻底能够正常运行,可是在后台的数据源却永远不会更新。若是数据可能每隔几分钟就发生变化怎么办?咱们可不想强迫用户去刷新整个页面才能从服务器得到最新数据。
若是咱们的缓存能够在后台每10秒更新一次岂不是很好?彻底赞成!做为用户,咱们没必要从新加载页面,若是数据发生变化的话,UI 会相应地更新。重申下,在真实的应用中咱们基本上不会使用轮询,而是使用服务器推送通知。但对于咱们这个小 Demo 应用来讲,间隔 10 秒的定时器已经足够了。
实现起来也至关简单。总而言之,咱们想要建立一个 Observable,它发出一系列根据给定时间间隔隔开的值,或者简单点说,咱们想要每 x 毫秒就生成一个值。咱们有几种实现方式。
第一种选择是使用 interval
。此操做符接收一个可选参数 period
,它定义了每次发出值间的时间间隔。参考下面的示例:
import { interval } from 'rxjs/observable/interval';
interval(10000).subscribe(console.log);
复制代码
这里咱们设置的 Observable 会发出无限序列的整数,每次发出值会间隔 10 秒。也就是说第一个值将会在 10 秒发出。为了更好地演示,咱们来看下 interval
操做符的弹珠图:
呃,果然如此。第一个值是“延迟”发出的,而这并不是咱们想要的效果。为何这么说?由于若是咱们从分类列表页导航至笑话列表页时,咱们必须等待 10 秒后才会向服务器发起数据请求以渲染页面。
咱们能够经过引入另外一个名为 startWith(value)
的操做符来修复此问题,这样一开始就会先发出给定的 value
,即初始值。可是,咱们能够作的更好!
若是我告诉你还有另一个操做符,它能够先根据给定的时间 (初始延迟) 发出值,而后再根据时间间隔 (常规的定时器) 来不停地发出值。timer
了解一下。
弹珠图时刻!
酷,可是它真的解决了咱们问题了吗?是的,没错。若是咱们将初始延迟设置为 0,并将时间间隔设置为 10 秒,这样它的行为就和 interval(10000).pipe(startWith(0))
是同样的,但却只使用了一个操做符。
咱们来使用 timer
操做符并将其运用在咱们现有的缓存机制当中。
咱们须要设置一个定时器,而后每次时间一到就发起 HTTP 请求来从服务器拉取最新数据。也就是说,对于每一个时间点咱们都须要使用 switchMap
来切换成一个获取笑话列表的 Observable 。使用 swtichMap
有一个好的反作用就是能够避免条件竞争。这是因为这个操做符的本质,它会取消对前一个内部 Observable 的订阅,而后只发出最新内部 Observable 中的值。
咱们缓存的其他部分都保持原样,咱们的流仍然是多播的,全部的订阅者都共享同一个底层数据源。
一样的,shareReplay
会将最新值发送给现有的订阅者,并为后来的订阅者重放最新值。
正如在弹珠图中所展现的,timer
每 10 秒发出一个值。每一个值都将转换成拉取数据的内部 Observable 。由于使用的是 switchMap
,咱们能够避免竞争条件,所以消费者只会收到值 1
和 3
。第二个内部 Observable 的值会被“跳过”,这是由于当新值发出时咱们其实已经取消对它的订阅了。
下面来将咱们刚刚所学到的应用到 JokeService
中:
import { timer } from 'rxjs/observable/timer';
import { switchMap, shareReplay } from 'rxjs/operators';
const REFRESH_INTERVAL = 10000;
@Injectable()
export class JokeService {
private cache$: Observable<Array<Joke>>;
constructor(private http: HttpClient) { }
get jokes() {
if (!this.cache$) {
// 设置每 X 毫秒发出值的定时器
const timer$ = timer(0, REFRESH_INTERVAL);
// 每一个时间点都会发起 HTTP 请求来获取最新数据
this.cache$ = timer$.pipe(
switchMap(_ => this.requestJokes()),
shareReplay(CACHE_SIZE)
);
}
return this.cache$;
}
...
}
复制代码
酷!是否想本身试试呢?常常尝试下面的在线 Demo 吧。从分类列表页导航至笑话列表页,而后见证奇迹的诞生。耐心等待几秒后就能看见数据更新了。记住,虽然缓存是每 10 秒刷新一次,但你能够在在线 Demo 中自由更改 REFRESH_INTERVAL
的值。
第 2 阶段在线 Demo: 点击查看。
咱们来简单回顾下到目前为止咱们所开发的内容。
当从 JokeService
请求数据时,咱们老是但愿请求缓存中的最新数据,而不是每次都请求服务器。缓存的底层数据每隔 10 秒刷新一次,数据传播到组件后将使得 UI 自动更新。
这是有些失败的。想象一下,咱们就是用户,当咱们正在看某条笑话时忽然笑话就消失了,这是由于 UI 自动更新了。这种糟糕的用户体验会让用户很生气。
所以,当有新数据时应该发通知提醒用户。换句话说,咱们想让用户来执行 UI 的更新操做。
事实上,要完成此功能咱们都不须要去修改服务层。逻辑至关简单。毕竟,咱们的服务层不该该关心发送通知以及什么时候、如何去更新屏幕上的数据,这些都应该是由视图层来负责。
首先,咱们须要由初始值来展现给用户,不然, 在第一次更新缓存以前, 屏幕将是空白的。咱们立刻就会明白这样作的缘由。设置初始值的流就像调用 getter 方法那样简单。此外,由于咱们只对首个值感兴趣,因此咱们可使用 take
操做符。
为了让逻辑能够复用,咱们建立一个辅助方法 getDataOnce()
。
import { take } from 'rxjs/operators';
@Component({
...
})
export class JokeListComponent implements OnInit {
...
ngOnInit() {
const initialJokes$ = this.getDataOnce();
...
}
getDataOnce() {
return this.jokeService.jokes.pipe(take(1));
}
...
}
复制代码
根据需求,咱们只想在用户真正执行更新时才更新 UI,而不是自动更新。那么用户如何实施你所要求的更新呢?当咱们单击 UI 中表示“更新”的按钮时, 才会执行此操做。暂时,咱们没必要考虑通知,而应该专一于点击按钮时的更新逻辑。
要完成此功能,咱们须要一种方式来建立来源于 DOM 事件的 Observable,在这里指按钮的点击事件。建立的方式有好几种,但最经常使用的是使用 Subject
做为模板和组件类中逻辑之间的桥梁。简而言之,Subject
是一种同时实现 Observer
(观察者) 和 Observable
的类型。Observables 定义了数据流并生成数据,而观察者能够订阅 Observables 并接收数据。
Subject 好的方面是咱们能够直接在模板使用事件绑定,而后当事件触发时调用 next
方法。这会将特定值广播给全部正在监听值的观察者们。注意,若是 Subject 的类型为 void
的话,咱们还能够省略该值。事实上,这正是咱们的实际场景。
咱们来实例化一个新的 Subject 。
import { Subject } from 'rxjs/Subject';
@Component({
...
})
export class JokeListComponent implements OnInit {
update$ = new Subject<void>();
...
}
复制代码
以后咱们就能够在模板中来使用它。
<div class="notification">
<span>There's new data available. Click to reload the data.</span>
<button mat-raised-button color="accent" (click)="update$.next()">
<div class="flex-row">
<mat-icon>cached</mat-icon>
UPDATE
</div>
</button>
</div>
复制代码
来看下咱们是如何使用事件绑定语法来捕获 <button>
上的点击事件的?当点击按钮时,咱们只是传播一个幽灵值从而通知全部活动的观察者。咱们称之为幽灵值是由于实际上并无传任何值,或者说传递的值的类型为 void
。
另外一种方式是使用 @ViewChild()
装饰器和 RxJS 的 fromEvent
操做符。可是,这须要咱们在组件类中“混入” DOM 并从视图中查询 HTML 元素。使用 Subject 的话,咱们只须要将二者桥接便可,除了咱们在按钮上添加的事件绑定以外,根本不会触及 DOM 。
好了,设置好视图后,咱们就能够切换至处理 UI 更新的逻辑了。
那么更新 UI 意味着什么?缓存是在后台自动更新的,而咱们想要点击按钮时才渲染从缓存中拿到的最新值,是这样吧?这意味着咱们的源头流是 Subject 。每次 update$
上发出值时,咱们就将其映射成给出最新缓存值的 Observable 。换句话说,咱们使用的是 高阶 Observable ( Higher Order Observable ) ,即发出 Observables 的 Observable 。
在此以前,咱们应该知道 switchMap
正好能够解决这种问题。但此次,咱们将使用 mergeMap
。它的行为与 switchMap
很相似,它不会取消前一个内部 Observable 的订阅,而是将内部 Observable 的发出值合并到输出 Observable 中。
事实,从缓存中请求最新值时,HTTP 请求早已完成,缓存也已经成功更新。所以,咱们并不会面临条件竞争的问题。虽然这看上去仍是异步的,但某种程度上来讲,它实际上是同步的,由于值是在同一个 tick 中发出的。
import { Subject } from 'rxjs/Subject';
import { mergeMap } from 'rxjs/operators';
@Component({
...
})
export class JokeListComponent implements OnInit {
update$ = new Subject<void>();
...
ngOnInit() {
...
const updates$ = this.update$.pipe(
mergeMap(() => this.getDataOnce())
);
...
}
...
}
复制代码
酷!每次“更新”时咱们都是从缓存中请求的最新值,而缓存使用的是咱们以前实现的辅助方法。
到这里,还差一小步就能够完成负责将笑话渲染到屏幕上的流。咱们所须要作的只是合并 initialJokes$
和 update$
这两个流。
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { merge } from 'rxjs/observable/merge';
import { mergeMap } from 'rxjs/operators';
@Component({
...
})
export class JokeListComponent implements OnInit {
jokes$: Observable<Array<Joke>>;
update$ = new Subject<void>();
...
ngOnInit() {
const initialJokes$ = this.getDataOnce();
const updates$ = this.update$.pipe(
mergeMap(() => this.getDataOnce())
);
this.jokes$ = merge(initialJokes$, updates$);
...
}
...
}
复制代码
咱们使用辅助方法 getDataOnce()
来将每次更新事件映射成最新的缓存值,这点很重要。回想一下,在这个方法内部使用了 take(1)
,它只取第一个值而后就完成流。这是相当重要的,不然最终获得的是一个正在进行中或实时链接到缓存的流。在这种状况下,基本上会破坏咱们仅经过点击“更新”按钮来执行 UI 更新的逻辑。
还有,由于底层的缓存是多播的,永远都从新订阅缓存以获取最新值是彻底安全的。
在继续完成通知流以前,咱们先暂停下来看看刚刚实现逻辑的弹珠图。
正如在图中所看到的,initialJokes$
很关键,由于若是没有它的话咱们只能在点击“更新”按钮后才能看到屏幕上的笑话列表。虽然数据在后台每 10 秒更新一次,但咱们根本没法点击更新按钮。由于按钮自己也是通知的一部分,但咱们却一直没有将其展现给用户。
那么,让咱们填补这个空白并实现缺失的功能。
咱们须要建立一个 Observable 来负责显示/隐藏通知。从本质上来讲,咱们须要一个发出 true
或 false
的流。当更新时,咱们想要的值是 true
,当用户点击“更新”按钮时,咱们想要的值是 false
。
此外,咱们还想要跳过缓存发出的首个(初始)值,由于它并非新数据。
若是使用流的思惟,咱们能够将其拆分为多个流,而后再将它们合并成单个的 Observable 。最终的流将具有显示或隐藏通知的所需行为。
理论到此为止!下面来看代码:
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { skip, mapTo } from 'rxjs/operators';
@Component({
...
})
export class JokeListComponent implements OnInit {
showNotification$: Observable<boolean>;
update$ = new Subject<void>();
...
ngOnInit() {
...
const initialNotifications$ = this.jokeService.jokes.pipe(skip(1));
const show$ = initialNotifications$.pipe(mapTo(true));
const hide$ = this.update$.pipe(mapTo(false));
this.showNotification$ = merge(show$, hide$);
}
...
}
复制代码
这里,咱们跳过了缓存的第一个值,而后监听它剩下全部的值,这样作的缘由是第一个值不是新数据。咱们将 initialNotifications$
发出的每一个值都映射成 true
以显示通知。一旦咱们点击通知里的“更新”按钮,update$
就会产生一个值,咱们能够将这个值映射成 false
以关闭通知。
咱们在 JokeListComponent
组件的模板中使用 showNotification$
来切换 class 以显示/关闭通知。
<div class="notification" [class.visible]="showNotification$ | async">
...
</div>
复制代码
耶!目前,咱们已经十分接近最终的解决方案了。在继续前进以前,咱们来试玩下在线 Demo 。不用着急,再来一步步地过遍代码。
第 3 阶段在线 Demo: 点击查看。
酷!一路走来咱们已经为咱们的缓存实现了一些很酷的功能。要结束本文并将缓存再提高一个等级的话,咱们还须要作一件事。做为用户,咱们想要可以在任什么时候间点来强制更新数据。
这并无什么复杂的,但要完成此功能咱们须要同时修改组件和服务。
咱们先从服务开始。咱们须要一个面向公众的 API 来强制缓存重载数据。从技术上来讲,咱们会完成当前缓存,并将其设置为 null
。这意味着下次咱们从服务中请求数据时会设置一个新的缓存,它会从服务器拉取数据并保存起来以便为后来的订阅者服务。每次强制更新时建立一个新缓存并非什么大问题,由于旧的缓存将会完成并最终被垃圾收集。实际上,这样作还有一个有用的反作用,就是重置了定时器,这决定是咱们想获得的效果。好比说,咱们等待 9 秒后点击“强制更新”按钮。咱们所指望的数据刷新了,但咱们不想看到 1 秒后弹出更新通知。咱们想要让计时器从新开始,这样当强制更新后再过 10 秒才应该触发自动更新。
销毁缓存的另外一个缘由是相比于不销毁缓存的版本,它的复杂度要小得多。若是是后者的话,缓存须要知道重载数据是不是强制执行的。
咱们来建立一个 Subject,它用来通知缓存以完成。这里咱们利用了 takeUnitl
操做符并将其加入到 cache$
流中。此外,咱们还实现了一个公开的 API ,它使用 Subject 来广播事件,同时将缓存设置为 null
。
import { Subject } from 'rxjs/Subject';
import { timer } from 'rxjs/observable/timer';
import { switchMap, shareReplay, map, takeUntil } from 'rxjs/operators';
const REFRESH_INTERVAL = 10000;
@Injectable()
export class JokeService {
private reload$ = new Subject<void>();
...
get jokes() {
if (!this.cache$) {
const timer$ = timer(0, REFRESH_INTERVAL);
this.cache$ = timer$.pipe(
switchMap(() => this.requestJokes()),
takeUntil(this.reload$),
shareReplay(CACHE_SIZE)
);
}
return this.cache$;
}
forceReload() {
// 调用 `next` 以完成当前缓存流
this.reload$.next();
// 将缓存设置为 `null`,这样下次调用 `jokes` 时
// 就会建立一个新的缓存
this.cache$ = null;
}
...
}
复制代码
光在服务中实现并无什么做用,咱们还须要在 JokeListComponent
中来使用它。为此,咱们将实现一个函数 forceReload()
,当点击“强制更新”按钮时会调用此函数。此外,咱们还须要建立一个 Subject 做为事件总线 ( Event Bus ),用于更新 UI 以及显示通知。咱们很快就会看到它的做用。
import { Subject } from 'rxjs/Subject';
@Component({
...
})
export class JokeListComponent implements OnInit {
forceReload$ = new Subject<void>();
...
forceReload() {
this.jokeService.forceReload();
this.forceReload$.next();
}
...
}
复制代码
这样咱们就能够将 JokeListComponent
模板中按钮联系起来,以强制缓存从新加载数据。咱们须要作的只是使用 Angular 的事件绑定语法来监听 click
事件,当点击按钮时调用 forceReload()
。
<button class="reload-button" (click)="forceReload()" mat-raised-button color="accent">
<div class="flex-row">
<mat-icon>cached</mat-icon>
FETCH NEW JOKES
</div>
</button>
复制代码
这样已经能够工做了,但前提是咱们先返回到分类列表页,而后再回到笑话列表页。这确定不是咱们想要的结果。当强制缓存重载数据时咱们但愿能当即更新 UI 。
还记得咱们已经实现好的流 update$
吗?当咱们点击“更新”按钮时,它会请求缓存中的最新数据。事实上,咱们须要的也是一样的行为,所以咱们能够继续使用并扩展此流。这意味着咱们须要合并 update$
和 forceReload$
,由于这两个流都是 UI 更新的数据源。
import { Subject } from 'rxjs/Subject';
import { merge } from 'rxjs/observable/merge';
import { mergeMap } from 'rxjs/operators';
@Component({
...
})
export class JokeListComponent implements OnInit {
update$ = new Subject<void>();
forceReload$ = new Subject<void>();
...
ngOnInit() {
...
const updates$ = merge(this.update$, this.forceReload$).pipe(
mergeMap(() => this.getDataOnce())
);
...
}
...
}
复制代码
就是这么简单,难道不是吗?是的,但尚未结束。实际上,咱们这样作只会“破坏”通知。在咱们点击“强制更新”按钮以前,一切都是好用的。一旦点击按钮后,屏幕和缓存中的数据依旧照常更新,但当等待了 10 秒后却并无通知弹出。问题在于强制更新将会完成缓存流,这意味着在组件中不会再接收到值。通知流 ( initialNotifications$
) 基本就是死掉了。这不是正确的结果,那么咱们如何来修复它呢?
至关简单!咱们监听 forceReload$
发出的事件,将其每一个发出的值都切换成一个新的通知流。这里取消对前一个流的订阅很重要。耳边是否回荡起铃声?就好像在告诉咱们这里须要使用 switchMap
。
咱们来动手实现代码!
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { merge } from 'rxjs/observable/merge';
import { take, switchMap, mergeMap, skip, mapTo } from 'rxjs/operators';
@Component({
...
})
export class JokeListComponent implements OnInit {
showNotification$: Observable<boolean>;
update$ = new Subject<void>();
forceReload$ = new Subject<void>();
...
ngOnInit() {
...
const reload$ = this.forceReload$.pipe(switchMap(() => this.getNotifications()));
const initialNotifications$ = this.getNotifications();
const show$ = merge(initialNotifications$, reload$).pipe(mapTo(true));
const hide$ = this.update$.pipe(mapTo(false));
this.showNotification$ = merge(show$, hide$);
}
getNotifications() {
return this.jokeService.jokes.pipe(skip(1));
}
...
}
复制代码
就这样。每当 forceReload$
发出值,咱们就取消对前一个 Observable 的订阅,而后切换成一个全新的通知流。注意,这里有一行代码咱们须要调用两次,就是 this.jokeService.jokes.pipe(skip(1))
。为了不重复,咱们建立了函数 getNotifications()
,它返回笑话列表的流,但会跳过第一个值。最后,咱们将 initialNotifications$
和 reload$
合并成一个名为 show$
的流。这个流负责在屏幕上显示通知。另外没有必要取消 initialNotifications$
的订阅,由于它会在缓存从新建立以前完成。其他的都保持不变。
嗯,咱们作到了。咱们来花点时间看看咱们刚刚实现内容的弹珠图。
正如在图中所看见的,对于显示通知来讲,initialNotifications$
十分重要。若是没有这个流的话,咱们只能在强制缓存更新后才有机会看到通知。也就是说,当咱们按需请求最新数据时,咱们必须不断地切换成新的通知流,由于前一个(旧的) Observable 已经完成并再也不发出任何值。
就是这样!咱们使用 RxJS 和 Angular 提供的工具实现了一个复杂的缓存机制。简答回顾下,咱们的服务暴露出一个流,它为咱们提供笑话列表。每隔 10 秒会触发 HTTP 请求来更新缓存。为了提高用户体验,咱们提供了更新通知,这样用户能够执行更新 UI 的操做。在此之上,咱们还为用户提供了一种按需请求最新数据的方式。
太棒了!这就是完整的解决方案。花费几分钟再来看一遍代码。而后尝试不一样的场景以确认是否一切都能正常运行。
第 4 阶段在线 Demo: 点击查看。
若是你稍后想要作些课后做业或开发脑力的话,这有几点改进想法:
特别感谢 Kwinten Pisman 帮助我完成代码的编写。我还要感谢 Ben Lesh 和 Brian Troncone 给予我有价值的反馈和提出一些改进点。此外,还要很是感谢 Christoph Burgdorf 对于文章和代码的审查。