🐹

CMCPS で CSS をコンポーネント化する

2021/03/20に公開

CMCPS で CSS の世界が変わるかもしれません

はじめに

Dwango でニコニコ生放送のフロント開発を担当している misuken です。

今回は先日書いた CSS Modules と CSS の Custom Properties の組み合わせで色々解決できそうな気がしてる を検証した結果、想像以上に有効な手段であることが判明したので、その具体的な手法を発表します。

対象者

  • CSS Modules や CSS in JS で迷いが生じている方
  • CSS 設計手法で頭を悩ませている方
  • デザイナーとお仕事するWebフロントエンジニア

注意点

IE11 が Custom Properties に対応しておらず、 polyfill でも :root {} 以外の指定に対応していないっぽいので、 IE11 も考慮するサービスでは適用できない、または Custom Properties を使用しない範囲で適用する前提となることに注意してください。

CMCPS とは

CMCPS とは CSS Modules & Custom Properties & Sass という3つの技術の良いところを組み合わせ、 CSS を扱いやすい形でコンポーネント化する手法として名付けたものです。

CMCPS の特徴としては以下。

  • CSS Modules はクラス名のハッシュ化のためだけに使用する
  • Custom Properties は React 等のコンポーネントで言う props 的立ち位置で使用する
  • Sass はスタイル定義のコンポーネント化のために使用する

css-loader の設定

webpack の css-loader あたりの設定はこんな感じ。

[
  "style-loader",
  {
    loader: "css-loader",
    options: {
      modules: {
        localIdentName: "___[local]___[hash:base64:5]",
      },
      localsConvention: "camelCase",
      importLoaders: 2,
    },
  },
  "postcss-loader",
  {
    loader: "sass-loader",
    options: {
      implementation: require("sass"),
    },
  }
]

HTMLソース

今回実験で使用した HTML は同構造同クラス名(最上位のセクションのみハッシュ付きのクラス名を追加)のセクションが3回入れ子になっていて、それが foo bar baz と3つ並んでいます。

<section class="___foo-section___3AZ9O section">
  <h1 class="heading">Foo Section</h1>
  <div class="body">
    <p class="message">Foo Section Message</p>
    <section class=" section">
      <h1 class="heading">Foo Child Section</h1>
      <div class="body">
        <p class="message">Foo Child Section Message</p>
        <section class=" section">
          <h1 class="heading">Foo GrandChild Section</h1>
          <div class="body">
            <p class="message">Foo GrandChild Section Message</p>
          </div>
        </section>
      </div>
    </section>
  </div>
</section>
<section class="___bar-section___2tTe3 section">
  ...
</section>
<section class="___baz-section___3NEzT section">
  ...
</section>

わかりやすくセクション単体の HTML を抜き出すとこんな感じ。

<section class="section">
  <h1 class="heading">heading</h1>
  <div class="body">{children}</div>
</section>

同構造同クラス名で入れ子になっている HTML に対して、スタイルを干渉させずに適用するのが一番難しいパターンなので、このパターンをクリアできれば、他の HTML 構造も問題なくクリアできるという想定です。

ディレクトリ構造

今回は最低限で良いので、セクションコンポーネントとそれを表示するだけの構成です。
カッコ内の番号は説明するファイルの順番を表しています。

├── App.tsx  - 3階層にネストしたセクションを3つ並べるコンポーネント (4)
├── app.scss - アプリケーションのスタイルを指定する場所 (1)
└── section
    ├── index.tsx  - セクションコンポーネントの定義場所 (3)
    └── index.scss - セクションで使用するスタイルセット (2)

スタイル適用部分の scss

まずはイメージしやすいように、スタイル適用部分の scss から説明します。
このファイルの責務は、すでに用意されているスタイルセットを選ぶことと、配置のスタイルを指定することです。

@extend している部分が6箇所ありますが、それらを切り替えるとそのスコープのスタイルだけがキレイに切り替わります。

app.scss
// section で提供されているスタイル定義を使用する
@use "./section";

.app {
  /*!*/
}

// foo-section は装飾無し
.foo-section {
  display: inline-block;
  width: 300px;
  margin: 10px;
}

// bar-section はボーダー装飾のテンプレートを適用
// .bar-section にはハッシュを付け、ブロック内部はハッシュを付けないように `:global` を指定
.bar-section:global {
  // @use で読み込んできた section に提供されている定義から選ぶ
  @extend #{section.$border-red};

  // 配置のスタイルはここで書ける
  display: inline-block;
  width: 300px;
  margin: 10px;

  // 子セレクタを使えば入れ子の同コンポーネントへ別々のスタイルを適用できる
  & > .body > .section {
    @extend #{section.$border-blue};

    & > .body > .section {
      @extend #{section.$border-lime};
    }
  }
}

// baz-section は背景装飾のテンプレートを適用
// 内部でやっていることは bar-section と同じ
.baz-section:global {
  @extend #{section.$bg-red};

  display: inline-block;
  width: 200px;
  margin: 10px;

  & > .body > .section {
    @extend #{section.$bg-blue};

    & > .body > .section {
      @extend #{section.$bg-lime};
    }
  }
}

スタイルセットの適用箇所を変更した例

以下は2箇所だけ修正を入れて Baz Child Section のスタイルを外し、 Baz GrandChild Section のスタイルを Bar GrandChild Section にも適用した例です。

ちゃんとスコープの範囲だけスタイルが変更の影響を受けていることがわかります。
また、 入れ子の中間の要素のスタイルを外して子に影響を与えない という意外と難しいことも簡単にできています。

これがハッシュ付きセレクタと子セレクタを組み合わせて使用している効果です。

Section で定義する scss

ここでは、 Section コンポーネントで使用する6パターンのスタイルセットを定義します。
このファイルでは Sass の Interpolation #{}Placeholder Selector %xxx を活用し、通常のセレクタは含めないようにします。

また HTML 構造に関する情報をファイル内に閉じて、外部にはスタイルセットのセレクタ名を持つ変数を提供するようにします。

section/index.scss
@use "sass:math"; // (乱数でテンプレートのプレースホルダセレクタの競合を回避する目的に利用)

// コンポーネント名を名前空間に指定
$ns: "section";

// ボーダー装飾用の各種セレクタ定義
$border: "%#{$ns}#{math.random(1000000)}-border";
$border-red: "#{$border}-red";
$border-blue: "#{$border}-blue";
$border-lime: "#{$border}-lime";

// ボーダー装飾用テンプレートの定義
#{$border} {
  border-width: 4px;
  border-style: solid;
  // 変更可能なプロパティは変数定義
  border-color: var(--#{$ns}-border-color);
  background-color: #fff;
  padding: 4px 16px;

  > .heading {
    font-weight: var(--#{$ns}-heading-font-weight);
  }

  > .body {
    > .message {
      text-align: var(--#{$ns}-message-text-align);
    }
  }
}

// ボーダー装飾用スタイルセットの定義(赤系)
#{$border-red} {
  @extend #{$border};

  --#{$ns}-border-color: #faa;
  --#{$ns}-heading-font-weight: bold;
  --#{$ns}-message-text-align: left;
}

// ボーダー装飾用スタイルセットの定義(青系)
#{$border-blue} {
  @extend #{$border};

  --#{$ns}-border-color: #aaf;
  --#{$ns}-heading-font-weight: bold;
  --#{$ns}-message-text-align: center;
}

// ボーダー装飾用スタイルセットの定義(緑系)
#{$border-lime} {
  @extend #{$border};

  --#{$ns}-border-color: #afa;
  --#{$ns}-heading-font-weight: bold;
  --#{$ns}-message-text-align: right;
}

// 背景装飾用の各種セレクタ定義
$bg: "%#{$ns}#{math.random(1000000)}-bg";
$bg-red: "#{$bg}-red";
$bg-blue: "#{$bg}-blue";
$bg-lime: "#{$bg}-lime";

// ボーダーの定義とは変数で設定できるプロパティセットが違う
#{$bg} {
  border-width: 4px;
  border-style: solid;
  background-color: var(--#{$ns}-background-color);
  padding: 4px 16px;

  > .heading {
    font-size: var(--#{$ns}-heading-font-size);
  }

  > .body {
    > .message {
      opacity: var(--#{$ns}-message-opacity);
    }
  }
}

#{$bg-red} {
  @extend #{$bg};

  --#{$ns}-background-color: #faa;
  --#{$ns}-heading-font-size: 14px;
  --#{$ns}-message-opacity: 0.9;
}

#{$bg-blue} {
  @extend #{$bg};

  --#{$ns}-background-color: #aaf;
  --#{$ns}-heading-font-size: 12px;
  --#{$ns}-message-opacity: 0.6;
}

#{$bg-lime} {
  @extend #{$bg};

  --#{$ns}-background-color: #afa;
  --#{$ns}-heading-font-size: 10px;
  --#{$ns}-message-opacity: 0.3;
}

スタイルのコンポーネント化

テンプレート定義という部分を Component、スタイルセットを Props として捉えてみると、実は書いてあるコードの抽象的な意味は React 等のコンポーネントとほぼ同じです。

  > .heading {
    font-weight: var(--#{$ns}-heading-font-weight);
  }
<h1 style={{ fontWeight: props.heading.fontWeight }} />

各種セレクタ定義に関しても、コンポーネント用に使うために用意された利用パターン名を enum 的な形で宣言して export していることと同じです。

これはまさにスタイルのコンポーネント化と言えるので、 React 等で培った概念がここでも有効であることを意味しています。

コンポーネント化したスタイルを分割

1枚の scss ファイルが Fat になってきたら、簡単に分割することも可能です。
styles/border.scssstyles/bg.scss にそれぞれの依存部分を移動して、 section/index.scss から @forward するだけで完了です。

section/index.scss
@forward "./styles/border" as border-*;
@forward "./styles/bg" as bg-*;

これも React 等のコンポーネントを整理するときの概念と同じで、以下のコードとも精通しています。

tsx の例
export * from "./foo";
export * from "./bar";

Section コンポーネント

最初の HTML 構造を作るだけの簡単なコンポーネントです。

ここは重要ではないので適当に作りましたが、コンポーネントのルートとなる <section> だけはクラス名を追加できるようにしています。

section/index.tsx
import * as React from "react";

type SectionProps = JSX.IntrinsicElements["section"];
export interface Props extends SectionProps {
  heading: JSX.IntrinsicElements["h1"];
}

export const Component: React.FC<Props> = ({ heading, children, ...props }) => {
  return (
    // コンポーネントのルートとなる要素はクラス名を外部から指定できるようにしておきます
    <section {...props} className={[props.className, "section"].join(" ")}>
      <h1 {...heading} className="heading" />
      <div className="body">{children}</div>
    </section>
  );
};

アプリケーションで HTML を描画

app.scss を読み込んで最上位の3つのセクションにクラス名を渡すだけ。

app.tsx
import * as React from "react";
import * as Section from "./section";

const classNames: { [P in "app" | "fooSection" | "barSection" | "bazSection"]?: string } = require("./app.scss");

export const Component: React.FC = () => {
  return (
    <div className="app">
      <Section.Component className={classNames.fooSection} heading={{ children: "Foo Section" }}>
        <p className="message">Foo Section Message</p>
        <Section.Component heading={{ children: "Foo Child Section" }}>
          <p className="message">Foo Child Section Message</p>
          <Section.Component heading={{ children: "Foo GrandChild Section" }}>
            <p className="message">Foo GrandChild Section Message</p>
          </Section.Component>
        </Section.Component>
      </Section.Component>
      <Section.Component className={classNames.barSection} heading={{ children: "Bar Section" }}>
        <p className="message">text-align: left</p>
        <Section.Component heading={{ children: "Bar Child Section" }}>
          <p className="message">text-align: center</p>
          <Section.Component heading={{ children: "Bar GrandChild Section" }}>
            <p className="message">text-align: right</p>
          </Section.Component>
        </Section.Component>
      </Section.Component>
      <Section.Component className={classNames.bazSection} heading={{ children: "Baz Section" }}>
        <p className="message">Opacity 0.9</p>
        <Section.Component heading={{ children: "Baz Child Section" }}>
          <p className="message">Opacity 0.6</p>
          <Section.Component heading={{ children: "Baz GrandChild Section" }}>
            <p className="message">Opacity 0.3</p>
          </Section.Component>
        </Section.Component>
      </Section.Component>
    </div>
  );
};

完成

基本的な実装はたったこれだけです。
あとは紹介した内容の応用でアプリケーション全体を構築すれば、完全に管理の行き届いた CSS の世界が手に入るので、これからは CSS 設計手法で頭を悩ませることはありません。

スタイルに依存することはほぼ全て scss ファイルで制御でき、エンジニアはコンポーネントのルート要素に className を設定するだけです。

エンジニアとデザイナーの責務境界としても適切ですし、 scss ファイル内はデザイナーが書きやすい短い名前の単純なセレクタしかありません。

Sass の基本機能のいくつかを覚えれば簡単に作れるほど小さな学習コストで済む点も含め、かなりハードルの低い手法と言えます。

本当にうまくいっているのか?

クラス名は短いものばかりだし、ほとんどシングルクラスだし、 scss ファイル内の定義も短いものばかりだし、これで本当にスタイルの競合が発生しないのか?と不安になるかもしれないので、その点についても解説していきます。

短いくてシングルのクラス名

上の方で少し触れましたが、 HTML ツリーはディレクトリツリーと同じ、クラス名はディレクトリ名と同じです。
セレクタはディレクトリツリーを grep する正規表現のようなもので、パスのような存在です。

ハッシュ付きセレクタによってある地点にルートを設定し、そこから grep した対象に対してスタイルを適用していると考えれば、今回紹介した短いクラス名で何の問題もないことがわかります。

実際に適用された結果を見ると、必ずハッシュ付きセレクタを起点としたスタイルしか存在しないので、競合が発生しないことがわかるかと思います。

右下のほうにある inherited from という上書きされている値は、親要素のスコープで適用されたスタイルセットです。
スタイルセットはカスタムプロパティを定義しているだけなので、 React コンポーネントで言えば props であり、少なくとも影響範囲は "値" のレベルまで絞り込まれていることになります。(セレクタには影響が及ばない)

上の例では、もしもセクションのスタイルを単体で確認しながら作成している際に、うっかり --section-background-color の指定を忘れてしまうと(本当は未指定でも見た目が変わらないものを想定)、入れ子内に設置したときに親のスコープの青色が反映されて壊れる可能性があります。

これを防ぐには、以下のように変更可能な全プロパティセットを持ったデフォルトのスタイルセットを用意し、 @extend することで解決できます。

// デフォルトのスタイルセット(全プロパティを網羅しておく)
#{$bg-default} {
  @extend #{$bg};

  --#{$ns}-background-color: #ccc;
  --#{$ns}-heading-font-size: 20px;
  --#{$ns}-message-opacity: 1;
}

// 〜 省略 〜

#{$bg-lime} {
  @extend #{$bg};
  @extend #{$bg-default};

  //--#{$ns}-background-color: #afa; // うっかり設定し忘れた
  --#{$ns}-heading-font-size: 10px;
  --#{$ns}-message-opacity: 0.3;
}

こうしておけば、単体で確認している際に壊れていないかが確認できますし、どこに設置しても単体で確認したときの表示内容が保証されます。

typo などのミスを防ぐために、カスタムプロパティ名も Sass で変数化して安全に配慮するのも良いかもしれません。

scss ファイル内の定義名

次に scss ファイル内の定義名の不安に関して。
定義名は他のファイルでも同じ名前を使う可能性があり、不安に思うかもしれませんが、利用する際 @use で読み込んだものは JavaScript や TypeScript の import * as Foo from "./foo" と同じように、モジュールとして名前空間に守られています。

2つの scss ファイル内で同じ名前の定義が存在していても、利用する側は両方読み込んできて一緒に使えます。

また、以下のように読み込んできた定義を @extend するところで、セレクタの名前の競合を不安に思うかもしれませんが、 @extend は Placeholder Selector % で行っているため問題ありません。

.bar-section:global {
  @extend #{section.$bg-red};

  & > .body > .section {
    @extend #{section.$bg-blue};

    & > .body > .section {
      @extend #{section.$bg-lime};
    }
  }

スタイルセットの短い定義名は定義を識別するためのパス( section.$bg-limesection/bg/lime と同じ )のような役割を果たすだけで、セレクタに使用されることは無いからです。

出力結果では、要素側のセレクタにスタイルセットのカスタムプロパティがコピーされます。

.___baz-section___3NEzT > .body > .section > .body > .section {
  --section-background-color: #afa;
  --section-heading-font-size: 10px;
  --section-message-opacity: 0.3;
}

カスタムプロパティ名

section という名前のコンポーネントが別々のディレクトリに2つ存在していて、それらの内部で同じプロパティを使用している場合、それらが親子関係になると、親のカスタムプロパティ名が子のカスタムプロパティ名と一致する場合があります。

ただし、このパターンでも、デフォルトのスタイルセットのように、プロパティ設定漏れを防ぐ手段を講じておけばスタイルが壊れることはありません。

詳細度やセレクタ定義の読み込み順ではなく、HTML ツリーの要素のスコープ単位でカスタムプロパティが更新される仕組みであるため、ツリーの下で定義したものが常に強くなります。

つまり、親子で言えば子で設定されたカスタムプロパティが強くなるので、同じカスタムプロパティ名が存在していても問題ありません。

結論

このように、よく問題が発生するポイントが抑えられていて、各責務の内部も境界も正規化されているため、問題が起きにくく予測が立ちやすい安定した仕組みであることがわかるかと思います。

HTML 要素一つ一つから CSS のプロパティ一つ一つまで全てを制御下に置いた状態で管理できるので、CMCPS はとてもうまく機能します。

アプリケーションの性質と相性

たしか React の仮想DOMあたりの思想か何かで

「HTML アプリケーションにおいては要素の更新は多くても、階層を移動するようなパターンは稀であり、階層を移動したら再構築のコストが発生するのは仕方ないが、階層が変わらなければ描画コストを抑えやすい」

というような内容を見たことがあります。

実際アプリケーションを開発する中でも、コンポーネントが階層の移動を必要とするパターンはほとんどありません。

そして、コンポーネントも適切に設計していればほとんどの UI は既存コンポーネントの組み合わせに落ち着くので、 静的な構造化 + 出し分け(いわゆる { foo?: FooProps } ) + 状態(属性)変化 でほとんどの部分が成立し、静的な部分が大きければ大きいほど複雑さが減ります。

CMCPS は静的な構造(骨格というほうが正しいかもしれない)との相性が良く、 CMCPS を使えばより静的な部分を増やすことができます。

それはより宣言的な領域が増え、より複雑さを減らすことに繋がると期待しています。

まとめ

今回 CMCPS という新しい CSS 設計手法を紹介しました。
JavaScript と CSS の接点が最小化され、書き味や可読性が良く、コンポーネント化したスタイルを簡単に適用でき、十分な安全性が確保されているなど、多くのメリットを感じていただけたのではないでしょうか。

CMCPS は Sass 内の最適化がしやすい作りになっているので、途中で説明したように責務ごとに分割したり、スタイルの分類ごとに部品化したり、さらに開発効率や保守性を高めることができると予想しています。

IE11 をサポートする必要のない環境の方は試してみてはいかがでしょうか。

補足

カスタムプロパティをどれくらい使用するか

コンポーネントにスタイルが1パターンしかないならカスタムプロパティを使う必要はありません。
しかし、予め利用箇所によって変更することが見込まれる部分はカスタムプロパティにしておいたほうが使いやすくできる可能性があります。

例えば、配置するときにサイズを決めたいといったレイアウト関係のプロパティなど。
カスタムプロパティ代替値 が使えるので、デフォルトで指定しておいて変数名を scss ファイル側で定義しておけば、配置するときに typo も防ぐことができ、利用しやすいでしょう。

scss でカスタムプロパティ名が変数として定義されていると、エディタのヒントなどから何が使えるかもわかるので、よりスムーズに開発できるようになります。

カスタムプロパティ名

今回紹介した例を作成した後に気付いたのですが、カスタムプロパティ名は --#{}--pattern-name--propety-name のように各部位ごとにハイフンを2〜3つにするなど、対象セレクタ、パターン、プロパティが識別できるような名前にしておいたほうが管理しやすくなりそうです。

宣伝

CMCPS や BCD Design (← AtomicDesign のモヤモヤが解決できます) のように整理された世界、秩序あるWebフロントを目指している方、そういったモダンな開発に興味のある方がいらっしゃいましたら、是非一緒にお仕事しましょう。
お待ちしております。
https://dwango.co.jp/recruit/

Discussion