原文連結:RxJS: Managing Operator State
原文作者:Nicholas Jamieson;發表於2019年2月12日
譯者:yk;如需轉載,請註明出處,謝謝合作!
攝影:Victoire Joncheray,來自 Unsplash
在 RxJS 5.5 引入了管道操作符(pipeable operators)之後,編寫使用者級(userland)操作符變得更為簡單了。
管道操作符屬於高階函式(higher-order function):即返回值為函式的函式。所返回的函式接受一個 observable(可觀察物件)作為引數,並返回一個 observable。所以,要建立一個操作符,你不必劃分 Operator
和 Subscriber
,只要寫一個函式就行了。
這聽起來很簡單。
然而在某些情況下你需要格外小心,尤其是當你的操作符在儲存內部狀態時更應如此。
舉個例子
讓我們來看這樣一個例子:一個將接收到的資料及其索引顯示到終端的 debug
操作符。
我們的操作符需要維護一些內部狀態:索引——每收到一次 next
通知時就會遞增。有個很戇的辦法是直接將狀態儲存在操作符的內部,就像這樣:
import { MonoTypeOperatorFunction } from "rxjs";
import { tap } from "rxjs/operators";
export function debug<T>(): MonoTypeOperatorFunction<T> {
let index = -1;
// 讓我們假設不存在 map 操作符,於是我們只能用 tap 來維護內部儲存中的索引
// 該操作符的目的是為了表明:執行結果取決於狀態儲存的位置
return tap(t => console.log(`[${++index}]: ${t}`));
}
複製程式碼
該辦法會存在許多問題,並導致一些意料之外的行為和難以定位的 bug。
存在的問題
第一個問題是:我們的操作符不具有引用透明(referentially transparent)性。當一個函式的返回值可以替代該函式而不影響程式執行,那麼我們稱這個函式是引用透明的。
讓我們來看看當這個操作符的返回值與多個 observables 進行組合時會發生什麼:
import { range } from "rxjs";
import { debug } from "./debug";
const op = debug();
console.log("first use:");
range(1, 2).pipe(op).subscribe();
console.log("second use:");
range(1, 2).pipe(op).subscribe();
複製程式碼
執行結果為:
first use:
[0] 1
[1] 2
second use:
[2] 1
[3] 2
複製程式碼
好吧,我知道這很令人意外。在第二個 observable 中,索引並沒有從 0 開始計數。
第二個問題是:只有在首次訂閱該操作符返回的 observable 時,其行為才會是合理的。
現在,讓我們多次訂閱由 debug
操作符組成的 observable,看看會發生什麼:
import { range } from "rxjs";
import { debug } from "./debug";
const source = range(1, 2).pipe(debug());
console.log("first use:");
source.subscribe();
console.log("second use:");
source.subscribe();
複製程式碼
執行結果為:
first use:
[0] 1
[1] 2
second use:
[2] 1
[3] 2
複製程式碼
還是同樣令人意外的結果:在第二次訂閱中,索引依舊沒有從 0 開始計數。
所以該如何解決這些問題呢?
解決方案
這兩個問題都可以通過基於每個訂閱的狀態儲存(storing the state on a per-subscription basis)來解決。以下是幾種實現方法:
第一種方法是使用 Observable
的建構函式來建立操作符返回值(observable)。如果將 index
變數放入傳給 constructor
的函式中,那麼每次訂閱的狀態都會被獨立儲存。寫法如下:
import { MonoTypeOperatorFunction, Observable } from "rxjs";
import { tap } from "rxjs/operators";
export function debug<T>(): MonoTypeOperatorFunction<T> {
return source => new Observable<T>(subscriber => {
let index = -1;
return source.pipe(
tap(t => console.log(`[${++index}]: ${t}`))
).subscribe(subscriber);
});
}
複製程式碼
第二種方法,也是我比較喜歡的,就是使用 defer
來實現基於每個訂閱的狀態儲存。如果將 index
變數放入傳給 defer
的工廠函式中,它就可以按每個訂閱獨立儲存狀態。寫法如下:
譯者注:
defer()
的引數為一個返回值為 observable 的工廠函式observableFactory
,詳見文件。
import { defer, MonoTypeOperatorFunction } from "rxjs";
import { tap } from "rxjs/operators";
export function debug<T>(): MonoTypeOperatorFunction<T> {
return source => defer(() => {
let index = -1;
return source.pipe(
tap(t => console.log(`[${++index}]: ${t}`))
);
});
}
複製程式碼
還有個較為複雜的方法,就是使用 scan
操作符。scan
會維護每個訂閱的狀態,該狀態由 seed
引數進行初始化,然後通過 accumulator
(累加器)函式計算並返回結果。在本例中,index
可以像這樣儲存在 scan
中:
譯者注:
accumulator
和seed
為scan()
的兩個引數,詳見文件。
import { MonoTypeOperatorFunction } from "rxjs";
import { map, scan } from "rxjs/operators";
export function debug<T>(): MonoTypeOperatorFunction<T> {
return source => source.pipe(
scan<T, [T, number]>(([, index], t) => [t, index + 1], [undefined!, -1]),
map(([t, index]) => (console.log(`[${index}]: ${t}`), t))
);
}
複製程式碼
如果用以上任意一種方法來代替一開始那個很戇的辦法,輸出將會是下面這樣:
first use:
[0] 1
[1] 2
second use:
[0] 1
[1] 2
複製程式碼
如你所願:一切都在意料之中。