コンポーネントにおけるObservableの購読

この章では AngularコンポーネントにおいてどのようにObservableを購読すべきなのかについて学びます。単純なsubscribeからはじめ、段階的にAsyncパイプを使ったリアクティブなパターンの使い方へステップアップしていきます。

Angularのコンポーネントの基本とRxJSの基本を習得したら、次に学ぶのは実際にどうやってObservableとコンポーネントを組み合わせるかというプラクティスです。うまくObservableを利用することで、ユーザーインタラクションとリアクティブに協調するコンポーネントを簡単に実装できるようになるでしょう。

明示的な購読

最初に解説する方法は明示的な購読です。これはObservableの使い方の中でもっとも基本的で単純な方法です。これは公式チュートリアルの中でも、HeroServiceから値を取得するために学んだはずです。

コンポーネントを実装する前に、まずはサンプル用の DataService を用意します。これはBehaviorSubjectを持つ単純なAngularのサービスです。setValue メソッドが valueSubject に値を流し、 valueChanges getterが valueSubject をObservableとして外部に公開します。

data.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
@Injectable({
providedIn: 'root'
})
export class DataService {
private valueSubject = new BehaviorSubject<any>('initial');
get valueChanges() {
return this.valueSubject.asObservable();
}
setValue(value: any) {
this.valueSubject.next(value);
}
}

そして AppComponentには新たな値を流すためのボタンを用意します。ここをスタートとして改めて学んでいきましょう。

import { Component } from '@angular/core';
import { DataService } from './data.service';
@Component({
selector: 'my-app',
template: `
<button (click)="updateValue()">Update Value</button>
`,
})
export class AppComponent {
constructor(private dataService: DataService) { }
updateValue() {
const value = new Date().toISOString();
this.dataService.setValue(value);
}
}

まずは DataServiceの値を表示するためのコンポーネントを作成します。 明示的なsubscribeをおこなう ExplicitSubscribeComponent クラスは、次のように記述します。value フィールドを持っていて、テンプレート内で補間構文 {{ value }} を使って表示しています。value フィールドの更新は、 valueChanges.subscribe メソッドに渡されたコールバック関数で行われています。

subscribe メソッドはコンストラクタ内で呼び出してはいけません。Angularのコンポーネントインスタンスが作成されるタイミングと、実際にコンポーネントがビューに配置されるタイミングは一致しません。予期しない挙動やエラーを防ぐために、ngOnInit よりも後のタイミングで呼び出しましょう。

explicit-subscribe.component.ts
import { Component, OnInit } from '@angular/core';
import { DataService } from '../data.service';
@Component({
selector: 'app-explicit-subscribe',
template: `<div>{{ value }}</div>`,
})
export class ExplicitSubscribeComponent implements OnInit {
value: any;
constructor(private dataService: DataService) { }
ngOnInit() {
this.dataService.valueChanges.subscribe(value => {
this.value = value;
});
}
}

subscribe メソッドを使う方法はシンプルに見えますが注意点があります。Observableのライフサイクル で述べたように、購読解除をしなければコンポーネントが破棄されたあとにメモリリークが発生します。おそらく、ngOnDestroy ライフサイクルフックメソッドを使って次のように unsubscribe メソッドを呼ぶ方法をまず最初に思いつくでしょう。

explicit-subscribe.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { DataService } from '../data.service';
@Component({ ... })
export class ExplicitSubscribeComponent implements OnInit, OnDestroy {
value: any;
private subscription: Subscription;
constructor(private dataService: DataService) { }
ngOnInit() {
this.subscription = this.dataService.valueChanges.subscribe(value => {
this.value = value;
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}

この方法はクラスフィールドとして Subscription オブジェクトを保持する必要があります。subscribe メソッドの戻り値を使うことになりますが、コンポーネントが購読する Observableが複数になると、煩雑なコードになってしまうのが欠点であり、購読解除忘れも発生しがちです。

ところで、Observableのライフサイクル で説明したとおり、Observableが完了するとその時点ですべての購読が自動的に解除されます。それを利用して、購読を開始する前の段階で、自動的に完了するように仕込んでおくことで購読解除忘れを防ぐことができます。

次の例では、ngOnDestroy ライフサイクルフックのタイミングで発行される onDestroy$ Subjectを作成し、 valueChanges にはtakeUntil オペレーターを追加します。takeUntil オペレータは、引数に指定したObservableに値が流れたときに、オペレーターが適用されたObservableを強制的に完了します。このように実装すると、複数のObservableになったときもクラスフィールドを増やすことなくそれぞれに takeUntil オペレーターを追加するだけです。

import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { DataService } from '../data.service';
@Component({
selector: 'app-explicit-subscribe',
template: `<div>{{ value }}</div>`,
})
export class ExplicitSubscribeComponent implements OnInit, OnDestroy {
value: any;
private onDestroy$ = new Subject();
constructor(private dataService: DataService) { }
ngOnInit() {
this.dataService.valueChanges
.pipe(takeUntil(this.onDestroy$))
.subscribe(value => {
this.value = value;
});
}
ngOnDestroy() {
this.onDestroy$.next();
}
}

Asyncパイプを使う

ここまで明示的な購読をどのようにうまく書くかについて解説しましたが、そもそも明示的に購読しなければ、購読解除について考える必要もありません。ここからは Observableで提供されるデータをテンプレートにバインディングするときに使える Async パイプ を紹介します。基本的な方針として、Asyncパイプで解決できるケースでは常にAsyncパイプを利用し、 明示的な購読は最低限に留めましょう。Observableをコアにしたリアクティブなアプリケーション設計を進める上で Asyncパイプは必需品です。

先ほどの ExplicitSubscribeComponent と同じことを Asyncパイプで実装するコンポーネントを、 AsyncPipeComponent という名前で次のように作成します。

async-pipe.component.ts
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { DataService } from '../data.service';
@Component({
selector: 'app-async-pipe',
template: `<div>{{ value$ | async }}</div>`,
})
export class AsyncPipeComponent {
value$: Observable<any>;
constructor(private dataService: DataService) {
this.value$ = this.dataService.valueChanges;
}
}

びっくりするほどコードがスッキリしました。購読や購読解除に関するコードはなくなりました。同期的なvalue フィールドを廃止して、Observable型の value$ フィールドに変更しています。value$ フィールドにはvalueChanges を代入し、テンプレートで {{ value$ | async }} のように Asyncパイプを適用しています。

なぜこれほどコンポーネントのコードが少なくなったかというと、明示的な購読の例で記述していた subscribe メソッドの呼び出しや unsubscribe メソッドの呼び出しを Asyncパイプが肩代わりしているからです。 Asyncパイプが適用されたObservableは、そのコンポーネントのライフサイクルに合わせて自動的に購読・購読解除がおこなわれます。これにより、開発者は面倒なRxJSのお作法から解放され、ビジネスロジックだけに集中できます。たとえば、valueChanges で流れてくる値を操作したいと思ったときには、this.value$ へ代入する際にオペレーターを追加するだけでいいのです。

this.value$ = this.dataService.valueChanges.pipe(
map(value => `Value: ${value}`)
);

もうひとつの良いところは、this.value$ の初期化をコンストラクタで完結できることです。購読を開始しているわけではないため、クラスフィールドへの代入は ngOnInit まで待たなくてもよいのです。

Asyncパイプは、コンポーネントの初期化時にフィールドとして存在する、無限なObservableにのみ使われるべきです。つまりユーザーインタラクションなどによって後から発生するHTTPリクエストのような有限のObservableは、Asyncパイプを使うのに適していません。

Single State Streamパターン

Asyncパイプを使うときに注意するのは、同じObservableに複数回 Asyncパイプを適用してしまうことです。たとえば次のようなテンプレートを書いてしまうと、同じObservableを2度購読することになります。少しなら影響はありませんが、購読の数が増えるのはメモリ使用量が上昇し、本来不要な変更検知処理も実行されるので、パフォーマンスに悪影響があります。さらに、Asyncパイプの処理タイミングが別なので、2つのデータバインディングの解決が常に完全に同時であることは保証されません。

<div>1. {{ value$ | async }}</div>
<div>2. {{ value$ | async }}</div>

この問題を解決するのが、筆者がSingle State Streamパターンと呼んでいる実装パターンです。テンプレートから参照するコンポーネントの状態を単一のストリームに合成し、Asyncパイプをテンプレートの先頭で1回だけ使うことで、その内側をあたかも同期的なテンプレートのように記述できます。

<ng-container *ngIf="state$ | async as state" >
<!-- 同期的に記述できる -->
  <div>1. {{ state.value }}</div>
  <div>2. {{ state.value }}</div>
</ng-container>

Single State Steamパターンを構成する重要な要素は ng-container タグと ngIf-as 構文です。まずはこれらを順番に紐解いていきましょう。

ng-containerタグ

<ng-container> タグはAngularのテンプレート内でだけ使える特殊なタグです。このタグはテンプレート内で任意の範囲をブロック化するために使いますが、実際にDOMへレンダリングされるときには除去されます

*ngIf*ngFor のような構造ディレクティブを使うときに、テンプレート内の階層構造をうまく表現するためのタグとしてよく用いられる擬似的なタグです。

ngIf-as 構文

*ngIfas 構文は、*ngIf に渡された式の評価結果を、テンプレート内で新しい変数に代入する機能です。同期的な *ngIf では使い所はありませんが、 Asyncパイプを併用すれば、Asyncパイプで得られた値が *ngIf に渡されるタイミングで、値を変数化できるのです。

<ng-container *ngIf="user$ | async as user">
{{ user.name }}
</ng-container>

しかしこのテンプレートには問題があります。user$ がnullを返したとき、 *ngIf の評価はfalseとなるため、内側のブロック全体が表示されなくなります。これを解決するには、次のようにfalseだったときのテンプレートを指定できる*ngIfelse 構文を使う必要があります。

<ng-container *ngIf="user$ | async as user; else nullUser">
{{ user.name }}
</ng-container>
<ng-template #nullUser>
User is null
</ng-template>

Single State Streamの合成

値がnullだったときの表示は else 構文でカバーすることもできますが、テンプレートが肥大化しがちです。そもそもの原因は Observableがnullを流したときに *ngIf の評価結果が falseになってしまうことです。つまり、常にtrueになるような評価式にしておくことで else 構文を使わなくても常に同じテンプレートで Observableのデータを描画できます。

その解決法として、テンプレートが必要とする状態を単一のストリーム(Observable)に合成します。筆者はこのストリームをSingle State Streamと呼んでいます。ストリームの合成にはRxJSの combineLatest 関数を利用します。はじめに合成されるストリームの型を State 型として定義し、コンポーネントが state$: Observable<State> フィールドを持つようにします。

combineLatest
async-pipe.component.ts
import { combineLatest } from 'rxjs';
type State = {
value: any;
};
@Component({})
export class AsyncPipeComponent {
// Single State Stream
readonly state$: Observable<State>;
constructor(private dataService: DataService) {
this.state$ = combineLatest(
[this.dataService.valueChanges], // 必要なストリームを合成する
([value]) => ({ value }), // 配列からオブジェクトに変換する
);
}
}

今回の例では valueChanges にしか依存していませんが、複数のストリームをここで合成できます。readonly を付与しているため、この state$ フィールドの値はコンストラクタ以外で変更されません。つまりコンポーネントのインスタンスが破棄されるまで同一のObservableオブジェクトを保持することを保証します。

こうして作成したSingle State Streamを、テンプレートの先頭でng-container タグの ngIf-as 構文を使って購読します。結果として、 value$ | async という式で得られていた値が、 state.value という形でアクセスできるようになります。 state は常に State 型のオブジェクトですから、 *ngIf の評価がfalseになることはありません。

<ng-container *ngIf="state$ | async as state">
  <div>1. {{ state.value }}</div>
  <div>2. {{ state.value }}</div>
</ng-container>

nullやundefined、0"" のような *ngIf の評価結果がfalseになってしまう値を Asyncパイプで扱うときには、Single State Streamパターンを使うのが便利です。そうでない場合にも、テンプレートでのAsyncパイプの使い方を統一できる利点もあり、ほとんどのコンポーネントに適用できる実用的な実装パターンです。