Chapter 04

1-1. コンポーネントのリファクタリング

lacolaco
lacolaco
2021.09.11に更新

1-1節では、複数の責任を持ったコンポーネントをリファクタリングし、メンテナンスしやすい単一責任のコンポーネントへと分割を進めるパターンを学びます。

コンポーネントの責任

現在のAppComponentは次のようになっています。

app.component.html
<ul>
  <li *ngFor="let user of users">
    #{{ user.id }} {{ user.first_name }} {{ user.last_name }}
  </li>
</ul>
app.component.ts
@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  users: User[] = [];

  constructor(private http: HttpClient) { }

  ngOnInit() {
    this.http.get<{ data: User[] }>('https://reqres.in/api/users').subscribe(resp => {
      this.users = resp.data;
    });
  }
}

今のAppComponentは次のようなたくさんの責任を負っています。

  1. アプリケーションブートストラップに使われるルートコンポーネントとなること
  2. ユーザーの配列をAPIから取得すること
  3. ユーザーの配列をリストとして表示すること
  4. ユーザーの情報を表示すること

これは単一責任原則に反しています。このままでは、AppComponentはいくつもの理由で変更されることになります。1つめのルートコンポーネントとしての役割は捨てることができないものですが、それ以外の責任はそれぞれを担当するコンポーネントに分割することができます。

まずはリストで反復される単位要素を分割することで、ひとつの責任を別のモジュールに切り出すことにしましょう。

UserListItemComponent の分割

ユーザーリストの繰り返し単位のコンポーネントを UserListItemComponent として分割します。このコンポーネントの責任は、渡された1件のユーザーをリストアイテムとして表示することです。それ以外の理由でこのコンポーネントが変更されてはいけません。

コンポーネントの分割は次のような手順で行います。

  1. 新しいコンポーネントのファイルを作成する(ng g component
  2. 元のコンポーネントのテンプレートから新しいコンポーネントに分割したい範囲を移植する
  3. 新しいコンポーネントに必要なプロパティやメソッドを定義する
  4. 元のコンポーネントのテンプレートを新しいコンポーネントで置き換える

UserListItemComponentのために切り出されるテンプレートは次の範囲です。

user-list-item.component.html
#{{ user.id }} {{ user.first_name }} {{ user.last_name }}

このテンプレートに必要な user プロパティを定義し、親コンポーネントからデータを受け取るためにインプットとして機能するようにします。
UserListItemComponent は次のようになります。

user-list-item.component.ts
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { User } from '../../user';

@Component({
  selector: 'user-list-item',
  templateUrl: './user-list-item.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserListItemComponent {
  @Input()
  user!: User;
}

Non-null アサーションオペレータ ! を使ってインプットプロパティを宣言している理由については AngularにおけるstrictPropertyInitializationのベストプラクティス を参考にしてください。

次のようにAppComponentのテンプレートの一部をUserListItemComponentで置き換えます。これでAppComponentはユーザー情報の表示に関する責任から開放されました。たとえばユーザーのメールアドレスを表示することになっても、AppComponentを変更する必要はありません。

app.component.html
<ul>
  <li *ngFor="let user of users">
    <user-list-item [user]="user"></user-list-item>
  </li>
</ul>

UserListComponentの分割

続いてもうひとつの責任、ユーザーの配列をどのようにリストとして表示するかという関心事をAppComponentから切り出すことにしましょう。同じように新しく UserListComponent を作成し、テンプレートを切り出します。

user-list.component.ts
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
import { User } from '../../user';

@Component({
  selector: 'user-list',
  templateUrl: './user-list.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserListComponent {

  @Input()
  users!: User[];
}
user-list.component.html
<ul>
  <li *ngFor="let user of users">
    <user-list-item [user]="user"></user-list-item>
  </li>
</ul>

結果として、AppComponentのテンプレートは次のようになります。AppComponentが持つ責任は、アプリケーションのルートコンポーネントであることと、ユーザーリストのデータを取得することになりました。ユーザーリストをどのように表示するかというUIの関心事はUserListComponentUserListItemComponentに分離できました。
次はユーザーリストのデータを取得する責任を移譲するため、UIとは関係のないビジネスロジックをサービスを使ってリファクタリングしましょう

app.component.html
<user-list [users]="users"></user-list>

ビジネスロジックの分離

ユーザーリストのデータを取得する責任はさらに2つの責任に分解できます。すなわち、HTTPクライアントを使ってユーザーリストを取得することと、取得したデータを UserListComponent に渡すために保持することです。まずはHTTPクライアントを使うビジネスロジックを切り出すことで、1つ目の責任を移譲することにしましょう。

Angularの一般的なアプローチとして、UIに関係しないビジネスロジックはコンポーネントから サービス に分離されます。Angularにおけるサービスという語彙は、特定の実装やインターフェースの名称ではなく、ある単一の関心のために作られた、依存性の注入により利用されるクラス全般を指します。

HTTPクライアントでユーザーリストを取得する処理をUserServiceというサービスに移譲します。

user.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { User } from '../user';

@Injectable({ providedIn: 'root' })
export class UserService {

  constructor(private http: HttpClient) { }

  getUsers(): Observable<User[]> {
    return this.http
      .get<{ data: User[] }>("https://reqres.in/api/users")
      .pipe(
        map(resp => resp.data)
      );
  }
}

AppComponentのコンストラクタでUserServiceを注入して getUsers メソッドを呼び出します。
HTTPクライアントを利用する処理をAppComponentから隠蔽することで、URLの変更やHTTPヘッダの追加のためにAppComponentが変更されることがなくなります。

app.component.ts
import { Component } from '@angular/core';
import { User } from './user';
import { UserService } from './service/user.service';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {

  users: User[] = [];

  constructor(private userService: UserService) { }

  ngOnInit() {
    this.userService.getUsers().subscribe(users => {
      this.users = users;
    });
  }
}

AppComponentの責務がどんどん減ってきましたね。あともう少しです。

ユーザーリストの状態管理

ユーザーリストを表示するための配列はAppComponentのクラスプロパティとして保持されています。この責任を UserService へ移譲しましょう。移譲するにあたり、UserServiceを次のように整理します。

  • get users$(): Observable<User[]>: ユーザーリストのデータをObservableとして返すgetterメソッド
  • fetchUsers(): void ユーザーリストのデータの取得を開始するメソッド

このように、副作用を起こさず値を返す クエリ と、副作用を起こし結果を返さない コマンド のメソッドを分離する考え方をコマンド・クエリ分離原則といいます。現在の getUsers() メソッドはHTTPリクエストを発行し、表示するユーザーリストを変更させる副作用がある一方で、呼び出し元に結果も返しています。これを分離し、HTTPリクエストを発行してアプリケーションの状態を書き換えるコマンドと、アプリケーションの状態を監視するクエリを作成します。

今回のリファクタリングでUserServiceはRxJSの BehaviorSubject を使ってユーザーの配列を保持します。BehaviorSubjectObservableであると同時にSubjectであるため、そのまま外部に露出すると UserServiceの外から値を書き込むことができてしまいます。そのためユーザーリストの状態はプライベートフィールドで保持し、getterを使って Observableとしてのインターフェースだけを公開します。

user.service.ts
import { Injectable } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { BehaviorSubject } from "rxjs";
import { map } from "rxjs/operators";
import { User } from "../user";

@Injectable({ providedIn: "root" })
export class UserService {
  private usersSubject = new BehaviorSubject<User[]>([]);

  get users$() {
    return this.usersSubject.asObservable();
  }

  constructor(private http: HttpClient) {}

  fetchUsers(): void {
    this.http
      .get<{ data: User[] }>("https://reqres.in/api/users")
      .pipe(map(resp => resp.data))
      .subscribe(users => {
        this.usersSubject.next(users);
      });
  }
}

このサービスをAppComponentから利用すると次のようになります。AppComponentが直接データを保持することをやめて、UserServiceが提供するusers$配列をUIに反映することだけが責任になっています。

ユーザーの配列が同期的な配列からObservableに変わったので、テンプレート中でAsyncパイプを使って購読することを忘れないようにしましょう。

app.component.ts
import { Component } from '@angular/core';
import { UserService } from './service/user.service';

@Component({
  selector: 'my-app',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  users$ = this.userService.users$;

  constructor(private userService: UserService) { }

  ngOnInit() {
    this.userService.fetchUsers();
  }
}

app.component.html
<user-list *ngIf="users$ | async as users" [users]="users"></user-list>

Asyncパイプの戻り値は必ずT | nullの型をとります。これは非同期データが解決されるまでの初期値が null だからです。そのため nullを許容しない UserListComponentのインプットへ値を渡すために *ngIfディレクティブで nullの可能性を排除する必要があります。

詳細については AsyncPipeの初期値null問題と非同期データバインディング を参照してください。

ついに、AppComponentからほとんどの責任を移譲することができました。今のAppComponentに残っているのはルートコンポーネントであることと、アプリケーションの起動時にユーザーリストの取得を開始することだけです。

ところで、今のAppComponentとUserServiceの関係を図にすると次のようになります。よくみるとこれはビューとストア、アクションによる単方向のデータフローを構成しており、簡易的ではありますがFluxに近い実装となっています。コンポーネントが状態の変更を待ち受け、コンポーネントからのアクションから発生する副作用により状態が更新されるという設計パターンは、Angularに限らず昨今のコンポーネント中心のアプリケーション開発において欠かせない考え方のひとつです。

AppComponentとUserServiceの関係図

アプリケーションが現在管理する状態はユーザーリストの配列だけですが、機能が増えてアプリケーションの状態が複雑になるに従って、UserServiceだけで管理することが単一責任原則に反していきます。

次のページからは少し複雑性が高くなっていくなかでのアプリケーション設計を学びましょう。