uhyo/blog

まとまったCSSを別のコンポーネントに分けないでほしい話

2020年10月15日 公開

この記事は、ReactでCSSを書くときに関連したCSSを別々のコンポーネントに分けるのをやめようという記事です。主な理由は、スタイリングという機能が複数コンポーネントに分散するのを防ぐためです。これには修正時に複数コンポーネントにまたがって修正が必要になるのを防ぐという意味もあります。

Flexboxの例

関連したCSSが複数の要素に分かれることはよくあります。その代表例がdisplay: flexです。例えばこんなレイアウトを考えてみましょう。左側のボックスの幅が決まっていて右側の幅が可変の2カラムレイアウトです。

左のカラム (100px)
右のカラム

このレイアウトはおおよそ次のように実現できます。

/* 親要素 */
display: flex;

/* 子要素(左) */
flex: 100px 0 0;

/* 子要素(右) */
flex: auto 1 0;

では、Reactではどのように書くでしょうか。CSS in JSの方法は何でもいいのですが、ここではstyled-componentsを用いて書いてみます。

だめな例

まず、やりがちですがだめな例です。

const Wrapper = styled(({ className }) =>
  <div className={className}>
    <LeftColumn />
    <RightColumn />
  </div>
)`
  display: flex;
`

const LeftColumn = styled(({ className }) =>
  <div className={className}>
    左のカラム (100px)
  </div>
)`
  flex: 100px 0 0;
  padding: 10px;
`

const RightColumn = styled(({ className }) =>
  <div className={className}>
    右のカラム
  </div>
)`
  flex: auto 1 0;
  padding: 10px;
`

なぜだめなのかと言えば、2カラムレイアウトを実現するために必要なCSSが複数のコンポーネントに分散しているからです。 これだとスタイルの全体像を把握するために3つのコンポーネントを読む必要があり、修正時、例えばdisplay: flexをやめてdisplay: gridにしたい場合、3つのコンポーネント全てを編集する必要があります。

良い例

この場合、次のようにすべきです。

const Wrapper = styled(({ className }) =>
  <div className={className}>
    <div>
      <LeftColumn />
    </div>
    <div>
      <RightColumn />
    </div>
  </div>
)`
  display: flex;

  & > div:first-child {
    flex: 100px 0 0;
  }

  & > div:last-child {
    flex: auto 1 0;
  }
`

const LeftColumn = styled(({ className }) =>
  <div className={className}>
    左のカラム (100px)
  </div>
)`
  padding: 10px;
`

const RightColumn = styled(({ className }) =>
  <div className={className}>
    右のカラム
  </div>
)`
  padding: 10px;
`

これならば、2カラムレイアウトに必要なCSSは全てWrapperに属しており、LeftColumnRightColumnはカラムの中身に集中しています。責務の観点からも、2カラムレイアウトを作るという責務がWrapperに集中しており、このようにCSSを分割する方が適切です。さらに言えば、Wrapperから直接LeftColumnRightColumnを参照するのではなく、WrapperLayoutみたいな名前にして、子コンテンツはLayoutを使う側から挿入するのも良いでしょう。

良くない例

ところで、元々の例に比べると「良い例」ではdivの層が一つ増えていますね。次のようにするのはだめなのかと思われるかもしれません。

const Wrapper = styled(({ className }) =>
  <div className={className}>
    <LeftColumn />
    <RightColumn />
  </div>
)`
  display: flex;

  & > div:first-child {
    flex: 100px 0 0;
  }

  & > div:last-child {
    flex: auto 1 0;
  }
`

しかし、これは悪手です。なぜなら、これは、LeftColumnRightColumndivをレンダリングするということに依存しているからです。すなわち、Wrapperの実装がLeftColumnRightColumnの内部実装に依存している形になり、好ましくありません。

また、& > *:first-child { ... }のようにするのも同様に避けるべきです。これはLeftColumnなどがdivではなく何をレンダリングしても対応できるように思えますが、LeftColumnなどが要素を一つだけレンダリングするということに依存してしまっています(Fragmentを用いれば一つのコンポーネントが複数の要素をレンダリングすることができます)。

他にも、記事を公開すると「親から子にclassNameを渡す」などの形で、親の側から子のスタイルを指定すれば良いのではないかという意見もありました。styled-componentsで書くとこんな感じです。

const Wrapper = styled(({ className }) =>
  <div className={className}>
    <LeftColumn />
    <RightColumn />
  </div>
)`
  display: flex;

  & > ${LeftColumn} {
    flex: 100px 0 0;
  }

  & > ${RightColumn} {
    flex: auto 1 0;
  }
`

つまり、LeftColumnRightColumn(の一番外の要素)をWrapperから指定するようにします。これならWrapperにスタイルが集中しているように見えます。

このやり方は最初のだめな例に比べれば幾分ましですが、やはりお勧めしません。WrapperからLeftColumnRightColumnの内部実装への依存が残っているからです。 つまり、こうするとレンダリングされたLeftColumnの中には、Wrapper由来のスタイルとLeftColumn由来のスタイルが混在することになります。この状態では、LeftColumn側のスタイルを変更すると、それとWrapper由来のスタイルがうまく噛み合わずに変な描画結果になってしまう恐れがあります。 これではLeftColumnがうかつに自身のスタイルを変更できないことになり、コンポーネントのインターフェース設計に失敗しています。外部から自身のスタイルを変更できるようなコンポーネントのインターフェースはアンチパターンと言うべきでしょう。

これらの諸問題を避けるためには、スタイリングが自身の中で完結して他に干渉しないコンポーネントと作るのが望ましいでしょう。

まとめ

ReactでCSSを書くときはコンポーネント間の依存を発生させないように書きましょう。

余談: react-wcの宣伝

上の例を見て、> div:first-childというのが物々しくて気に入らないと思った方が居るかもしれません。実は、筆者が最近作ったreact-wcならこの点を解決して次のようにクラスを使って書けます。Shadow DOMを使っているのでleftrightといったクラス名が他とかぶることは心配する必要がありません。いいねと思った方は試してみてください。

const Wrapper = html`
  <style>
    :host {
      display: flex;
    }
    .left {
      flex: 100px 0 0;
    }
    .right {
      flex: auto 1 0;
    }
  </style>
  <div class="left">${slot("left")}</div>
  <div class="right">${slot("right")}</div>
`;

<Wrapper
  left={<LeftColumn />}
  right={<RightColumn />}
/>