Angularでよりリアクティブに実装する記事も書いたので合わせて読んでいただけると嬉しいです。
より簡潔に、より安全にリアクティブプログラミングする
まえおき
AnuglarでWebアプリケーションを実装するという前提のもと、書かれた内容です。
なので、サンプルコードはAngularのコードになっています。
RxJSの基本的な知識があることを前提に記述しています。
間違いや語弊などありましたら、優しくコメントいただけると嬉しいです。
なぜリアクティブに実装したいのか
コンポーネントやアプリケーションで保持している状態が、どういうふうに変化していくかを把握するのが容易になる。
というのが、リアクティブに実装したいモチベーションの一つだと思っています。
ここで言っている コンポーネントやアプリケーションの状態
というのは、最終的に画面に表示される結果に影響してくるものなので、状態の変化が把握しやすいというのは、画面の挙動を把握しやすいというところに繋がってくるはずです。
実装からアプリケーションの挙動が把握しやすい というのは、複雑なWebアプリケーションを実装していく上では重要なポイント(メンテナンス性とかにおいて)になってくると思うので、上記にあげた コンポーネントやアプリケーションで保持している状態が、どういうふうに変化していくかを把握するのが容易になる。
というのは十分なモチベーションになりうるのかなと思います。
他にも、リアクティブに実装するモチベーションはいくつかあるかと思いますが、今回はこの観点で考えてみたいと思います。
なぜ状態の変化の把握が容易になるのか
リアクティブな実装では、アプリケーションで発生する様々なイベントと、流動的に変化していく状態の関係性を宣言的に記述していくことになると思います。
この 関係性を宣言的に記述する という性質が、コンポーネントやアプリケーションで保持している状態が、どういうふうに変化していくかを把握するのが容易になる
というところに繋がってくるんじゃないかなと思うわけです。
この話を、簡単なTodoアプリの実装を例に考えてみたいと思います。
命令的な実装例
まずは、リアクティブじゃない命令的な実装例です。
このコンポーネントで保持する状態は、Todoリスト
、フィルタ条件
、フィルタされたTodoリスト
の3つがあります。
話をシンプルにするために、フィルタされたTodoリスト
も、TodoItemオブジェクトのリストとして保持するようにします。
ここで主に見てほしいのは、フィルタされたTodoリスト
の状態を更新しているタイミングです。
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
type FilterCondition = 'completed' | 'all' | 'todo';
type TodoItem = { id: string; title: string; completed: boolean; };
@Component({
selector: 'app-todo-page',
templateUrl: './todo-page.component.html',
styleUrls: ['./todo-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoPageComponent implements OnInit {
// Todoリスト
items: TodoItem[] = [
{id: uuidv4(), title: 'todo0', completed: false},
{id: uuidv4(), title: 'todo1', completed: true},
{id: uuidv4(), title: 'todo2', completed: false},
{id: uuidv4(), title: 'todo3', completed: true},
{id: uuidv4(), title: 'todo4', completed: false},
{id: uuidv4(), title: 'todo5', completed: true},
];
// フィルタされたTodoリスト
filteredItems: TodoItem[];
// フィルタ条件
filterCondition: FilterCondition = 'all';
constructor() {
this.executeFilter(); // フィルタ実行
}
ngOnInit(): void {
}
executeFilter() {
switch (this.filterCondition) {
case 'todo':
this.filteredItems = this.items.filter(v => !v.completed);
break;
case 'completed':
this.filteredItems = this.items.filter(v => v.completed);
break;
case 'all':
default:
this.filteredItems = this.items;
break;
}
}
changeFilterCondition(condition: FilterCondition) {
this.filterCondition = condition;
this.executeFilter(); // フィルタ実行
}
add() {
this.items = [...this.items, {
id: uuidv4(),
title: uuidv4().slice(0, 5),
completed: false
}];
this.executeFilter(); // フィルタ実行
}
remove(id: string) {
this.items = this.items.filter(v => v.id !== id);
this.executeFilter(); // フィルタ実行
}
}
<h2>Todo List(Filtered)</h2>
<p>filter condition: {{filterCondition}}</p>
<div>
<button (click)="changeFilterCondition('all')">all</button>
<button (click)="changeFilterCondition('todo')">todo</button>
<button (click)="changeFilterCondition('completed')">completed</button>
</div>
<button (click)="add()">add</button>
<ul>
<ng-container *ngFor="let item of filteredItems">
<li>
<span>title: {{ item.title }}, completed: {{ item.completed }}</span>
<button (click)="remove(item.id)">remove</button>
</li>
</ng-container>
</ul>
フィルタされたTodoリスト
の状態を更新しているタイミングは、コンポーネント初期化時、フィルタ条件変更時、Todo追加時、Todo削除時となっています。
そして、この実装だと次のことがら問題になってくると思っています。
- どういう時に
フィルタされたTodoリスト
を更新しないといけないかを、細かく開発者が意識する必要がある。-
Todoリスト
の状態を変更する別の処理が増えた時、毎回フィルタされたTodoリスト
も更新する必要があるのかを意識しないといけないので、バグにつながりやすい。 - 第三者(他人または未来の自分)が実装を修正する時に、
フィルタされたTodoリスト
の更新もれに繋がったりする。
-
- Todo追加処理や削除処理の中で、
Todoリスト
への追加、削除処理以外のことをしている。 - フィルタ条件更新処理の中で、
フィルタ条件
の更新以外のことをしている。
本来フィルタされたTodoリスト
は、Todoリスト
とフィルタ条件
のどちらかに更新があったタイミングで状態を更新すればいいはずで、Todo追加時、削除時などの細かいタイミングは気にしたくないはずです。
本来気にしたいタイミングだけ気にするように実装できれば、上記にあげた3つの問題は解消されると思われます。
リアクティブな実装例
先ほどの実装をリアクティブに書き直すと下記のようになります。
このコンポーネントで保持する状態は、命令的な実装の例と同様にTodoリスト
、フィルタ条件
、フィルタされたTodoリスト
の3つです。
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
import { BehaviorSubject, combineLatest, } from 'rxjs';
import { map, take } from 'rxjs/operators';
type FilterCondition = 'completed' | 'all' | 'todo';
type TodoItem = { id: string; title: string; completed: boolean; };
@Component({
selector: 'app-reactive-todo-page',
templateUrl: './reactive-todo-page.component.html',
styleUrls: ['./reactive-todo-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ReactiveTodoPageComponent implements OnInit {
// Todoリスト
readonly items$: BehaviorSubject<TodoItem[]> = new BehaviorSubject([
{id: uuidv4(), title: 'todo0', completed: false},
{id: uuidv4(), title: 'todo1', completed: true},
{id: uuidv4(), title: 'todo2', completed: false},
{id: uuidv4(), title: 'todo3', completed: true},
{id: uuidv4(), title: 'todo4', completed: false},
{id: uuidv4(), title: 'todo5', completed: true},
]);
// フィルタ条件
readonly filterCondition$: BehaviorSubject<FilterCondition> = new BehaviorSubject('all');
// フィルタされたTodoリスト
readonly filteredItems$ = combineLatest([
this.items$,
this.filterCondition$
]).pipe(
map(([items, condition]) => {
// フィルタの実行
switch (condition) {
case 'todo':
return items.filter(v => !v.completed);
case 'completed':
return items.filter(v => v.completed);
case 'all':
return items;
}
})
);
constructor() { }
ngOnInit(): void {
}
changeFilterCondition(condition: FilterCondition) {
this.filterCondition$.next(condition);
}
add() {
// Todoのリストに項目を追加
this.items$.pipe(take(1)).subscribe(items => {
const newItem = {
id: uuidv4(),
title: uuidv4().slice(0, 5),
completed: false
};
this.items$.next([...items, newItem]);
});
}
remove(id: string) {
// Todoのリストから指定された項目を削除
this.items$.pipe(take(1)).subscribe(items => {
const itemsAfterRemove = items.filter(v => v.id !== id);
this.items$.next(itemsAfterRemove);
});
}
}
<h2>Todo List(Filtered)</h2>
<p>filter condition: {{filterCondition$ | async}}</p>
<div>
<button (click)="changeFilterCondition('all')">all</button>
<button (click)="changeFilterCondition('todo')">todo</button>
<button (click)="changeFilterCondition('completed')">completed</button>
</div>
<button (click)="add()">add</button>
<ul>
<ng-container *ngFor="let item of filteredItems$ | async">
<li>
<span>title: {{ item.title }}, completed: {{ item.completed }}</span>
<button (click)="remove(item.id)">remove</button>
</li>
</ng-container>
</ul>
ここでフィルタされたTodoリスト
の状態を更新しているのは、最初にfilteredItems$を定義している部分のみです。
その定義で、Todoリスト
とフィルタ条件
のどちらかに更新があったタイミングで、フィルタされたTodoリスト
を更新する、というのが宣言的に表現されており、上記であげたアプリケーションで発生する様々なイベントと、流動的に変化していく状態の関係性を宣言的に記述していく
という形に基づいているのがわかるかと思います。
また、この形式になっていることで、初期化時の定義だけを確認すれば、filteredItems$の値がどういうふうに変化していくのかが把握しやすいかと思います。
// フィルタされたTodoリスト
readonly filteredItems$ = combineLatest([
this.items$,
this.filterCondition$
]) // this.items$またはthis.filterCondition$に更新があったタイミングでpipe内の処理が実行される
.pipe(
map(([items, condition]) => {
// フィルタの実行
switch (condition) {
case 'todo':
return items.filter(v => !v.completed);
case 'completed':
return items.filter(v => v.completed);
case 'all':
return items;
}
})
);
この形にしたことで、命令的な実装の時に問題にあげた、3つの問題を解決することができるかと思います。
- どういう時に
フィルタされたTodoリスト
を更新しないといけないかを、細かく開発者が意識する必要がある。- ->
Todoリスト
かフィルタ条件
に更新があったタイミングだけを意識すれば良い
- ->
- Todo追加処理や削除処理の中で、
Todoリスト
への追加、削除処理以外のことをしている。- ->
Todoリスト
への追加、削除処理のみだけを行う実装になっている
- ->
- フィルタ条件更新処理の中で、
フィルタ条件
の更新以外のことをしている。- ->
フィルタ条件
の更新処理のみだけを行う実装になっている
- ->
まとめ
- リアクティブに実装するというのは、アプリケーションで発生する様々なイベントと、流動的に変化していく状態の関係性を宣言的に記述するということになっていそう。
- イベントと状態の関係性を宣言的に記述することで、コンポーネントやアプリケーションで保持している状態が、どういうふうに変化していくかを把握するのが容易になりそう。
- なので、リアクティブに書きたい。
補足
命令的な実装例で、下記のようにgetterを使えば良いという話もあるかと思いますが、いくつか問題が残ってしまいます。
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { v4 as uuidv4 } from 'uuid';
type FilterCondition = 'completed' | 'all' | 'todo';
type TodoItem = { id: string; title: string; completed: boolean; };
@Component({
selector: 'app-todo-page',
templateUrl: './todo-page.component.html',
styleUrls: ['./todo-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoPageComponent implements OnInit {
// Todoリスト
items: TodoItem[] = [
{id: uuidv4(), title: 'todo0', completed: false},
{id: uuidv4(), title: 'todo1', completed: true},
{id: uuidv4(), title: 'todo2', completed: false},
{id: uuidv4(), title: 'todo3', completed: true},
{id: uuidv4(), title: 'todo4', completed: false},
{id: uuidv4(), title: 'todo5', completed: true},
];
// フィルタ条件
filterCondition: FilterCondition = 'all';
constructor() {}
ngOnInit(): void {}
// フィルタされたTodoリスト
get filteredItems() {
switch (this.filterCondition) {
case 'todo':
return this.items.filter(v => !v.completed);
case 'completed':
return this.items.filter(v => v.completed);
case 'all':
default:
return this.items;
}
}
changeFilterCondition(condition: FilterCondition) {
this.filterCondition = condition;
}
add() {
this.items = [...this.items, {
id: uuidv4(),
title: uuidv4().slice(0, 5),
completed: false
}];
}
remove(id: string) {
this.items = this.items.filter(v => v.id !== id);
}
}
<h2>Todo List(Filtered)</h2>
<p>filter condition: {{filterCondition}}</p>
<div>
<button (click)="changeFilterCondition('all')">all</button>
<button (click)="changeFilterCondition('todo')">todo</button>
<button (click)="changeFilterCondition('completed')">completed</button>
</div>
<button (click)="add()">add</button>
<ul>
<ng-container *ngFor="let item of filteredItems">
<li>
<span>title: {{ item.title }}, completed: {{ item.completed }}</span>
<button (click)="remove(item.id)">remove</button>
</li>
</ng-container>
</ul>
ここでは、次の点が問題になるかと思っています。
- 特定の状態に更新があったことをトリガに、特定の処理を実行できない
- フィルタ処理が無駄に実行されることがある(Angularにおいて)
特定の状態に更新があったことをトリガに、特定の処理を実行できない
getterだと、結局その中の処理タイミングはgetterを呼び出したタイミングになってしまいます。
この例だと、template側でfilteredItems
をbindしているので、結果としてTodoリスト
またはフィルタ条件
に変更があった時に、フィルタされたTodoリスト
を返却するという挙動になるのですが、それは結果としてそうなっているだけで、厳密にいうとタイミングとしては別の意味になります。
上記にあげた実装だと、イベントハンドラー(addメソッドやremoveメソッド)が実行された時に、Angularの変更検知処理が走りします。
この変更検知処理の中でgetterの処理が実行されているのです。
なので、ここでのフィルタが行われるタイミングを厳密にいうと、Angularの変更検知処理のタイミングになり、Todoリスト
またはフィルタ条件
に変更があった時ということではないのです。
よって、Angularの変更検知処理のタイミング以外で、フィルタ処理を実行したい場合は、結局命令的に実装するしかなくなってしまうと思います。
変更検知方式をOnPushにしたときの挙動の説明は下記の記事が参考になりました。
Angular OnPush Change Detection and Component Design - Avoid Common Pitfalls
フィルタ処理が無駄に実行されることがある(Angularにおいて)
これは先ほど挙げたAngularの変更検知処理に関する話になります。
getter内が処理されるタイミングはAngularの変更検知処理のタイミングになるので、Todoリスト
またはフィルタ条件
が更新された時以外にも実行されてしまいます。
例として、TodoPageComponentにカウンタの機能をつけた時の挙動をみてください。
※getter内の処理で--- execute filter
をコンソールに表示するようにしています。
※incrementボタン押下時はコンポーネントないで保持しているcountをインクリメントする以外の処理はしていないです。
Todoリストとは全く別の状態の更新であるにも関わらず、getter内の処理が実行されてしまっています。
filteredItemsのgetterだけだとそこまで問題になることは少ないかと思いますが、templateにbindしているgetterの数が増えたり、getter内で重い処理をしていたりするとパフォーマンスに大きく影響してくるというのは容易に想像がつくと思います。
よって、特定の状態の変更をトリガに、何かしらの処理を行いたいような場合は、リアクティブに書くのが良さそうです。