ANDPAD のデザインシステム「Tsukuri」の Web 向け実装について - UI コンポーネントの開発

この記事の概要

  • Lit を用いて Web Component を実装しています
  • Custom Element Manifest の情報を元に Vue や React のコンポーネントを機械的に実装しています
  • デザイナーと開発者がデザインシステムの語彙を用いてコミュニケーションを取りやすいような設計をしています

1. はじめに

Web フロントエンド開発を中心に行っている寺島です。

この記事はアンドパッドで開発しているデザインシステム 『Tsukuri』 の Web 向け実装である『Tsukuri for Web』の構築について紹介する二つ目の記事です。 以前の記事を前提に作成しているため、先にそちらを読むことをお勧めします。

tech.andpad.co.jp

この記事では Tsukuri for Web で提供している UI コンポーネントについて実装方法などを中心に紹介します。

記事を通して以下を満たすことを目的としています。

  • Web フロントエンド開発者に対してWeb Component やその利用方法について実例を通して紹介をする
  • Web フロントエンド開発者がアンドパッドに興味をもってもらうこと

2. Web Component の実装について

2.1. 利用ライブラリ

Web Component の仕様自体は一般的なライブラリより簡素なものであるため、本格的に開発を行う場合は何かのライブラリを併用することが一般的です。

併用できるライブラリには以下などがあります。

  • レンダリングのみにフォーカスを当てた一般的な View ライブラリ (React など)
  • Web Component としてビルドする機能を持ったライブラリ (VueSvelte など)
  • Web Component を作ることに特化したライブラリ

Tsukuri for Web のコンポーネントらは、デザインシステムの実装としてのコンポーネントライブラリです。 従って、アプリケーションでもそれぞれ UI ライブラリやフレームワークを利用していることが一般的です。 そのため、Tsukuri for Web で利用するライブラリはできるだけ小さいことが望ましいです。

また、前述の通り Web Component の仕様が簡素であるため、これを拡張して使いやすいインターフェースを提供してくれたり、より効率的な表示の更新などを助けてくれたりする方が望ましいと考えました。

上記を踏まえて、今回は Web Component を作ることに特化したライブラリを利用することにしました。

Web Component の実装に焦点を当てたライブラリはいくつかあります。 基本的に Web Component に関する情報は React 等に比べて少ないため比較的情報が得やすいものが望ましいと考えました。 そこで、比較的情報が得やすくユーザも多い LitStencil を候補としました。

これらのライブラリはかなり方向性が異なります。Lit は主に以下の要素から成り立ちます。

  • HTMLElement を継承し、ライフサイクルなどを拡張した LitElement クラス
  • 仮想 DOM を使わないで差分更新ができるテンプレートリテラルベースの DOM レンダリング機能を持つ lit-html

このように、Web の標準的な機能を用いつつ十分な機能を備えた上で比較的小さい機能にとどめているような印象を持ちました。

一方で、 Stencil は自身をコンパイラーと表現しており、 JSX を利用できることをはじめとしてより多くの機能を備えていたり、ビルド回りまで専用の CLI ツールとして用意されています。

はじめは、Stencil の方が開発が効率的に行えるかもしれないとも考えました。 しかし、Stencil のツールチェーンに大きく依存することになり、前回の記事で示した方針に沿わないことになります。

一方で Lit はこの点に関して高い自由度を持ちます。 これは Tsukuri for Web では大きなメリットになります。さらにライブラリのサイズも Lit のトップページで紹介されているように5KB程度と非常に小さいです。

上記のような理由で Tsukuri for Web では Lit を利用することにしました。

2.2. Web Component のインターフェース

一般的に Web Component の利用者は以下のインターフェースを利用できます。

  • DOM Attribute (以降では単に "Attribute" と記載)
  • DOM Property
  • Event
  • Slot

このうち、Attribute・Event・Slot のみを利用することにしました。 また、Attribute は StringBooleanNumber のみを期待し、ObjectArray などの構造化データをシリアライズして利用するようなインターフェースにはしていません。

DOM Property を利用しないということは、以下の様な利用をしないということです。

const element = document.querySelector("my-custom-element")
element.someProps = {
  data: "hoge"
}
element.someMethod("foo")

一方で、内部的には DOM Property を利用している箇所があります。 これは複数のコンポーネントで UI を構成する場合にあるコンポーネントが別のコンポーネントを制御する必要があるからです。

例えば、タブコンポーネントを以下の様に実現する場合、TabsTabTabPanel コンポーネントの表示・非表示や選択状態を制御する必要がありました。 その際は、 Tabs コンポーネントが TabTabPanel コンポーネントの DOM Property を操作するような実装をしました。

<Tabs>
  <Tab slot="tab">タブ1</Tab>
  <Tab slot="tab">タブ2</Tab>
  <TabPanel slot="tab-panel">コンテンツ1</TabPanel >
  <TabPanel slot="tab-panel">コンテンツ2</TabPanel >
</Tabs>

上記のような制限は、コードで必要十分な表現ができる限りは利用できるインターフェースを絞ることで、後述するラッパーの実装を容易にしたり、各コンポーネントの責任を明確にしたりすることを期待しています。

多くの場合、構造化が必要な UI は、基本的なデータの Attribute とコンポーネントの組み合わせで表現できます。 例えば、見出しといくつかの子要素を持つリストは以下の様に使うコンポーネントで表現できます (ここで、構造化データは JSON で表現します)。

<MyList 
  label="見出し" 
  items='[{ label: "子要素見出し1"}, { label: "子要素見出し2"}]'
></MyList>

一方で slot を利用すれば以下の様にも表現できます。

<MyList label="見出し">
  <MyListItem label="子要素見出し1"></MyListItem>
  <MyListItem label="子要素見出し2"></MyListItem>
</MyList>

3. ほかライブラリのラッパーコンポーネントについて

前述の通り、Tsukuri for Web で実装する Web Component は、Attribute・Event・Slot のインターフェースのみを利用しています。 従って、これらについて各ライブラリネイティブなインターフェースと互換を持たせればよいです。

このセクションでは、互換を持たせる対象となる各インターフェースの情報を収集する方法と、その情報をもとにした具体的な実装をコンポーネントライブラリごとに説明します。 この互換性を持たせる実装は定型的であるため、機械的な実装が可能です。そこで、実際にはここで説明したようなコンポーネントのコードはプログラムで出力しています。

互換性のロジックを実装した汎用的な関数を定義することもできますが、問題があったときのデバッグなどを容易にすることや、内部への依存をしないようにするためにそのようにしています。

3.1. Custom Element Manifest

Custom Element Manifest は Custom Element (この記事では Web Component と呼称。以降も引き続き Web Component と記載) の仕様を記述するためのフォーマットです。 以下のリポジトリで管理されています。

github.com

また、Custom Element Manifest は @custom-elements-manifest/analyzer というライブラリを使って出力できます。

例えば、Lit を用いて以下の様なコンポーネントを実装したとき

import { LitElement, property, html, customElement } from 'lit';

/**
 * @slot hoge
 * @event {CustomEvent<string>} hoge-event
 */
@customElement("my-element")
export class MyElement extends LitElement {

  @property()
  foo = ""

  render(){
      return html`
        <p>
            <slot name="hoge">
            <button 
               @click=${()=> this.dispatchEvent(new CustomEvent("hoge-event", {
                 detail: "clicked"
                }))}
            >${this.foo}</button>
        </p>`
  }
}

以下の様なマニュフェストが出力されます (一部分のみを抜粋しています。このリンク先のPlaygroundで全体を確認できます)

{
  "schemaVersion": "1.0.0",
  "modules": [
    {
      "kind": "javascript-module",
      "path": "src/my-element.js",
      "declarations": [
        {
          "name": "MyElement",
          "slots": [
            {
              "name": "hoge"
            }
          ],
          "events": [
            {
              "type": {
                "text": "CustomEvent<string>"
              },
              "name": "hoge-event"
            }
          ],
          "attributes": [
            {
              "name": "foo",
              "type": {
                "text": "string"
              },
              "default": "\"\"",
              "fieldName": "foo",
              "attribute": "foo"
            }
          ],
          "tagName": "my-element",
          "customElement": true
        }
      ],
    }
  ]
}

このようにコンポーネントのタグ名や Attribute などの情報が含まれています。 以降で紹介する各ライブラリのラッパーは、ここに記述されている情報のみを用いて実装します。

3.2. React コンポーネントの実装

先ほどの MyElement コンポーネントなら、例えば、以下の様なコードでラッパーを実装することができます。

type Props = {
  foo?: string;
  onHogeEvent?: (payload: string) => void;
  children?: ReactNode
}

export const MyElementWrapper = (props: Props) => {
  const ref = useRef<MyElement>();

  const { children, onHogeEvent, ...attrs} = props;

  useEffect(() => {
    let handler: (e: CustomEvent<string>) => void;
    if (onHogeEvent !== undefined) {
      handler = (e: CustomEvent<string>) => onHogeEvent(e.detail)
      ref.current?.addEventListener("hoge-event", handler );
    }
    return () => {
      if (handler !== undefined)
        ref.current?.removeEventListener("hoge-event", handler );
    };
  }, [onHogeEvent]);

  return (
    <my-element ref={ref} { ...attrs }>{children}</my-element>
  );
};

以下の様に利用します。

<MyElementWrapper foo="foo" onHogeEvent={(payload)=> console.log(payload)}>
  <p slot="hoge">スロット要素</p>
</MyElementWrapper>

React のイベントはネイティブイベントではないため、カスタムイベントをハンドリングするときには自分でハンドラを設定する必要がある点には注意が必要です。 一方で experimental バージョンの React ではこれがサポートされているため今後のアップデートに期待です。

このような互換性に関する情報は以下のページで公開されています。

custom-elements-everywhere.com

3.3. Vue コンポーネントの実装

ANDPAD の実装で用いられている Vue のメジャーバージョンには 2, 3 いずれも存在しています。 そのためこれらをサポートするために、それぞれに対応できる実装を用意しています。

バージョン互換を持たせるために、ref など、 composition-api に含まれる API は vue-demi を経由して利用します。

また、 render 関数のインターフェースが異なるため、その部分は別の実装をする必要があります。 ここでは、わかりやすさのために、Vue 2 と 3 それぞれのバージョンごとに分けて紹介します。

実際の実装では vue-demi の isVue2 などを見ながら、一つの実装でそれぞれのバージョンに対応できるようになっており、利用者は Vue のバージョンアップ時に Tsukuri for Web の更新は不要になっています。

Vue 2 バージョンは例えば以下の様にラッパーコンポーネントを実装できます。

import { defineComponent, h, useSlots } from "vue-demi";

export default defineComponent({
  name: "MyElement",
  props: {
    foo: {
      type: String,
      required: false,
      default: "",
    },
  },
  emits: {
    "hoge-event": null,
  },
  setup(props, { emit }) {
    const slots = useSlots();
    return () =>
      h(
        "my-element",
         {
           attrs: {
             foo: props["foo"]
           },
           on: {
             "hoge-event": (e: CustomEvent<string>) => emit("hoge-event", e.detail)
           },
         },
         Object.entries(slots)
           .flatMap(([name, fn]) => fn({}).map((slot) => ({ name, slot })))
           .map(({ name, slot }) => {
              // デフォルト slot ならそのままでよい
              if (name === "default") return slot;
              // コンポーネントではない場合はラッパー要素に slot 属性を付与する
              if (!slot.tag?.match(/^vue-component/)) return h(
                 "span",
                 { style: "display: contents;", attrs: { slot: name } },
                 [slot],
              );
              // コンポーネントの場合は DOM Attribute に slot 属性を追加する
              return {
                ...slot,
                data: {
                  ...(slot.data || {}),
                  attrs: {
                    ...(slot.data?.attrs || {}),
                    slot: name,
                  },
                },
              };
           })
      );
  },
});

そして Vue 3 バージョンですが、例えば以下です。

import { defineComponent, h, useSlots } from "vue-demi";

export default defineComponent({
  name: "MyElement",
  props: {
    foo: {
      type: String,
      required: false,
      default: "",
    },
  },
  emits: {
    "hoge-event": null,
  },
  setup(props, { emit }) {
    const slots = useSlots();
    return () =>
      h(
        "my-element",
        {
          foo: props["foo"],
          onHogeEvent: (e: CustomEvent<string>) => emit("hoge-event", e.detail)
        },
        Object.entries(slots)
          .flatMap(([name, fn]) => fn({}).map((slot) => ({ name, slot })))
          .map(({ name, slot }) => {
            // デフォルト slot ならそのままでよい
            if (name === "default") return slot;
            // コンポーネントではない場合はラッパー要素に slot 属性を付与する
            if (typeof slot.type === "symbol") {
              return h("span", { style: "display: contents;", slot: name }, [slot]);
            }
            // Vue コンポーネントなら slot 属性を足す
            return { ...slot, props: { ...(slot.props || {}), slot: name } }
          })
      )
  },
});

Vue 3 では DOM プロパティや Attribute などの区別がなくなり、slot のデータも変わっているので、実装にもその違いがあります。 また、Vue 3 では対象の DOM オブジェクトのプロパティに値を設定しようとするという挙動の違いがあります。

以下の様に Web Component 自身の属性に応じてスタイルなどを実装したい場合などには注意が必要です。

:host {
  --text-size: 16px;
}

/* size 属性が small の時はフォントサイズを小さくする */
:host([size="small"]) {
   --text-size: 12px;
}

#text {
  font-size: var(--text-size);
}

4. 具体的なコンポーネントの例

ここまで説明したような形で Tsukuri for Web のコンポーネントライブラリを実装しています。 最後に、Tsukuri for Web で実装した、デザインシステムの実装らしいコンポーネントの例を一つだけ紹介します。

デザインシステム Tsukuri では色のデザイントークンが定義してあります。 これには、基本的なカラーパレットや、その中からいくつかの色をピックアップし意味づけ (例えば、強調したい、補足的など) をした色セットなどがあります 。 これらの色を組み合わせた利用を想定されるものは、コントラスト比などのアクセシビリティに配慮された設計がされています。 このような定義があるおかげで、その時の意味などに合った背景・前景の色を選択するだけで、適切なコントラスト比を守ったビジュアルを実装できます。

一方で、これらのトークンは名前と色の組に過ぎないため、トークン間の関係を間違えたり、名前の勘違いをする恐れがあります。 せっかく、よく検討されて定義されたトークンのセットがあるのに、トークンの選択を間違えてしまうと台無しです。

そこで、BackgroundSurface という背景に相当するコンポーネントを実装しています。(このコンポーネント名はデザイントークンの名前に由来しています)

これらのコンポーネントは例えば、以下の様に利用します。 (以下に出てくる Text も Tsukuri for Web で実装しているコンポーネントです)

<Background>
   <Text>Backgroundの上のテキスト</Text>
   <Surface name="accent">
      <Text>Accent Surface の上のテキスト</Text>
   </Surface>
</Background>

Background と Surface はそれぞれ与えられたプロパティに応じて背景色が設定されます。 そして、Text コンポーネントの文字色は与えられた属性と、自身に最も近い祖先の Background・Surface コンポーネントで決定し、 結果として以下に示した図のような表示となります。

Background・Surface コンポーネントの例

直接の親子関係に限らない、コンポーネントの子孫・祖先間の連携するようなふるまいを実現する機能として React の ContextVue の Provide / Inject があります。

このようなふるまいを Web Component でも実現するために、Web 標準の API をベースに実装したコンテキストの機能を @lit-labs/context の実装を拡張して利用しています。 Web 標準の API をベースに実装したコンテキストについては、手前味噌ですが、以下の記事で説明しているので気になる方は参照ください。

zenn.dev

この機能は、単に使い間違えを防ぐというだけではなく、デザインシステムで定義された語彙を開発者とデザイナーがそのまま利用することができるというところが大きなポイントです。

デザインシステムで定義された語彙を用いて、「Background の上に『Backgroundの上のテキスト』というテキストを置いて、新しく Accent Surface を配置して、その上に 『Accent Surface の上のテキスト』というテキストを置く」という会話を行い、その内容とソースコードのギャップができるだけ小さくなるような設計しています。

同じ語彙を用いてコミュニケーションを取れるようになることはデザインシステムを作るメリットの一つです。 また、そのことがソフトウェア開発で大きな意味を持つことはドメイン駆動設計などが流行っていることを考えると多くの人が承知していることだと思います。

このような、デザイナーと Web フロントエンドエンジニアのギャップを埋められるような実装を目指してコンポーネントの開発を行っています。

5. おわりに

アンドパッドで開発しているデザインシステム『Tsukuri』の Web 向け実装である 『Tsukuri for Web』について、 その中心に位置する UI コンポーネントの開発について紹介しました。 Web Component を利用した例は最近増えてきているもののまだまだ少ないため、参考になる情報が提供できていれば幸いです。

また、この記事をきっかけにアンドパッドに興味を持った Web フロントエンド開発者がいらっしゃったらお気軽にご連絡をいただけると嬉しいです。

今回は UI コンポーネントのみにフォーカスして紹介しましたが、次回の記事では Tsukuri for Web のリポジトリ全体や、テスト・リリースなどについて紹介したいと思います。

Product Engineer / Engineer of new business(Vue.js/Nuxt.jsメイン) | 株式会社アンドパッド

Product Engineer / Engineer of new business(React/Next.jsメイン) | 株式会社アンドパッド

UI/UXデザイナー | 株式会社アンドパッド

その他のポジションについては、下記サイトをご覧ください!

engineer.andpad.co.jp

この記事の続編となる記事が公開されました。
リポジトリの構成・開発ツールについてまとめましたので、合わせてお読みください。

tech.andpad.co.jp