以前、こんな記事を書きました。
「Todoを取得するコンポーネント」と「Todoを表示するコンポーネント(レンダラー)」を分け、slot とカスタムイベントで組み合わせることで、再利用性を高めよう、という内容です。
このアプローチ自体は今でも気に入っているのですが、あらためて実装を眺めていて、ひとつ引っかかる点が出てきました。
「取得」と「表示」は分けたはずなのに、両者をつなぐ配線が、使う側(親)に漏れ出しているのではないか?
今回はこの「責務は分けたのに、カプセル化としては中途半端」という違和感を出発点に、レンダラーが自分でデータ源につながる設計へと作り替えてみます。
前回のおさらい
前回のパターンは、ざっくり以下のような構造でした。
| コンポーネント | 役割 |
|---|---|
| my-todos | 自分のTodoを取得し、todo-fetched イベントで親にIDを渡す |
| card-renderer / list-renderer | 受け取ったIDをもとにTodoを描画する |
使うときは、こう書きます。
<my-todos @todo-fetched=${this.handleTodoFetched}>
<card-renderer .ids=${this.ids}></card-renderer>
</my-todos>
my-todos を team-todos に、card-renderer を list-renderer に差し替えるだけでユースケースを切り替えられる、というのが狙いでした。
何が気になったのか
このコードをよく見ると、データは my-todos から card-renderer へ 直接 流れていません。
my-todos ──todo-fetched(イベント)──▶ 親コンポーネント
│ this.ids に保存
▼
card-renderer ◀──.ids=${this.ids}────── 親コンポーネント
つまり実際の流れは my-todos → 親 → card-renderer で、親が仲介役(グルーコード)を担っています。
親側の実装を見ると、それがはっきりします。
@property({ type: Array })
ids = []
handleTodoFetched(event: CustomEvent) {
this.ids = event.detail.ids // イベントを受けて
}
// render() の中で
// <my-todos @todo-fetched=${this.handleTodoFetched}>
// <card-renderer .ids=${this.ids}></card-renderer> // 手で流し込む
// </my-todos>
ここから、いくつかの問題が見えてきます。
-
配線が使う側に漏れている
todo-fetchedを購読して、idsを state に持って、レンダラーに.idsで流し込む——この一連の配線を、このコンポーネントを使う人が毎回書かなければなりません。「取得と表示をどうつなぐか」というのは本来コンポーネント側の内部事情のはずなのに、それが外に出てしまっています。 -
slotの入れ子が見た目だけになっている
<my-todos>の中に<card-renderer>が入っているように書きますが、前述のとおりデータはこの入れ子を通りません。構造とデータ経路が一致していないので、コードを読んだときの直感を裏切ります。 -
detailの形を親が知っている必要がある
event.detail.idsという形を親がハードコードしています。イベントの中身はmy-todosの内部実装のはずですが、これも外に漏れています。
責務は「取得」と「表示」に分かれているものの、その2つを協調させる知識がカプセル化されていない、というのが違和感の正体でした。
「ファイル(コンポーネント)が分かれていること」と「カプセル化できていること」は別物、ということですね。前者は満たしていても、後者が漏れているというのが今回のケースです。
改善案:レンダラーが自分でデータ源につながる
そこで発想を変えて、「親が配線する」のをやめ、レンダラー自身が slot に入れられたデータ源を見つけて購読する形にします。
キーになるのは Lit の @queryAssignedElements() です。これを使うと、レンダラーは自分の slot に差し込まれた要素を取得でき、そこにイベントリスナーを張れます。
構造はこうなります。
todo-cards(表示)
└─ slot に入った子(データ源)を queryAssignedElements で取得
└─ my-todo-data(取得)──load(イベント)──▶ todo-cards が受信
データが slot の関係に沿って データ源 → レンダラー と素直に流れ、親は登場しません。
① ドメインとデータ取得を分ける
まず、やり取りするデータをドメインモデルとして定義します。前回はIDの配列を渡していましたが、今回はTodoそのものを渡します。
export class Todo {
constructor(
public id: number,
public title: string,
public completed: boolean,
) {}
}
export type Todos = Todo[];
取得のロジックはデータ層に寄せます。
export const fetchTodos = (): Promise<TodosResponseJson> => {
return Promise.all(
[4, 97, 195, 80, 35].map((id) =>
fetch(`https://dummyjson.com/todos/${id}`).then((res) => res.json()),
),
);
};
② 描画しない「データ源」コンポーネント
取得だけを担う、描画を一切持たないコンポーネントを用意します。取得が終わったら load イベントでドメインオブジェクトを流すだけです。
@customElement("my-todo-data")
export class MyTodoData extends LitElement {
private _task = new Task(this, {
task: async () => {
const json = await fetchTodos();
this.dispatchEvent(
new CustomEvent<Todos>("load", { detail: toTodos(json) }),
);
},
args: () => [],
});
}
前回の my-todos が「取得しつつ <slot> を描画していた」のに対し、こちらは取得に専念するヘッドレスなコンポーネントになっています。
③ レンダラーが slot の子を購読する
ここが今回の肝です。レンダラーは @queryAssignedElements() で slot に入れられたデータ源を取り出し、その load を自分で購読します。
@customElement("todo-cards")
export class TodoCards extends LitElement {
@queryAssignedElements()
datasources!: HTMLElement[];
@state()
data!: Todos;
private handleLoad = (e: Event) => {
this.data = (e as CustomEvent<Todos>).detail;
};
private slotChanged() {
this.datasources.forEach((d) => d.removeEventListener("load", this.handleLoad));
this.datasources.forEach((d) => d.addEventListener("load", this.handleLoad));
}
render() {
return html`
<slot style="display: none;" @slotchange=${() => this.slotChanged()}></slot>
<div class="todos">
${this.data?.map((t) => html`<todo-card .todo=${t}></todo-card>`)}
</div>
`;
}
}
データ源は描画する必要がないので、slot は display: none; で隠しておき、slotchange のタイミングで購読を張り直しています。
リーフの todo-card は、Todoオブジェクトを受け取って描画するだけの純粋な表示コンポーネントです。前回は各リーフが todoId から個別に fetch していましたが、今回は取得済みのデータを渡すだけなので、その重複もなくなりました。
@customElement("todo-card")
export class TodoCard extends LitElement {
@property({ type: Object })
todo!: Todo;
render() {
return html`<div class="todo-card">${this.todo.title}</div>`;
}
}
④ 使う側は要素を1つ置くだけ
最後に、データ源とレンダラーを組み合わせたコンポーネントを用意します。
@customElement("my-todo-card")
export class MyTodoCard extends LitElement {
render() {
return html`
<todo-cards>
<my-todo-data></my-todo-data>
</todo-cards>
`;
}
}
すると、使う側はこうなります。
<my-todo-card></my-todo-card>
イベントの購読も、state の保持も、プロパティの配線も、一切書く必要がありません。 表示をリスト型に変えたいなら <my-todo-list> を置けばよく、内部で todo-cards が todo-list に差し替わっているだけです。差し替え可能という前回のメリットは保ったまま、配線が中に隠れました。
Before / After
| 観点 | 前回(親が配線) | 今回(レンダラーが自己接続) |
|---|---|---|
| 使う側の記述 | ❌ イベント購読+state+プロパティ束縛が必要 | ✅ 要素を1つ置くだけ |
| 取得↔表示の結合場所 | ❌ コンポーネントの外(親)に漏れる | ✅ コンポーネント合成の内側で完結 |
slot の意味 |
❌ 見た目だけ。データは通らない | ✅ slot の関係とデータ経路が一致 |
| データの渡し方 | ❌ IDを渡し、リーフが再 fetch
|
✅ ドメインオブジェクトを渡すだけ |
| イベントの形 | ❌ 親が detail の形を知っている |
✅ データ源とレンダラーの間で閉じている |
カプセル化の観点でどちらが正しいか
責務分離という言葉だけ見ると、前回のパターンでも「取得」と「表示」は分かれています。ですが、その2つを協調させるロジックが親に漏れている時点で、カプセル化としては破れています。使う側が毎回同じ配線を書かされ、slot の構造とデータ経路が食い違い、イベントの中身まで外から見えている——これは実装詳細の漏洩です。
一方、今回のパターンは、取得(my-todo-data)と表示(todo-cards)をつなぐロジックが合成の内側に閉じています。使う側は内部構造もイベント名もデータの形も知らずに、<my-todo-card> と書くだけで済みます。slot の関係とデータの流れも一致しています。
というわけで、「取得と表示の責務分離」というテーマに対してより正しくカプセル化できているのは、レンダラーが自分でデータ源を購読する今回のパターン、というのが結論です。
もちろんタダではありません。@queryAssignedElements() や slotchange の扱い、ドメイン層の導入などで、前回より概念とコード量は増えます。todo-cards と todo-list で購読ロジックがほぼ同じになるので、基底クラスやミックスインに寄せたくなる場面も出てきます。ここは「カプセル化を成立させるための代償」として割り切るかどうか、という判断になります。
まとめ
- コンポーネントを分けても、両者をつなぐ配線が使う側に漏れていれば、カプセル化としては不十分
- 前回のパターンは親がグルーコードを担うため、
slotの構造とデータ経路が一致せず、配線が外に漏れていた -
@queryAssignedElements()を使い、レンダラー自身がslotの子(データ源)を購読する形にすると、配線が合成の内側に閉じる - 結果として、使う側は
<my-todo-card>と要素を1つ置くだけで済み、差し替え可能というメリットも保てる - 「ファイルを分けた」=「カプセル化できた」ではない、というのが今回の一番の学びでした