👏

rx-angularのススメ

2022/12/23に公開約15,000字

本記事は、Angular Advent Calendar 2022 24日目の記事です。
23日目 25日目

一昨年のAngular Advent Calendar 2020で、「Angularの状態管理まとめ」という記事を書きました。
その記事において、@rx-angular/stateというライブラリをご紹介しましたが、このライブラリはrx-angularと名のつくライブラリ群が目指している世界観のほんの一部分に過ぎません。

そこで、今回の記事ではrx-angularをさらに深掘りして、私がどのようにこのライブラリ群を活用しているのか、また、「rx-angularが目指している世界観」についてを、私が分かる範囲でご紹介してみたいと思います。

サンプルアプリケーションを用意しています。
https://stackblitz.com/edit/angular-ivy-m171dq

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な変数を取り扱うことは、不必要に煩雑になるために避けられてきた印象があります。

すなわち、以下のような書き方です。

traditional-p.component.html
<ul>
  <li *ngFor="let task of tasks">{{ task.subject }}</li>
</ul>
traditional-p.component.ts
@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.DefaultChangeDetectionStrategy.OnPushのどちらを選択しているかによって変わります。

前者の場合は、随時変更検知が行われているため、配列自体のインスタンスが変更されていない場合、つまり配列の項目が追加・削除されたりした場合でも表示が変わりますが、後者の場合は配列のインスタンスが変更された場合のみ表示が変わります。配列の中身が変更された場合には表示が変わりません[2]

さて、このようにChangeDetectionStrategy.OnPushに設定していて、うまく表示が切り替わらない場合、手動で変更検知を行う必要があります。

まずは、親コンポーネントでタスク追加ボタンをクリックした際にイベントを発生させ、子コンポーネントでそれを購読し手動で変更検知を走らせてみます。

traditional-p.component.ts
@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パイプで子コンポーネントに割り当ててみます。

traditional.service.ts
@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: '件名' }]);
  }
}
traditional-c.component.html
<app-traditional-p [tasks]="tasks$ | async"></app-traditional-p>
<button (click)="add()">追加</button>
traditional-c.component.ts
@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との合わせ技により、リアクティブに取り扱うことができます。

reactive-p.component.html
<ul>
  <li *ngFor="let task of tasks$ | async">{{ task.subject }}</li>
</ul>
reactive-p.component.ts
@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を挟むと、親コンポーネントにイベントを伝達する前にフック処理ができて便利です。

reactive-p.component.html
<ul>
  <li *ngFor="let task of tasks$ | async; let i = index">
    {{ task.subject }} <button (click)="onClickedDelete$.next(i)">削除</button>
  </li>
</ul>
reactive-p.component.ts
@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]ngForngIfといったディレクティブの代わりに、独自の変更検知機構を用いて動作するパイプ・ディレクティブ群を提供するものです。

  • 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をこねこねしてチューニングするヘルパーのセットです。

変更検知機構にはいくつか戦略の種類があり、挙動が異なります。
https://www.rx-angular.io/docs/cdk/render-strategies

特に、Concurrent Strategiesに含まれている各戦略を使用するように設定すると、テンプレートの描画挙動がConcurrent Reactっぽくなります。

また、描画の優先順位をつけることができるようになります。ファーストビューでは特に重要でない情報の描画優先度を下げたり、逆に重要な情報の優先度を上げることができます。

筆者はその辺を考えるのが面倒なので、グローバルにBasic Strategieslocalを指定し、正常に動作しないときだけnative戦略を使用するようにしています。

ただし、rxForを使用する際は、Concurrent Strategiesに含まれている戦略を使用すべきです。サンプルアプリケーションで実験するとわかりやすいですが、特に大規模な配列を描画する際に、local戦略だと1行1行を順々に描画するような挙動を示すため、全体的な描画速度が低くなります。全行の即時描画を求める場合はimmediate戦略がよいでしょう。

実際の使用例

それでは、実際にrx-angularを使用したコードを例示してみます。

ソースコード
npm i @rx-angular/state @rx-angular/template @rx-angular/cdk
app.module.ts
@NgModule({
  imports: [BrowserModule, FormsModule, LetModule, ForModule], // <-- LetModule/ForModuleを追加する
  // 略
})
export class AppModule {}
rx-angular-c.component.html
<app-rx-angular-p
  [tasks]="tasks$ | async"
  (clickAdd)="onReceiveClickAdd()"
  (clickDelete)="onReceiveClickDelete($event)"
></app-rx-angular-p>
rx-angular-c.component.ts
@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);
  }
}
rx-angular-p.component.html
<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>
rx-angular-p.component.ts
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.service.ts
@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の部分を抜き出してご紹介します。

rx-angular-p.component.html
<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というプロパティによって、変更検知機構の戦略を設定することができます。役割等勘案し、お好みに応じて設定してください。

rx-angular-p.component.ts
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を学んで、カリカリのパフォーマンスチューニングを楽しんでみてはいかがでしょうか。

脚注
  1. https://qiita.com/Quramy/items/83f4fbc6755309f78ad2 ↩︎

  2. 変数のインスタンスが変更されたときにのみ変更とみなされるAngularデフォルトの変更検知の仕様で、Angular初見殺しの一つです。配列と同様に、オブジェクト内の子プロパティだけを変更したときも同じ挙動となります。 ↩︎

  3. 変数のletが嫌われているのと同じ心理です。 ↩︎

  4. zipWith(v7でdeprecatedになったzipの後継)というRxJSのオペレーターがあります。これでできるんちゃう?知らんけど(適当) ↩︎

  5. https://www.rx-angular.io/docs/cdk/render-strategies/strategies/basic-strategies
    rx-angularのドキュメント上では、Angularデフォルトを「グローバル変更検知/プルベース」、rx-angularを「ローカル変更検知/プッシュベース」として対比がなされています。 ↩︎

  6. https://github.com/angular/angular/blob/40c138c13d17b638908999faafc9eb4cca0202fb/packages/common/src/pipes/async_pipe.ts#L158
    markForCheckが使われています。 ↩︎

  7. https://www.rx-angular.io/docs/template/api/let-directive#motivation ↩︎

  8. 副産物として、publicな関数が全く不要になります。大規模なコンポーネントになっても、この書き方を徹底している限りは不要です。テンプレート以外への無用なAPIの公開を防ぐことができます。 ↩︎

  9. ちなみに、出力するプロパティを絞る場合はselectSliceパイプを用い、this.state.select(selectSlice(['key', ...]))と書きます。 ↩︎

Discussion

ログインするとコメントできます