rx-angularのススメ
本記事は、Angular Advent Calendar 2022 24日目の記事です。
← 23日目 25日目→
一昨年のAngular Advent Calendar 2020で、「Angularの状態管理まとめ」という記事を書きました。
その記事において、@rx-angular/state
というライブラリをご紹介しましたが、このライブラリはrx-angularと名のつくライブラリ群が目指している世界観のほんの一部分に過ぎません。
そこで、今回の記事ではrx-angularをさらに深掘りして、私がどのようにこのライブラリ群を活用しているのか、また、「rx-angularが目指している世界観」についてを、私が分かる範囲でご紹介してみたいと思います。
サンプルアプリケーションを用意しています。
rx-angularとは何であるか
Zone less Angular
Angularでは、コンポーネントの状態をDOMに反映するために「変更検知」を行っています。例えば、@Input
によって入力された変数の値が変わったとき、DOMは再描画され、表示が変更されます。
この変更検知機能において中核的な役割を担っているのが、Zone.js
と呼ばれているライブラリです。
Zone.js
は、ブラウザ上で起こる様々な非同期処理にフックを追加し、任意の処理を実行できるようにするライブラリ[1]で、これを活用して変更検知処理を随時実行することにより、テンプレートを更新していました。
この機構はブラウザにとっては荷が重く、描画遅延の原因になっていましたので、パフォーマンスチューニングのために、ChangeDetectionStrategy.OnPush
等を用いて、不必要に変更検知を走らせない工夫が各所で行われていたと思います。
しかし、2年ほど前から、「そもそもZone.jsを使わずに変更検知すれば良いのではないか?」という流れが生まれ始めました。すなわち、「せっかくRxJSを使っているんだから、もっとリアクティブに変数管理して、値が流れてきたときに変更検知すればいいのでは?」と。
これが「Zone less Angular」という考え方です。
リアクティブでないAngularアプリケーション
従来のAngularアプリケーションでは、コンポーネントレベルでObservableな変数を取り扱うことは、不必要に煩雑になるために避けられてきた印象があります。
すなわち、以下のような書き方です。
<ul>
<li *ngFor="let task of tasks">{{ task.subject }}</li>
</ul>
@Component({
selector: 'app-traditional-p',
templateUrl: './traditional-p.component.html',
styleUrls: ['./traditional-p.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TraditionalPComponent {
@Input() tasks: Task[];
constructor() {}
}
@Input
で渡ってきたtasks
変数を、そのままテンプレート上で用いています。
この書き方の場合、Zone.jsを用いた変更検知が行われますが、挙動はChangeDetectionStrategy.Default
とChangeDetectionStrategy.OnPush
のどちらを選択しているかによって変わります。
前者の場合は、随時変更検知が行われているため、配列自体のインスタンスが変更されていない場合、つまり配列の項目が追加・削除されたりした場合でも表示が変わりますが、後者の場合は配列のインスタンスが変更された場合のみ表示が変わります。配列の中身が変更された場合には表示が変わりません。[2]
さて、このようにChangeDetectionStrategy.OnPush
に設定していて、うまく表示が切り替わらない場合、手動で変更検知を行う必要があります。
まずは、親コンポーネントでタスク追加ボタンをクリックした際にイベントを発生させ、子コンポーネントでそれを購読し手動で変更検知を走らせてみます。
@Component({
selector: 'app-traditional-p',
templateUrl: './traditional-p.component.html',
styleUrls: ['./traditional-p.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TraditionalPComponent implements OnInit {
@Input() tasks: Task[];
@Input() add$: Observable<void>;
constructor(private readonly cd: ChangeDetectorRef) {}
ngOnInit() {
this.add$.subscribe(() => {
this.cd.detectChanges();
});
}
}
すると、ChangeDetectionStrategy.Default
を指定した際と同様に表示が変わりました。
しかし、複数箇所で配列の変更が行われる場合、その都度変更検知用のイベントを発火しなければならないため、このパターンは辛みのほうが大きいです。
そこで、tasks
自体をObservableな変数としてサービスで管理し、async
パイプで子コンポーネントに割り当ててみます。
@Injectable({ providedIn: 'root' })
export class TraditionalService {
private readonly _tasks$ = new BehaviorSubject<Task[]>([]);
readonly tasks$ = this._tasks$.asObservable();
constructor() {}
add(): void {
const current = this._tasks$.getValue();
this._tasks$.next([...current, { subject: '件名' }]);
}
}
<app-traditional-p [tasks]="tasks$ | async"></app-traditional-p>
<button (click)="add()">追加</button>
@Component({
selector: 'app-traditional-c',
templateUrl: './traditional-c.component.html',
styleUrls: ['./traditional-c.component.css'],
})
export class TraditionalCComponent {
readonly tasks$ = this.traditionalService.tasks$;
constructor(private readonly traditionalService: TraditionalService) {}
add() {
this.traditionalService.add();
}
}
このようにすると、配列の中身が変更された都度、別インスタンスとして値が注入されるので、OnPush
に設定していてもキチンと表示が変更されるようになり、変更検知を自ら行う必要がなくなりました。
リアクティブなAngularアプリケーション
この考え方をもっと発展させて、ありとあらゆる変数をリアクティブに取り扱うことができないかを考えてみます。
例えば、@Input
はsetterメソッドとRxJSのBehaviorSubject
との合わせ技により、リアクティブに取り扱うことができます。
<ul>
<li *ngFor="let task of tasks$ | async">{{ task.subject }}</li>
</ul>
@Component({
selector: 'app-reactive-p',
templateUrl: './reactive-p.component.html',
styleUrls: ['./reactive-p.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReactivePComponent {
private _tasks$ = new BehaviorSubject<Task[]>([]);
readonly tasks$ = this._tasks$.asObservable();
@Input() set tasks(tasks: Task[]) {
this._tasks$.next(tasks);
}
constructor() {}
}
このようなシンプルなコンポーネントだと、正直あまり旨味は感じないと思います。もっと巨大なコンポーネントになって、複数の@Input
の値から別の値を作りたいときや、特定の値のときに表示を切り替えさせたくない場合は、Observableが挟まっていると宣言的に書けたりするので諸々楽です。
従来の書き方でも、ngOnChanges
内で値を判定して云々…とすることで同じことは可能ですが、無駄に編集可能なクラスプロパティが増えてしまう[3]のと、ngOnChanges
自体が何らかの@Input
の値が変更されたときに実行される関数のため、一部の値しか使わない処理を行うときにifで場合分けしないといけなかったりと多少冗長です。
@Output
の場合は、それ自体がEventEmitter
というObservableのスーパーセットなので、そのままでも良いのですが、これも一旦Subject
を挟むと、親コンポーネントにイベントを伝達する前にフック処理ができて便利です。
<ul>
<li *ngFor="let task of tasks$ | async; let i = index">
{{ task.subject }} <button (click)="onClickedDelete$.next(i)">削除</button>
</li>
</ul>
@Component({
selector: 'app-reactive-p',
templateUrl: './reactive-p.component.html',
styleUrls: ['./reactive-p.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ReactivePComponent {
private _tasks$ = new BehaviorSubject<Task[]>([]);
readonly tasks$ = this._tasks$.asObservable();
readonly onClickedDelete$ = new Subject<number>();
@Input() set tasks(tasks: Task[]) {
this._tasks$.next(tasks);
}
@Output() readonly delete = new EventEmitter<number>();
constructor() {
this.onClickedDelete$.asObservable().subscribe((index) => {
console.log(index);
this.delete.emit(index);
});
}
}
クリック等のイベントを一旦すべてObservableにまとめてしまうことで、例えば「ボタンAとボタンBが両方押されたときに@Output
したい」といった処理が可能になります。[4]
このような書き方をすることで、RxJSの豊富なパイプ群を活かしつつ、複雑な処理の流れを宣言的に書くことができるようになります。
rx-angularは何を解決するか
Angularに実装されているデフォルトの変更検知機構は、AppComponent
から順に子コンポーネントを呼び出し、Zone.jsで各フレームごとに常に状況を監視して、「変更されているか」のマーキングがあるコンポーネントを描画更新対象とするような動きをしています。
したがって、ツリーの下の方にある場末のコンポーネントが更新された場合でも、Angularは常にルートコンポーネントから順繰りにマーキングがあるかどうか検証していて、これがパフォーマンスを悪化させています。
rx-angularは、Angularデフォルトの変更検知機構とは逆の考え方で、RxJSを用いて「コンポーネントの変更があったとき、そのコンポーネントだけを描画更新する」ための変更検知機構を独自に作成しました。[5]
そして、「なぜリアクティブなコンポーネントを作るのか」の答えはここにあり、変数をObservableとして取り扱い、コンポーネントの描画更新を能動的に行う必要があるからなのです。
本ライブラリ群は、Angularの変更検知機構という根幹部分の考え方を真逆に変え、より優れたパフォーマンスを得るためのもので、そのために必要なお膳立てをしてくれるものになります。
@rx-angular/state
一昨年の記事でご紹介しました、「リアクティブなコンポーネントストアを作成するためのライブラリ」になります。
コンポーネント内でリアクティブな値を管理することが非常に簡単になり、都度都度BehaviorSubject
を生成して冗長なコードを量産したり、ObservableをtakeUntil
で破棄するようにいちいち設定する必要がなくなります。
また、用意されているAPIにはデフォルトでObservableに対するパフォーマンスチューニングが含まれています。stateful
というオペレーターで、rx-angularと関係のない任意のObservableに対してチューニングを施すことも可能です。
今回は具体的な使用法については触れませんので、詳しくは一昨年の記事をご覧いただきたいと思います。
@rx-angular/template
このライブラリは、Angularデフォルトの変更検知機構に依存しているasync
パイプ[6]やngFor
、ngIf
といったディレクティブの代わりに、独自の変更検知機構を用いて動作するパイプ・ディレクティブ群を提供するものです。
- rxLet
- rxFor
- rxIf
- push
などがあります。
この中で特に多用するのがrxLet
で、上記のようなリアクティブなコンポーネントを実装すると、テンプレート上にasync
パイプが乱立しがちです。当然ながら、個々のパイプがそれぞれObservableを購読しているため、同一のObservableに対し数十個も購読がある、みたいな状態になり得ます。
こちらの記事では、その解決策としてSingle State Stream
パターンを紹介していますが、実装する際、以下のようにngIf
を使って若干ハック的に実装する必要があります。
<ng-container *ngIf="vm$ | async as vm">
...
</ng-container>
このパターン自体は非常に有用です。しかし、async
パイプのサブスクリプションに伴うパフォーマンスの問題や、オーバーレンダリング、Observableに偽の値が流れてきた際に中身が非表示になってしまう、テンプレート全体を評価するため変更検出が非効率といった問題があります。[7]
そこで、rxLet
を使って以下のように書き換えることができます。
<ng-container *rxLet="vm$; let vm">
...
</ng-container>
購読はこの1回のみ、vm
変数にすべての状態が含まれていて、ただの同期的な変数として取り扱うことができるようになります。
@rx-angular/cdk
その独自の変更検知機構の設定を行ったり、Observableをこねこねしてチューニングするヘルパーのセットです。
変更検知機構にはいくつか戦略の種類があり、挙動が異なります。
特に、Concurrent Strategies
に含まれている各戦略を使用するように設定すると、テンプレートの描画挙動がConcurrent React
っぽくなります。
また、描画の優先順位をつけることができるようになります。ファーストビューでは特に重要でない情報の描画優先度を下げたり、逆に重要な情報の優先度を上げることができます。
筆者はその辺を考えるのが面倒なので、グローバルにBasic Strategies
のlocal
を指定し、正常に動作しないときだけnative
戦略を使用するようにしています。
ただし、rxFor
を使用する際は、Concurrent Strategies
に含まれている戦略を使用すべきです。サンプルアプリケーションで実験するとわかりやすいですが、特に大規模な配列を描画する際に、local
戦略だと1行1行を順々に描画するような挙動を示すため、全体的な描画速度が低くなります。全行の即時描画を求める場合はimmediate
戦略がよいでしょう。
実際の使用例
それでは、実際にrx-angularを使用したコードを例示してみます。
ソースコード
npm i @rx-angular/state @rx-angular/template @rx-angular/cdk
@NgModule({
imports: [BrowserModule, FormsModule, LetModule, ForModule], // <-- LetModule/ForModuleを追加する
// 略
})
export class AppModule {}
<app-rx-angular-p
[tasks]="tasks$ | async"
(clickAdd)="onReceiveClickAdd()"
(clickDelete)="onReceiveClickDelete($event)"
></app-rx-angular-p>
@Component({
selector: 'app-rx-angular-c',
templateUrl: './rx-angular-c.component.html',
styleUrls: ['./rx-angular-c.component.css'],
})
export class RxAngularCComponent {
readonly tasks$ = this.rxAngularService.tasks$;
constructor(private readonly rxAngularService: RxAngularService) {}
onReceiveClickAdd() {
this.rxAngularService.add();
}
onReceiveClickDelete(index: number): void {
this.rxAngularService.delete(index);
}
}
<ng-container *rxLet="vm$; let vm; strategy: 'local'">
<ul>
<li *rxFor="let task of vm.tasks; let i = index; strategy: 'immediate'">
{{ task.subject }}
<button (click)="onClickedDelete$.next(i)">削除</button>
</li>
</ul>
<button (click)="onClickedAdd$.next()">追加</button>
</ng-container>
type State = {
tasks: Task[];
};
@Component({
selector: 'app-rx-angular-p',
templateUrl: './rx-angular-p.component.html',
styleUrls: ['./rx-angular-p.component.css'],
providers: [RxState],
})
export class RxAngularPComponent {
// ViewModel
readonly vm$ = this.state.select();
// Input Event
private readonly _onChangedInputTasks$ = new Subject<Task[]>();
// Output Event
readonly onClickedAdd$ = new Subject<void>();
readonly onClickedDelete$ = new Subject<number>();
@Input() set tasks(tasks: Task[]) {
this._onChangedInputTasks$.next(tasks);
}
@Output() clickAdd = new EventEmitter<void>();
@Output() clickDelete = new EventEmitter<number>();
constructor(private readonly state: RxState<State>) {
this.state.connect('tasks', this._onChangedInputTasks$.asObservable());
this.state.hold(this.onClickedAdd$.asObservable(), () => {
this.clickAdd.emit();
});
this.state.hold(this.onClickedDelete$.asObservable(), (index) => {
this.clickDelete.emit(index);
});
}
}
@Injectable({ providedIn: 'root' })
export class RxAngularService {
private readonly _tasks$ = new BehaviorSubject<Task[]>([]);
readonly tasks$ = this._tasks$.asObservable();
constructor() {}
add(): void {
const current = this._tasks$.getValue();
const add = Array.from({ length: 1000 }, (_, i) => ({
subject: `件名${i + 1}`,
}));
this._tasks$.next([...current, ...add]);
}
delete(index: number): void {
const current = this._tasks$.getValue();
this._tasks$.next(current.filter((_, i) => i !== index));
}
}
特に重要なPresentational Componentの部分を抜き出してご紹介します。
<ng-container *rxLet="vm$; let vm; strategy: 'local'">
<ul>
<li *rxFor="let task of vm.tasks; let i = index; strategy: 'immediate'">
{{ task.subject }}
<button (click)="onClickedDelete$.next(i)">削除</button>
</li>
</ul>
<button (click)="onClickedAdd$.next()">追加</button>
</ng-container>
rxLet
ディレクティブを使用する場合、殆どの場合はテンプレート内全体でまんべんなく変数を使用すると思いますので、一番上位に<ng-container>
を用意して、そこにrxLet
を記述します。もちろん、対象のエレメントに直接ディレクティブ指定することもできます。
rxFor
は、ngFor
からの置き換えを目的に作られたディレクティブです。ngFor
のプロパティはrxFor
にもだいたい用意されていますので、ngFor
から書き換えるだけで動作します。
両ディレクティブとも、Observable、静的な値両方使用できます。また、strategy
というプロパティによって、変更検知機構の戦略を設定することができます。役割等勘案し、お好みに応じて設定してください。
type State = {
tasks: Task[];
};
@Component({
selector: 'app-rx-angular-p',
templateUrl: './rx-angular-p.component.html',
styleUrls: ['./rx-angular-p.component.css'],
providers: [RxState],
})
export class RxAngularPComponent {
// ViewModel
readonly vm$ = this.state.select();
// Input Event
private readonly _onChangedInputTasks$ = new Subject<Task[]>();
// Output Event
readonly onClickedAdd$ = new Subject<void>();
readonly onClickedDelete$ = new Subject<number>();
@Input() set tasks(tasks: Task[]) {
this._onChangedInputTasks$.next(tasks);
}
@Output() clickAdd = new EventEmitter<void>();
@Output() clickDelete = new EventEmitter<number>();
constructor(private readonly state: RxState<State>) {
this.state.connect('tasks', this._onChangedInputTasks$.asObservable());
this.state.hold(this.onClickedAdd$.asObservable(), () => {
this.clickAdd.emit();
});
this.state.hold(this.onClickedDelete$.asObservable(), (index) => {
this.clickDelete.emit(index);
});
}
}
今回は具体的には触れてきませんでしたが、@rx-angular/state
をガチで使い込んだ結果、最終的にはこのような形のコンポーネントになりました。
Angularネイティブな@Input
と@Output
は入出力の役割に特化させ、イベント自体をObservableとして取り扱うことにより、よりRxJSを活用したリアクティブで宣言的なデータ操作が可能となっています。[8]
また、vm$
として、RxStateで管理しているプロパティすべてをObservableとして出力することによって、テンプレート上ですべてのプロパティを使用できるようになっています。[9]
hogehoge$ | async
を書きまくるのは割と苦痛だったので、めちゃくちゃ楽になりました。
あとがき
rx-angularが目指している世界観と実際の使用例について、分かる範囲でざっくりご紹介してきました。
「Angularは重い」と言われがちですが、rx-angularをうまく使うことによって、大規模なアプリケーションにおいても根本的なパフォーマンス改善が期待できます。
今後のAngular本体の進歩と合わせて、爆速動作するAngularアプリケーションの作成も夢ではなくなってきましたので、みなさんもrx-angularを学んで、カリカリのパフォーマンスチューニングを楽しんでみてはいかがでしょうか。
-
変数のインスタンスが変更されたときにのみ変更とみなされるAngularデフォルトの変更検知の仕様で、Angular初見殺しの一つです。配列と同様に、オブジェクト内の子プロパティだけを変更したときも同じ挙動となります。 ↩︎
-
変数の
let
が嫌われているのと同じ心理です。 ↩︎ -
zipWith
(v7でdeprecatedになったzip
の後継)というRxJSのオペレーターがあります。これでできるんちゃう?知らんけど(適当) ↩︎ -
https://www.rx-angular.io/docs/cdk/render-strategies/strategies/basic-strategies
rx-angularのドキュメント上では、Angularデフォルトを「グローバル変更検知/プルベース」、rx-angularを「ローカル変更検知/プッシュベース」として対比がなされています。 ↩︎ -
https://github.com/angular/angular/blob/40c138c13d17b638908999faafc9eb4cca0202fb/packages/common/src/pipes/async_pipe.ts#L158
markForCheck
が使われています。 ↩︎ -
https://www.rx-angular.io/docs/template/api/let-directive#motivation ↩︎
-
副産物として、publicな関数が全く不要になります。大規模なコンポーネントになっても、この書き方を徹底している限りは不要です。テンプレート以外への無用なAPIの公開を防ぐことができます。 ↩︎
-
ちなみに、出力するプロパティを絞る場合は
selectSlice
パイプを用い、this.state.select(selectSlice(['key', ...]))
と書きます。 ↩︎
Discussion