クロスプラットフォームデザインシステム、1.5年の記録(2)
クロスプラットフォームデザインシステム、1.5年の記録
FEConf2023で発表した「クロスプラットフォームデザインシステム、1.5年の記録」をまとめた記事です。発表内容を2回に分けて公開します。第1回ではデザインシステムとデザイントークンについて学び、コンポーネントの構成要素を把握してデザイナーと開発者のコミュニケーション問題を解決します。第2回では第1回の内容を基にコンポーネントの実装とAPIの設計について学びます。本文に挿入された画像の出典はすべてこのコンテンツと同じタイトルの発表資料で、個別の出典は記載していません。発表資料はFEConf2023のウェブサイトからダウンロードできます。
FEConf2023で発表された「クロスプラットフォームデザインシステム、1.5年の記録」/ハ・テヨン カラントフロントエンドエンジニア
この記事では、前回公開した「クロスプラットフォームデザインシステム、1.5年の記録(1)」で学んだ定義を実際のコンポーネント実装にどのように適用できるかを解説します。この章の目標は以下の通りです。
- 一貫性と柔軟性を両立するAPI構成
- クロスプラットフォーム指向のパッケージ設計
- コンポーネント仕様をコードに反映するパターンの理解
柔軟性 VS 一貫性
前回学んだChakraとSpectrumのAPIを再度確認してみましょう。製品言語を作る立場として、一貫性を最優先に追求し、右側のAPIのように簡潔に提供したいと思うのは当然です。しかし、一貫性を追求する形で提供することで、すべてのケースの90%をカバーできるとしても、ユーザーの立場からは、カバーできない10%のケースによって開発が遅延する可能性があります。
私は、一貫性を提供する基本パッケージと柔軟性を提供する合成可能なパッケージを分離することで、この問題にアプローチしています。
実際の実装では、以下のようにパッケージをCore、Composable、Pre-Composedの3つの層に分離します。事前に組み合わせられた(Pre-Composed)コンポーネントを利用して一貫性をデフォルトとして提供します。そして、Styledと同様の方式のスタイリングプロパティを提供します。ただし、marginなどのレイアウトに限定して提供します。それ以上の柔軟性が必要な場合はComposableパッケージを使用し、その使用方式が事前に組み合わせられたコンポーネントを使用するのと比べて難しくないようにします。
コンポーネントの実装
上図で、コンポーネント機能のコアに相当するロジックは状態チャートとDom Bindingで構成されています。この記事では状態チャートの活用については触れず、Dom Bindingについてのみ解説します。
機能:Dom Binding
まず、以下のような要件を持つコンポーネントが必要だと仮定しましょう。4つの構造を持ち、チェック状態を表す状態を持ちます。そして、クリックするとそのチェック状態が切り替わり、Disabledが注入されると切り替えが発生しないコンポーネントです。
- 構造 - Root、Control、Input、Label
- 状態 - isSelected : boolean
- 相互作用 - click
- コンテキスト - isDisabled : boolean
Dom Bindingを実装するために、まず構造について表現してみましょう。先ほど述べた4つの構造を以下のように宣言できます。
これらのプロパティは最終的にJSXに以下のようにスプレッドされて機能を提供します。
状態をDomに適用するのは、状態チャートから受け取った状態に基づいてエレメントのプロパティを決定することです。以下の場合、isSelectedをinputPropsのcheckedにバインディングすることで実装できます。
相互作用は、イベントハンドラーが状態チャートにイベントを伝達することで実装します。以下の場合、inputPropsのonChangeが発生すると状態チャートに'TOGGLE'というイベントを伝達します。状態チャートは'TOGGLE'というイベントとisDisabledというコンテキストに基づいて、現在の状態が変更されるべきかどうかを判断し、更新された状態をDom Bindingに伝達します。
コンテキストの適用も状態の適用と同様に、エレメントのプロパティを決定することで実装されます。両者の違いは、状態チャートが提供する状態ではなく、外部から注入されたプロパティをそのまま使用する点です。以下の場合、inputPropsのdisabledプロパティに外部から注入されたctxのisDisabledをバインディングすることで表現されています。
このように記述されたロジックはReact依存性がないため、シンラッパー(thin-wrapper)の記述を通じてReactに統合されます。
状態とコンテキストに対するインターフェースをそれぞれ宣言し、それをextendsすることで状態変化に関するすべてのコールバックを追加できます。これにより、ヘッドレスチェックボックスコンポーネントが要求するすべてのプロパティを宣言できます。
そして、以下のようにこのプロパティを受け取って状態チャートに伝達し、その結果を再度Dom Bindingに伝達し、その結果をそのまま返すだけでReactラッパーを実装できます。実際のコードでは、Reactとの状態同期のためにuseSyncExternalStoreやuseEffectなどのいくつかの技法がさらに含まれる必要がありますが、以下では省略して表現しています。
形態
これまで機能領域のコンポーネント実装について学びました。次に形態の場合を見てみましょう。CSSとJavaScript言語は異なるため、Hookのようにラッパーを記述するのは難しいです。JavaScriptでCSSを直接使用することはできないためです。代わりに、単一のスキーマでCSSとCSS in JSを一緒に生成する方式で整合性を維持しています。
今回は以下のような要件のコンポーネントがあると仮定しましょう。
- 構造 - Root、Control、Icon、Label
- 視覚オプション - size = large、medium
- 状態オプション - selected
- デザイン決定 - largeの場合、root height = 32px / mediumの場合、root height = 24px / selectedの場合、control背景= primary
まず構造について表現してみましょう。Dom bindingと同様に構造を表現することから始めます。
そして、以下のようにVariants式を使用して視覚オプションを表現できます。以下の場合、largeのときrootのheightが32pxになり、mediumのときheightは24pxになると表現しています。
次に状態オプションは、データ属性をセレクターとして使用することで表現できます。例えば、以下のようにdata-selectedというデータ属性が存在する場合、コントロールの背景色(background)をprimaryに変更する方式で実装できます。ただし、このdata-selectedというセレクターはHTMLが自動的に追加してくれません。そのため、Dom bindingにも関連コードを再度追加する必要があります。
以下のようにDom bindingのcontrolPropsにselectedの有無をデータ属性として伝達するロジックが追加されます。これは状態オプションが共通の関心事だったことを考えると、当然追加されるべきであることがわかります。
このように記述されたスキーマに基づいて、以下のようにセレクターとして使用されるクラス名は構造と視覚オプションに基づいて規則に従って生成できます。
そして状態オプションもCSSのネスティング構文を活用してセレクターとして生成できます。
最後に生成されたCSSコードに対応するCSS in JSコードを一緒に生成します。以下のコードは2つのパートで構成されています。まず視覚オプションを表すインターフェースであるcheckboxVariantPropsと、視覚オプションをSlot別クラス名に変換する関数を一緒に提供します。例えば、sizeがlargeとして渡されると、視覚オプション例で記述した'checkbox__root–size_large'のようなクラス名を返す方式です。
最終的にこのように作られた機能と形態のインターフェースを再度extendsし、必要な追加プロパティを受け取ってチェックボックスのコンポーネントインターフェースを完成させます。
そして以下のように結合されたプロパティを通じて機能のHooksを呼び出し、形態のクラス名生成関数をそれぞれ呼び出すことができます。そしてreturn文の下のJSXを見ると、機能のHooksから得られたAPIと形態から得られたクラス名をそれぞれJSXにスプレッドし、クラス名にバインディングして機能と形態を合成しています。このコードは非常にシンプルで反復的であるため、ユーザーがComposableパッケージを使用して再度実装する負担が少なくなっています。
このように本来の目標であった事前に組み合わせられたコンポーネントと組み合わせ可能なパッケージを分離して、一貫性と柔軟性を両方提供するAPIを構成できます。
そしてReact依存的なコードはすべてシンプルなバインディングとして記述されたため、他のフレームワークでの再利用性が高まりました。
さらに、コンポーネントを定義した方法に応じて、コンポーネントがレンダリングされる可能性のあるすべてのケースを事前に計算できます。これに基づいてスナップショットテストやQA自動化も可能です。
そしてカラントチームでは、以下のようにFigma variablesを使用してコンポーネント仕様ドキュメントをFigmaとWebに自動生成し、これをコンポーネントコードにすべて同期する方法も実験しています。
私がこれまで説明したコンポーネントへのアプローチと実装は、すべて巨人の肩の上に立って見渡して得られたものです。特にAdobe Spectrum、Zag.js、Class Variance Authorityの影響を大きく受けています。もしデザインシステムを構築しようとするチームや深く理解しようとする方は、これらのライブラリを確認することをお勧めします。
まとめ
今日取り上げたトピックを整理すると、以下のようになります。
- デザインシステムの目標設定
- デザイントークンの定義と活用
- アトミックデザインが混乱する理由
- コンポーネント構成要素の解剖
- デザイナーと開発者のコミュニケーション問題の解決
- コンポーネントの実装とAPI設計
この内容に基づいて私が学んだことは以下の通りです。この内容は私が新しくデザインシステムを作る際に必ず注意する部分なので、覚えていただけると幸いです。
- デザインシステムの目標とベンチマーク対象を明確に設定する
- デザイン意図をエンコードする際に安易な抽象化を避ける
- コンポーネントの機能/形態を分離して最小単位を定義する
- 状態圧縮でコミュニケーションを改善し状態爆発を除去する
- 関心事の分離によるコミュニケーションの循環参照を除去する
- ユーザーがコンポーネントを直接組み立てやすい環境を提供する
究極的に上記の内容に基づいて一貫性と柔軟性を同時に達成することが、私がこれまでデザインシステムを作りながら学んだことです。この記事を通じて、確信を持って幸せにデザインシステムを作る開発者がより多くなることを願い、記事を締めくくります。ありがとうございました。
Discussion