rxjs最佳實踐
RxJS
是JavaScript中最流行的函數響應式編程(FRP)
。每天都有很多人在項目中使用RxJS。大多數開發人員都知道常見的代碼精簡的最佳實踐,但RxJS的最佳實踐呢?當涉及到FRP時,你是否知道該做什麼和不該做什麼?如何在代碼中應用它們?
本教程將重點介紹我在日常編寫代碼時使用的幾種最佳實踐,並附上實際的例子。內容涵蓋以下幾點:
- 避免將所有的邏輯代碼寫在
subscribe
中 - 取消訂閱
- 避免重複邏輯
- 用鏈式替代嵌套
- 用
share
處理相同的流 - 不要暴露
subjects
- 使用
彈珠圖
測試
話不多說,let’s get it!
避免將所有的邏輯代碼寫在subscribe
中
這句話對熟悉Rxjs的人來說是一針見血的,但這是RxJS初學者常犯的錯誤。在你學會如何響應式思考之前,你可能很容易寫出下列代碼:
pokemon$.subscribe((pokemon: Pokemon) => {
if (pokemon.type !== "Water") {
return;
}
const pokemonStats = getStats(pokemon);
logStats(pokemonStats);
saveToPokedex(pokemonStats);
});
pokemon$ Observable
會產生Pokemon
對象,我們訂閱它是爲了訪問這個對象,並執行一些操作,比如:如果Pokemon
類型是Water
就提前返回,對getStats()
函數進行調用,記錄這個函數返回的統計數據,最後,將數據保存到Pokedex
中。我們所有的邏輯都在subscribe
函數裏面,這是一種絕對非響應式的做法。
然而,這段代碼看起來是不是和我們在傳統的命令式編程範式中看到的一模一樣?既然RxJS是一個函數響應式編程,我們就必須告別傳統的思維方式,開始響應式思維(流!純函數!)。
那麼我們如何讓我們的代碼變得響應式呢?通過使用RxJS爲我們提供的pipe
操作符:
pokemon$
.pipe(
filter(({ type }) => type === "Water"),
map(pokemon => getStats(pokemon)),
tap(stats => logStats(stats))
)
.subscribe(stats => saveToPokedex(stats));
看,我們的代碼通過一些簡單的改動就從命令式
變成了響應式
。它看起來更加簡潔了!
Node:有一部分邏輯(saveToPokedex()
函數)仍然保留在subscribe
中。是因爲把最後一部分邏輯保留在subscribe
裏可以讓代碼更易閱讀。當然,你可以自由選擇是否使subscribe
完全爲空。
關於pipe
中的操作符可通過官網進行了解。
取消訂閱
在使用Observables時,內存泄漏是很危險的。因爲,一旦我們訂閱了一個Observable
,它就會無限期地輸出值,直到滿足以下兩個條件之一。
- 我們手動取消了對
Observable
的訂閱 - 它自己完成了
看起來很簡單,那讓我們來看看如何取消訂閱一個Observable
:
pokemonSubscription = pokemon$.subscribe(pokemon => {
// Do something with pokemon
});
pokemonSubscription.unsubscribe();
在上面的例子中,你可以看到,我們必須將pokemon$ Observable
的訂閱存儲在一個變量中,然後手動調用unsubscribe()
。目前看來並不難。
但如果我們有更多的Observable
需要訂閱,會發生什麼呢?
const pokemonSubscription = pokemon$.subscribe(pokemon => {
// Do something with pokemon
});
const trainerSubscription = trainer$.subscribe(trainer => {
// Do something with trainer
});
const numberSubscription = number$.subscribe(number => {
// Do something with number
});
function stop() {
pokemonSubscription.unsubscribe();
trainerSubscription.unsubscribe();
numberSubscription.unsubscribe();
}
正如你所看到的,隨着我們在代碼中添加更多的Observables,我們需要跟蹤越來越多的訂閱,我們的代碼開始顯得有點擁擠。難道就沒有更好的方法來告訴我們的Observables取消訂閱嗎?幸運的是,有,而且非常非常簡單。
我們可以使用Subject
和takeUntil()
操作符,來控制Observables的完成。怎麼做呢?下面是一個例子:
const stop$ = new Subject<void>();
trainer$
.pipe(takeUntil(stop$)).subscribe(trainer => {
// Do something with trainer
});
pokemon$
.pipe(takeUntil(stop$)).subscribe(pokemon => {
// Do something with pokemon
});
number$
.pipe(takeUntil(stop$)).subscribe(number => {
// Do something with number
});
function stop() {
stop$.next();
stop$.complete();
}
讓我們解釋下上面發生了什麼。我們已經創建了一個stop$ Subject
,並且已經用takeUntil
操作符將三個Observable
管道化。當stop$ Subject
產生值的時候,這三個Observable
將會停止輸出值。
那麼我們如何讓stop$ Observable
輸出值呢?就是通過調用next()
,每當調用stop()
函數時,stop$ Observable
就會輸出,所有的Observables就會自動完成。
不再需要存儲任何訂閱和調用unsubscribe()
了?takeUntil
萬歲!
避免重複邏輯
我們都知道重複的代碼是個不好的信號,是應該避免的。如果你不知道,你應該去了解下DRY原則
。那麼你可能想知道哪些情況下會導致有重複的RxJS邏輯。讓我們來看看下面的例子:
import { interval, Subject } from "rxjs";
import { takeUntil, filter, scan } from "rxjs/operators";
const number$ = interval(1000);
const stop$: Subject<void> = new Subject();
number$
.pipe(
takeUntil(stop$),
filter(number => isMultipleOfTen(number))
)
.subscribe(number => getPokemonById(number));
number$
.pipe(
takeUntil(stop$),
scan(number => number + 1, 0)
)
.subscribe(score => console.log({ score }));
如你所見,我們有一個number$ Observable
,它每秒鐘都輸出一次。我們對這個Observable
訂閱兩次:一次是爲了用scan()
記錄分數,一次是每十秒調用getPokemonByID()
函數。看似很簡單,但…
注意到我們在Observables中重複了takeUntil()
邏輯嗎?只要我們的代碼允許,就應該避免這種情況。怎麼避免呢?通過將這個邏輯附加到源Observable
中,就像這樣:
import { interval, Subject } from "rxjs";
import { takeUntil, filter, scan } from "rxjs/operators";
const stop$: Subject<void> = new Subject();
const number$ = interval(1000).pipe(takeUntil(stop$));
number$
.pipe(filter(number => isMultipleOfTen(number)))
.subscribe(number => getPokemonById(number));
number$
.pipe(scan(number => number + 1, 0))
.subscribe(score => console.log({ score }));
用鏈式替代嵌套
避免嵌套訂閱非常重要。因爲嵌套會讓代碼變得複雜、凌亂、難以測試,並且會導致一些非常討厭的錯誤。
"什麼是嵌套訂閱?"你可能會問。就是我們在一個Observable
的訂閱塊中訂閱另一個Observable
。讓我們來看看下面的代碼:
getTrainer().subscribe(trainer =>
getStarterPokemon(trainer).subscribe(pokemon =>
// Do stuff with pokemon
)
);
看起來不是很整齊,對吧?上面的代碼很混亂,很複雜,而且,如果我們需要調用更多的返回Observables的函數,我們將不得不繼續添加越來越多的訂閱。這開始聽起來像是訂閱地獄。那麼,我們該如何避免嵌套訂閱呢?
答案是使用更高階的映射操作符。這些運算符有switchMap
、mergeMap
等。
爲了修正我們的例子,我們要利用switchMap
操作符。爲什麼要這樣做呢?因爲switchMap
會從之前的Observable
中退訂,並切換到內部的Observable
,在我們的例子中,這就是完美的解決方案。但是,請注意,根據自己的需要,你可能需要使用不同的高階映射操作符。
getTrainer()
.pipe(
switchMap(trainer => getStarterPokemon(trainer))
)
.subscribe(pokemon => {
// Do stuff with pokemon
});
用share
處理相同的流
你的Angular代碼是否總會發出重複的HTTP請求?想知道爲什麼?繼續閱讀,你會發現這個常見的bug背後的原因。
大多數Observable是cold的。這意味着當我們訂閱它們時,它們的生產者纔會被創建和激活。對於cold Observable
來說,每次我們訂閱它們時,都會創建一個新的生產者。所以,如果我們訂閱一個cold Observable
五次,就會創建五個生產者。
那麼生產者到底是什麼呢?即Observable
的值的來源(例如,一個DOM事件,一個HTTP請求,一個數組等),這對我們響應式程序員來說意味着什麼呢?好吧,比如說,如果我們對一個發出HTTP請求的Observable
訂閱了兩次,就會有兩次HTTP請求。
下面的例子(借用Angular的HttpClient
)會觸發兩個不同的HTTP請求,因爲pokemon$
是一個cold Observable
,我們要訂閱它兩次:
pokemon$ = http.get(/* make an http request here*/);
/*Every time we subscribe to pokemon$, an http request will be made*/
pokemon$
.pipe(
flatMap(pokemon => pokemon),
filter(({ type }) => type === "Fire")
)
.subscribe(pokemon => {
// Do something with pokemon
});
pokemon$.pipe(switchMap(pokemon => getStats(pokemon))).subscribe(stats => {
// Do something with stats
});
你可以想象,這種行爲只會導致討厭的bug,那麼我們如何避免它呢?難道就沒有一種方法可以多次訂閱一個Observable,而不會因爲它的源一次次被創建而觸發重複的邏輯嗎?當然有。請允許我介紹一下share()
操作符。
這個操作符用來允許多次訂閱一個Observable,而不重新創建它的源。換句話說,它將一個Observable由cold變hot。讓我們看看它是如何使用的:
pokemon$ = http.get(/* make an http request here*/).pipe(share());
/*The pokemon$ Observable is now hot, we won't have multiple http requests*/
pokemon$
.pipe(
flatMap(pokemon => pokemon),
filter(({ type }) => type === "Fire")
)
.subscribe(pokemon => {
// Do something with pokemon
});
pokemon$.pipe(switchMap(pokemon => getStats(pokemon))).subscribe(stats => {
// Do something with stats
});
如果你嘗試過你會發現,我們的問題神奇地解決了。通過添加share()
操作符,即使我們訂閱了兩次,也只會發出一個HTTP請求。
需要注意的是。因爲hot Observable
不會複製源,如果我們晚點訂閱一個流,我們將無法訪問之前發出的值。shareReplay()
操作符可以作爲解決這個問題的方法。
不要暴露subjects
使用服務來重用Observable
是一種常見的做法。但是很多開發者常犯的錯誤就是通過這樣的方式將這些Subject
直接暴露給外部。
class DataService {
pokemonLevel$ = new BehaviorSubject<number>(1);
stop$: Subject<void> = new Subject();
number$ = interval(1000).pipe(takeUntil(this.stop$));
}
不要這樣做。通過暴露Subject
,我們允許任何人向其推送數據–更不用說這完全打破了DataService
類的封裝。與其暴露Subject
,不如暴露Subject
的數據。
"這不是同樣的事情嗎?"你可能會想知道。答案是否定的。如果我們暴露一個Subject
,那麼就會使它的所有方法都可用,包括next()
函數,它是用來使Subject
發出一個新值。另一方面,如果我們只是暴露它的數據,就不會讓Subject
的方法可用,只是讓它發出的值可用。
那麼,如何才能暴露Subject
的數據而不暴露它的方法呢?通過使用asObservable()
操作符–它將Subject
轉換爲Observable
。由於Observable
沒有next()
函數,所以Subject
的數據將不會被篡改。
class DataService {
private pokemonLevel = new BehaviorSubject<number>(1);
private stop$: Subject<void> = new Subject();
pokemonLevel$ = this.pokemonLevel.asObservable();
increaseLevel(level: number) {
if (!this.isValidLevel(level)) {
throw new Error("Level is not valid");
}
this.pokemonLevel.next(level);
}
stop() {
this.stop$.next();
}
private isValidLevel(level: number): boolean {
return level % 2 === 0;
}
}
在上面的代碼中,我們有四個不同的事情發生。
pokemonLevel
和stop$ Subject
現在都是私有的,因此不能從DataService
類外部訪問。- 有了一個
pokemonLevel$ Observable
,它是通過調用pokemonLevel Subject上的asObservable()
操作符創建的。這樣,我們就可以從類外訪問pokemonLevel
數據,同時保證Subject
不受操縱。 - 你可能已經注意到,對於
stop$ Subject
,我們並沒有創建一個Observable
。這是因爲我們不需要從類外訪問stop$
的數據。 - 現在有兩個公共方法,分別命名爲
increaseLevel()
和stop()
。後者很簡單,很容易理解。它允許我們使私有的stop$
主體從類外發出–從而完成所有有管道takeUntil(stop$)
的Observable
。 increaseLevel()
作爲一個過濾器,只允許我們向pokemonLevel() Subject
傳遞某些值。
這樣一來,任何數據都無法進入我們的Subject中,Subject在類中得到了很好的保護。
注意:Observable有complete()
和error()
方法,這些方法還是可以用來搞亂Subject的。封裝是關鍵。
使用彈珠圖(marble)
測試
我們應該知道,編寫測試和編寫代碼本身一樣重要。然而,如果想到要編寫RxJS測試,你就會覺得有點望而生畏…不要害怕。從RxJS 6+開始,RxJS marble-testing utils
將使測試工作變得非常簡單。不熟悉彈珠圖的可以看這裏。
即使你是RxJS的初學者,你也應該或多或少地理解這些圖。它們相當直觀,而且讓你很容易理解一些比較複雜的RxJS操作符的工作原理。RxJS測試工具允許我們使用這些彈珠圖來編寫簡單、直觀、可視化的測試。你所要做的就是從rxjs/testing
模塊中導入TestScheduler
,然後開始編寫測試!
讓我們通過測試number$ Observable
來看看是如何做到的:
import { TestScheduler } from "rxjs/testing";
import { Observable } from "rxjs";
import { filter } from "rxjs/operators";
describe("Awesome testing with Marble Diagrams", () => {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
const isMultipleOfTen = (number: number) => number % 10 === 0;
it("should filter numbers that aren't multiples of ten", () => {
scheduler.run(({ cold, expectObservable }) => {
const values = {
a: 1, b: 2, c: 3, d: 4, e: 5, f: 6, g: 7, h: 8, i: 9, j: 10
};
const number$ = cold("-a-b-c-d-e-f-g-h-i-j|", values);
const expectedMarbleDiagram = "-------------------a|";
const expectedValues = { a: 10 };
const result = number$.pipe(filter(number => isMultipleOfTen(number)));
expectObservable(result).toBe(expectedMarbleDiagram, expectedValues);
});
});
});
由於深入研究彈珠圖測試並不是本教程的目標,所以我只簡單介紹一下上述代碼中出現的關鍵概念,以便我們對發生的事情有一個基本的瞭解:
TestScheduler
:用於虛擬時間。它接收一個回調,將被helper調用(在示例中,helper指cold()
和expectObservable()
)。Run()
:用於虛擬時間。當回調返回時,自動調用flush()
。-
:每個-
代表1毫秒的虛擬時間。Cold()
: 創建一個cold Observable
,其訂閱在測試開始時開始。|
: 表示一個Observable的完成。- 因此,
expectedMarbleDiagram
期望在20ms時發出a
。 expectedValues
變量包含了Observable
發出的每個項目的預期值。在我們的例子中,a
是唯一會被髮射的值,它等於10
。ExpectObservable()
:安排一個斷言,當testScheduler
刷新時,這個斷言將被執行。在我們的例子中,我們的斷言期望number$ Observable
像expectedMarbleDiagram
一樣,其值包含在expectedValues
變量中。
你可以在RxJS的官方文檔中找到更多關於helpers的信息。
使用RxJS marble-testing utils
的優勢:
- 避免了大量的模板代碼。(Jasmine Marbles的用戶可能體會到這一點。)
- 使用起來非常簡單直觀。
- 它很有趣! 即使你並不熱衷於寫測試,但我可以保證你會喜歡彈珠測試。
再次拋出一個例子,這次的特色是pokemon$ Observable
測試:
import { TestScheduler } from "rxjs/testing";
import { filter, map } from "rxjs/operators";
describe("Awesome testing with Marble Diagrams", () => {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toEqual(expected);
});
it("should filter non-Water type pokemon and add attack property", () => {
scheduler.run(({ cold, expectObservable }) => {
const values = {
a: { name: "Bulbasur", type: "Grass" },
b: { name: "Charmander", type: "Fire" },
c: { name: "Squirtle", type: "Water" }
};
const marbleDiagram = "-a-b-c|";
const pokemon$ = cold(marbleDiagram, values);
const expectedMarbleDiagram = "-----c|";
const expectedValues = {
c: { name: "Squirtle", type: "Water", attack: 30 }
};
const result = pokemon$.pipe(
filter(({ type }) => type === "Water"),
map(pokemon => ({ ...pokemon, attack: 30 }))
);
expectObservable(result).toBe(expectedMarbleDiagram, expectedValues);
});
});
});