LWCに慣れてきた頃、とにかくコンポーネント間の連携はCustomEventを使えば何でもできると思い込んでいた時期があった。特に親から子へのデータ受け渡しでさえ、@apiプロパティを使うのではなく、親が子に対してイベントを発火させ、子がそれを購読する、という一見すると柔軟な設計を試みた。
当時の私は、JavaScriptのイベント駆動モデルに慣れていたこともあり、「イベントこそが疎結合の極みだ!」と信じて疑わなかった。親コンポーネントが子の状態を直接操作するのを避け、子コンポーネント自身がイベントに反応して振る舞いを変更する方が、再利用性も高まると考えていたのだ。
その時の判断ミス:親から子へのデータ伝達をCustomEventで行う
具体的には、親コンポーネントで取得したリストデータを子コンポーネントに渡して表示させたい、という要件があった。普通なら、子の@apiプロパティにリストを渡すのが定石だ。
// 親コンポーネント (親が子にデータを渡す通常の方法)
<template>
<c-child-component items={parentData}></c-child-component>
</template>
// 子コンポーネント
import { api, LightningElement } from 'lwc';
export default class ChildComponent extends LightningElement {
@api items; // 親から直接データを受け取る
}
ところが、当時の私は以下のような実装を選んだ。
// 親コンポーネント (当時、私が選んだ設計)
<template>
<c-child-component onready={handleChildReady}></c-child-component>
</template>
import { LightningElement } from 'lwc';
import getSomeData from '@salesforce/apex/SomeController.getSomeData';
export default class ParentComponent extends LightningElement {
parentData = [];
connectedCallback() {
this.fetchData();
}
async fetchData() {
this.parentData = await getSomeData();
// ここではまだ子にデータを渡さない
}
handleChildReady() {
// 子が "ready" イベントを発火した時に、親から子にデータを送るCustomEventを発火
const sendDataEvent = new CustomEvent('parentdatareceived', {
detail: { data: this.parentData }
});
this.template.querySelector('c-child-component').dispatchEvent(sendDataEvent);
// ↑今ならこれは絶対しない。querySelectorで子コンポーネントのDOM要素を取得して直接dispatchEvent?冗談だろ。
// もしくは、親コンポーネント自身のDOM要素に対してdispatchEventして、子がそれをlistenする。
// いずれにせよ、複雑すぎる。
}
}
// 子コンポーネント (当時、私が選んだ設計)
import { LightningElement } from 'lwc';
export default class ChildComponent extends LightningElement {
childItems = [];
connectedCallback() {
// 子自身のDOMにイベントリスナーを追加して、親からのデータイベントを待つ
this.addEventListener('parentdatareceived', this.handleParentDataReceived);
// 子が準備できたことを親に伝えるイベント
this.dispatchEvent(new CustomEvent('ready'));
}
handleParentDataReceived(event) {
this.childItems = event.detail.data;
}
}
この設計の何がまずかったか。まず、親コンポーネントが子コンポーネントに対して直接dispatchEventしている点だ。これはコンポーネント間の結合度を高める。なぜなら、親が子の特定のイベント名とdetailの構造を知っている必要があるからだ。しかも、this.template.querySelector('c-child-component').dispatchEvent(sendDataEvent);というコードは、親が子のDOM構造に依存していることを意味する。子の要素がテンプレート内でどのように配置されているかを知っている必要がある。これはLWCのコンポーネント指向原則に反する。
さらに、CustomEventでデータを渡すことは、TypeScriptを使ったとしても型安全性が低い。event.detailの構造は実行時まで分からないため、開発中にタイプミスがあっても気づきにくい。@apiプロパティであれば、プロパティの型が明確になり、IDEの恩恵も受けやすい。
このプロジェクトでは、子コンポーネントが複数存在したり、孫コンポーネントにまでデータを渡す必要が出てきたりした時に、イベントが雪崩のように発生し、何がどのイベントでトリガーされているのか、どこでデータが変更されているのかを追うのが非常に困難になった。デバッグはもはやイベントのログを睨みつける作業と化し、そのたびに「なぜこんな設計にしたんだろう」と後悔した。
今なら別の選択をする
今なら、この要件に対しては素直に@apiプロパティを使う。親から子へのデータ伝達は、最もシンプルで堅牢な方法だ。
もし、コンポーネント階層が深くなったり、非直接的な兄弟コンポーネント間で連携が必要になったりするなら、Lightning Message Service (LMS) や、シンプルなPub-Subパターン、あるいはServiceコンポーネントの導入を検討する。少なくとも、querySelectorで子コンポーネントを特定し、直接イベントを発火させるような愚行は二度としない。
あの時の「疎結合への過度な信仰」と「イベント駆動モデルへの盲信」が、結果的にメンテナンス困難なコードを生み出したことを反省している。シンプルさが一番だということを、この経験を通じて痛感した。
これは当時の自分向けのメモだ。
コメント
コメントを投稿