背景
对于前端而言,在某些场景下,我们需要对后端请求的数据做缓存。通过这种方式,一来可以提高应用的响应速度,特别是某些接口比较费时,相关的数据变化又不很频繁。二来可以减轻后端服务器的压力,毕竟不用每一次都向后端发送请求。
现状
目前接触的项目中,针对前端缓存,可以通过以下的三种方式实现:
1、Apollo
项目中我们使用graphql 与后端进行通信,前端的Apollo 可以通过下面的方式进行配置。配置以后,对于后端返回的数据,都会进行缓存。当第二次向后端请求相同的数据时,就不会再发送http 请求了。
@NgModule({
imports: [ApolloModule],
providers: [
{
provide: APOLLO_OPTIONS,
useFactory() {
return {
cache: new InMemoryCache(options)
};
},
}],
})
class AppModule {}
2、Ngrx
Ngrx 状态管理从某种意义上来说也可以作为前端的缓存使用。我们从后端取回数据以后,存到Ngrx 的store 里面,后续的操作就基于store 里面的数据,不再向后端进行请求。
3、Rxjs
有时候,如果没有对Apollo进行缓存配置,也不想把数据放在Ngrx 里面,还可以把后端返回的数据存入Rxjs 流里面。使用Rxjs 流对数据进行缓存。
基于Rxjs 的缓存
我们看看如何通过Rxjs 来缓存后端数据。在这之前,我们先看看Rxjs 中关于冷流、热流和Subject的概念。
冷流
在Rxjs 里面,如果一个流的数据产生依赖于Observer,那么这个流就是冷流。比如说如下这样的一个流:
const cold$ = interval(1000).pipe(take(3));
当我们对这个流进行订阅
cold$.subscribe({
next: value => console.log('observer 1 ', value),
complete: () => console.log('observer 1 completed')
});
setTimeout(() => {
cold$.subscribe({
next: value => console.log('observer 2 ', value),
complete: () => console.log('observer 2 completed')
})
}, 1500);
打印结果为
observer 1 0
observer 1 1
observer 2 0
observer 1 2
observer 1 completed
observer 2 1
observer 2 2
observer 2 completed
第0s 的时候Observer 1 订阅了cold$,cold$ 开始产生数据。1.5s 以后,Observer 2 订阅cold$,cold$ 又开始重新产生数据。最后Observer 1 和Observer 2 都接收到了0, 1, 2, completed 信息,cold$ 产生了两次数据。
对于冷流而言,每一次的订阅都会使得数据源被重新触发,所有的数据重新生成一次。
热流
与冷流相对应的就是热流,如果一个流产生数据与Observer 没有关系。有Observer 也产生,没有Observer 也产生,那么这个流就是热流。最常见的就是通过fromEvent 产生的dom 事件。
Subject、share
另一个我们还需要使用到的概念就是Subject。Subject既可以作为Observer 去订阅上游的Observable,也可以作为Observable 被下游的Observer 订阅。 它可以将一个冷流转化为一个热流。
在上图中,Subject 位于冷流和Observer 之间,它接收一个冷流作为输入。下游的Observer 不再直接订阅冷流,而是订阅这个Subject。
const cold$ = interval(1000).pipe(take(3));
const hot$ = cold$.pipe(share())
hot$.subscribe({
next: value => console.log('observer 1 ', value),
complete: () => console.log('observer 1 completed')
});
setTimeout(() => {
hot$.subscribe({
next: value => console.log('observer 2 ', value),
complete: () => console.log('observer 2 completed')
})
}, 2500);
在上面的代码中,我们通过interval 产生了一个冷流,这个冷流通过share 操作符(share 操作符会使用一个Subject 去订阅上游的冷流),返回了一个热流。observer 1 订阅这个热流,2.5s 以后,observer 2也订阅这个热流。最后打印的结果为:
observer 1 0
observer 1 1
observer 1 2
observer 2 2
observer 1 completed
observer 2 completed
可以看到observer 2 并没有拿到0 和1,因为这两个数据在它订阅之前已经被observer 1消费掉了。
如果把上面代码稍微改一下,
const cold$ = interval(1000).pipe(take(3));
const hot$ = cold$.pipe(share())
hot$.subscribe({
next: value => console.log('observer 1 ', value),
complete: () => console.log('observer 1 completed')
});
setTimeout(() => {
hot$.subscribe({
next: value => console.log('observer 2 ', value),
complete: () => console.log('observer 2 completed')
})
}, 4000);
现在我们在4s 的时候,才用observer 2 去订阅返回的热流。这时候应该打印什么呢?按照我们刚刚的说法,hot$ 的数据产生不依赖于Observer,而且hot$ 已经完结了。那么observer 2应该接收不到数据。但是打印的结果却是
observer 1 0
observer 1 1
observer 1 2
observer 1 completed
observer 2 0
observer 2 1
observer 2 2
observer 2 completed
可以看到observer 2 接收到了所有的数据。产生这个情况的原因是,share 这个操作符做了额外的处理。当作为中间人的Subject 完结了以后,如果有新的Observer 订阅,就会重新生成一个新的Subject 对象。这个新的Subject 对象会订阅冷流,使得冷流又重新生成一次数据,所以observer 2 能获得所有的数据。使用multicast(() => new Subject()), refCount() 可以达到和share 操作符类似效果,不过新版的Rxjs 中已经不建议使用multicast操作符了。
shareReplay
刚刚我们使用share 操作符将一个冷流转化为热流。如果看一下Rxjs 的源码,我们会发现share 直接使用了一个最简单的Subject 对象来订阅冷流。在Rxjs 中还有一个ReplaySubject,ReplaySubject 可以对数据进行缓存,缓存的大小通过构造参数进行控制。如果使用ReplaySubject 去订阅上游的冷流呢?操作符shareReplay 就是这样做的,使用
ReplaySubject 进行订阅。
既然ReplaySubject 可以对数据进行缓存,那么我们现在现在看看shareReplay 操作符的作用:
const cold$ = interval(1000).pipe(take(3));
const hot$ = cold$.pipe(shareReplay(1))
hot$.subscribe({
next: value => console.log('observer 1 ', value),
complete: () => console.log('observer 1 completed')
});
setTimeout(() => {
hot$.subscribe({
next: value => console.log('observer 2 ', value),
complete: () => console.log('observer 2 completed')
})
}, 4000);
我们将前面的share 操作符换成了shareReplay(1),这里的1 表示将缓存最近的一个数据。
observer 1 0
observer 1 1
observer 1 2
observer 1 completed
observer 2 2
observer 2 completed
我们可以看到运行的结果中,当observer 2 4s 以后去订阅hot$,由于shareReplay 缓存了最后一个数据,所以这时observer 2 获得了最后返回的那个数据。
缓存后端请求
export class AnimalService {
constructor(private httpClient: HttpClient) { }
getDog(): Observable {
return this.httpClient.get('/dog');
}
}
上面的代码中,调用getDog 方法会返回一个请求后端数据的流。在组件A 和组件B 中,分别订阅了这个流。
组件A
export class AComponent implements OnInit{
public dog: Dog;
constructor(private animalService: AnimalService ) {}
ngOnInit(): void {
this.animalService.getDog()
.subscribe((dog) => {
this.dog = dog;
});
}
}
组件B
export class BComponent implements OnInit{
public dog: Dog;
constructor(private animalService: AnimalService ) {}
ngOnInit(): void {
this.animalService.getDog()
.subscribe((dog) => {
this.dog = dog;
});
}
}
由于向后端请求数据的流是一个冷流,在组件A 订阅的时候,会向后端发送请求。在组件B 订阅的时候,又会向后端发送请求。
接下来我们使用shareReplay 对后端请求返回的数据进行缓存。
export class AnimalService {
private cachedDog$: Observable;
constructor(private httpClient: HttpClient) { }
getDog(): Observable {
if (!this.cachedDog$) {
this.cachedDog$ = this.httpClient.get('/dogs').pipe(shareReplay(1));
}
return this.cachedDog$;
}
invalidateCachedDog() {
this.cachedDog$ = null;
}
}
我们在AnimalService 里面增加了一个缓存数据的流cachedDog$,这个流经过shareReplay 操作符变成了一个热流,并且缓存最近的一个值。
在组件A 调用getDog时,cachedDog$ 初始值为undefined,进入if 块,cachedDog$ 被赋值为this.httpClient.get('/dogs').pipe(shareReplay(1)),返回cachedDog$。订阅这个流,向后端发送请求,并且会在请求回来以后,把值进行缓存。
组件B 调用getDog的时候,会直接返回cachedDog$ 这个流,订阅这个流,会返回shareReplay 缓存的值,不会向后端发送请求。通过这种方式,我们就达到了缓存的效果。
如果希望缓存的值失效,可以直接调用invalidateCachedDog 方法,直接将cachedDog$ 设为null。下一次调用getDog的时候,就会重新向后端请求了。
更新缓存
但是调用invalidateCachedDog 方法,只会在下一次调用getDog并订阅的时候才会重新请求。如果我现在已经订阅了getDog,并不会获得最新值。
比如说
组件A
export class AComponent implements OnInit{
public dog: Dog;
constructor(private animalService: AnimalService ) {}
ngOnInit(): void {
this.animalService.getDog()
.subscribe((dog) => {
this.dog = dog;
});
}
}
组件B
export class BComponent implements OnInit{
public dogs: Dog;
constructor(private animalService: AnimalService ) {}
click(): void {
this.animalService.invalidateCachedDog();
}
}
如果组件A 已经调用ngOnInit 了方法,订阅了缓存。这个时候,组件B 调用click 方法清空缓存。组件A 里面的数据是不会更新的,除非重新触发ngOnInit 方法。
我们可以再给cachedDog$ 加一个刷新缓存的流refreshCachedDog$。
class AnimalService {
private cachedDog$: Observable<Dog>;
private refreshCachedDog$ = new BehaviorSubject(null);
constructor(private httpClient: HttpClient) { }
public getDog(): Observable<Dog> {
if (!this.cachedDog$) {
this.cachedDog$ = this.refreshCachedDog$
.pipe(
switchMap(() => this.httpClient.get('/dogs')),
shareReplay(1)
);
}
return this.cachedDog$;
}
public invalidateCachedDog(): void {
this.cachedDog$ = null;
}
public refreshCachedDog(): void {
this.refreshCachedDog$.next(null);
}
}
如果希望组件A 里面的数据更新,可以在组件B 的click 里面调用refreshCachedDog 方法,触发缓存数据的更新。
总结
我们首先看了当前项目中缓存后端数据的三种方式。接着介绍了如果用Rxjs 进行缓存所需要的知识,包括冷流、热流、Subject,shareReplay 操作符。最后使用shareReplay 操作符简单实现了一个请求数据的缓存,代码虽然比较简单,但是如果项目中有缓存的需要,可以直接进行使用。