uhyo/blog

CSSとコンポーネント設計に対する考察

2020年12月20日 公開

近年のフロントエンド開発にはコンポーネントという概念が付いて回ります。React・Vue・AngularといったViewライブラリでは、コンポーネントを定義してそれを組み合わせてアプリを作ります。また、いわゆるWeb Componentsとして知られる仕様群により、ライブラリに依存せずに“コンポーネント”を作ることもできるようになってきています。

コンポーネントは、何らかの機能(あるいは責務)を持った部品です。また、コンポーネントによっては再利用される(アプリ内の複数の箇所から利用される)ことを意図しているものや、そもそもライブラリとして配布されているようなものもあります。アプリの機能の一部分を抜き出したものという見方をすれば、コンポーネントというのは関数にとても類似した概念であることが分かります。

コンポーネント設計によって、言い換えればアプリがどのような機能を持ったコンポーネントたちに分解するかによって、ソースコードのメンテナンスしやすさは大きく上下します。悪い設計のコンポーネントは、変更する際のコスト(修正対象箇所の断定、修正による影響範囲の断定、リグレッションが無いことの保証にかかるコスト等)が大きく、結果的にアプリケーションの保守開発の速度を悪化させます。実際のところ、アプリケーション開発の初期の段階では良い設計にこだわるよりも開発速度を重視したほうが動くものを早く作れるという説もあります。そうしたい場合は、初期の開発速度と引き換えに保守のコストを積み上げることになりますから、そのトレードオフについてよく検討して判断すべきでしょう。

さて、フロントエンド開発において、コンポーネントの責務はいくつかに分類されます。言うまでもなく、複数の種類の責務が入り混じったようなコンポーネントは設計が望ましくありません。

その中でもこの記事で注目したいのはUIの提供と言う責務、さらに具体的に言えば描画された要素の見栄えに責任を持つという責務です。技術的には、CSSを利用する要素の描画を司るコンポーネントということになります。

この記事では、このようなコンポーネントの設計について考察します。特に、コンポーネントのスタイルを外から拡張可能にするべきかどうかについて筆者の考えを紹介します。結論は「コンポーネントがclassNameを外から受け取るのはやめろ」です。

ここからは筆者が詳しいTypeScript + Reactを例に用いていきます。

classNameを渡してコンポーネントを拡張する

いきなりですが、見栄えに責任を持つコンポーネントの具体例を見てみましょう。緑色のボタンです。

コードはこんな感じです。CSSはいわゆるCSS Modulesを使っています。

/* LoginButton.module.css */
.button {
  appearance: none;
  width: 6em;
  height: 2.5em;
  border: 1px solid #55bb55;
  border-radius: 3px;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  background-color: #ddffdd;
  color: #004400;
}
// LoginButton.tsx
import React from "react"
import styles from "./LoginButton1.module.css"

export const LoginButton = () => (
  <button className={styles.button}>ログイン</button>
)

では、ボタンにバリエーションが欲しくなった場合はどうするのが良いでしょうか。一つの方法は、外からclassNameを受け取れるようにして、使う側に拡張を任せることです。LoginButtonを次のように修正して、propsから受け取ったclassNamebuttonに適用するようにしてみましょう。

// LoginButton.tsx
import React from "react"
import styles from "./LoginButton.module.css"

type Props = {
  className?: string;
};

export const LoginButton: React.VFC<Props> = ({ className }) => (
  <button className={styles.button + (className ? ` ${className}` : "")}>
    ログイン
  </button>
)

こうすることで、LoginButtonのスタイルを必要に応じて自由に拡張できるようになりました。例えば、大きさが違うボタンや、色が緑ではなく赤のボタンを作ることができます。

import React from "react"
import { LoginButton } from "./LoginButton"
import styles from "./LoginButtons.module.css"

export const RedLoginButton = () => (
  <LoginButton className={styles.redButton} />
)

export const BigLoginButton = () => (
  <LoginButton className={styles.bigButton} />
)

一見問題ないように見えますが、しばらく運用していくとこの方針には問題が発生します。例えば、デザインが変更してログインボタンにはbox-shadowをつけることになったとしましょう。

.button {
  appearance: none;
  width: 6em;
  height: 2.5em;
  border: 1px solid #55bb55;
  border-radius: 3px;
  /* ↓これを追加 */
  box-shadow: 0 0 3px 3px #ddffdd;
  display: inline-flex;
  justify-content: center;
  align-items: center;
  background-color: #ddffdd;
  color: #004400;
}

すると、このようにログインボタンにいい感じのシャドウが付きました。

ここで、先ほどのRedLoginButtonを見直してみましょう。

なんと、そこには不気味な緑色に光る赤いボタンが!!!!!!

言うまでもなく、これは意図した表示ではありません。RedLoginButtonはシャドウも赤いべきですね。結局、LoginButtonに加えた変更によって、RedLoginButtonの表示が思いがけず壊れてしまったことになります。

もう一つ別の例をお見せしましょう。今度の例では、CSSアニメーションを使ってログインボタンが動き回ることにしましょう。やることはこんな感じです。

.movingButton {
  animation: buttonMove 0.4s ease infinite;
}
export const MovingLoginButton = () => (
  <LoginButton1 className={styles.movingButton} />
)

これをレンダリングしてみると次のようになります。活きがいいですね。

MovingLoginButton →

さて、今度は元々のLoginButtonにもアニメーションを追加したくなったとしましょう。拡大縮小して目立たせる狙いです。

.button {
  appearance: none;
  /* (((中略) */)
  color: #004400;
  /* ↓これを追加 */
  animation: scaling ease 2s infinite;
}

LoadingButton →

どちらもanimationを使っていて、すでに不穏な感じがしますね。では、このように修正した場合、先ほどのMovingLoginButtonはどうなるでしょうか。さっそく見てみましょう。

MovingLoadingButton →

何ということでしょう、あんなに活きがよかったボタンがただその場で拡大縮小するだけになってしまいました。使うコンポーネントを間違えたわけではありません。お察しの通り、拡張される側のLoginButtonと拡張する側のMovingLoginButtonで同じanimationプロパティを使ったのが原因です。調べてみると、次のスクリーンショットから分かるように、MovingLoginButton側のanimationLoginButton側のanimationで上書きされてしまっています。

スクリーンショット

これもやはり、LoginButton側の修正によってそれを使う側のMovingLoginButtonが意図せず壊れてしまった事例です。

責任はどのコンポーネントにあるのか?

では、この事象が起こってしまった原因はどこにあるのでしょうか。RedLoginButtonMovingLoginButtonの場合発生している事象が少し違うので、別々に考える必要があります。ただ、共通して言えることもあります。

筆者の考えでは、そもそもLoginButtonclassNameを受け取る機能を持たせたことが間違いです。それがなぜかというのは、LoginButtonというコンポーネントの責務を考えてみれば分かります。

一旦classNameのことは忘れて考えれば、LoginButtonの責務は「緑色のログインボタンを表示すること」です。「緑色の」とあるように、このコンポーネントは見栄え(スタイル)に関する責務を持っています。これは、LoginButtonの実装の中にCSSがあることから分かる疑いようのない事実です。

次に、LoginButtonclassNameを受け取るようになったことで、LoginButtonに新たな責務が追加されました。それは「classNameを通じて自由にスタイルを拡張できること」です。「自由に」というのは、クラス名を通じてどんなCSSでも適用できることを表しています。

問題は、これが非常に難易度の高い責務であるということです。特に、後方互換性のある形でこの責務を全うし続けるのはほぼ不可能です。MovingLoginButtonの例で考えてみると、この例ではLoginButtonの実装でanimationプロパティを追加したことで、MovingLoginButtonanimationを使ってLoginButtonを拡張することができなくなってしまいました。これは、この変更によってLoginButtonが持っていた「animationプロパティでスタイルを拡張できる」という責務に反しています(上で述べたのは「自由にスタイルを拡張できる」でしたが、「自由に」なので当然これも含まれています)。つまり、LoginButtonの実装にanimationプロパティを追加するのは自らの責務の一部を放棄する後方互換性のない変更であり、MovingLoginButtonが後方互換性のない変更の被害に遭ってしまったということになります。

このように、classNameで自由にスタイルを拡張できるようにしてしまったコンポーネントは、後方互換性のない形であとからCSSに変更を加えるのはほぼ不可能です。さらに言えば、.class > divのような他のDOM要素へのスタイル指定もできてしまうことから、コンポーネント内部のDOM構造を変更するのも後方互換性を損なう恐れがあります。汎用コンポーネントとして作られたコンポーネントならば後方互換性は重要ですから、一度作って使われ始めた汎用コンポーネントはもう修正できないということです。ライブラリとして公開した場合は、内部実装を変えるたびにメジャーバージョンを上げるのが誠実でしょう。

以上の説明から分かるように、classNameで自由にスタイルを拡張できるコンポーネントは、修正することができず、メンテナンス性の観点からはわざわざもはや再利用可能なコンポーネントとした意味が皆無です。一応、変更を加えるたびに全ての使用元を点検するならば可能ですが、その労力は非常に大きく、汎用コンポーネントを作る目的に正面から逆行しています。汎用コンポーネントというのは変更を一気に適用できることに意味があり、二度と書き換えない前提ならばわざわざ汎用コンポーネントを作らずに毎回コピペしても何の問題も無いのですから1

まとめると、LoginButtonclassNameを受け取ることにより難しすぎる責務を背負ってしまい、それを全うできなかった結果としてMovingLoginButtonを壊してしまったということになります。

次にRedLoginButtonの場合を考えてみましょう。RedLoginButtonに起きた問題は、LoginButtonbox-shadowを追加した結果このようにRedLoginButtonが描画するボタンの色がbox-shadowの色と食い違ってしまったというものでした。

この場合は、LoginButtonRedLoginButtonの責務設計に問題があったと考えられます。RedLoginButtonがやっていることは何だったでしょうか? 一見「LoginButtonの色を赤に変える」だったように見えますが、そうではありません。より正確には、「LoginButtonの背景色とボーダーカラーを赤に変える」でした。だから、LoginButtonの仕様変更に対応できなかったのです。

そもそも、RedLoginButtonは赤いボタンを表示するという目的を達成するためになぜ「背景色とボーダーカラーを赤に変える」という方法を選択したのでしょうか。明らかに、それはRedLoginButtonLoginButtonの内部実装を知っていたからです。最初のLoginButtonの実装を見れば、背景色とボーダーカラーを変えればボタンの色を変えられることが分かります。

ここにバッドプラクティスがあります。RedLoginButtonLoginButtonの内部実装に依存した実装になっているため、両者の責務境界が曖昧になってしまっています。そのために、LoginButtonの変更によってRedLoginButtonが壊れるという事故が発生してしまいました。コンポーネントが別のコンポーネントの内部実装に依存するというのは、依存される側の後方互換性の維持が非常に困難になることを意味します。前述の議論と同様に、これではやはりコンポーネント分割した意味がありません。

そして、筆者はLoginButtonclassNameを受け取るというインターフェースが、LoginButtonの内部構造への依存を促進してしまっていると考えています。このインターフェースでは、2つのコンポーネントに由来するスタイルが同じDOM要素に同居することになります。このような状況でスタイルをうまく制御するためには内部構造(classNameを受け取る側のコンポーネントに由来するスタイル)を知る必要が生まれてしまうのは致し方ありません。

厳密なことを言えば、classNameを渡さなくても、CSSには子要素セレクタ(>)などもあるため親コンポーネントから子コンポーネント(が描画するDOM要素)にスタイルを注入することは可能です。これも明らかに子コンポーネントの内部構造に依存しているため避けるべきですが、子コンポーネントから取れる防御策は多くなく、人間の良心で防がなければならないのが残念なところです。どうしても防御が必要ならば、筆者が開発したライブラリCastellaを使って子要素の中身をShadow DOMに入れることで外からの干渉を防ぐことができます。

望ましい解決法とコンポーネントのインターフェース

これらの問題の解決法は、言うまでもなくclassNameを受け取るのをやめることです。そもそもMovingLoginButtonの場合はわざわざLoginButtonclassNameを渡さなくても実装できますよね。次のように、LoginButtonを別のspanで囲んでそちらにスタイルを当てれば解決です。

export const MovingLoginButton = () => (
  <span className={styles.movingButton}>
    <LoginButton />
  </span>
)

動いていて分かりにくいですが、拡大縮小しながら動いていますね。

筆者の考えでは、この方法が使えるならばこれが最も望ましい解決法です。この場合MovingLoginButtonの責務は「LoginButtonを動かすこと」であり、どんな見栄えのボタンが動くのかについて関与しません。LoginButtonがどのように書き換えられたとしても、MovingLoginButtonはそれを動かすことができるでしょう。

一方、RedLoginButtonの場合はそうもいかないでしょう。この場合、LoginButtonで定義されている色を上書きする必要がありますが、これはLoginButtonの内部構造を知らずにRedLoginButtonの側から正しく行うことは不可能です。したがって、LoginButtonの方で色の出し分けの責務を持つことになります。例えばこんな感じです。

type Props = {
  color?: "green" | "red"
}

export const LoginButton: React.VFC<Props> = ({ color = "green" }) => (
  <button className={
    color === "green" ? styles.greenButton : styles.redButton
  }>
    ログイン
  </button>
)

この例からわかるように、コンポーネントのインターフェースはコンポーネントの責務を明確にする役割を持ちますLoginButtoncolorを入力として受け取ることをインターフェースに明示したことによって、それぞれのcolorに対して正しい表示をする責任がLoginButtonにあることが明確になりました。もしLoginButtonという末端のコンポーネントが色に関する情報を持っているのが設計上望ましくなければ、いわゆるthemingなどの手段で解決していくことになります。

こうすることで、LoginButtonbox-shadowを追加する場合、緑の場合と赤の場合の両方で期待通りの表示がされるようにLoginButtonが修正されることが期待できます。注意しなければならないことは、LoginButtonは自身のインターフェースのみを見て実装されなければいけないことです。例えば、LoginButtonbox-shadowの有り無しを制御できるhasShadow?: booleanというpropが追加されたとしましょう。このとき、LoginButtonが対応しなければならないパターンは4パターンに増えます。例えば「color"red"hasShadowtrueのパターンは存在しないから実装をサボろう」のような考え方をしてはいけません。なぜなら、これはLoginButtonの内部実装が、LoginButtonを使う側に依存してしまうことになるからです2

改めて考えると、LoginButtonclassNameを受け取るというのは「どんなスタイルが来ても対応します!」という宣言であり、非常に大言壮語です。上の例のcolorのように、自分で責任を持てる範囲で拡張可能性を持たせるべきであるというのが筆者の考えです。

ただ、別の問題として、いろいろな需要に対応しようとするとpropsが増えてしまうという問題も考えられます。いろいろな入力を受け取るようになると、前述の通りLoginButtonの責任が掛け算で増加していくことになるため、現実的に保証できる範囲をすぐに超えてしまいますね。

これに対する根本的な解決策はデザインシステムを整備することです。デザインのパターンを現実的にメンテナンス可能な形に抑えて制御することにより、その実装にかかる責任も自ずと制御されるでしょう。それが難しい場合は、そもそも解決しようとしている問題が難しいためどうにもならない面もありますが、コンポーネントを別々にして責任範囲を小さくするといった対症療法をとることになるでしょう。

まとめると、コンポーネントのインターフェースはそのコンポーネントの責務の定義です。インターフェースを切った以上、そのインターフェースに従って入力された要求を適切に処理する責任はそのコンポーネントにあります。責任を持てない入力を受け取るべきではありません。そうしなければ、コンポーネント間の責務境界が崩壊し、コンポーネントが互いの内部実装の依存しあいメンテナンスが困難になってしまうでしょう。

とはいっても、現実的にはエスケープハッチとしてclassNameの注入が必要な場面があるかもしれません。そういった場合の筆者のお勧めは、classNameの使用に対して責任は持たないとコンポーネント側で宣言することです。例えばprop名をdangerouslySetClassNameなどにするとよいでしょう(もちろんコメントやドキュメント等で責任を持たない旨を書いておくことも必要です)。

スタイルを責務としないコンポーネントについて

一つ思い出していただきたいのが、この記事での考察対象はスタイルを責務に持つコンポーネントたちだったということです。では、そうではないコンポーネントの場合はどうでしょうか。ちょうどよい例としてはHeadless UIが挙げられます。これは、「スタイルに関する責務を持たない」ことを明示的に掲げた汎用コンポーネント群を提供するライブラリです。一つHeadless UIのexampleから引用します。

<Switch
  checked={enabled}
  onChange={setEnabled}
  className={`${
    enabled ? 'bg-blue-600' : 'bg-gray-200'
  } relative inline-flex h-6 rounded-full w-8`}
>
  <span className="sr-only">Enable notifications</span>
  <span
    className={`${
      enabled ? 'translate-x-4' : 'translate-x-0'
    } inline-block w-4 h-4 transform bg-white rounded-full`}
  />
</Switch>

SwitchがHeadless UIのコンポーネントですが、おや、classNameを受け取っていますね。これは良いのでしょうか。

筆者の意見では、100点満点とは言えませんが、悪いコンポーネント設計ではないと考えています。

その理由を考えるためには、このSwitchコンポーネントが受け取ったclassNameに対してどのような責務を負っているのか考えてみてください。Switchの責務はトグルスイッチを表すDOM要素たちを描画することであり、セマンティクスやアクセシビリティ的に正しいものを描画できるのが売りです。つまり、Switchの責務はDOM要素を描画することであり、classNameに対して負っている責務は「描画されたDOM要素に渡されたclassNameを持たせること」でしかないと解釈できます。言い換えれば、Switch自身がスタイルに責任を負っていないゆえに、スタイルのソースが二重になるという問題を回避しています。

これにより、Switchに渡されるclassNameによってどのような見栄えになるかに関する責任はSwitchを使う側のみが持つことになります。これならこの記事で述べたような問題を回避することができます。

では、100点満点ではないというのはどの部分を指しているのでしょうか。これは、スタイルはCSSだけでなくDOM構造にも依存してしまう点を危惧しています。つまり、classNameによって渡されたスタイルが、Switchによって描画されるDOMの構造に依存してしまう可能性を捨て切れません。これはSwitchを使う側が悪いとも言えますが、コンポーネントを使う側で気をつけるというのはベストな解決策とは言えません。

ただ、上の例からも分かるように、Headless UIはTailwind CSSと組み合わせることを意図して作られています。Tailwind CSSのみを使っている限りはこのような問題は発生しにくいと考えられます。Headless UIを使うならおとなしくTailwind CSSと組み合わせるのが安全ということですね。

まとめ

この記事では、スタイルを司るコンポーネントがclassNameを受け取ることによって発生する諸問題について議論しました。コンポーネントは自身のインターフェースに責任を持つべきであるという観点と、コンポーネントは他のコンポーネントの内部実装に依存するべきではないという2つの観点から、コンポーネントがclassNameを持つべきではないという筆者の考えを紹介しました。


  1.  フロントエンドの話なので、汎用コンポーネントにした方がバンドルサイズを削減できるとかそういう事情はあるかもしれません。その場合、汎用コンポーネントを作る動機が増すことになるのでなおさら変更しやすい汎用コンポーネントを作らなければならないはずですが。
  2. もし本当にcolor"red"hasShadowtrueのパターンをサポートしないならば、それを明示した型をインターフェースにしましょう。TypeScriptならそのような型定義が可能です。