使用RXJS實現高級緩存

引言

在構建Web應用程序時,性能應始終是頭等大事。我們可以採取許多措施來加快Angular應用程序的運行速度,例如Tree-Shaking,AoT(提前編譯),懶加載模塊或緩存。爲了提高關於Angular應用程序性能實踐方面的全面瞭解,我們強烈建議您查看 Minko Gechev撰寫的Angular Performance Checklist

在這篇文章中,我們專注於緩存。

實際上,緩存是提升我們的網站的性能的最有效的方法,尤其是用戶在使用帶寬受限或者低速網絡的時候。
有幾種緩存數據或資源的方法。 靜態資源最常用的是使用標準瀏覽器緩存或Service Worker。 儘管Service Workers也可以緩存API請求,但它們通常對於緩存圖像,HTML,JS或CSS文件等資源更爲有用。 爲了緩存應用程序數據,我們通常使用自定義機制。
無論我們使用哪種機制,緩存通常都會提高應用程序的響應速度,降低網絡成本,並具有在網絡中斷期間網站內容仍可用的優勢。 換句話說,當網站內容因緩存而距離使用者更近時,例如在客戶端,請求不會引起額外的網絡活動,並且緩存的數據可以更快地查找到,因爲我們節省了整個網絡往返的時間。

在本文中,我們將使用RxJS和Angular提供的工具開發高級緩存機制。

我認爲如果你讀完本文並完全理解,那麼你將對RxJS的使用有深刻的認識。你也會更加清楚在你的項目裏何時何地去使用RxJS。同時你會瞭解到許多常用的操作符具體的用法和意義。

動機

時不時地我們的腦海中經常會浮現一個問題,即如何在到處使用Observable的Angular應用程序中緩存數據。 大多數人對如何使用Promises緩存數據有很好的瞭解,但是由於複雜性(大型API),思維方式的根本轉變(從命令式到聲明式)以及衆多概念,在函數式/響應式編程方面會感到不知所措。因此,很難將基於Promises的現有緩存機制實際轉換爲Observables,尤其是如果您希望該機制更高級的話。

在Angular應用程序中,我們通常通過HttpClientModuleHttpClient執行HTTP請求。 它的所有API都是基於Observable的,這意味着諸如get,post,put或delete之類的方法會返回Observable。 因爲Observable本質上是Lazy的,所以僅當我們調用subscribe時才發出請求。 但是,在同一個Observable上多次調用subscribe將導致一遍又一遍地重新創建源Observable,並因此對每個訂閱執行一個請求。 我們稱此爲“cold Observables”。

如果你對這個概念不熟悉的話,我們已經寫了一篇文章Cold vs Hot Observables

Angular的這種基於Observable的Http請求可能導致使用Observables實現緩存機制變得棘手。 雖然也有簡單的方法,但是通常需要大量的樣板文件,可能最終可以繞過RxJS。這雖然可行,但是如果我們想要利用Observable的強大功能,不推薦用這樣的方法。 簡單來說,我們都不會想駕駛一輛馬車引擎的法拉利,對吧?

需求

在深入研究代碼之前,讓我們先定義這種高級緩存機制的需求。

我們想要構建一個名爲World of Jokes的應用程序。 這是一個簡單的應用,可以隨機顯示給定類別的笑話。 爲了簡單明瞭和集中注意力,只有一個類別。

該應用程序包含三個組件:AppComponentDashboardComponentJokeListComponent

AppComponent是我們的入口點,它呈現工具欄以及根據當前路由器狀態填充的
<router-outlet>DashboardComponent僅顯示類別列表。 從這裏,我們可以導航到JokeListComponent,然後將笑話列表呈現到屏幕上。

這些笑話本身是使用Angular的HttpClient服務從服務器中提取的。 爲了使組件的職責集中並且分離關注點,我們希望創建一個JokeService來處理請求數據的任務。然後,該組件可以簡單地注入服務並通過其公共API訪問數據。

以上所有隻是我們應用程序的架構,還沒有涉及緩存。

從儀表板導航到列表視圖時,我們更喜歡從緩存請求數據,而不是每次都從服務器請求數據。此緩存的基礎數據將每10秒更新一次。

當然,對於生產應用而言,每10秒輪詢一次新數據並不是一個可靠的策略,我們寧願使用更復雜的方法來更新緩存(例如,Web套接字推送更新)。 但是,我們將在此處嘗試簡化操作,以專注於緩存方面。

無論如何,我們都會收到某種更新通知。對於我們的應用程序,我們希望UI(JokeListComponent)中的數據在緩存更新時不自動更新,而是等待用戶強制執行UI更新。爲什麼?設想一個用戶可能正在讀其中一個笑話,但是突然之間,數據消失了,因爲數據是自動更新的。那將是超級煩人和糟糕的用戶體驗。因此,只要有新數據可用,我們的用戶就會收到通知。但是是否執行更新將由用戶來決定。

爲了使其更加有趣,我們希望用戶也能夠強制更新。這與僅更新UI不同,因爲強制更新意味着先要從服務器請求數據,然後更新緩存,然後相應地更新UI。

讓我們總結一下我們要構建的內容:

  • 我們的應用包含兩個組件,從組件A導航到組件B時,應優先從緩存請求B的數據,而不是每次從服務器請求B的數據
  • 緩存每10秒更新一次
  • UI中的數據不會自動更新,並且需要用戶強制執行更新
  • 用戶可以隨時進行強制更新,這將導致網絡請求,然後更新緩存和UI

最終效果會是這樣的:

實現基礎的緩存

讓我們從簡單開始,一步步到最終的,成熟的解決方案。

第一步是創建新服務。

接下來,我們將添加兩個接口,一個接口描述Joke,另一個接口用於封裝HTTP請求返回數據的類型。 這比較符合TypeScript的代碼風格,但最重要的是,它使代碼開發更方便和易讀。

export interface Joke {
  id: number;
  joke: string;
  categories: Array<string>;
}

export interface JokeResponse {
  type: string;
  value: Array<Joke>;
}

現在,我們實現JokeService。我們不想顯示數據是從緩存中提供還是從服務器中請求的實現細節,因此我們僅暴露一個Joke屬性,返回一個獲取Jokes列表的Observable。

爲了執行HTTP請求,我們需要確保在服務的構造函數中注入HttpClient服務。

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)
    );
  }
}

有了這個,我們就擁有實現Joke的getter方法的一切了。

一種簡單的方法是直接返回this.requestJokes(),但這並不能解決問題。我們已經知道HttpClient公開的所有方法(例如get)都返回Code Observables。這意味着將爲每個訂閱者重新發射整個數據流,從而導致HTTP請求的開銷。畢竟,緩存是爲了加快應用程序的加載時間,並將網絡請求數量限制爲最小。

相反,我們想讓流變得很熱。不僅如此,每個新訂閱者都應收到最新的緩存值。其實有一個非常方便的操作符叫做 shareReplay。此運算符返回一個Observable,該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)
    );
  }
}

我們已經討論過上面代碼裏的大部分內容。但是,等等,私有屬性cache$和getter裏的if語句是咋回事?答案很簡單。 如果我們直接返回this.requestJokes().pipe(shareReplay(CACHE_SIZE)),則每個訂閱者都將創建一個新的緩存實例。 但是,我們希望在所有訂閱者之間共享一個實例。 因此,我們將實例保存在私有屬性cache$中,並在首次調用getter時對其進行初始化。 所有後續的訂閱者都可以接收到共享實例,而無需每次都重新創建緩存。

讓我們更加形象的看一下我們剛剛實現的東西:

上圖描述了我們的場景中涉及的對象,即請求一個Joke列表以及這些對象之間交換消息的次序。讓我們細分一下,以瞭解發生了什麼。

我們從導航到列表組件的dashboard開始。

在初始化組件和Angular調用ngOnInit生命週期之後,我們通過調用JokeService暴露的getter函數jokes來請求jokes列表。由於這是我們第一次請求數據,因此緩存本身爲空且尚未初始化,這意味着JokeService.cache$現在是undefined。在get函數內部,我們調用requestJokes()。這將爲我們提供一個可以從服務器發出數據的Observable。同時,我們使用shareReplay運算符來做我們期望的事情。

shareReplay運算符會在原始源和所有將來的訂閱者之間自動創建一個ReplaySubject(關於這個譯者之前寫過博客介紹:徹底理解RxJS裏面的Observable 、Observer 、Subject
)。 一旦訂閱者數量從零增加到一,它將把Subject連接到基礎源Observable並廣播其所有值。 所有將來的訂閱者都將被連接到介於兩者之間的那個Subject,因此實際上只對基礎源Code Observable進行了一個訂閱。這稱爲多播,它是我們實現簡單緩存的基礎。

一旦數據從服務器返回就會被緩存。

注意cache$在消息交換序列圖中是一個獨立的對象,它應該是用來代表在使用者(subscribers)和基礎源(HTTP請求)之間創建的ReplaySubject的。

下一次我們在列表頁面請求數據,緩存會立即重放最近的數據而且把數據發送給消費者。沒有發生Http請求。

很簡單,是吧?

爲了真正理解這一點,讓我們更進一步,看一下緩存在Observable級別的工作方式。 爲此,我們使用marble圖來可視化觀察數據流是如何工作的:

marble圖清楚地表明,基礎源頭Observable只有一個訂閱,而所有消費者都簡便的訂閱了共享Observable,即ReplaySubject。 我們還可以看到,只有第一個訂閱者觸發了HTTP調用,所有其他訂閱者都獲得了最新值的重播。

最後,讓我們看一下JokeListComponent以及如何顯示數據。 第一步是注入JokeService。 之後,在ngOnInit內部,我們初始化屬性jokes$,它是使用服務暴露的getter函數返回的值。getter會返回類型爲Array<Joke>的Observable,而這正是我們想要的。

@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>

Cool! 這是我們的簡單緩存的實現。 要驗證該Http請求是否僅發出一次,請打開Chrome的DevTools,點擊“Network”標籤,然後選擇XHR。 在Dashboard上開始,進入列表視圖,然後來回導航。

自動更新

到目前爲止,我們已經在幾行代碼中構建了一個簡單的緩存機制。 實際上,大部分繁重的工作都是由 shareReplay運算符完成的,該運算符負責緩存和重播最新值。

這樣可以很好地工作,但是數據永遠不會在後臺實際更新。 如果數據可能每隔幾分鐘更改一次怎麼辦? 我們當然不希望強迫用戶重新加載整個頁面只是爲了從服務器獲取最新數據。

如果我們的緩存每10秒在後臺更新一次,那會很酷嗎?當然!作爲用戶,我們不必重新加載頁面,並且如果數據已更改,則用戶界面也會相應更新。同樣,在實際應用程序中,我們很可能甚至不使用輪詢,而是使用服務器推送通知。對於我們的小型演示應用程序,刷新間隔爲10秒就可以了。

實現起來很容易。簡而言之,我們想創建一個Observable,它發出以給定時間間隔隔開的一系列數據,或者簡單地說,我們想每X毫秒產生一個值。爲此,我們有幾種選擇。

第一種選擇是使用interval。 該運算符采用一個可選的參數period,該週期定義了每次發射之間的時間。示例:

import { interval } from 'rxjs/observable/interval';

interval(10000).subscribe(console.log);

在這裏,我們設置了一個Observable,它發出無限的整數序列,其中每個值每10秒發射一次。 這也意味着第一個值在一定程度上被給定的時間間隔延遲了。 爲了更好地演示這種行爲,讓我們看一下interval的marble圖。

是的,正如預期的那樣。 第一個值延遲了10s,這不是我們想要的。 爲什麼? 因爲如果我們來到dashboard並導航至joke列表組件以閱讀一些有趣的joke,那麼我們將需要等待10秒鐘,然後才從服務器請求數據並將其呈現到屏幕上。

我們可以通過引入另一個稱爲startWith(value)的運算符來解決此問題,該運算符一開始會發出給定的value作爲初始值。例如interval(10000).pipe(startWith(0))

但是我們可以做得更好。

如果我告訴你,有一個操作符可以在給定的持續時間(初始延遲)之後,然後在每個週期(間隔)之後發出一系列值呢?歡迎認識timer

很酷,但這可以解決我們的問題嗎? 是的,當然可以。 如果將initialDelay設置爲零(0)並將period設置爲10秒,則最終會出現與使用interval(10000).pipe(startWith(0))相同的行爲,但我們僅使用了一個運算符。

讓我們將其集成到現有的緩存機制中。

我們必須設置一個timer,對於每個滴答,我們都想發出一個HTTP請求以從服務器獲取新數據。也就是說,對於每個滴答聲,我們都需要switchMap一個Observable,訂閱時去獲取一個新的joke列表。使用switchMap有一個積極的影響,那就是避免多次請求形成競爭情況。 這是由於該運算符的性質,它會unsubscribe之前的Observable,而僅從最近的Observable發射數據。

我們其餘的緩存保持不變,這意味着我們的流仍然是多播的,並且所有訂閱者共享一個基礎源。

再次說明, shareReplay會將新的值廣播給已有的訂閱者,將最近的值重播給新的訂閱者。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-cNtleqA6-1590053457082)(https://user-gold-cdn.xitu.io/2020/5/20/1723190fa227af79?w=1801&h=1273&f=png&s=22409)]

正如我們在上圖看到的,Timer每10s發射一個值,對每個值我們都將其轉換爲內部Observable來獲取數據。因爲我們使用了switchMap,所以我們避免了競爭情況。因此消費者只接收到13。第二個內部Observable被跳過了因爲當值到達的時候我們已經取消訂閱了。

讓我們將我們學習到的知識應用起來並相應的更新JokeService

import { timer } from 'rxjs/observable/timer';
import { switchMap, shareReplay } from 'rxjs/operators';

const REFRESH_INTERVAL = 10000;
const CACHE_SIZE = 1;

@Injectable()
export class JokeService {
  private cache$: Observable<Array<Joke>>;

  constructor(private http: HttpClient) { }

  get jokes() {
    if (!this.cache$) {
      // Set up timer that ticks every X milliseconds
      const timer$ = timer(0, REFRESH_INTERVAL);

      // For each tick make an http request to fetch new data
      this.cache$ = timer$.pipe(
        switchMap(_ => this.requestJokes()),
        shareReplay(CACHE_SIZE)
      );
    }

    return this.cache$;
  }

  ...
}

發送更新通知

讓我們回顧一下到目前爲止已完成的工作。

當我們從JokeService請求數據時,我們總是優先從緩存請求數據,而不是每次都從服務器請求數據。此緩存的基礎數據每10秒刷新一次,當這種情況發生時,數據將傳播到組件,導致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可以同時實現 ObserverObservable。Observables定義數據流併產生數據,而Observers可以訂閱可觀察對象並接收數據。

Subject的好處是,我們可以簡單地在模板中使用事件綁定,然後在觸發事件時調用next。這時它會將指定的值廣播到所有正在監聽值的觀察者。注意,如果Subject爲void類型,我們也可以省略該值,這對於我們的情況正好是對的。

關於Subject的內容,在之前譯者的文章 徹底理解RxJS裏面的Observable 、Observer 、Subject
中也都解釋過。

我們繼續,實例化一個新的Subject:

import { Subject } from 'rxjs/Subject';

@Component({
 ...
})
export class JokeListComponent implements OnInit {
 update$ = new Subject<void>();
 ...
}

現在我們可以在模版裏面使用它:

<div class="notification">
  <span>There is 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>上的click事件了嗎? 當我們單擊按鈕時,我們只是傳播一個虛值,使所有正常的觀察者得到通知。 之所以稱其爲虛值,是因爲我們實際上沒有傳入任何值,或者至少沒有傳入void類型的值。

另一種方法是將@ViewChild()裝飾器與RxJS的fromEvent操作符結合使用。 但是,這需要我們直接操作DOM並從視圖中查詢HTML元素。 使用Subject,我們實際上只是在兩個方面架起了橋樑,除了我們要添加到按鈕上的事件綁定之外,根本沒有接觸DOM。

好的,現在視圖這邊的工作都搞好了,讓我們來切換到負責更新UI的邏輯。

所以更新UI意味着什麼呢?緩存會在後臺自動更新,當我們點擊那個按鈕的時候我們希望渲染緩存中的最新值,對吧?這意味着在這種情況下,我們的源數據流是Subject。 每次在update$上廣播一個值時,也就是每次點擊’Update’按鈕時,我們都希望將此次發出的值映射到一個Observable上,從而爲我們提供最新的緩存值

之前我們已經知道switchMap操作符可以完全解決這種問題。這次我們使用mergeMap來代替它。這個操作符和switchMap非常相似,區別在於它不會取消訂閱先前投影的內部Observable,而只是將內部發射合併到輸出Observable中。

也就是this.update$.pipe(mergeMap(()=>this.getDataOnce()));。而getDataOnce方法也就是this.jokeService.jokes.pipe(take(1));

實際上,當從緩存中請求最新值時,HTTP請求已經完成,並且緩存已成功更新。 因此,我們在這裏並沒有真正面臨競爭情況的問題。 儘管它似乎是異步的,但實際上有點同步,因爲該值將在同一“滴答”內發出。

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())
    );
    ...
  }
  ...
}

Cool! 對於每次“更新”,我們都使用我們之前實現的getDataOnce方法從緩存中請求最新值。

從這裏之後我們只差一小步就能拿到需要在屏幕上渲染的Jokes列表。我們要做的就是把初始的Jokes列表和我們的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()使得每個更新事件發生時,都會去獲取最新的緩存值。 如果我們還記得的話,getDataOnce在內部使用take(1),它只取第一個值然後就會結束流。這很關鍵,否則我們將獲得持續不斷的流或與緩存的實時連接, 這會導致我們只希望通過單擊“更新”按鈕來執行UI更新的邏輯無法實現。

另外,由於底層緩存是多播的,因此始終重新訂閱該緩存以獲取最新值是完全沒有問題的。

在我們繼續實現Notification數據流之前,我們先來花一點時間使用marble圖看看我們已經實現的東西。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-go3E6Isi-1590053457083)(https://user-gold-cdn.xitu.io/2020/5/21/172350d31700988c?w=2119&h=1815&f=png&s=28825)]

如上圖所示,initialJokes$是非常重要的,否則我們只有在單擊“更新”時才能屏幕上看到一些內容出現。現在雖然數據已經每10秒在後臺更新一次,但我們無法點擊此按鈕。這是因爲按鈕是通知的一部分,而我們沒有將通知顯示給用戶。

讓我們填補這一空白,來實現缺少的功能。

爲此,我們必須創建一個Observable來負責顯示或隱藏通知。本質上,我們需要一個發出truefalse的流。我們希望這個流在有更新來到時爲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$監聽緩存發出的所有值,但是跳過了第一個值,因爲它不是刷新。對於initialNotifications$上發出的每個新值,我們將其映射爲true以顯示通知。一旦我們單擊通知中的“更新”按鈕,就會在update$上生成一個值,我們可以簡單地將其映射爲false,從而使通知消失。

我們在JokeListComponent 的模版裏面使用showNotification$來做爲一個開關,顯示或者隱藏通知。

<div class="notification" [class.visible]="showNotification$ | async">
  ...
</div>

好極了! 我們真的很接近最終解決方案。 但是在繼續之前,讓我們嘗試一下並進行現場演示。 請花一些時間,並逐步逐步執行代碼。

手動獲取最新數據

太棒了!我們已經走了很長一段路,並且已經爲我們的緩存實現了一些非常酷的功能。要結束本文並把我們的緩存提升到一個全新的水平,我們還有一件事要做。作爲用戶,我們希望能夠在任何時間點強制進行更新。

其實並沒有那麼複雜,但是我們必須同時涉及組件和服務才能使其正常工作。

讓我們從我們的服務開始。我們需要一個API來強制緩存去重新加載數據。從技術上講,我們將結束當前的緩存並將其設置爲null。這意味着下次我們從服務中請求數據時,我們將建立一個新的緩存,獲取數據並將其存儲給以後的訂閱者。每次我們執行更新時,創建新的緩存都沒什麼大不了的,因爲它會完成並最終被垃圾回收。實際上,這具有積極的作用,即我們也必須重置計時器,這是絕對需要的。假設我們已經等待了9秒鐘,然後點擊“獲取新Joke”。我們希望數據會刷新,但是1秒鐘後不會彈出通知。也就是說,我們希望重新啓動timer,以便在強制執行更新之後又需要10秒鐘才觸發自動更新。

銷燬緩存的另一個原因是,與保持緩存始終運行的機制相比,它的複雜度要低得多。因爲如果是緩存始終運行,則緩存需要知道是否執行了重新加載。

讓我們創建一個Subject類reload$用來告訴緩存可以結束了。 我們將利用takeUntil操作符將其放入我們的cache$流中。 此外,我們實現了在內部將緩存設置爲null,並在reload$上廣播事件的forceReload方法。

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() {
    // Calling next will complete the current cache instance
    this.reload$.next();

    // Setting the cache to null will create a new cache the
    // next time 'jokes' is called
    this.cache$ = null;
  }

  ...
}

僅此一項還不夠,所以讓我們繼續在JokeListComponent中使用服務裏的forceReload。 爲此,我們將實現一個forceReload()函數,只要我們單擊“FETCH NEW JOKES”的按鈕,就會調用該函數。 此外,我們需要創建一個Subject,用作EventBus以更新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$嗎?當我們單擊“更新”按鈕時,會從緩存中請求最新數據this.jokeSevice.jokes.pipe(take(1))。而現在我們需要完全相同的行爲,因此我們可以繼續擴展此流。 這意味着我們必須同時合併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())
    );
    ...
  }
  ...
}

很簡單對吧,不過我們還沒完成。其實我們這樣破壞了notifications$。一切都很正常,直到我們點擊“Fetch new Jokes”按鈕。數據在屏幕上和緩存中都已經更新了,但是當我們等待10S之後並沒有出現通知框。因爲強制更新會銷燬緩存實例(forceReload方法裏的this.cache$ = null;),意味着我們在組件裏的initialNotifications$(this.jokeService.jokes.pipe(skip(1));)也就接收不到任何值。那麼我們如何修復這個問題呢?

很簡單!我們監聽forceReload$上的事件,併爲每個值切換到新的通知流。
重要的是我們要unsubscribe上一個流。 聽起來這裏好像很需要switchMap,不是嗎?也就是this.forceReload$.pipe(switchMap(() => this.getNotifications()));

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$發出一個值時,也就是用戶每次點擊FETCH NEW JOKES按鈕,我們就會從先前的Observable取消訂閱並切換到新的通知流。 請注意,這裏有一些代碼是我們需要做兩次的,即this.jokeService.jokes.pipe(skip(1))。 我們沒有重複代碼,而是創建了一個函數getNotifications(),該函數僅返回一個Jokes流,但跳過第一個值。 最後,我們將initialNotifications$reload$合併到一個稱爲show$的流中。 這個流負責在屏幕上顯示通知。 也無需取消訂閱initialNotifications$,因爲此流在下一次訂閱重新創建緩存之前就已經結束了。其餘的保持不變。

讓我們花點時間看一下我們剛剛實現的內容的更直觀的表示。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-UyHGu2dV-1590053457088)(https://user-gold-cdn.xitu.io/2020/5/21/1723543f75c6f031?w=2956&h=3280&f=png&s=76506)]

正如我們在marble圖中看到的那樣,initialNotifications$對於顯示通知非常重要。 如果我們缺少此流,則僅當我們強制更新緩存時纔會看到通知。 就是說,當我們自己直接從服務器請求新數據時,我們必須不斷切換到新的通知流,因爲先前的Observable會結束,並且不再發出值。

我們已經做到了,並使用RxJS和Angular提供的工具實現了複雜的緩存機制。 回顧一下,我們的服務公開了一個流,該流向我們提供了一個Jokes列表。 HTTP請求每10秒鐘定期觸發一次以更新緩存。 爲了改善用戶體驗,我們顯示了一條通知,以便用戶自己強制更新UI。 最重要的是,我們還爲用戶提供了一種按需直接從服務器請求新數據的方法。

太棒了!這是最終的解決方案。

最終代碼文件:

app.component.ts

import { Component } from '@angular/core';
import { Router, NavigationEnd, RouterEvent } from '@angular/router';
import { Observable } from 'rxjs/Observable';
import { filter, map } from 'rxjs/operators';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: [ './app.component.scss' ]
})
export class AppComponent  {
  isRoot: Observable<boolean>;
  constructor(private router: Router) {}
  ngOnInit() {
    this.isRoot = this.router.events.pipe(
      filter(x => x instanceof NavigationEnd),
      map((x: RouterEvent) => x.url != '/')
    );
  }
}

app.component.html

<mat-toolbar color="primary">
  <mat-toolbar-row class="flex">
    <span class="stretch">World of Jokes</span>
    <button mat-icon-button>
      <mat-icon aria-label="Login">menu</mat-icon>
    </button>
  </mat-toolbar-row>
  <mat-toolbar-row class="cta-row" *ngIf="isRoot | async">
    <button class="back-button" mat-button routerLink="/">
      <mat-icon aria-label="Login">keyboard_arrow_left</mat-icon>
      Back to Dashboard
    </button>
  </mat-toolbar-row>
</mat-toolbar>

<router-outlet></router-outlet>

dashboard.component.ts

import { Component, OnInit } from '@angular/core';
@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  styleUrls: ['./dashboard.component.scss']
})
export class DashboardComponent implements OnInit {
  constructor() { }
  ngOnInit() {
  }
}

dashboard.component.html

<div class="container">
  <mat-card>
    <div class="card-content">
      <span class="stretch">Chuck Norris</span>
      <a color="accent" routerLink="jokes" mat-button>VIEW</a>
    </div>
  </mat-card>
</div>

joke.service.ts

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { timer } from 'rxjs/observable/timer';
import { switchMap, shareReplay, map, takeUntil } from 'rxjs/operators';

export interface Joke {
  id: number;
  joke: string;
  categories: Array<string>;
}

export interface JokeResponse {
  type: string;
  value: Array<Joke>;
}

const API_ENDPOINT = 'https://api.icndb.com/jokes/random/5?limitTo=[nerdy]';
const REFRESH_INTERVAL = 10000;
const CACHE_SIZE = 1;

@Injectable()
export class JokeService {
  private cache$: Observable<Array<Joke>>;
  private reload$ = new Subject<void>();

  constructor(private http: HttpClient) { }

  // This method is responsible for fetching the data.
  // The first one who calls this function will initiate 
  // the process of fetching data.
  get jokes() {
    if (!this.cache$) {
      // Set up timer that ticks every X milliseconds
      const timer$ = timer(0, REFRESH_INTERVAL);
          
      /* For each timer tick make an http request to fetch new data
         We use shareReplay(X) to multicast the cache so that all 
         subscribers share one underlying source and do not re-create 
         the source over and over again. We use takeUntil to complete
         this stream when the user forces an update.*/
      this.cache$ = timer$.pipe(
        switchMap(() => this.requestJokes()),
        takeUntil(this.reload$),
        shareReplay(CACHE_SIZE)
      );
    }

    return this.cache$;
  }

  // Public facing API to force the cache to reload the data
  forceReload() {
    this.reload$.next();
    this.cache$ = null;
  }

  // Helper method to actually fetch the jokes
  private requestJokes() {
    return this.http.get<JokeResponse>(API_ENDPOINT).pipe(
      map(response => response.value)
    );
  }
}

joke-list.component.ts

import { Component, OnInit } from '@angular/core';
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';

import { Memoize } from 'lodash-decorators';

import { JokeService, Joke } from '../joke.service';

@Component({
  selector: 'app-joke-list',
  templateUrl: './joke-list.component.html',
  styleUrls: ['./joke-list.component.scss']
})
export class JokeListComponent implements OnInit {
  jokes$: Observable<Array<Joke>>;
  showNotification$: Observable<boolean>;
  update$ = new Subject<void>();
  forceReload$ = new Subject<void>();

  constructor(private jokeService: JokeService) { }

  ngOnInit() {
    const initialJokes$ = this.getDataOnce();

    const updates$ = merge(this.update$, this.forceReload$).pipe(
      mergeMap(() => this.getDataOnce())
    );

    this.jokes$ = merge(initialJokes$, updates$);

    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$);
  }

  getDataOnce() {
    return this.jokeService.jokes.pipe(take(1));
  }

  getNotifications() {
    return this.jokeService.jokes.pipe(skip(1));
  }

  forceReload() {
    this.jokeService.forceReload();
    this.forceReload$.next();
  }

  @Memoize()
  getVotes(id: number) {
    return Math.floor(10 + Math.random() * (100 - 10));
  }
}

joke-list.component.html

<div class="notification" [class.visible]="showNotification$ | async">
  <span>There is 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>

<main>
  <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>

  <mat-card *ngFor="let joke of jokes$ | async">
    <div class="joke-content flex">
      <span class="vote">{{ getVotes(joke.id) }}</span>
      <span class="stretch" [innerHTML]="joke.joke"></span>
      <button mat-icon-button>
        <mat-icon class="heart" aria-label="Like">favorite</mat-icon>
      </button>
    </div>
  </mat-card>
</main>

展望

如果您以後需要一些家庭作業,請考慮以下改進建議:

  • 添加錯誤處理
  • 將組件中的邏輯重構爲服務以使其可重用

本文由本人翻譯自Dominic Elm的文章《Advanced caching with RxJS》,如果你需要使用此譯文,請與我聯繫。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章