0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Web Componentsの「取得」と「表示」の分離を、カプセル化の観点で見直す

0
Posted at

以前、こんな記事を書きました。

「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-todosteam-todos に、card-rendererlist-renderer に差し替えるだけでユースケースを切り替えられる、というのが狙いでした。

何が気になったのか

このコードをよく見ると、データは my-todos から card-renderer直接 流れていません。

my-todos ──todo-fetched(イベント)──▶ 親コンポーネント
                                        │ this.ids に保存
                                        ▼
card-renderer ◀──.ids=${this.ids}────── 親コンポーネント

つまり実際の流れは my-todos → 親 → card-renderer で、親が仲介役(グルーコード)を担っています

親側の実装を見ると、それがはっきりします。

my-app.ts
@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>

ここから、いくつかの問題が見えてきます。

  1. 配線が使う側に漏れている
    todo-fetched を購読して、ids を state に持って、レンダラーに .ids で流し込む——この一連の配線を、このコンポーネントを使う人が毎回書かなければなりません。「取得と表示をどうつなぐか」というのは本来コンポーネント側の内部事情のはずなのに、それが外に出てしまっています。
  2. slot の入れ子が見た目だけになっている
    <my-todos> の中に <card-renderer> が入っているように書きますが、前述のとおりデータはこの入れ子を通りません。構造とデータ経路が一致していないので、コードを読んだときの直感を裏切ります。
  3. detail の形を親が知っている必要がある
    event.detail.ids という形を親がハードコードしています。イベントの中身は my-todos の内部実装のはずですが、これも外に漏れています。

責務は「取得」と「表示」に分かれているものの、その2つを協調させる知識がカプセル化されていない、というのが違和感の正体でした。

「ファイル(コンポーネント)が分かれていること」と「カプセル化できていること」は別物、ということですね。前者は満たしていても、後者が漏れているというのが今回のケースです。

改善案:レンダラーが自分でデータ源につながる

そこで発想を変えて、「親が配線する」のをやめ、レンダラー自身が slot に入れられたデータ源を見つけて購読する形にします。

キーになるのは Lit の @queryAssignedElements() です。これを使うと、レンダラーは自分の slot に差し込まれた要素を取得でき、そこにイベントリスナーを張れます。

構造はこうなります。

todo-cards(表示)
  └─ slot に入った子(データ源)を queryAssignedElements で取得
       └─ my-todo-data(取得)──load(イベント)──▶ todo-cards が受信

データが slot の関係に沿って データ源 → レンダラー と素直に流れ、親は登場しません。

① ドメインとデータ取得を分ける

まず、やり取りするデータをドメインモデルとして定義します。前回はIDの配列を渡していましたが、今回はTodoそのものを渡します。

domain/todo.ts
export class Todo {
  constructor(
    public id: number,
    public title: string,
    public completed: boolean,
  ) {}
}

export type Todos = Todo[];

取得のロジックはデータ層に寄せます。

data/data.ts
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 イベントでドメインオブジェクトを流すだけです。

data/MyTodoData.ts
@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 を自分で購読します。

TodoCards.ts
@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>
    `;
  }
}

データ源は描画する必要がないので、slotdisplay: none; で隠しておき、slotchange のタイミングで購読を張り直しています。

リーフの todo-card は、Todoオブジェクトを受け取って描画するだけの純粋な表示コンポーネントです。前回は各リーフが todoId から個別に fetch していましたが、今回は取得済みのデータを渡すだけなので、その重複もなくなりました。

TodoCard.ts
@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つ置くだけ

最後に、データ源とレンダラーを組み合わせたコンポーネントを用意します。

MyTodoCard.ts
@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-cardstodo-list に差し替わっているだけです。差し替え可能という前回のメリットは保ったまま、配線が中に隠れました。

Before / After

観点 前回(親が配線) 今回(レンダラーが自己接続)
使う側の記述 ❌ イベント購読+state+プロパティ束縛が必要 ✅ 要素を1つ置くだけ
取得↔表示の結合場所 ❌ コンポーネントの外(親)に漏れる ✅ コンポーネント合成の内側で完結
slot の意味 ❌ 見た目だけ。データは通らない slot の関係とデータ経路が一致
データの渡し方 ❌ IDを渡し、リーフが再 fetch ✅ ドメインオブジェクトを渡すだけ
イベントの形 ❌ 親が detail の形を知っている ✅ データ源とレンダラーの間で閉じている

カプセル化の観点でどちらが正しいか

責務分離という言葉だけ見ると、前回のパターンでも「取得」と「表示」は分かれています。ですが、その2つを協調させるロジックが親に漏れている時点で、カプセル化としては破れています。使う側が毎回同じ配線を書かされ、slot の構造とデータ経路が食い違い、イベントの中身まで外から見えている——これは実装詳細の漏洩です。

一方、今回のパターンは、取得(my-todo-data)と表示(todo-cards)をつなぐロジックが合成の内側に閉じています。使う側は内部構造もイベント名もデータの形も知らずに、<my-todo-card> と書くだけで済みます。slot の関係とデータの流れも一致しています。

というわけで、「取得と表示の責務分離」というテーマに対してより正しくカプセル化できているのは、レンダラーが自分でデータ源を購読する今回のパターン、というのが結論です。

もちろんタダではありません。@queryAssignedElements()slotchange の扱い、ドメイン層の導入などで、前回より概念とコード量は増えます。todo-cardstodo-list で購読ロジックがほぼ同じになるので、基底クラスやミックスインに寄せたくなる場面も出てきます。ここは「カプセル化を成立させるための代償」として割り切るかどうか、という判断になります。

まとめ

  • コンポーネントを分けても、両者をつなぐ配線が使う側に漏れていれば、カプセル化としては不十分
  • 前回のパターンは親がグルーコードを担うため、slot の構造とデータ経路が一致せず、配線が外に漏れていた
  • @queryAssignedElements() を使い、レンダラー自身が slot の子(データ源)を購読する形にすると、配線が合成の内側に閉じる
  • 結果として、使う側は <my-todo-card> と要素を1つ置くだけで済み、差し替え可能というメリットも保てる
  • 「ファイルを分けた」=「カプセル化できた」ではない、というのが今回の一番の学びでした :thumbsup:

関連記事

参考

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?