基于rxjs 的前端缓存(rxjs 多播)

1,612 阅读5分钟

背景

对于前端而言,在某些场景下,我们需要对后端请求的数据做缓存。通过这种方式,一来可以提高应用的响应速度,特别是某些接口比较费时,相关的数据变化又不很频繁。二来可以减轻后端服务器的压力,毕竟不用每一次都向后端发送请求。

现状

目前接触的项目中,针对前端缓存,可以通过以下的三种方式实现:

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

另一个我们还需要使用到的概念就是SubjectSubject既可以作为Observer 去订阅上游的Observable,也可以作为Observable 被下游的Observer 订阅。 它可以将一个冷流转化为一个热流。 92028dca5289db620fa10a19223ec4b.png 在上图中,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 并没有拿到01,因为这两个数据在它订阅之前已经被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 中还有一个ReplaySubjectReplaySubject 可以对数据进行缓存,缓存的大小通过构造参数进行控制。如果使用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 操作符简单实现了一个请求数据的缓存,代码虽然比较简单,但是如果项目中有缓存的需要,可以直接进行使用。