概要
AngularでWebアプリケーションを開発した時に、ついつい粒度の大きいComponentを作ってしまったんですが、表示部分と振る舞い部分を再利用したくなるケースが出てきました。
そうした時に、どうやって再利用できるようにしたのかという内容です。
InputとOutputを使って処理を外だしする
まず最初にやろうとしたやり方です。
Angularのコンポーネントを作成する時に、利用ケースごとに表示するデータの内容や処理が異なる場合はInputとOutputを使って、親コンポーネントで具体的な処理を実装するというケースが多いかと思います。
しかし、大きめのコンポーネントを共通化しようとすると、下記のようにInputとOutputが大量になってしまいます。
<app-data-table
[columns]="SENSOR_COLUMNS"
[editableColumns]="SENSOR_EDITABLE_COLUMNS"
[data]="sensorData"
[sortKey]="'name'"
[filterCondition]="filterCondition"
[isEnableHoverEvent]="true"
[isFixHead]="true"
(updatedSensorData)="updateSensorData($evnet)"
(clickedUpdateButton)="invokeSyncSensorData($event)"
(clickedFilterButton)="filterSensorData($event)"
(clickedSortButton)="sortSensorData($event)"
>
</app-data-table>
こうなった時に何が嫌かというと、次のようなことを開発者が意識しないといけなくなるので、注意しながら実装していく必要があります。(もしかしたら最新のversionだとtemplate側のparseが強化されていたりVSCodeのプラグインつかえば解決できるものがあるかもです。そういうのがあれば教えていただけると嬉しいです)
- 子コンポーネントのInput名やOutput名を変更した時に、影響範囲を特定するのが面倒。AoTビルドしないと、利用している場所でエラーになってくれない。
- テンプレートでは型推論がどうしても弱くなるので、Outputでemitされてくる値の型の認識が違ったまま実装してもエラーになってくれない。
- 何を親コンポーネントで必ず実装しないといけないのかがわかりづらい。子コンポーネントでemitされるeventのevent処理を、親側で実装するの忘れていても実際に動かすまで気づけない。
なので、共通化できない処理を実装していく部分に時に制約を与えて、想定している実装と異なっていたらすぐにエラーを教えてもらえるようにしたいなと考え、次のやり方を検討してみました。
依存関係逆転の原則の考えのもと、Serviceに処理を切り出す
外だしする処理の抽象クラスを定義して、利用ケースごとに抽象クラスを実装したServiceを作成するという方法です。
こうすることで、抽象にそっていない実装があればすぐにエラーになるので、開発者が意識することを減らせるんじゃないかと思います。
具体的なやり方を書いていきます。
1. 抽象クラスの定義
Input、Outputで定義しているようなものを抽象クラスのメンバとして定義していきます。
実装が必須じゃないイベントやプロパティはオプショナルにすれば、Serviceを実装する時に必ず実装が必要なものが明示的になります。
export abstract class DataTableService<T> {
abstract columns: (keyof T)[];
abstract editableColumns: (keyof T)[];
abstract data: T[];
abstract sortKey?: keyof T;
abstract filterCondition?: { [K in keyof T]: string[]; };
abstract isEnableHoverEvent?: boolean;
abstract isFixHead?: boolean;
abstract updatedSensorData: (data: T) => void;
abstract clickedUpdateButton: (data: T) => void;
abstract clickedFilterButton?: (key: keyof T, value: string[]) => void;
abstract clickedSortButton?: (key: keyof T) => void;
}
2. 利用ケースごとに抽象クラスの具象Serviceを実装
利用ケースごとに、上で定義した抽象クラスにそってServiceを実装していきます。
下記は、センサーデータを表示するケースのService例です。
import { Injectable } from '@angular/core';
import { SensorData } from 'src/app/interface/sensor-data';
import { DataTableService } from '../data-table.service';
import { ApiService } from 'src/app/core/api.service';
const SENSOR_DATA_TABLE_COLUMNS: (keyof SensorData)[] = ['name', 'sensorAA', 'sensorBB', 'sensorCC', 'sensorDD'];
const SENSOR_DATA_TABLE_EDITABLE_COLUMNS: (keyof SensorData)[] = ['sensorAA', 'sensorBB', 'sensorCC', 'sensorDD'];
@Injectable({
providedIn: 'root'
})
export class SensorDataTableService implements DataTableService<SensorData> {
private sensorDataList: SensorData[] = [];
constructor(private api: ApiService) { }
get columns() {
return SENSOR_DATA_TABLE_COLUMNS;
}
get editableColumns() {
return SENSOR_DATA_TABLE_EDITABLE_COLUMNS;
}
get dataList() {
return this.sensorDataList;
}
updatedSensorData(data: SensorData) {
// 具体的なupdate処理
const reqBody = {};
this.api.updateSensorData(reqBody);
}
clickedUpdateButton(data: SensorData) {
//update buttonがクリックされたときの処理
}
}
ここのServiceが、利用ケースの具体的な処理を実装する部分になります。
3. 共通化したいコンポーネントに潜んでいる、具体的な処理をなくす
共通化したいコンポーネントから具体的な処理をなくしていきます。
そのために、まずinjectするServiceは抽象クラスにし、ここでの処理はその抽象クラスのメソッドを呼び出すような実装にします。
これで、再利用性をあげたいコンポーネントが具体的な処理に依存しなくなったので、再利用性をあげることができます。
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { DataTableService, DataTableDefaultService } from './data-table.service';
import { SensorData } from 'src/app/interface/sensor-data';
import { DeviceData } from 'src/app/interface/device-data';
@Component({
selector: 'fei-data-table',
templateUrl: './data-table.component.html',
styleUrls: ['./data-table.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DataTableComponent implements OnInit {
constructor(
// 抽象クラスを指定
private dataTableService: DataTableService<SensorData | DeviceData>
) { }
ngOnInit() {}
get columns() {
return this.dataTableService.columns;
}
get editableColumns() {
return this.dataTableService.editableColumns;
}
get dataList() {
return this.dataTableService.dataList;
}
update(data: any) {
this.dataTableService.update(data);
}
}
4. 利用する側で、抽象化されている処理の具象を決定する
上記の3 Stepを行うことで、コンポーネントの再利用性が上がりました。
ただ、そのコンポーネントをそのまま利用できるわけではありません。
コンポーネントの処理は抽象のままなので、利用する側で具体的な処理を決定してあげないといけません。
実際にどうやるかというと、Angularが依存性を解決する際に、抽象クラスであるDataTableServiceはSensorDataServiceが利用されるようにします。
AngularのDIについてはこの記事が参考になりました。
https://qiita.com/lacolaco/items/61eed550d1f6070b36ab
import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
import { DataTableService } from '../data-table.service';
import { SensorDataTableService } from './sensor-data-table.service';
@Component({
selector: 'fei-sensor-data-table',
templateUrl: './sensor-data-table.component.html',
styleUrls: ['./sensor-data-table.component.scss'],
// DataTableServiceをinjecする時にはSensorDataTableServiceを利用する
providers: [{ provide: DataTableService, useClass: SensorDataTableService }],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SensorDataTableComponent implements OnInit {
constructor() { }
ngOnInit() {
}
}
useClassに指定するServiceを利用ケースごとに切り替えれば、そのコンポーネントの具体的な処理が変わります。
これで、表示部分や振る舞いに関する部分が再利用可能になり、具体的なロジックは利用ケースごとに切り替えが可能になりました。
最後に
- 通常のInput/Outputの方法と、serviceに切り出す方法を混在させるのかどうかがまだ自分の中で決めれていない状態です。
- アプリケーションの中でどちらかの方法に統一するのか、どちらを使うかの基準を決めてその基準にのっとって使っていくのか
- もっと役割ごとにコンポーネントを分割して、コンポーネントの役割が小さくなるようにやるのが正しいのかもしれない
- とりあえず大きく作ってしまったComponentを手軽に安全に共通化する手段としてはアリかもとおもいました
明日は@dayjournal さんです!