[譯] RxJS: 操作符狀態管理

m8524769發表於2019-04-14

原文連結:RxJS: Managing Operator State
原文作者:Nicholas Jamieson;發表於2019年2月12日
譯者:yk;如需轉載,請註明出處,謝謝合作!

[譯] RxJS: 操作符狀態管理

攝影:Victoire Joncheray,來自 Unsplash

在 RxJS 5.5 引入了管道操作符(pipeable operators)之後,編寫使用者級(userland)操作符變得更為簡單了。

管道操作符屬於高階函式(higher-order function):即返回值為函式的函式。所返回的函式接受一個 observable(可觀察物件)作為引數,並返回一個 observable。所以,要建立一個操作符,你不必劃分 OperatorSubscriber,只要寫一個函式就行了。

這聽起來很簡單。

然而在某些情況下你需要格外小心,尤其是當你的操作符在儲存內部狀態時更應如此。

舉個例子

讓我們來看這樣一個例子:一個將接收到的資料及其索引顯示到終端的 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 中:

譯者注:accumulatorseedscan() 的兩個引數,詳見文件

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
複製程式碼

如你所願:一切都在意料之中。

相關文章