Chapter 12

3-3.Observable のリファクタリング

lacolaco
lacolaco
2023.04.08に更新

RxJS を使っているとついつい複雑なコードになりがちです。そのようなときには、自作のオペレーターを使ってメンテナンス性を高めることができます。Observable の pipe() メソッドに渡せるオペレーターは、RxJS が提供するものだけではなく、独自に作成することもできます。カスタムオペレーターを作ることで、複雑な処理をモジュール化して再利用性を高め、オペレーターの意図を明確にし、テストしやすくします。

mapfilter のようなオペレーターの実体は、Observable を引数に取り Observable を返す関数です。 そのインターフェースに従っていれば、RxJS がビルトインで提供していない独自のオペレーターを自由に作ることができます。

引数を固定したオペレーターを動的に生成する

たとえば次の例は、 渡された数値を N 倍にするというオペレーターを、multiplyNumber 関数が動的に生成して返しています。 pipe 関数は、すでに存在するオペレーターを組み合わせて新しいオペレーターを作るために用意されているユーティリティで、ここでは RxJS ビルトインの map オペレーターを組み込み、引数に数値を N 倍にする関数を渡しています。このように、既存のオペレーターの引数をあらかじめ固定したプリセットのようなカスタムオペレーターを作ることができます。

// カスタムオペレーターを返す関数
function multiplyNumber(N: number) {
  // pipe 関数で既存のオペレーターを組み合わせる
  return pipe(map((value: number) => value * N));
}

const obs = new Subject();

obs
  .pipe(
    // カスタムオペレーターを使う
    multiplyNumber(5)
  )
  .subscribe((value) => {
    console.log(value);
  });

obs.next(10); // 50 が出力される

一連のオペレーターのセットをまとめる

pipe 関数によって、複数のオペレーターによる一連の連続的な処理をひとつのオペレーターとして束ねることができます。これは通常のオブジェクト指向プログラミングにおけるメソッドのように捉えられます。つまり、手続き的なひとかたまりの処理に名前をつけて、新しいひとつのサブルーチンとして再利用可能にするのです。

次の例は、 input 要素の keyup イベントを監視し、ユーザーの入力に合わせて処理をおこなう例です。この例では 4 つのオペレーターを使ってイベントを処理しています。

const input = document.getElementById('input');

const keyup$: Observable<KeyboardEvent> = fromEvent<KeyboardEvent>(input, 'keyup');

keyup$
  .pipe(
    // KeyboardEventから値を取り出す
    map((e: KeyboardEvent) => (e.target as HTMLInputElement).value),
    // テキストが2文字以上である場合のみ処理する
    filter((text) => text.length > 2),
    // 10ms以内の変更をdebounceする
    debounceTime(10),
    // 前回の値から変更があるときのみ処理する
    distinctUntilChanged()
  )
  .subscribe((value) => {
    console.log(value);
  });

このようなケースでは、4 つのオペレーターがセットになって協調することが期待されており、その順番も重要です。ただし単に pipe メソッドの中で並べただけではその意図は十分に伝わりませんし、最終的にどのような値が購読可能になるのか想像することも困難です。

上の例をカスタムオペレーターでリファクタリングすると次のようになります。 typeahead オペレーターとして名前をつけることでその内容をカプセル化し、意図が明確になります。またモジュールとしてエクスポートすれば、他の場所で利用することもできます。

// カスタムオペレーターの実装
export function typeahead() {
  return pipe(
    // KeyboardEventから値を取り出す
    map((e: KeyboardEvent) => (e.target as HTMLInputElement).value),
    // テキストが2文字以上である場合のみ処理する
    filter((text) => text.length > 2),
    // 10ms以内の変更をdebounceする
    debounceTime(10),
    // 前回の値から変更があるときのみ処理する
    distinctUntilChanged()
  );
}

const input = document.getElementById('input');

const keyup$: Observable<KeyboardEvent> = fromEvent<KeyboardEvent>(input, 'keyup');

// カスタムオペレーターを使う
keyup$.pipe(typeahead()).subscribe((value) => {
  console.log(value);
});