0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

なぜリアクティブプログラミングをしたいのかを考えてみる

Last updated at Posted at 2020-08-03

Angularでよりリアクティブに実装する記事も書いたので合わせて読んでいただけると嬉しいです。
より簡潔に、より安全にリアクティブプログラミングする

まえおき

AnuglarでWebアプリケーションを実装するという前提のもと、書かれた内容です。
なので、サンプルコードはAngularのコードになっています。

RxJSの基本的な知識があることを前提に記述しています。

間違いや語弊などありましたら、優しくコメントいただけると嬉しいです。

なぜリアクティブに実装したいのか

コンポーネントやアプリケーションで保持している状態が、どういうふうに変化していくかを把握するのが容易になる。
というのが、リアクティブに実装したいモチベーションの一つだと思っています。

ここで言っている コンポーネントやアプリケーションの状態 というのは、最終的に画面に表示される結果に影響してくるものなので、状態の変化が把握しやすいというのは、画面の挙動を把握しやすいというところに繋がってくるはずです。

実装からアプリケーションの挙動が把握しやすい というのは、複雑なWebアプリケーションを実装していく上では重要なポイント(メンテナンス性とかにおいて)になってくると思うので、上記にあげた コンポーネントやアプリケーションで保持している状態が、どういうふうに変化していくかを把握するのが容易になる。 というのは十分なモチベーションになりうるのかなと思います。

他にも、リアクティブに実装するモチベーションはいくつかあるかと思いますが、今回はこの観点で考えてみたいと思います。

なぜ状態の変化の把握が容易になるのか

リアクティブな実装では、アプリケーションで発生する様々なイベントと、流動的に変化していく状態の関係性を宣言的に記述していくことになると思います。

この 関係性を宣言的に記述する という性質が、コンポーネントやアプリケーションで保持している状態が、どういうふうに変化していくかを把握するのが容易になる というところに繋がってくるんじゃないかなと思うわけです。

この話を、簡単なTodoアプリの実装を例に考えてみたいと思います。

命令的な実装例

まずは、リアクティブじゃない命令的な実装例です。

このコンポーネントで保持する状態は、Todoリストフィルタ条件フィルタされたTodoリストの3つがあります。
話をシンプルにするために、フィルタされたTodoリストも、TodoItemオブジェクトのリストとして保持するようにします。

ここで主に見てほしいのは、フィルタされたTodoリストの状態を更新しているタイミングです。

todo-page.component.ts
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(); // フィルタ実行
  }

}

todo-page.component.html
<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つです。

reactive-todo-page.component.ts
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);
    });
  }

}
reactive-todo-page.component.html

<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$の値がどういうふうに変化していくのかが把握しやすいかと思います。

reactive-todo-page.component.ts(抜粋)
  // フィルタされた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を使えば良いという話もあるかと思いますが、いくつか問題が残ってしまいます。

todo-page.component.ts(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);
  }

}
todo-page.component.html(命令的な実装例と同じ)
<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をインクリメントする以外の処理はしていないです。

画面収録 2020-07-26 11.56.35.gif

Todoリストとは全く別の状態の更新であるにも関わらず、getter内の処理が実行されてしまっています。

filteredItemsのgetterだけだとそこまで問題になることは少ないかと思いますが、templateにbindしているgetterの数が増えたり、getter内で重い処理をしていたりするとパフォーマンスに大きく影響してくるというのは容易に想像がつくと思います。

よって、特定の状態の変更をトリガに、何かしらの処理を行いたいような場合は、リアクティブに書くのが良さそうです。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?