[譯] RxJS 高階快取

SangKa發表於2018-08-17

原文連結: blog.thoughtram.io/angular/201…

本文為 RxJS 中文社群 翻譯文章,如需轉載,請註明出處,謝謝合作!

如果你也想和我們一起,翻譯更多優質的 RxJS 文章以奉獻給大家,請點選【這裡】

溫馨提示: 文章較長,原文中寫的是40分鐘閱讀,建議大家午後有大把空閒時間再慢慢讀來

開發 Web 應用時,效能始終都是重中之重。要想提升 Angular 應用的速度,我們可以做一些工作,比如要搖樹優化 (tree-shaking)、AoT (ahead-of-time)、模組的懶載入以及快取。想要對 Angular 應用的效能提升的實戰技巧有一個全面瞭解的話,我們強烈推薦你參考由 Minko Gechev 撰寫的 Angular 效能檢測表。在本文中,我們將專注於快取。

實際上,快取是提升網站使用者體驗的最有效的方式之一,尤其是當使用者使用寬頻受限的裝置或網路環境較差。

快取資料或資源的方式有很多種。靜態資源通常都是由標準的瀏覽器快取或 Service Workers 來進行快取。雖然 Service Workers 也可以快取 API 請求,但是對於影像、HTML、JS 或 CSS 檔案等資源的快取,它們通常更為有用。我們通常使用自定義機制來快取應用的資料。

無論我們使用的是什麼機制,快取通常都是提升應用的響應能力減少網路花銷,並具有內容在網路中斷時可用的優勢。換句話說,當內容被快取的更接近消費者時,比如在客戶端,請求將不會導致額外的網路活動,並且可以更快速地檢索快取資料,從而節省了網路往返的整個過程。

在本文中,我們將使用 RxJS 和 Angular 提供的工具來開發一個高階快取機制。

目錄

動機

不時地就會有人問,如何在大量使用 Observables 的 Angular 應用中快取資料?大多數人對於如何使用 Promises 來快取資料有不錯的理解,但當切換至響應式程式設計時,便會因為它的複雜度 (龐大的 API)、思維轉化 (從命令式到宣告式) 和眾多概念而感到不知所措。因此,很難將一個基於 Promises 的現有快取機制轉換成基於 Observables 的,當你想要快取機制變得更高階點時更是如此。

在 Angular 應用中通常使用 HttpClientModule 中的 HttpClient 來執行 HTTP 請求。HttpClient 的所有 API 都是基於 Observable 的,也就是說像 getpostputdelete 等方法返回的都是 Observable 。因為 Observables 天生是惰性的,所以只有當我們呼叫 subscribe 時才會真正發起請求。但是,對同一個 Observable 呼叫多次 subscribe 會導致源 Observable 一遍又一遍地重新建立,每個訂閱 (subscription) 上執行一個請求。我們稱之為冷的 Observables 。

如果你對此完全沒有概念的話,我們之前寫過一篇此主題的文章: 冷的 vs 熱的 Observables 。(譯者注: 想了解冷的 vs 熱的 Observables,還可以推薦閱讀這篇文章)

這種行為將導致使用 Observables 來實現快取機制變得很棘手。簡單的方法往往就需要相當數量的樣板程式碼, 我們可能會選擇繞過 RxJS, 這也是可行的,但如果我們想要最終駕馭 Observables 的強大力量時,這種方式是不推薦的。說白了就是我們不想開配備小型摩托車引擎的法拉利,對吧?

需求

在深入程式碼之前,我們先來為要實現的高階快取機制制定需求。

我們想要開發的應用名為笑話世界。這是一個簡單的應用,它只是根據制定的分類來隨機展示笑話。為了讓應用更簡單、更專注,我們只設定一個分類。

應用有三個元件: AppComponentDashboardComponentJokeListComponent

AppComponent 元件是應用的入口,它渲染工具欄和 <router-outlet>,後者會根據當前路由器狀態來填充內容。

DashboardComponent 元件只展示分類的列表。在這可以導航至 JokeListComponent 元件,它負責將笑話列表渲染到螢幕中。

笑話是使用 Angular 的 HttpClient 服務從伺服器拉取的。要保持元件的職責單一和概念分離,我們想建立一個 JokeService 來負責請求資料。然後元件只需通過注入此服務便可以通過它的公有 API 來訪問資料。

以上就是我們這個應用的架構,目前還沒有涉及到快取。

當從分類列表頁導航至笑話列表頁時,我們更傾向於請求快取中的最新資料,而不是每次都向伺服器發起請求。而快取的底層資料會每10秒鐘自動更新。

當然,對於生產級應用來說,每隔10秒輪詢新資料並非是個好選擇,一般來說會使用一種更成熟的方式來更新快取 (例如 Web Socket 推送更新)。但在這裡我們將保持簡單性,以便於專注於快取本身。

我們將會以某種形式來接收更新通知。對於這個應用來說,當快取更新時,我們不想 UI (JokeListComponent) 中的資料自動更新,而是等待使用者來執行 UI 的更新。為什麼要這樣做?想象一下,使用者可能正在讀某條笑話,然後突然間因為資料的自動更新這條笑話就消失了。這樣的結果就是由於這種較差的使用者體驗,讓使用者很生氣。因此,我們的做法是每當有新資料時提示使用者更新。

為了更好玩一些,我們還想要使用者能夠強制快取更新。這與僅更新 UI 不同,因為強制更新意味著從伺服器請求最新資料、更新快取,然後相應地更新 UI 。

來總結下我們將要開發的內容點:

  • 應用有兩個元件 A 和 B,當從 A 導航至 B 時應該從快取中請求 B 的資料,而不是每次都請求伺服器
  • 快取每隔10秒自動更新
  • UI 中的資料不會自動更新,而是需要使用者執行更新操作
  • 使用者可以強制更新,這將會發起 HTTP 請求以更新快取和 UI

下面是應用的預覽圖:

App Preview

實現基礎快取

我們先從簡單的開始,然後一步步地打造出最終成熟的解決方案。

第一步是建立一個新的服務。

接下來,我們來新增兩個介面,一個是描述 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 方法時對其進行初始化。後續的所有消費者都將共享此例項而無需每次都重新建立快取。

通過下面的圖來更直觀地看下我們剛剛實現的內容:

[譯] RxJS 高階快取

在上圖中,我們可以看到描述我們場景中所涉及到的物件的序列圖,即請求笑話列表和在物件之間交換訊息的佇列。我們分解來看,以便更好地瞭解我們正在做什麼。

我們從 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) 來對流的工作原理進行視覺化展示:

[譯] RxJS 高階快取

彈珠圖看上去十分清晰,底層的 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 操作符的彈珠圖:

[譯] RxJS 高階快取

呃,果真如此。第一個值是“延遲”發出的,而這並非我們想要的效果。為什麼這麼說?因為如果我們從分類列表頁導航至笑話列表頁時,我們必須等待 10 秒後才會向伺服器發起資料請求以渲染頁面。

我們可以通過引入另一個名為 startWith(value) 的操作符來修復此問題,這樣一開始就會先發出給定的 value,即初始值。但是,我們可以做的更好!

如果我告訴你還有另外一個操作符,它可以先根據給定的時間 (初始延遲) 發出值,然後再根據時間間隔 (常規的定時器) 來不停地發出值。timer 瞭解一下。

彈珠圖時刻!

[譯] RxJS 高階快取

酷,但是它真的解決了我們問題了嗎?是的,沒錯。如果我們將初始延遲設定為 0,並將時間間隔設定為 10 秒,這樣它的行為就和 interval(10000).pipe(startWith(0)) 是一樣的,但卻只使用了一個操作符。

我們來使用 timer 操作符並將其運用在我們現有的快取機制當中。

我們需要設定一個定時器,然後每次時間一到就發起 HTTP 請求來從伺服器拉取最新資料。也就是說,對於每個時間點我們都需要使用 switchMap 來切換成一個獲取笑話列表的 Observable 。使用 swtichMap 有一個好的副作用就是可以避免條件競爭。這是由於這個操作符的本質,它會取消對前一個內部 Observable 的訂閱,然後只發出最新內部 Observable 中的值。

我們快取的其餘部分都保持原樣,我們的流仍然是多播的,所有的訂閱者都共享同一個底層資料來源。

同樣的,shareReplay 會將最新值傳送給現有的訂閱者,併為後來的訂閱者重放最新值。

[譯] RxJS 高階快取

正如在彈珠圖中所展示的,timer 每 10 秒發出一個值。每個值都將轉換成拉取資料的內部 Observable 。因為使用的是 switchMap,我們可以避免競爭條件,因此消費者只會收到值 13 。第二個內部 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 更新的邏輯。

還有,因為底層的快取是多播的,永遠都重新訂閱快取以獲取最新值是完全安全的。

在繼續完成通知流之前,我們先暫停下來看看剛剛實現邏輯的彈珠圖。

[譯] RxJS 高階快取

正如在圖中所看到的,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$ 發出的每個值都對映成 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$ 的訂閱,因為它會在快取重新建立之前完成。其餘的都保持不變。

嗯,我們做到了。我們來花點時間看看我們剛剛實現內容的彈珠圖。

[譯] RxJS 高階快取

正如在圖中所看見的,對於顯示通知來說,initialNotifications$ 十分重要。如果沒有這個流的話,我們只能在強制快取更新後才有機會看到通知。也就是說,當我們按需請求最新資料時,我們必須不斷地切換成新的通知流,因為前一個(舊的) Observable 已經完成並不再發出任何值。

就是這樣!我們使用 RxJS 和 Angular 提供的工具實現了一個複雜的快取機制。簡答回顧下,我們的服務暴露出一個流,它為我們提供笑話列表。每隔 10 秒會觸發 HTTP 請求來更新快取。為了提升使用者體驗,我們提供了更新通知,這樣使用者可以執行更新 UI 的操作。在此之上,我們還為使用者提供了一種按需請求最新資料的方式。

太棒了!這就是完整的解決方案。花費幾分鐘再來看一遍程式碼。然後嘗試不同的場景以確認是否一切都能正常執行。

第 4 階段線上 Demo: 點選檢視

展望

如果你稍後想要做些課後作業或開發腦力的話,這有幾點改進想法:

  • 新增錯誤處理
  • 將邏輯從元件中重構至服務中,以使其可複用

特別鳴謝

特別感謝 Kwinten Pisman 幫助我完成程式碼的編寫。我還要感謝 Ben LeshBrian Troncone 給予我有價值的反饋和提出一些改進點。此外,還要非常感謝 Christoph Burgdorf 對於文章和程式碼的審查。

相關文章