翻訳:CSSユーティリティクラスと「関心の分離」(いかにしてユーティリティファーストにたどり着いたか)

Tailwind CSS作者のAdam Wathan氏による「CSS Utility Classes and "Separation of Concerns"」の日本語訳です。翻訳に当たって原著者の許諾を得ています。


ここ数年の間に私のCSSの書き方は、非常に「セマンティックな」アプローチから「機能本位の(functional)CSS」と呼ばれるものへと移行してきました。

このやり方でCSSを書くと多くの開発者からかなりの反感を買うことがあるので、私がどのようにしてここまでたどり着いたのかを説明し、その過程で得た教訓や洞察を共有したいと思います。

段階1:「セマンティックな」CSS

CSSの優れた部分を学ぼうとしているとき、耳にするであろうベストプラクティスのひとつが「関心の分離」です。

考え方としては、HTMLにはコンテンツの情報だけを含めるべきであり、スタイリングの決定はすべてCSSで行うべきだということです。

このHTMLを見てみてください:

<p class="text-center">
    Hello there!
</p>

.text-centerクラスを見ましたか? テキストの中央配置はデザイン上の決定事項なので、このコードは「関心の分離」に反します。なぜならスタイリングの情報をHTMLに漏れ出させてしまっているからです。

その代わりに推奨されるアプローチは、対応するコンテンツにもとづいたクラス名を要素に付与し、それらのクラスをCSSのためのフックとしてマークアップのスタイリングに利用することです:

<style>
.greeting {
    text-align: center;
}
</style>

<p class="greeting">
    Hello there!
</p>

このアプローチの真骨頂はつねにCSS Zen Gardenであり、このサイトは「関心を分離すれば」スタイルシートを差し替えるだけで完全にリデザインし直せることを示すために設計されています。

私のワークフローは次のようになりました:

1. なにか新しいUIを作るために必要なマークアップを書きます(この場合は著者の略歴カード)

<div>
  <img src="https://cdn-images-1.medium.com/max/1600/0*o3c1g40EXj65Fq9k." alt="">
  <div>
    <h2>Adam Wathan</h2>
    <p>
      Adam is a rad dude who likes TDD, Active Record, and garlic bread with cheese. He also hosts a decent podcast and has never had a really great haircut.
    </p>
  </div>
</div>

2. コンテンツについて叙述するクラスを追加します:

- <div>
+ <div class="author-bio">
    <img src="https://cdn-images-1.medium.com/max/1600/0*o3c1g40EXj65Fq9k." alt="">
    <div>
      <h2>Adam Wathan</h2>
      <p>
        Adam is a rad dude who likes TDD, Active Record, and garlic bread with cheese. He also hosts a decent podcast and has never had a really great haircut.
      </p>
    </div>
  </div>

3. 新しいマークアップをスタイリングするためにこれらのクラスをCSS/Less/Sassの「フック」として利用します:

.author-bio {
  background-color: white;
  border: 1px solid hsl(0,0%,85%);
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  overflow: hidden;
  > img {
    display: block;
    width: 100%;
    height: auto;
  }
  > div {
    padding: 1rem;
    > h2 {
      font-size: 1.25rem;
      color: rgba(0,0,0,0.8);
    }
    > p {
      font-size: 1rem;
      color: rgba(0,0,0,0.75);
      line-height: 1.5;
    }
  }
}

これが最終結果のデモです:

See the Pen "Semantic" mapping layer (terrible idea!) by Adam Wathan (@adamwathan) on CodePen.

このアプローチは直感的に納得できたので、しばらくの間はこのようにHTMLやCSSを書いていました。

しかしそのうち少しずつ違和感を覚えはじめました。

私は「関心を分離」していたのですが、CSSとHTMLの間にはまだ明らかな結合がありました。ほとんどの場合において私のCSSマークアップの鏡のようで、入れ子になったCSSセレクタでHTML構造を完全に反映していました。

私のマークアップはスタイリングの決定に関心を持ちませんでしたが、CSSマークアップの構造にかなり関心を持っていました。

結局、私の関心はそれほど分離していなかったのかもしれません。

段階2:構造からスタイルを切り離す

この結合に対する解決策を探し回った後に私は、より多くのクラスをマークアップに追加して直接標的にできるようにすること、セレクタの詳細度を低く保ち、CSSを特定のDOM構造に依存させないようにすることなど、より多くの推奨事項を見つけ始めました。

この考え方を提唱するもっとも有名な方法論はBlock Element Modifer、略してBEMです。

BEMのようなアプローチをとると、著者の略歴カードは次のようになります:

<div class="author-bio">
  <img class="author-bio__image" src="https://cdn-images-1.medium.com/max/1600/0*o3c1g40EXj65Fq9k." alt="">
  <div class="author-bio__content">
    <h2 class="author-bio__name">Adam Wathan</h2>
    <p class="author-bio__body">
      Adam is a rad dude who likes TDD, Active Record, and garlic bread with cheese. He also hosts a decent podcast and has never had a really great haircut.
    </p>
  </div>
</div>

そして、CSSは次のようになります:

.author-bio {
  background-color: white;
  border: 1px solid hsl(0,0%,85%);
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  overflow: hidden;
}
.author-bio__image {
  display: block;
  width: 100%;
  height: auto;
}
.author-bio__content {
  padding: 1rem;
}
.author-bio__name {
  font-size: 1.25rem;
  color: rgba(0,0,0,0.8);
}
.author-bio__body {
  font-size: 1rem;
  color: rgba(0,0,0,0.75);
  line-height: 1.5;
}

View on CodePen

これは大きな改善のように感じられました。マークアップはまだ「セマンティック」であり、スタイリングの決定を含んでいませんでしたが、CSSは今ではマークアップ構造から分離されたように感じられます。不必要なセレクタの詳細度を回避できるというおまけつきで。

しかし、私はジレンマに陥りました。

似たようなコンポーネントへの対応

たとえば新しい機能として、カードレイアウトで表示される記事のプレビューをサイトに追加する必要があるとします。

この記事のプレビューカードには、上部に幅いっぱいの画像、その下に余白付きのコンテンツセクション、太字のタイトル、そしていくつかの小さな本文があったとします。

著者の略歴にそっくりだとしましょう。

関心を分離しつつ、どう対処していくのがベストでしょうか?

.author-bioクラスを記事プレビューにも適用することはできません。それはセマンティックではないでしょう。そのため.article-previewを独自のコンポーネントにする必要があります。

マークアップは次のようになります:

<div class="article-preview">
  <img class="article-preview__image" src="https://i.vimeocdn.com/video/585037904_1280x720.webp" alt="">
  <div class="article-preview__content">
    <h2 class="article-preview__title">Stubbing Eloquent Relations for Faster Tests</h2>
    <p class="article-preview__body">
      In this quick blog post and screencast, I share a trick I use to speed up tests that use Eloquent relationships but don't really depend on database functionality.
    </p>
  </div>
</div>

しかしCSSはどのように扱えばいいのでしょうか?

オプション1:スタイルの複製

ひとつのアプローチとしては、.author-bioスタイルをそのまま複製してクラス名を変更する方法があります。

.article-preview {
  background-color: white;
  border: 1px solid hsl(0,0%,85%);
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  overflow: hidden;
}
.article-preview__image {
  display: block;
  width: 100%;
  height: auto;
}
.article-preview__content {
  padding: 1rem;
}
.article-preview__title {
  font-size: 1.25rem;
  color: rgba(0,0,0,0.8);
}
.article-preview__body {
  font-size: 1rem;
  color: rgba(0,0,0,0.75);
  line-height: 1.5;
}

これは機能しますが、もちろんあまりDRYではありません。またこれらのコンポーネントがわずかに異なる方法で変更されやすくなる(パディングやフォントの色が異なるかもしれません)ため、デザインの一貫性が失われる可能性があります。

オプション2:著者の略歴コンポーネント@extend

もうひとつの方法は、お好みのプロプロセッサの@extend機能を使うことです。すでに.author-bioコンポーネントで定義されているスタイルに便乗させます。

.article-preview {
  @extend .author-bio;
}
.article-preview__image {
  @extend .author-bio__image;
}
.article-preview__content {
  @extend .author-bio__content;
}
.article-preview__title {
  @extend .author-bio__name;
}
.article-preview__body {
  @extend .author-bio__body;
}

View on CodePen

@extendを少しでも使うことは一般的に推奨されませんが、それはさておきこれで問題が解決したような気がしますよね?

CSSの重複を削除し、マークアップはまだスタイリングの決定から解放されています。

しかし、もうひとつの選択肢を検討してみましょう。

オプション3:コンテンツに依存しないコンポーネントを作成する

.author-bio.article-previewコンポーネントは「セマンティック」の観点ではなんの共通点もありません。ひとつは著者の略歴、もうひとつは記事のプレビューです。

しかしすでに見てきたように、デザイン的にはかなりたくさんの共通点があります。

そのため必要に応じて、両者の共通するものにちなんだ名前の新しいコンポーネントを作成し、そのコンポーネントを両方のタイプのコンテンツで再利用することもできます。

これを.media-cardと呼びましょう。

これがそのCSSです:

.media-card {
  background-color: white;
  border: 1px solid hsl(0,0%,85%);
  border-radius: 4px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  overflow: hidden;
}
.media-card__image {
  display: block;
  width: 100%;
  height: auto;
}
.media-card__content {
  padding: 1rem;
}
.media-card__title {
  font-size: 1.25rem;
  color: rgba(0,0,0,0.8);
}
.media-card__body {
  font-size: 1rem;
  color: rgba(0,0,0,0.75);
  line-height: 1.5;
}

そして、著者の略歴のマークアップは次のようになります:

<div class="media-card">
  <img class="media-card__image" src="https://cdn-images-1.medium.com/max/1600/0*o3c1g40EXj65Fq9k." alt="">
  <div class="media-card__content">
    <h2 class="media-card__title">Adam Wathan</h2>
    <p class="media-card__body">
      Adam is a rad dude who likes TDD, Active Record, and garlic bread with cheese. He also hosts a decent podcast and has never had a really great haircut.
    </p>
  </div>
</div>

と、これが記事プレビューのマークアップです:

<div class="media-card">
  <img class="media-card__image" src="https://i.vimeocdn.com/video/585037904_1280x720.webp" alt="">
  <div class="media-card__content">
    <h2 class="media-card__title">Stubbing Eloquent Relations for Faster Tests</h2>
    <p class="media-card__body">
      In this quick blog post and screencast, I share a trick I use to speed up tests that use Eloquent relationships but don't really depend on database functionality.
    </p>
  </div>
</div>

CSSの重複はこのアプローチによっても削除できますが、今は「関心を混合している」のではないでしょうか?

マークアップは思いがけず、これらのコンテンツの両方をメディアカードのようなスタイルにしたいことを知ってしまっています。記事プレビューの見た目は変えずに、著者の略歴の見た目だけを変えたいとしたらどうでしょうか?

以前は、スタイルシートを開いて、2つのコンポーネントのいずれかのために新しいスタイルを決めるだけでした。今ではHTMLを編集する必要があります! 不敬!

でも、ちょっとその裏面について考えてみましょう。

同じスタイリングを必要とする新しいタイプのコンテンツを追加しなければならない場合はどうでしょうか?

「セマンティックな」アプローチを使うと、新しいHTMLを書き、コンテンツ固有のクラスをスタイリングの「フック」として追加し、スタイルシートを開き、新しいコンテンツタイプ用の新しいCSSコンポーネントを作成し、そして共有スタイルを、重複して適用するか@extendまたはmixinを使用して適用します。

コンテンツに依存しない.media-cardクラスを使えば、必要なのは新しいHTMLを書くことだけで、スタイルシートを開く必要はありません。

本当に「関心を混合している」のであれば、複数の箇所に変更を加える必要がないと言えるのでしょうか?

「関心の分離」は論点のすり替え

HTMLとCSSの関係を「関心の分離」という観点で考えてみると白黒は非常にはっきりしています。

関心の分離があるか(良い!)、ない(悪い!)かのどちらかです。

これはHTMLとCSSの正しい考え方ではありません。

その代わり、依存関係の方向を考えます。

HTMLとCSSの書き方は2通りあります:

  1. 「関心の分離」
    HTMLに依存するCSS

    コンテンツにもとづいたクラス名をつけることで(.author-bioのように)、HTMLをCSSの依存関係として扱います。

    HTMLは独立しています。HTMLがどのように見えるかは気にせず、HTMLが統制する.author-bioのようなフックを公開しているだけです。

    一方でCSSは独立したものではありません。HTMLが公開すると決めたクラスを知る必要があり、HTMLのスタイルを整えるためにそれらのクラスを標的にする必要があります。

    このモデルではHTMLは再スタイリング可能になりますが、CSSは再利用できません。

  2. 「関心の混合」
    CSSに依存するHTML。

    UIのパターンを繰り返した後に、コンテンツに依存しない方法でクラス名をつけると(.media-cardのように)、CSSはHTMLの依存関係として扱われます。

    CSSは独立しています。どのようなコンテンツに適用されるかの関心は持たず、マークアップに適用できる構成要素のセットを公開しているだけです。

    HTMLは独立していません。CSSによって提供されているクラスを利用しており、その組み合わせによって望ましいデザインを達成するためには、どのようなクラスが存在するかを知る必要があります。

    このモデルではCSSは再利用可能になりますが、HTMLは再スタイリングできません。

CSS Zen Gardenは最初のアプローチを取り、BootstrapBulmaのようなUIフレームワークは2つ目のアプローチを取ります。

本質的にはどちらも「間違っている」のではなく、特定の文脈の中でなにがより重要かを判断しているに過ぎません。

あなたが取り組んでいるプロジェクトでは、再スタイリング可能なHTMLと再利用可能なCSSのどちらにより価値があるでしょうか?

再利用性の選択

転機となったのはNicolas GallagherのAbout HTML semantics and front-end architectureを読んだことです。

ここで彼の指摘をすべて繰り返すつもりはありませんが、私が取り組んでいる種類のプロジェクトにとって、再利用可能なCSSのために最適化することが正しい選択であるとそのブログ記事を読んで確信したのは言うまでもありません。

段階3:コンテンツに依存しないCSSコンポーネント

この時点での私の目標はコンテンツにもとづいたクラスを作ることを明確に避けることで、その代わりに可能な限り再利用可能な方法ですべてのものに名前をつけようとしました。

その結果、次のようなクラス名になりました:

  • .card
  • .btn, .btn--primary, .btn--secondary
  • .badge
  • .card-list, .card-list-item
  • .img--round
  • .modal-form, .modal-form-section

などなど。

再利用可能なクラスを作ることに力を入れ始めて気づいたことがあります:

コンポーネントが多ければ多いほど、あるいはコンポーネントがより個別的であればあるほど再利用は困難になります。

直観的な例を挙げてみましょう。

いくつかのフォームセクションとその下部に送信ボタンがあるフォームを構築していたとします。

フォームのコンテンツをすべて.stacked-formコンポーネントの一部として考えた場合、送信ボタンには.stacked-form__buttonのようなクラスを付与することになるかもしれません:

<form class="stacked-form" action="#">
  <div class="stacked-form__section">
    <!-- ... -->
  </div>
  <div class="stacked-form__section">
    <!-- ... -->
  </div>
  <div class="stacked-form__section">
    <button class="stacked-form__button">Submit</button>
  </div>
</form>

しかしサイト上にはフォームの一部ではない別のボタンがあり、同じようにスタイルを整える必要があるかもしれません。

そのボタンはスタックされたフォームの一部ではないので、.stacked-form__buttonクラスを使うのはあまり理にかなっていません。

これらのボタンはいずれもそれぞれのページでの主要なアクションなので、コンポーネントの共通点にもとづいてボタンの名前をつけ、.stacked-form__の接頭辞を完全に削除して.btn--primaryと呼べばどうでしょうか?

  <form class="stacked-form" action="#">
    <!-- ... -->
    <div class="stacked-form__section">
-     <button class="stacked-form__button">Submit</button>
+     <button class="btn btn--primary">Submit</button>
    </div>
  </form>

ここで、このスタックされたフォームをフローティングカードの中にあるように見せたいとします。

ひとつのアプローチとしては、モディファイアを作成してこのフォームに適用することが考えられます。

- <form class="stacked-form" action="#">
+ <form class="stacked-form stacked-form--card" action="#">
    <!-- ... -->
  </form>

しかしすでに.cardクラスがあるのであれば、既存のカードとスタックされたフォームを使ってこの新しいUIを合成(compose)してみるのはどうでしょうか?

+ <div class="card">
    <form class="stacked-form" action="#">
      <!-- ... -->
    </form>
+ </div>

このようなアプローチを取ることで、どんなコンテンツでも受け入れられる.cardとどんなコンテナの中にでも入れ子にできる柔軟な.stacked-formとを所有することになります。

コンポーネントからより多くの再利用性を得ていますし、新しいCSSを書く必要もありませんでした。

サブコンポーネントよりも合成

スタックされたフォームの下部に別のボタンを追加する必要があり、既存のボタンとは少し間隔を開けたかったとします:

<form class="stacked-form" action="#">
  <!-- ... -->
  <div class="stacked-form__section">
    <button class="btn btn--secondary">Cancel</button>
    <!-- Need some space in here -->
    <button class="btn btn--primary">Submit</button>
  </div>
</form>

ひとつの方法として、.stacked-form__footerのような新しいサブコンポーネントを作成し、.stacked-form__footer-itemのように各ボタンに付加的なクラスを追加し、子孫セレクタを使ってマージンを追加できます:

  <form class="stacked-form" action="#">
    <!-- ... -->
-   <div class="stacked-form__section">
+   <div class="stacked-form__section stacked-form__footer">
-     <button class="btn btn--secondary">Cancel</button>
-     <button class="btn btn--primary">Submit</button>
+     <button class="stacked-form__footer-item btn btn--secondary">Cancel</button>
+     <button class="stacked-form__footer-item btn btn--primary">Submit</button>
    </div>
  </form>

CSSは次のようになります:

.stacked-form__footer {
  text-align: right;
}
.stacked-form__footer-item {
  margin-right: 1rem;
  &:last-child {
    margin-right: 0;
  }
}

しかしどこかのサブナビやヘッダーに同じ問題があったとしたらどうでしょうか?

.stacked-form__footer.stacked-formの外で再利用することはできないので、ヘッダーの中にサブコンポーネントを作ることになるかもしれません:

  <header class="header-bar">
    <h2 class="header-bar__title">New Product</h2>
+   <div class="header-bar__actions">
+     <button class="header-bar__action btn btn--secondary">Cancel</button>
+     <button class="header-bar__action btn btn--primary">Save</button>
+   </div>
  </header>

ですが.stacked-form__footerの構築に費やした苦労を、新しい.header-bar__actionsコンポーネントでも繰り返さなければなりません。

これは最初のころにコンテンツ駆動型のクラス名で遭遇した問題と似ているように感じませんか?

この問題を解決するひとつの方法は、再利用しやすい新しいコンポーネントをまるごと取り出した上で合成を行うことです。

.actions-listのようなものを作るのではないでしょうか:

.actions-list {
  text-align: right;
}
.actions-list__item {
  margin-right: 1rem;
  &:last-child {
    margin-right: 0;
  }
}

これで、.stacked-form__footer.header-bar__actionsコンポーネントを完全に取り除き、代わりに両方の場面で.actions-listを使用できます:

<!-- Stacked form -->
<form class="stacked-form" action="#">
  <!-- ... -->
  <div class="stacked-form__section">
    <div class="actions-list">
      <button class="actions-list__item btn btn--secondary">Cancel</button>
      <button class="actions-list__item btn btn--primary">Submit</button>
    </div>
  </div>
</form>

<!-- Header bar -->
<header class="header-bar">
  <h2 class="header-bar__title">New Product</h2>
  <div class="actions-list">
    <button class="actions-list__item btn btn--secondary">Cancel</button>
    <button class="actions-list__item btn btn--primary">Save</button>
  </div>
</header>

しかしこれらのアクションリストのうちのひとつが左揃えになり、もうひとつが右揃えになるとすればどうでしょうか? .actions-list--left.actions-list--rightというモディファイアを作るのではないでしょうか?

段階4:コンテンツに依存しないコンポーネント+ユーティリティクラス

これらのコンポーネント名を考えようとするといつも疲労困憊します。

.actions-list--leftのようなモディファイアを作ると、CSSプロパティをひとつ割り当てるためだけにまったく新しいコンポーネントモディファイアを作ることになります。名前にすでにleftと入っているので、どのみち「セマンティック」だとごまかすこともできないでしょう。

左揃えと右揃えのモディファイアを必要とする別のコンポーネントがあった場合、そのために新しいコンポーネントモディファイアを作成するのはどうでしょうか?

この場合、.stacked-form__footer.header-bar__actionsを削除して単一の.action-listに置き換えることにしたとき直面していた問題に戻ります:

重複よりも合成を選びます。

では2つのアクションリストがあり、ひとつは左揃えにする必要がありもうひとつは右揃えにする必要があるとしたら、合成を用いてどのようにその問題を解決できるでしょうか?

配置ユーティリティ

この問題を解決するためには、望ましい効果を与える再利用可能な新しいクラスをコンポーネントに追加できるようにする必要があります。

すでにモディファイアを.actions-list--left.actions-list--rightと呼ぶ予定でしたので、これらの新しいクラスを.align-left.align-rightのように呼ばない理由はありません:

.align-left {
  text-align: left;
}
.align-right {
  text-align: right;
}

これで、スタックされたフォームボタンを合成を使って左揃えにできるようになりました:

<form class="stacked-form" action="#">
  <!-- ... -->
  <div class="stacked-form__section">
    <div class="actions-list align-left">
      <button class="actions-list__item btn btn--secondary">Cancel</button>
      <button class="actions-list__item btn btn--primary">Submit</button>
    </div>
  </div>
</form>

そして、ヘッダーボタンを右揃えにしました:

<header class="header-bar">
  <h2 class="header-bar__title">New Product</h2>
  <div class="actions-list align-right">
    <button class="actions-list__item btn btn--secondary">Cancel</button>
    <button class="actions-list__item btn btn--primary">Save</button>
  </div>
</header>

心配しないでください

もしHTMLの中に「left」と「right」という言葉があるのを見て気持ち悪く感じたとすれば、この時点でずっと前からUIの視覚的なパターンにちなんだ名前のコンポーネントを使っていることを思い出してください。

.stacked-form.align-rightよりも「セマンティック」であるというフリをすることはありません。どちらもマークアップのプレゼンテーションにどのように影響するかにちなんで名付けられたもので、特定のプレゼンテーション的な結果を得るためにマークアップの中でそれらのクラスを使っています。

CSSに依存したHTMLを書いています。フォームを.stacked-formから.horizontal-formに変更したい場合は、CSSでなくマークアップを変更します。

無駄な抽象化を削除する

この解決策において興味深いのは、.actions-listが実は役に立たなくなったことです。

削除しましょう:

- .actions-list {
-   text-align: right;
- }
  .actions-list__item {
    margin-right: 1rem;
    &:last-child {
      margin-right: 0;
    }
  }

しかし今では.actions-listなしに.actions-list__itemを所有しているのが少し変な感じです。.actions-list__itemコンポーネントを作成せずにもとの問題を解決する方法はあるでしょうか?

思い返せばこのコンポーネントを作成した理由は、2つのボタンの間に少しのマージンを追加するためでした。.actions-listはボタンリストのメタファーとしてかなりいい線を行っていましたが、「アクション」ではない項目間の間隔が必要な場合もあるでしょう。

もっと再利用性の高い名前は.spaced-horizontal-listのようなものでしょうか? 本当にスタイリングが必要なのは子要素だけなので、実際の.actions-listコンポーネントはすでに削除しています。

余白ユーティリティ

子要素だけスタイリングするのであれば、複雑な擬似セレクタを使ってグループとしてスタイリングするのではなく、子要素だけを独立してスタイリングした方が簡単なのではないでしょうか?

要素の隣に余白を追加するもっとも再利用性の高いやり方は、「この要素の隣には余白があるはずだ」と表すクラスです。

すでに.align-left.align-rightのようなユーティリティを追加していますが、右のマージンを追加するだけのユーティリティを新たに作るのはどうでしょうか?

.mar-r-smのような、要素の右に少しのマージンを追加するためのユーティリティクラスを新たに作成してみましょう:

- .actions-list__item {
-   margin-right: 1rem;
-   &:last-child {
-     margin-right: 0;
-   }
- }
+ .mar-r-sm {
+   margin-right: 1rem;
+ }

今ではフォームとヘッダーは次のようになっています:

<!-- Stacked form -->
<form class="stacked-form" action="#">
  <!-- ... -->
  <div class="stacked-form__section align-left">
    <button class="btn btn--secondary mar-r-sm">Cancel</button>
    <button class="btn btn--primary">Submit</button>
  </div>
</form>

<!-- Header bar -->
<header class="header-bar">
  <h2 class="header-bar__title">New Product</h2>
  <div class="align-right">
    <button class="btn btn--secondary mar-r-sm">Cancel</button>
    <button class="btn btn--primary">Save</button>
  </div>
</header>

.actions-listという概念全体がどこにもなく、CSSは小さくなり、クラスの再利用性は高まりました。

段階5:ユーティリティファーストCSS

これに私がピンとくると、ビジュアルを微調整するための一般的なユーティリティクラスのスイート全体を構築するまでに時間はかかりませんでした。

  • テキストサイズ、色、ウェイト
  • ボーダーカラー、幅、ポジション
  • 背景色
  • フレックスボックスユーティリティ
  • パディングとマージンのヘルパー

これの素晴らしいところは、新しいCSSを書かなくてもまったく新しいUIコンポーネントを構築できることです。

私のプロジェクトからこのような「商品カード」のコンポーネントを見てみましょう:

マークアップは次のようになります:

<div class="card rounded shadow">
    <a href="..." class="block">
        <img class="block fit" src="...">
    </a>
    <div class="py-3 px-4 border-b border-dark-soft flex-spaced flex-y-center">
        <div class="text-ellipsis mr-4">
            <a href="..." class="text-lg text-medium">
                Test-Driven Laravel
            </a>
        </div>
        <a href="..." class="link-softer">
            @icon('link')
        </a>
    </div>
    <div class="flex text-lg text-dark">
        <div class="py-2 px-4 border-r border-dark-soft">
            @icon('currency-dollar', 'icon-sm text-dark-softest mr-4')
            <span>$3,475</span>
        </div>
        <div class="py-2 px-4">
            @icon('user', 'icon-sm text-dark-softest mr-4')
            <span>25</span>
        </div>
    </div>
</div>

使われているクラスの数を見ると最初はためらうかもしれませんが、これをユーティリティから合成するのではなく本当のCSSコンポーネントとして作りたいと思ったとしましょう。それをなんと呼べばいいのでしょう?

コンポーネントがひとつのコンテキストでしか使用できなくなるので、コンテンツ固有の名前は使いたくありません。

こんな感じでしょうか?

.image-card-with-a-full-width-section-and-a-split-section { ... }

もちろんそんなはずはありません。馬鹿げています。その代わりに、前に話したような小さなコンポーネントから合成したいと思うでしょう。

それらのコンポーネントはなんなのでしょうか?

カードの中に入っているのかもしれませんね。すべてのカードに影があるわけではないので、.card--shadowedモディファイアをつけたり、.shadowユーティリティを作成して、任意の要素に適用可能なようにもできます。その方が再利用できそうですからそうしましょう。

このサイトのカードの中には角が丸くなっていないものもありますが、これは角が丸くなっています。.card--roundedにすることもできますが、サイト上には他の要素も同じように丸められていることがあり、それらはカードではありません。roundedユーティリティの方が再利用しやすいでしょう。

1番上にある画像はどうでしょうか?もしかして、.img--fittedのようなものでカードいっぱいになってしまうのではないでしょうか?サイト上には親要素の幅に合わせてなにかを配置しなければならない場所がいくつかありますが、それは必ずしも画像ではありません。.fitヘルパーだけの方がいいかもしれません。

これで、私がなにをしようとしているか分かるでしょう。

再利用性に焦点を当ててその道筋をたどれば自然と、再利用可能なユーティリティからこのコンポーネントを構築することが終着点となります。

強制的な一貫性

小さくて合成可能なユーティリティを使う最大の利点のひとつは、チームの開発者全員がつねに固定されたオプションの中から値を選択するようになることです。

HTMLのスタイルを整える必要があり、「このテキストはもう少し暗くする必要がある」と考えたとき、ベースとなる$text-colorを微調整するためにdarken()関数に手を伸ばしたことは何度あったでしょうか?

それとも「このフォントはもう少し小さい方がいい」と、作業中のコンポーネントfont-size: .85emを追加したのでしょうか?

任意の値ではなく、相対的な色や相対的なフォントサイズを使っているので「正しい」ことをしているように感じます。

しかし自分のコンポーネントのテキストは10%暗くすることにして、ほかの誰かは自分のコンポーネントのテキストを12%暗くするとしたらどうでしょうか? いつの間にか402個のユニークなテキスト色がスタイルシートの中に入っていることになります。

なにかをスタイリングする方法が新しいCSSを書くことになっているすべてのコードベースでこれは起こります。

  • GitLab:402のテキスト色、239の背景色、59のフォントサイズ
  • Buffer:124のテキスト色、86の背景色、54のフォントサイズ
  • HelpScout:198のテキスト色、133の背景色、67のフォントサイズ
  • Gumroad:91のテキスト色、28の背景色、48のフォントサイズ
  • Stripe:189のテキスト色、90の背景色、35のフォントサイズ
  • GitHub:163のテキスト色、147の背景色、56のフォントサイズ
  • ConvertKit:128のテキスト色、124の背景色、70のフォントサイズ

これは、新しいCSSのかたまりはすべて空白のキャンバスに書かれるのであり、好きな値の使用を妨げるものはなにもないからです。

変数やmixinを使って一貫性を強制することもできますが、新しいCSSの1行1行が新たな複雑さを生み出す機会になっています。より多くのCSSを追加してもあなたのCSSがよりシンプルになることはありません。

代わりに既存のクラスを適用すれば、突然File size キャンバスが真っ白になってしまう問題は解決します。

暗い文字を少し弱めたい場合はどうでしょう? .text-dark-softクラスを追加してください。

フォントサイズを少し小さくする必要があれば? .text-smクラスを使ってください。

プロジェクトの全員が限られたオプションの中からスタイルを選択すると、プロジェクトのサイズに合わせてCSSが直線的に増大してしまうことを防ぎ、タダで一貫性が得られます。

それでもコンポーネントは作成すべき

一部の機能本位のCSS擁護派と私とで少し意見が異なる点のひとつは、ユーティリティだけでなにかを構築すべきではないと思うということです。

Tachyons(これは素晴らしいプロジェクトです)のような人気のあるユーティリティベースのフレームワークを見てみると、純粋なユーティリティからボタンスタイルを作成していることがわかります:

<button class="f6 br3 ph3 pv2 white bg-purple hover-bg-light-purple">
  Button Text
</button>

おっとっと。これを分解してみましょう:

  • f6:フォントサイズのスケールで6番目のフォントサイズを使用します(Tachyonsでは.875rem)
  • br3:半径のスケールで3番目のボーダーの半径を使用します(.5rem)
  • ph3:横方向のパディングにはパディングのスケールで3番目のサイズを使用します(1rem)
  • pv2:縦方向のパディングにはパディングのスケールで2番目のサイズを使用します(.5rem)
  • white:白いテキスト色を使用します
  • bg-purple:紫の背景を使用します
  • hover-bg-light-purple:ホバー時に明るい紫の背景を使用します

同じクラスの組み合わせで複数のボタンが必要な場合は、CSSではなくテンプレートで抽象化するのがTachyonsで推奨されている方法です。

たとえばVue.jsを使っていたとすれば、次のようなコンポーネントを作るとします。

<ui-button color="purple">Save</ui-button>

そして、このように定義されています:

<template>
  <button class="f6 br3 ph3 pv2" :class="colorClasses">
    <slot></slot>
  </button>
</template>

<script>
export default {
  props: ['color'],
  computed: {
    colorClasses() {
      return {
        purple: 'white bg-purple hover-bg-light-purple',
        lightGray: 'mid-gray bg-light-gray hover-bg-light-silver',
        // ...
      }[this.color]
    }
  }
}
</script>

これは多くのプロジェクトにとって素晴らしいアプローチですが、テンプレートベースのコンポーネントを作成するよりも、CSSコンポーネントを作成した方が実用的なユースケースが多いと私は今でも思っています。

私が取り組んでいるようなプロジェクトでは、サイト上のすべての小さなウィジェットをテンプレート化するよりも、7つのユーティリティを束ねた.btn-purpleクラスを新たに作成した方が普通はシンプルです。

ですが、最初はユーティリティを使って構築します。

私がCSSユーティリティファーストと呼んでいるのは、できる限りはすべてユーティリティから構築し、繰り返しのパターンのみを抽出するようにしているからです。

プリプロセッサとしてLessを使用している場合は、既存のクラスをmixinとして使用することができます。つまりこの.btn-purpleコンポーネントを作成するためには、エディタのマルチカーソルウィザードを少し使うだけです:

残念ながらSassやStylusでは、ユーティリティクラスごとに別のmixinを作成しないとこのようなことができないので少し手間がかかります。

もちろんコンポーネント内のすべての宣言がユーティリティから来るとは限りません。親要素の上にカーソルを置いたときに子要素のプロパティを変更するなど、要素間の複雑なインタラクションはユーティリティクラスのみでは難しいので、最善の判断をしてよりシンプルに感じる方法を取ってください。

早すぎる抽象化は不要

CSSコンポーネントファーストのアプローチを取ることで、たとえ再利用されることがないとしても、そのためのコンポーネントを作成することになります。この早すぎる抽象化がスタイルシートの肥大化と複雑化の原因となっています。

ナビバーを例に挙げてみましょう。あなたのアプリではメインナビのマークアップを何度書き換えていますか?

私のプロジェクトでは通常、メインのレイアウトファイルの中で一度だけ行います。

最初にユーティリティを使って構築し、気になる重複があったときだけコンポーネントに抽出するようにすれば、ナビバーコンポーネントを抽出する必要性は生まれないでしょう。

代わりに、あなたのナビバーは次のようになります:

<nav class="bg-brand py-4 flex-spaced">
  <div><!-- Logo goes here --></div>
  <div>
    <!-- Menu items go here -->
  </div>
</nav>

抽出する価値のあるものはなにもありません。

これは単なるインラインスタイルなのではないでしょうか?

このアプローチを見て、HTML要素にスタイルタグ(訳注:タグではなく属性が正しい)を投げて必要なプロパティを追加するのと変わらないようにも思えますが、私の経験ではまったく違います。

インラインスタイルでは選択する値に制約がありません。

ひとつのタグはfont-size: 14px、別のタグはfont-size: 13px、別のタグはfont-size: .9em、別のタグはfont-size: .85remとすることができます。

新しいコンポーネントのための新しいCSSを書くときに直面するのと同じ空白のキャンバスの問題です。

ユーティリティは選択を強制します:

これはtext-smtext-xsか?

使うべきはpy-3py-4か?

使いたいのはtext-dark-softtext-dark-faintか?

好きな値を選ぶことはできず、キュレーションされたリストから選択しなければなりません。

文字色は380色ではなく、結局10色か12色になります。

私の経験では、コンポーネントファーストで作業するよりもユーティリティファーストで構築することで、より一貫した外観のデザインが得られると考えています。

(訳注:クラス化によってメディアクエリを利用できるという利点もあり、実際にTailwind CSSではメディアクエリごとのクラスが提供されています

どこから始めるか

このアプローチが興味深く思えるなら、注目すべき価値のあるフレームワークをいくつか紹介します:

最近では私がTailwind CSSというフリーのオープンソースPostCSSフレームワークもリリースしていますが、これはユーティリティファーストで作業し、繰り返されるパターンからコンポーネントを抽出するという考え方にもとづいて設計されています:

興味のある方はTailwind CSSのウェブサイトにアクセスして試してみてください。

実践的レイアウトプリミティブ

CSSにおける汎用化の先送り、ユーティリティファーストCSS、レイアウトプリミティブ」の続き。



同じようなレイアウトを実現するためのCSSを僕は実のところ何度も繰り返し書いていた。そのたびに新しいコンポーネントを作り、意図を表明するための名前を捻り出し、やってることはたいして変わらないのに別々になった実装を増やしていた。その総量に埋もれて全体が見えなくなっていった。

個別のコンポーネントを汎用的なように変換するのは難しい。本当にまったく同じレイアウトならそれほど難しくないが、多くの場合には微妙な差分がある。余白の大きさが違う、グリッドのカラム数が違う、コンテナの幅が違う。いかにしてそれらに規則性を見い出してうまくいく設計ができるかは、腕の見せどころとも言える一方で再現性がなく見通しのつかない仕事だと思っていた。

Every Layoutのレイアウトプリミティブはそのようなレイアウトの構成要素が最小単位まで分解され、パターン集として文書化されたもの。これら最小単位のパターンはパターンと対応するコンポーネントとして切り出せる粒度になっていて、また複数のパターン同士を組み合わせることによって最終的なレイアウトを実現する前提で設計されている。有名なところではBootstrapのグリッドシステムの設計が近い:

<div class="container">
  <div class="row">
    <div class="col-sm">
      One of three columns
    </div>
    <div class="col-sm">
      One of three columns
    </div>
    <div class="col-sm">
      One of three columns
    </div>
  </div>
</div>

出典:Grid system · Bootstrap v4.5

.rowは子要素をカラムとして扱うためのコンテナであり、.col-smはカラムである子要素につねに対応するので、カラムのパターンとしては.row.col-smはセットになるが、.containerはコンテナ幅を制御するためだけのクラスであるためカラムの実現には必要ではない。.container.rowは互いに依存関係を持たない独立した存在であり、それぞれは自らの責務のみを意識している。そしてテンプレート側でこれら別々のパターンを組み合わせることで、最終的な結果として、制御されたコンテナ幅の中でカラム分割されたレイアウトが実現される。

このように独立したパターンを組み合わせてレイアウトする利点は、あるパターンの利用が別のパターンに制約されなくなることにより組み合わせ可能なバリエーションが大幅に増え、より少ないCSSでより多くのレイアウトを実現できるようになる冗長性にある。あるパターンに3のバリエーションがあり、別のパターンには5のバリエーションがあるとき、テンプレート側でそれらをかけ合わせると15のバリエーションを表現できるようになる。CSSは8のままで。

レイアウトプリミティブを利用するとそれだけで相当数のレイアウトが表現できる。すべてのパターンがプリミティブであり、かつそれぞれが組み合わせ可能なことを前提としているからだ。ひとつひとつのパターンとしても利用頻度が高いものが多く、自分が携わるほとんどのサイトの構築において汎用的なコンポーネント設計のパターンとして効果を上げた実績もある。

しかしEvery Layoutでの解説にもとづくとそのままでは現実のプロジェクトに適用しづらい部分がある。そのひとつはパターンのバリエーションをカスタムプロパティを用いて表現していること。多くのプロジェクトではIE11でもほとんど同じように表示できることを求められるのでこれは採用できない。ポリフィルもあまり信用できないし。もうひとつはメディアクエリによる上書きを意図的に想定していないこと。Every Layoutはレイアウトの制御をブラウザや公理に委ねることを強く主張しており、ビューポート幅にもとづくスタイル宣言の変更というある種恣意的なレイアウトの操作に依存しないようにレイアウトプリミティブも設計されている。しかしそれでは少なくとも業務での実践は難しいだろう。

この記事ではレイアウトプリミティブを現実のプロジェクトに取り入れるために行ったいくつかの対処方法を紹介する。レイアウトプリミティブをコンポーネントとして実装していく方向で進めるが、後述する理由によりすべてをコンポーネントにはしない(あるいはできない)。なおこの記事ではそれぞれのレイアウトプリミティブについては詳しく言及せず、あらかじめ理解されている前提で述べる。必要に応じてEvery Layoutをご参照いただきたい。また記事中では説明の都合上、ソースコードの一部のみを抜粋して掲載している。完全な状態は記事の末尾でまとめて確認できる。

バリエーション

Stackは縦方向に配置された要素間に均一の余白を設定するパターン。この場合の余白の大きさの指定方法として次のような実装が紹介されている。

<div class="stack">
  <p>Lorem ipsum dolor sit amet.</p>
  <p>Lorem ipsum dolor sit amet.</p>
  <h2>title</h2>
  <p>Lorem ipsum dolor sit amet.</p>
  <p>Lorem ipsum dolor sit amet.</p>
</div>
.stack {
  --space: 1.5rem;
}

.stack > * + * {
  margin-top: var(--space);
}

h2,
h2 + * {
  --space: 3rem;
}

.stack--spaceプロパティの上書きによってデフォルトの余白が変更可能になっており、個別の要素の前後の余白は--spaceプロパティの宣言によって、詳細度の高い.stack > * + *margin-topを上書きせずに変更できる。

--spaceプロパティのおかげで.stackは自らが表現する余白のバリエーションを知っておかなくてもよくなる。場面に応じて利用する側から指定されればそれに対応できる。これがカスタムプロパティを使えないとすれば、BEMのモディファイアのようなやり方で余白の大きさを指定することになる。モディファイアでそのままサイズを指定することはできないので、キーであるモディファイアと値との対応を考えなければならない。つまりあらゆる余白のバリエーションを把握する必要がある。

デザインガイドラインとして余白のバリエーションが文書化されていればそれを利用できるが、多くの場合では各ページのデザインファイルから地道に拾い上げていくしかない。しかしそうするとバリエーションが膨大になってしまったり、デザイン変更のたびにバリエーションが影響を受けて再考の作業が生まれてしまう。多くの箇所で再利用する前提のコンポーネントに参照されている以上、余白のバリエーションは可能な限り早い段階でフリーズさせたい。最初からあらかじめわかっている状態にできればベストだ。そのため特定のページだけに依存せずあらゆるプロジェクトに適用できるパターンを模索してきたが、現在としては「音楽、数学、タイポグラフィ」で紹介された8px(0.5rem)を基数としてフィボナッチ数列をかけ合わせて生成されたバリエーションをベースに少し変形させたものを足がかり的に利用している。その状態から最後まで変更なしのままプロジェクトを見届けることもあれば、細かい調整が必要になることもあるが、まったく見当違いの設定になっていることはなかった。

こうして計画した余白のバリエーションから、Sassを利用してモディファイアを次のように実装している。

_core.scss:

// Spacing

$-spacing-unit: 0.5rem;

$spacing-1: $-spacing-unit / 2;
$spacing-2: $-spacing-unit * 1;
$spacing-3: $-spacing-unit * 1.5;
$spacing-4: $-spacing-unit * 2;
$spacing-5: $-spacing-unit * 3;
$spacing-6: $-spacing-unit * 5;
$spacing-7: $-spacing-unit * 8;
$spacing-8: $-spacing-unit * 13;
$spacing-9: $-spacing-unit * 21;

$spacings: (
  0: 0,
  1: $spacing-1, // 0.25rem =   4px
  2: $spacing-2, //  0.5rem =   8px
  3: $spacing-3, // 0.75rem =  12px
  4: $spacing-4, //    1rem =  16px
  5: $spacing-5, //  1.5rem =  24px
  6: $spacing-6, //  2.5rem =  40px
  7: $spacing-7, //    4rem =  64px
  8: $spacing-8, //  6.5rem = 104px
  9: $spacing-9, // 10.5rem = 168px
);

(余白のバリエーションを個別の変数とマップで宣言しているのはエディタの補完とループのためという事情。ループを用いない場面では個々の変数を参照する。)

_Stack.scss:

/**
 * Spacing variant:
 *
 * <div class="Stack -s{spacing}"></div>
 */

@each $spacing-key, $spacing in $spacings {
  $name: s#{$spacing-key};

  .Stack.-#{$name} > * + * {
    margin-top: $spacing;
  }
}
<div class="Stack -s4">
  <p>foo</p>
  <p>bar</p>
  <p>baz</p>
</div>

こうしてあらゆる縦の余白のバリエーションがひとつのコンポーネントだけで実現できるようになった。しかしこれだけでは特定の箇所に異なる余白がある場合に対応できない。それについては2つのやり方を場面によって使い分けている。ひとつはユーティリティクラスの導入だ。なんとなく導入されたユーティリティクラスは悪い設計を招いてしまうが、利用の目的がはっきりとしていれば問題はない。この場合ではStackのコンテキスト内で個別のmargin-topを設定すること。またそのユーティリティクラスを使うことで新たなセレクタを増やさず済ませられる場合にも利用できる。

_utilities.scss:

// margin-top property

/**
 * Usage:
 *
 * <div class="Stack -s2">
 *   <div>foo</div>
 *   <div class="mt-4">bar</div>
 *   <div>baz</div>
 * </div>
 *
 * <div class="mt-3"></div>
 *
 * Spacing variant:
 *
 * <div class="mt-{spacing}"></div>
 */

@each $spacing-key, $spacing in $spacings {
  $name: mt-#{$spacing-key};

  .#{$name} {
    margin-top: $spacing !important;
  }
}

margin-bottompaddingなどのユーティリティクラスは本当に必要になるタイミングまで作らない。

もうひとつのやり方は、Stackとそもそも別のコンポーネントを作ってしまうこと。新しいコンポーネントは増えてしまうが、特殊な対応は個別のコンポーネントに切り出して閉じ込めておいた方がいい場合もある。あるいはすでに存在するコンポーネントにBEMでいうエレメントとして追加するのであれば比較的気兼ねなく行えるだろう。

<div class="ArticleBody">
  <p>レイアウトプリミティブを現実のプロジェクトに取り入れるために…</p>
  <h2>バリエーション</h2>
  <p>Stackは縦方向に配置された要素間に均一の余白を…</p>
</div>

_ArticleBody.scss

.ArticleBody > * + * {
  margin-top: 1.5rem;
}

.ArticleBody > h2,
.ArticleBody > h2 + * {
  margin-top: 3rem;
}

ほとんどのレイアウトプリミティブは余白を表現する責務を持つので、余白のモディファイアについては先ほどのバリエーションを参照することで同様の解法がとれる。しかしStackの余白は縦方向のみなのに対して、たとえばClusterには縦横両方向の余白がある。Every Layoutでは縦横で同じ余白が挿入される実装になっているが、汎用性としては別々の値を指定できた方が良い。そのために全方向の余白・X軸の余白・Y軸の余白を個別に指定できる別々のモディファイアを設定するようにした:

_Cluster.scss:

.Cluster {
  display: block;
  overflow: hidden;
}

.Cluster > * {
  display: flex;
  flex-wrap: wrap;
}

/**
 * Spacing variant:
 *
 * <div class="Cluster -s{spacing}"></div>
 * <div class="Cluster -sx{spacing}"></div>
 * <div class="Cluster -sy{spacing}"></div>
 */

@each $spacing-key, $spacing in $spacings {
  $name: s#{$spacing-key};

  .Cluster.-#{$name} > * {
    margin: ($spacing / 2 * -1);
  }

  .Cluster.-#{$name} > * > * {
    margin: ($spacing / 2);
  }
}

@each $spacing-key, $spacing in $spacings {
  $name-x: sx#{$spacing-key};

  .Cluster.-#{$name-x} > * {
    margin-right: ($spacing / 2 * -1);
    margin-left: ($spacing / 2 * -1);
  }

  .Cluster.-#{$name-x} > * > * {
    margin-right: ($spacing / 2);
    margin-left: ($spacing / 2);
  }

  $name-y: sy#{$spacing-key};

  .Cluster.-#{$name-y} > * {
    margin-top: ($spacing / 2 * -1);
    margin-bottom: ($spacing / 2 * -1);
  }

  .Cluster.-#{$name-y} > * > * {
    margin-top: ($spacing / 2);
    margin-bottom: ($spacing / 2);
  }
}

またEvery Layoutではあまり言及されていない点として、ネガティブマージンによってはみ出る領域をoverflow: hiddenで非表示にしてしまうハックの扱いにくさがある。この手を使ってしまうと、ネガティブマージン以外にもはみ出るoutlineプロパティやbox-shadowプロパティ、あるいは外方向に動くアニメーションが見切れてしまう。これについては、デフォルトとしてはoverflow: hiddenを設定しておいて、問題になる箇所だけを個別にオプトアウトするようにした:

_Cluster.scss:

.Cluster {
  display: block;
}

/**
 * Overflow variant:
 *
 * <div class="Cluster -overflow"></div>
 */

.Cluster:not(.-overflow) {
  overflow: hidden;
}

.Cluster > * {
  display: flex;
  flex-wrap: wrap;
}

デフォルトをhiddenにしているのは、ネガティブマージンによるはみ出しの方がより無意識的なバグを生んでしまいそうに思えるから。

そしてClusterには余白だけでなくjustify-contentalign-itemsも個別に設定できるようになっている。オリジナルではカスタムプロパティで直接値を渡せるが、モディファイアとして表現するには余白と同様にあらかじめバリエーションを列挙する必要がある。これらのプロパティでよく使う値はだいたいわかっているのでほぼ決め打ちにできる。

_Cluster.scss

/**
 * Justify variant:
 *
 * <div class="Cluster -justify-{justify}"></div>
 */

$Cluster-justifiers: (
  start: flex-start,
  end: flex-end,
  center: center,
  between: space-between,
);

@each $justifier-key, $justifier in $Cluster-justifiers {
  $name: justify-#{$justifier-key};

  .Cluster.-#{$name} > * {
    justify-content: $justifier;
  }
}

/**
 * Align variant:
 *
 * <div class="Cluster -align-{align}"></div>
 */

$Cluster-aligners: (
  start: flex-start,
  end: flex-end,
  center: center,
  stretch: stretch,
);

@each $aligner-key, $aligner in $Cluster-aligners {
  $name: align-#{$aligner-key};

  .Cluster.-#{$name} > * {
    align-items: $aligner;
  }
}

次に、CenterはBootstrapの.containerに似たパターンで、max-widthの値はカスタムプロパティから渡される想定になっている。具体的な幅はサイトによってまちまちなので、この設定は実際のものを作り始めてみるまでどうにもならない。多くのサイトは複数のコンテンツ幅を組み合わせて構成されているので、作りながらバリエーションを探って、やはりこれもモディファイアで表現していく:

_Center.scss:

.Center {
  box-sizing: content-box;
  display: block;
  max-width: 60rem;
  margin-right: auto;
  margin-left: auto;
}

.Center.-wide {
  max-width: 75rem;
}

.Center.-narrow {
  max-width: 45rem;
}

/**
 * Gutters variant:
 *
 * <div class="Grid -noGutters"></div>
 */

.Center:not(.-noGutters) {
  padding-right: $spacing-5;
  padding-left: $spacing-5;
}

これは単純化した例で、実際にはブレイクポイントごとに個別の幅を設定することが多い。しかしブレイクポイントをまたいだときの変化が均一化されていない場合もあり、モディファイアの設計が難しいパターンかもしれない。そういった場合はStackについて述べたように別コンポーネント化してしまうか、あるいはメディアクエリ付きモディファイアの導入を検討する。

メディアクエリ

ビューポート幅が変化しても同じパターンのレイアウトのままで成立させられる場面は多いが、余白などのキーだけは個別に変更が必要になることがほとんどだ。たとえば狭いビューポート幅では余白も狭く、広いビューポート幅では余白も広くというのが典型的。結局これに対応できないと再利用性のない個別のコンポーネントを作り込んでいくしかなくなってしまう。そのためメディアクエリごとに機能するモディファイアを用意して利用側で個別に指定する形で対処した:

_core.scss:

$mq-breakpoints: (
  xs: 0,
  sm: 36em, //  576px
  md: 48em, //  768px
  lg: 64em, // 1024px
  xl: 80em, // 1280px
);

@mixin breakpoint($key, $until: false) {
  @if map.has-key($mq-breakpoints, $key) == false {
    @error "`#{$key}` not found in $mq-breakpoints";
  }
  $breakpoint: map.get($mq-breakpoints, $key);
  $is-zero: $breakpoint == 0;

  @if $is-zero and $until {
    @error "Breakpoints are not available for screens smaller than 0px";
  }

  @if $is-zero {
    @content;
  } @else if $until {
    @media not all and (min-width: #{$breakpoint}) {
      @content;
    }
  } @else {
    @media (min-width: #{$breakpoint}) {
      @content;
    }
  }
}

_Stack.scss:

/**
 * Spacing variant:
 *
 * <div class="Stack -s{spacing}"></div>
 * <div class="Stack -{breakpoint}:s{spacing}"></div>
 */

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $spacing-key, $spacing in $spacings {
      $name: s#{$spacing-key};

      .Stack.-#{$breakpoint-prefix}#{$name} > * + * {
        margin-top: $spacing;
      }
    }
  }
}
<div class="Stack -s3 -md:s5 -lg:s6">
  <p>foo</p>
  <p>bar</p>
  <p>baz</p>
</div>

ほかのパターンの余白やjustify-content、ユーティリティクラスなどについても同様に実装する。

メディアクエリごとの宣言を追加することでCSSの出力サイズは増えてしまうが、レイアウトプリミティブが全体の個別性を吸収することで結果的にはむしろサイズを抑えられる場合もある。あるいはレイアウトプリミティブによってCSSの実装時間を節約することで、より費用対効果の高いパフォーマンス改善に臨めるとも考えられる。

メディアクエリごとのモディファイアを記述する煩雑さについては、テンプレートエンジンの機能によってある程度軽減できる。たとえばReactであれば、次のような宣言によって上記と同様のクラス属性値が出力されるようにすると良い:

<Stack s={[3, null, 5, 6]}>
  <p>foo</p>
  <p>bar</p>
  <p>baz</p>
</Stack>

こうすれば必要に応じて型チェックも挿入できる。配列としてキーを渡すアイデアStyled SystemのArray Propsから拝借した。同様のインターフェースはPugのmixin機能などでも実現できる。

またオリジナルのレイアウトプリミティブの中には、メディアクエリには依存せずに要素自身の幅の変化によって子要素の並びが変わるSidebarやSwitchがある。これらには並びを変化させるブレイクポイントとしてそのタイミングの要素の幅をカスタムプロパティで指定するが、メディアクエリを前提とした設計であれば普通にメディアクエリで上書きした方が素直だろう。ブレイクポイントの指定は先述と同様にモディファイアで行う:

_Switcher.scss:

.Switcher > * {
  display: flex;
  flex-direction: column;
}

.Switcher > * > * {
  flex-shrink: 0;
  width: 100%;
}

/**
 * Row variant:
 *
 * <div class="Switcher -row"></div>
 * <div class="Switcher -{breakpoint}:row"></div>
 */

$Switcher-row-name: row;

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    .Switcher.-#{$breakpoint-prefix}#{$Switcher-row-name} > * {
      flex-direction: row;
    }

    .Switcher.-#{$breakpoint-prefix}#{$Switcher-row-name} > * > * {
      flex-shrink: 1;
    }
  }
}
<div class="Switcher -md:row">
  <div>
    <p>foo</p>
    <p>bar</p>
    <p>baz</p>
  </div>
</div>

ビューポートの幅によってレイアウトプリミティブのパターン自体を切り替えたい場面もある。狭い幅ではカードをReel(横スクロール)で並べ、広い幅ではGridで並べるというような。個別のコンポーネントにすれば共通のマークアップで実現できなくもないが、ここは汎用性のためブレイクポイントごとに別々のマークアップを使う。JavaScriptで出し分けてもいいが、両方のパターンを含んだ静的テンプレートを記述した上で、メディアクエリごとにdisplay: noneを制御するユーティリティクラスを付与する方が簡単になる:

_utilities.scss:

// display property

/**
 * Usage:
 *
 * <div class="hidden md:block">hello</div>
 *
 * Display variant:
 *
 * <div class="{display}"></div>
 * <div class="{breakpoint}:{display}"></div>
 */

$-displayers: (
  block: block,
  inline: inline,
  hidden: none,
  inlineBlock: inline-block,
);

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $name, $displayer in $-displayers {
      .#{$breakpoint-prefix}#{$name} {
        display: $displayer !important;
      }
    }
  }
}
<div class="md:hidden">
  <div class="CardReel">
    <div class="Card">...</div>
    <div class="Card">...</div>
    <div class="Card">...</div>
  </div>
</div>

<div class="hidden md:block">
  <div class="Grid -sm:col-2 -lg:col-3 -s3">
    <div>
      <div>
        <div class="Card">...</div>
      </div>
      <div>
        <div class="Card">...</div>
      </div>
      <div>
        <div class="Card">...</div>
      </div>
    </div>
  </div>
</div>

この場合カードの内容をテンプレートの2箇所に記述しなければならないが、テンプレートエンジンを使っていれば問題にならないだろう。

ちなみにReelはカスタムプロパティの表現を代替するのが難しく、また利用頻度も少ないため個別のコンポーネントにすることが多い。

そのほかのIEへの対処

Gridについてはdisplay: gridによる実装ではグリッドアイテムの折り返しをIEで実現できないので、フレックスボックスを用いた独自の実装にしている:

.Grid > * {
  display: flex;
  flex-wrap: wrap;
}

.Grid > * > * {
  width: 100%;
}

/**
 * Columuns variant:
 *
 * <div class="Grid -col-{columns}"></div>
 * <div class="Grid -{breakpoint}:col-{columns}"></div>
 */

$Grid-columns-list: (2, 3, 4);

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $columns in $Grid-columns-list {
      $name: col-#{$columns};

      .Grid.-#{$breakpoint-prefix}#{$name} > * > * {
        width: percentage(1 / $columns);
      }
    }
  }
}

/**
 * Spacing variant:
 *
 * <div class="Grid -s{spacing}"></div>
 * <div class="Grid -{breakpoint}:s{spacing}"></div>
 */

@each $breakpoint-key, $breakpoint in $mq-breakpoints {
  $uses-media-query: $breakpoint != 0;
  $breakpoint-prefix: if($uses-media-query, "#{$breakpoint-key}\\:", null);

  @include breakpoint($breakpoint-key) {
    @each $spacing-key, $spacing in $spacings {
      $name: s#{$spacing-key};

      .Grid.-#{$breakpoint-prefix}#{$name} > * {
        margin: ($spacing / 2 * -1);
      }

      .Grid.-#{$breakpoint-prefix}#{$name} > * > * {
        padding: ($spacing / 2);
      }
    }
  }
}

またIEにはflex-direction: columnを利用するとそのフレックスアイテムや子孫の固有のアスペクト比(intrinsic aspect ratios)が維持されないバグがある。StackとSwitcherではflex-direction: columnを利用しているので、フレックスアイテムに対してflex-shrink: 0を明示的に指定することでバグを回避している:

_Stack.scss:

.Stack {
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
}

.Stack > * {
  flex-shrink: 0;
}

_Switcher.scss:

.Switcher > * {
  display: flex;
  flex-direction: column;
}

.Switcher > * > * {
  flex-shrink: 0;
  width: 100%;
}

しかしそれでも予期しないバグはまれに発生するため、そういったときには別コンポーネントを作るなどしてバグを回避するなにかしらの対応をしている。

外部からのスタイル宣言の上書き

実装の汎用化を図ろうといろいろ工夫しても例外的な対応が必要になってしまうのは珍しくない。もっともレイアウトプリミティブによる汎用化は全体の個別性の程度を軽減させるためのメソッドであって、これだけですべてを完全に表現し切るのが目的ではない。Every Layoutはレイアウトプリミティブをプログラミング言語におけるプリミティブなデータ型に例えている。標準的なレイアウトの型を活用することで、すべてのレイアウトは実現できないにしても、無駄なルーティンワークはかなり削減できるだろうという話だ。

モディファイアによってバリエーションを表現するこのアプローチの難点は利用するあらゆる値を中央集権的に管理しなければならない点だ。末端のページでのちょっとしたアドリブのためにコアに手を入れなければならないというような。余白のバリエーションなら比較的規則化しやすいが、Sidebarの幅やSwitchのアイテムの比率などどうしても必要なときになってみるまでわからないものもある。こうした場合には汎用的な解決を考えるのは諦めて、レイアウトプリミティブをラップする個別のコンポーネントなどを作って上書きするようにする:

<div class="MyComponent">
  <div class="Switch">
    <div>
      <div>default</div>
      <div class="MyComponent__featuredItem">featured</div>
      <div>default</div>
    </div>
  </div>
</div>
<div class="MyComponent">
  <div class="Stack">
    <p>Lorem ipsum dolor sit amet.</p>
    <p>Lorem ipsum dolor sit amet.</p>

    <div class="MyComponent__specialItem">
      <div class="Card">...</div>
    </div>

    <p>Lorem ipsum dolor sit amet.</p>
  </div>
</div>

こうすればエッジケースへの対応の影響を局所的にできる。

またレイアウトプリミティブとそれ以外のコンポーネントの境界を明確にするためにレイヤリングを行うこともできる。既存のCSSアーキテクチャでいうとITCSSのObjectsとComponentsのモデルが適しているように思える。ObjectsはOOCSSと同様の装飾がなく汎用的なパターンだ。装飾はComponentsのレイヤーで施され、また汎用性のないスタイルもここに属する。レイアウトプリミティブのみがあらかじめObjectsとして位置していて、プロジェクトの開始後に追加された実装は基本的にはComponentsとして扱うのが良いだろう。

// Center, Cluster, Grid, Stack, Switcher...
@import "objects/*";

// ArticleBody, Card, CardReel, MyComponent...
@import "components/*";

ITCSSの目的はスタイル記述順の制御であり、同じ詳細度の宣言はより後のレイヤーによって上書きされる仕組み。もっともレイアウトプリミティブでは全称セレクタを多用するので、詳細度が高まっていてあまりうまくは機能させられないが……。はっきり区別させる意味では役に立つ。

各レイアウトプリミティブのユースケースの例示

よくある「目に見える」コンポーネントカタログと違って、レイアウトプリミティブのユースケースは最初は少し想像しづらい。その汎用性ゆえに抽象的で、具体例が欲しくなる。その解決のためにパターンごとのユースケースを掲載したスタイルガイドを作成した。

f:id:yuheiy:20200517215124j:plain

_shifted/3-objects.pug at master · yuheiy/_shifted

利用方法はこれらの例から学習できるだろう。

プロジェクトのセットアップ

これまで紹介してきたレイアウトプリミティブの実装はひとつのリポジトリに集約させていて、それらがあらかじめ用意された状態で新しいプロジェクトをはじめられるようにしてある。基本的なパターンを繰り返し実装し直す手間を省いて、個別の問題により集中できるようにする狙いがある。

_shifted/boilerplate-static/src/objects at master · yuheiy/_shifted

カスタムプロパティを用いずに汎用化するのが困難なパターンや、利用頻度が少ないパターンは含んでいない。

ちなみにCSS以外の開発環境構築についても汎用化できないか長らく考えていて、これについてもまたいつか書きたい。

宣伝

この記事では自分なりのEvery Layoutの応用について書いたが、その根底には原著が伝えるもとの意図がなければ成立しない。しかしながらEvery Layoutは英語であり有料コンテンツであるためになかなか紹介しづらく、また読んでもらうハードルも高く、非常にやりきれない思いになっていた。

そうしたところで偶然、編集者の岡本さんにお声がけいただき、Every Layoutを日本語訳して出版する事の運びとなった。友人の横内さんとも一緒に。

原著の内容をなんとかうまく伝えられるよう精一杯やりますので、みなさん何卒よろしくお願いします。

CSSにおける汎用化の先送り、ユーティリティファーストCSS、レイアウトプリミティブ

CSSは普通、セレクタの記述から始まる。目の前にあるHTML片に対してどのようなスタイリングを施すかという前に、いかにしてそのHTML片を選択するかという意識が先に来る。あらかじめ完成したHTML文書へ向けてスタイルを適用していくのであればそれでうまくやれるのかもしれない。だが広く行われているウェブデザインの制作では、まずゴールとして定められた描画結果だけがあり、そこから逆算してHTMLとCSSを書き進めていく。つまり個別の結果だけがある状態で実装に取り掛かることになる。実装のために必要な構造化はたいてい後手に回る。

それでもCSSセレクタから始まることは変わらない。実装を進めるためにはまずセレクタを書かなければならない。セレクタは規則の根幹である。バグを減らし、開発を効率的にするためには、あらゆるスタイリングの意図をセレクタに反映させるのが基本だ。しかし最初から正確にその意図を把握できる機会はまれであり、現実には、無理矢理こじつけた妄想のような規則性を実装してしまう場合も少なくない。それが瞬く間にサイト全体に広まって取り返しがつかなくなることも。

これを回避するためには、コンポーネントなどに局所的な利用を明示するような名前最初はつけておくことだ。再利用性を念頭においたCSS設計では一般に、やや曖昧なコンテンツに依存しない命名が奨められるが、最初からそうするのは時期尚早だと経験上感じられる。まずは再利用性を制限するために、そのコンポーネントが利用される箇所やコンテンツの性質を積極的に反映させる。サイトのホームでニュース記事を表示させているカルーセルならば、「HomeNewsCarousel」のような冗長すぎる名前を選ぶのがむしろ良い。そして同じコンポーネントを別のページやコンテンツについても利用するのであれば、それがはっきりとわかってから、ふたたびその段階で判明しているコンテキストに応じてコンポーネントの名前をつけ直す。たとえばフィーチャーしたい記事をコンテンツ種別ごとの複数のカルーセルの繰り返しによってホームに表示するのであれば「HomeFeaturedCarousel」とか、別ページでもニュース記事を表示させるために使うのであれば「NewsCarousel」とか。

このように利用箇所に応じたコンテキストを明示し、変化があれば見直しの上で追従していく作業を、プロジェクトの生存期間中は半永久的に行う。利用するコードの意図がつねに明快になっている意味では健全だが、難点としてはただ、めんどくさい。特に開発の初期段階ではところ構わず再利用できた方が手数が少なくて楽な場合もある。しかし時に想定よりもはるかに長い期間メンテナンスされ続けるCSSにおいて、いかにすればこのめんどくささを軽減させて継続的な意図の反映を行なっていけるのか。

ユーティリティファーストCSS

ユーティリティーファースト(またはAtomic)CSSと呼ばれるアプローチがある。スタイル宣言と対応する細かなクラスがフレームワークとしてあらかじめひと通り用意されており、ユーザーは基本的には新たにCSSを書かずともHTML上でクラスを組み合わせていくだけでスタイリングが行えるというもの。

もっとも人気の実装であるTailwind CSSでは、たとえばチャットの通知アラートは、ユーザーが新しくCSSを記述しなくても次のHTMLだけで作ることができる。

<div class="max-w-sm mx-auto flex p-6 bg-white rounded-lg shadow-xl">
  <div class="flex-shrink-0">
    <img class="h-12 w-12" src="/img/logo.svg" alt="ChitChat Logo">
  </div>
  <div class="ml-6 pt-1">
    <h4 class="text-xl text-gray-900 leading-tight">ChitChat</h4>
    <p class="text-base text-gray-600 leading-normal">You have a new message!</p>
  </div>
</div>

出典:Utility-First - Tailwind CSS

これを利用すれば初手にまず名前を考えるという作業はスキップできる。一見スタイルの再利用性の問題がありそうに思えるが、昨今のプロジェクトではなにかしらのテンプレートエンジンを採用するはずなので、テンプレート機能を用いてマークアップコンポーネント的に管理すれば解決できる。要点は後から共通化できることである。

インラインstyle属性との違いとしては、まずインラインstyle属性では利用できないメディアクエリや擬似クラスがユーティリティークラスとして用意されている点。そして次に各スタイル宣言の値が特定のバリエーションによって意図的に制約されるという点。単にインラインstyle属性を使うのでは、宣言の値は場当たり次第でユニークになってしまうことがある。余白やフォントサイズ、テキストの色など、これらの判断が宣言ごとにバラバラになっているとシステムとしての一貫性がなくなってしまう。

Tailwind CSSはあらかじめ決められた値のバリエーションと対応するユーティリティクラスだけを提供している。たとえば色についてはデフォルトのカラーパレットが設定されていて、カラーパレットにある値だけが色に関するプロパティと対応するユーティリティクラスになっている。提供されるクラスを使う限りはカラーパレットのルールを外れないというわけだ。

最初からこのフレームワークを利用してページをデザインするなら制約として機能するだろう。しかしそれを意識せずにすでにSketchなどのデザインツールでデザインされたページがあったとすれば、当然フレームワークの設定値(デザインシステムの文脈ではデザイントークンと呼ばれる)は意図に沿わない間違った制約になってしまう。ユーザーが任意の値によってデザイントークンを設定できるようになっていたとしても、やはり汎用化の話と同じく、最初から正しいデザイントークンを見つけ出すこと自体が困難だ。仕組み上、ほとんどのユーティリティクラスはデザイントークンとセットになっていないと存在できないので、ユーティリティファーストのアプローチは結果的に成り立たなくなってしまう。

さらにかなりの数が存在するユーティリティクラスの命名規則を覚える必要もある。長期的に付き合っていくプロジェクトではまだしも、そうでない場合に少し関わる程度のメンバーが毎度これに慣れるというのはそれなりの負担になる。

そしてこれはいわば低レイヤーのフレームワークであり、CSSフレームワークというよりはCSSを組み立てるためのフレームワークと表現した方が近い。既存のコンポーネントのようなものは用意されていないので、最初はすべてのものをユーザーが組み上げなければならない。

ユーティリティファーストCSSについてここまでで浮上した問題をまとめると、正しいデザイントークンの発見を前提にしないとアプローチが成り立たないことと、CSSの記述方法を代替する以上のものではないということだ。ではどうすればいいのか?

レイアウトプリミティブ

Every Layoutが提唱するレイアウトプリミティブは、頻繁に出現するレイアウトの最小要素を、レスポンシブデザインを前提としたCSSにおいても再利用可能にしたパターンのこと。

たとえば「The Stack」は、縦方向に繰り返す要素間に共通の余白を挿入するためのパターン。

<div class="Stack">
  <p>Lorem ipsum dolor sit amet consectetur.</p>
  <p>Lorem ipsum dolor sit amet consectetur.</p>
  <p>Lorem ipsum dolor sit amet consectetur.</p>
</div>
.Stack > * + * {
  margin-top: 1.5rem;
}

「The Center」は、要素の幅を特定のサイズを超えないように制限した上で中央に寄せるパターン。

<div class="Center">
  <p>Lorem ipsum dolor sit amet consectetur.</p>
  <p>Lorem ipsum dolor sit amet consectetur.</p>
  <p>Lorem ipsum dolor sit amet consectetur.</p>
</div>
.Center {
  max-width: 40rem;
  margin-right: auto;
  margin-left: auto;
  padding-right: 1rem;
  padding-left: 1rem;
}

このようなパターンが今のところ合計で12個紹介されている

ページには12のリンクがパターンを簡略化したアイコンとともに掲載されている

レイアウトプリミティブの特徴は、パターンの役割がとにかく純粋であること。責務を混合させずに独立させることによって、かなり広範囲の問題に対してパターンが適用できるようになっている。

それぞれのパターンは相互に組み合わせて利用する前提で設計されている。たとえばダイアログは次のような構成で実装できる。

ダイアログはCluster、Stack、Box、Centerの各レイアウトプリミティブで構成されている

登録フォームならこんな感じに。

3つのフィールドと送信ボタンを持つフォームはCenter、Stack、Box、Centerのレイアウトプリミティブで構成されている

あるいは講演でのスライド。

テキストが中央に配置され、前と次のボタンが下部に配置されたスライド。Cover、Box、Stack、Sidebarのプリミティブで構成されている。

いずれも出典は「Composition: Every Layout」より。

個人的な経験則として、レイアウトプリミティブのパターンは実際にかなり多くのレイアウトの実装に適用できる。それぞれのパターンをクラスとして再利用できるようにしておくと、結果的にCSSの総量をかなり削減できる。つまりはCSSを書く場面が減り、新しく作らなければならないコンポーネントや要素の数が減り、命名の機会が減る。もちろん共通化はテンプレートエンジンで行える。

パターンの収集という意味でレイアウトプリミティブは絶妙である。ウェブデザインの中で無意識的に繰り返されていたようなレイアウトの手法を拾い上げ、極めて汎用的な形式知に変換することによって、思いもしない抽象化の可能性が提示されたように感じた。OOCSSの原則であった「構造とスキンの分離」は、ページからそのパターンを発見する困難さゆえに機能しなかった。大袈裟かもしれないが、レイアウトプリミティブはウェブデザインの普遍的なパターンに思える。設計を進めていく最中でパターンを発見していくのには無理があり、あらかじめわかっているパターンを拠り所にできる方が間違いがないだろう。(レスポンシブデザインという制約が昨今のレイアウト規則を画一化した結果とも言えるかもしれない。)

しかし残念ながらEvery Layoutで紹介されている実装はそのままでは現実のプロジェクトには適用しづらい。特定の画面幅への最適化を避けて意図的にメディアクエリによるブレイクポイントに依存しない仕組みになっていたり、IE11で利用できない機能にしっかり依存していたり……。これらにはある程度納得できる理屈がありつつも、業務においても「そういうことで」とするにはかなり無理がある。ただそれでもこのアイデアはなんとか活用してみたかったので、1年近く苦心して、ある程度安定したプラクティスを見つけ出すことができた。それについては次の記事「実践的レイアウトプリミティブ」で紹介する。

参考文献

React Hooksで保持する参照を毎回初期化しないようにする

React Hooksを使ってオブジェクトへの参照を保持する場合、初期値を伴うuseRef()を使うと次のようになる:

const MyComponent: React.FC<Props> = () => {
  const instanceRef = React.useRef(createInstance())
  return ...
}

こうするとコンポーネントが再レンダーされるたびに毎回初期化を行ってしまって無駄な処理になるほか、初期化に副作用があったりすると厄介になる。

useState()によって初期化するとこの問題が回避できる:

// useRef () will initialize a reference on every render.
// useState () allows initialization only on first render.
// https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
function useLazyInitializableRef<T>(create: () => T): T {
  const [value] = React.useState(create)
  return value
}

const MyComponent: React.FC<Props> = () => {
  const instance = useLazyInitializableRef(createInstance)
  return ...
}

(ちなみにtsx内ではアロー関数にジェネリクスが使えない)

useState()の引数に渡した関数は初回レンダー時のみ実行され、後続のレンダーでは無視される

react-useにあるuseMeasure()の実装を見ていて気づいた。

翻訳:Rich Harris「形而上学とJavaScript」に関する見解(ReactによるDOMの抽象化の不完全性について)

この記事は「Thoughts on Rich Harris’ “Metaphysics and JavaScript”」の日本語訳です。Svelteのコーディングスタイルや記事として言及している講演のスライド併せて参照しなければ理解しづらい内容です

公開にあたっては著者のJim Nielsen氏に許諾をいただいています。

要旨としては、ユーザーにとってSvelteは実際のDOMとのメンタルモデルのギャップが少なく感じられるデザインであって、結果的に、Reactより理解しやすいプログラミングモデルになっているという話です。


Svelteの考案者であるRich Harris氏は、Reactのプログラミングの側面を批判する「形而上学とJavaScript」というタイトルの最近の講演のスライド共有しました。この講演には非常に説得力があり、Reactを利用した私の経験にも当てはまるとわかりました。

この記事では彼の論点のいくつかを反芻して、Reactでのプログラミングに関する私自身の感情をさらに解明し、明確にするために役立てたいと思います。

はじめに:私はReactが大好きで毎日使っています。以降の話はそれを変えてしまうものではありません。

UIは状態からなる関数であるか?

Richは講演の冒頭で、「神聖な牛の中でもっとも神聖なもの」(訳注:ヒンドゥー教では牛は神聖な存在であり、原文のsacred cowsは神聖なるものという慣用句)を批判する計画であることを明らかにしました。UIはアプリケーションの状態からなる関数であるということについてです。

UI = F(STATE)

この公式化こそがReactのプログラミングモデルの核心ですが、しかしこれが間違いであると主張するつもりはありません。私はこれが不完全であると主張します。Reactアプリを作ったときに実際になにが起こっているかの説明というよりは、イデオロギー的な主張であるということです。そしてこれは私たちがコードについてどう考えるかについて、より深い真実を曖昧にしてしまう思考様式を表していると私は主張します。

Gut punch.

では公式UI=F(STATE)はどのように間違っているのでしょうか? ああ、でも彼はそれが「間違い」だとは言わずに「不完全」だと言いました。彼は「UIは状態からなる関数である」というのはReactアプリを作ったときになにが起こっているかを正確に説明するものではないと言います。

私はRich氏の言葉を引用していますが、彼が言ったのは、UIがアプリケーションの状態からなる関数であるという考え方は抽象化であり、うまく機能してかなりあなたを助けますが、それでもただの抽象化だということ。抽象化は便利ですが、それでも素材の上にあるレイヤーであることに変わりはありません。下層の素材の上をきれいに塗装できないとわかったときには非常に厄介になることがあります。抽象化の欠点を克服するための一貫した戦術を見つけられなければ、火事が起こり、フラストレーションで燃え尽きるまで絶えず軋轢が生じます。

Julio Biason氏が書いた記事「私が苦労しながら学んだ物事(ソフトウェア開発の30年で)」を思い出します。彼は、デザインパターンは解決策を見つけるためではなく、解決策を説明するために使うべきであると述べています。

(繰り返しますが個人的な意見では)デザインパターンが適用されているのを見ると、ほとんどの場合、解決策を見つけるための方法として適用されていました。そのため最終的には解決策をひねって、時には問題の方をパターンに適合させることになるでしょう。

彼は特に「デザインパターン」について語っていますが、UIは状態からなる関数であるというイデオロギーをめぐるRich氏の主張にもかなりよく当てはまるようです。言い換えれば、UIは状態からなる関数であるという考え方は非常に有用であることがわかります。複雑さを乗り切るのに役立ちます。しかしながらそれは、現実の世界でウェブアプリケーションを実際に構築する方法、つまりDOMという基礎の上にあるイデオロギー的な抽象概念です。Rich氏はこれについて時間をかけて説明します。

「UI = F(STATE)」はイデオロギー的な声明だと思いますが、私が言いたいのは、資本主義イデオロギー共産主義イデオロギーのような他のイデオロギーと同じように、個人やグループが純粋に認識論的な理由以外で保持している規範的な信念や価値観の集合であり、大まかに言うと、「人々が実際に生きている世界ではなく、人々が想像したいと思っている世界を表している」ということです。

私は「イデオロギー」という言葉を、否定的なものではなく記述的ラベルとして使用しています。

しかしイデオロギーは危険を伴う場合があります。なぜなら彼らがいつもしているように、現実に逆らって苛立ち始めると、イデオロギーの衝動は常に、理論を修正することよりも、現実を作り直すことにあるからです。

「UIは状態からなる関数」というのは複雑なウェブアプリケーションを構築するための適切な解決策に近い理想論であり、私たちはReactのプログラミングモデルを通じてそのアイデアを活用しています。ですが今日では、私たちが実際に構築する素材(DOM)に合うように、イデオロギー的な解決策に多くのものを加えなければならないという不可避的な欠陥があります。どちらかというと、むしろトレードオフになると言うべきかもしれません。それは、私たちが生きているイデオロギーが現実のミスマッチと摩擦するときに軋轢を生みます。

私が本当に称賛したいのは、フレームワークの背後にあるイデオロギーと、ウェブアプリケーション構築の現実とのミスマッチに対処しようとするRich氏の試みです。素晴らしいことです。これはReact対Vueのような話をするときに聞きたいものです。単なる「ReactはJSXを使い、VueはHTMLテンプレートを使っている」という話でなく。

話が脱線しました。Rich氏が中断したところから続けましょう。

では関数型UIに対して非常に素朴なアプローチを取ったとしたらどうなるでしょう。たとえばある状態からなにかしらのUIを生成する関数を作成します。これには、その状態が変化したときに関数を再び呼び出すイベントリスナーが含まれます。

Rich氏の講演より、状態からなる関数としてのUIの簡単な例を描いたアニメーションGIF

const render = state => {
  document.body.innerHTML = `
    <h1>Hello ${state.name}</h1>
    <input value="${state.name}">
  `;

  const input = document.querySelector('input');

  input.oninput = () => {
    render({
      name: input.value
    });
  };
};

render({ name: 'world' });

ここでなにが起こっているのかがわかりますか? 「状態からなる関数としてのUI」というアイデアを示す非常に単純でインタラクティブな体験を実装しようとしています。「Hello XXX」というテキストがありますが、ここではXXXは<input>の値で埋められます。JavaScriptのコードはinputの変更を監視するだけに過ぎず、変更が行われるたびにDOMにアクセスして<body>のすべてを消去し、inputの新しいステートフルな値に基づいてUIを再描画します。

この例は、UIはアプリケーションの状態からなる関数であるという考え方をよく表しています。ではここでの問題はなんでしょうか。なぜこれが機能しないのでしょうか? UIには、nameというアプリケーションの状態だけでなく、より多くの状態があります。Rich氏は言う。

古いinputにはフォーカスがあり、新しいinputにはフォーカスがないため、機能しないことがわかります。

コードが実行されるたびにDOMにアクセスし、<body>のすべてを新しいHTMLに置き換えます。しかしおそらく気がついていなかったのは、DOMにはすでになにかしらの状態、つまりinputにフォーカスがあるということです。新しい状態で再描画するために<body>のすべてを削除すると、特定のinputにフォーカスがあるというDOMにあらかじめ含まれている暗黙の状態が失われます。Reactは制御された(controlled)inputとともにこの問題を処理(解決)してくれますが、もしかするとあなたはご存知なかったでしょうか? Rich氏の指摘に沿って簡単な例を作るまで、私はそれを完全には理解していませんでした。「クソッ、ブラウザがデフォルトでどの状態を処理するか把握していなかった」。したがってUIは必ずしもアプリケーションの状態からなる関数ではないようです。

Rich氏は、DOMの暗黙的な状態をアプリケーションのコードに引き上げることでこの問題を解決できることを示しています。なにが起こるのでしょう? 特にアプリとそのインタラクションが複雑になるにつれて、同じ種類の問題が次々と発生します。

つまりDOMはステートフルであることがわかります。しかし私たちがReactのようなフレームワークから逃れようとしているのはそれだと思いました。そしてRich氏が指摘しているミスマッチがあります。フレームワークには必ずしも現実と一致しないイデオロギー的な傾向があります。このようにして、私たちは現実に合わせてイデオロギーを再構築するのではなく、イデオロギーに合わせて現実を再構築しようとすることがよくあるのです。

Rich氏が続けて指摘しているように、DOMの暗黙の状態(inputのフォーカス、要素がマウントされた時間など)は、ユーザー(開発者)が画面になにを表示するかを決定する上でいつでも重要な要素です。この暗黙の状態の多くは簡単にはアプリケーションに持ち込めません。

純粋な関数を使ってUIを表現したいという願望はDOMの本質と真っ向から対立しています。状態→ピクセル変換を記述するには最適な方法であり、ゲームレンダリングやジェネレーティブアートには最適です。しかしウェブ上でアプリを構築していると、そのアイデアはステートフルなメディアの実際に逆らってしまいます。

あるレベルでは、ReactはDOMの多くの面倒を見てくれていることをご存知でしょう。知っています。しかし「UI=F(STATE)」という基本的なアイデアを取り入れたアプリをJavaScriptで(Rich氏が提供した例のように)構築しようとしたとき、Reactが私のためにどれだけのことをしているのかが分かり始めました。

そしてそれが、私がRich氏の議論の頂点として見いだしたものへと導いてくれるのです。

もちろんReactが「DOMの暗黙的な状態の競合」をどのように処理するかはわかっています。Reactは仮想DOMツリーの新しいノードをDOMの既存のノードにマップします。つまり、Reactは明らかに機能していない基盤を機能的に抽象化したものです。

私の経験では、抽象化と抽象化の間のギャップが大きければ大きいほど、プログラマーが「インピーダンスミスマッチ」と呼ぶものに悩まされる可能性が高く、Reactでもそれを経験しているように思うのです。

つまりもっとも基本的なレベルでは、Reactのプログラミングモデルとウェブの命令型APIとの間にミスマッチがあります。ウェブには性質があり、Rich氏は「UIは状態からなる関数」という考えがそれに反することをうまく説明しているように思います。

ではここからどこへ向かうのでしょうか。私にはわかりません。しかしこれを書くことで、私が頻繁に感じる摩擦をよりよく理解できるようになりました。そして「Svelteが存在する理由」という講演のための完璧な準備です。

Eleventyとwebpackの連携

Eleventyは静的サイトジェネレーターとして柔軟かつ欲しい機能も揃っていて、僕が携わるほぼすべての静的HTMLベースのプロジェクトで採用している。一方でアセットのビルドなどは責務外なので別途webpack等と組み合わせる必要がある。

そこで困るのがCache bustingをどのように実装するかだ。たとえばHTMLファイルが1枚しかないクライアントサイドレンダリング前提のSPAであれば、HtmlWebpackPluginでHTMLファイルも丸ごとwebpackで生成してしまえるので、パスの解決についてユーザーがケアする必要がない。

しかしEleventyをベースにする場合では、HTMLの生成はEleventy側で行うため、何かしらのやり方でEleventyにwebpackのManifestを渡してやらないといけない。この受け渡しのためには、ManifestをJSONファイルとして出力するWebpack Manifest Pluginを使うのが一般的であるようだ。このJSONファイルには、MiniCssExtractPluginが出力するCSSfile-loaderによって読み込まれた画像などのパスも含まれるので、一通りのアセット類はwebpackでビルドするようにすればCache bustingの対象にできる。

EleventyではDataディレクトリにJSONファイルなどを配置するとHTMLテンプレートから参照できるようになるので、Manifestをまずはそこに出力してみる。説明を単純にするためにかなり簡略化したビルド。

.
├── dist/
│   ├── build/
│   │   ├── assets/
│   │   │   └── background.d41d8cd9.svg
│   │   ├── main.095d67a3.css
│   │   └── main.3a6b57da.js
│   └── index.html
├── src/
│   ├── assets/
│   │   ├── background.svg
│   │   └── logo.svg
│   ├── scripts/
│   │   └── main.js
│   ├── site/
│   │   ├── _data/
│   │   │   └── assetManifest.json
│   │   ├── build/
│   │   │   ├── assets/
│   │   │   │   └── background.d41d8cd9.svg
│   │   │   ├── main.095d67a3.css
│   │   │   └── main.3a6b57da.js
│   │   └── index.njk
│   └── styles/
│       └── main.css
├── gulpfile.js
├── package-lock.json
├── package.json
└── webpack.config.js

.eleventy.js:

module.exports = (eleventyConfig) => {
  eleventyConfig.addPassthroughCopy('src/site/build')
  eleventyConfig.setUseGitIgnore(false)

  return {
    dir: {
      input: 'src/site',
      output: 'dist',
    },
  }
}

webpack.config.js:

const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const ManifestPlugin = require('webpack-manifest-plugin')

const srcDir = path.join(__dirname, 'src')

module.exports = {
  mode: 'production',
  context: srcDir,
  entry: ['./scripts/main.js', './styles/main.css'],
  output: {
    path: path.join(srcDir, 'site/build'),
    filename: '[name].[contenthash:8].js',
    publicPath: '/build/',
  },
  module: {
    rules: [
      {
        oneOf: [
          {
            test: /\.css$/i,
            use: [
              { loader: MiniCssExtractPlugin.loader },
              { loader: 'css-loader' },
            ],
          },
          {
            exclude: [/\.js$/, /\.json$/],
            use: {
              loader: 'file-loader',
              options: {
                name: '[path][name].[contenthash:8].[ext]',
              },
            },
          },
        ],
      },
    ],
  },
  resolve: {
    alias: {
      '~assets': path.join(srcDir, 'assets'),
      assets: path.join(srcDir, 'assets'),
    },
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: 'main.[contenthash:8].css' }),
    new ManifestPlugin({
      fileName: path.join(srcDir, 'site/_data/assetManifest.json'),
    }),
  ],
}

gulpfile.js:

const spawn = require('cross-spawn')
const gulp = require('gulp')
const del = require('del')
const webpack = require('webpack')
const webpackConfig = require('./webpack.config')

const clean = () => {
  return del(['dist', 'src/site/_data/assetManifest.json', 'src/site/build'])
}

const assets = (done) => {
  webpack(webpackConfig).run((err, stats) => {
    if (err) {
      console.error(err.stack || err)

      if (err.details) {
        console.error(err.details)
      }

      done(err)
      return
    }

    console.log(stats.toString('minimal'))

    if (stats.hasErrors()) {
      done(new Error('webpack compilation errors'))
      return
    }

    done()
  })
}

const eleventy = () => {
  return spawn('./node_modules/.bin/eleventy', [], {
    stdio: 'inherit',
  })
}

const build = gulp.series(clean, assets, eleventy)
exports.build = build

src/site/index.njk:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello</title>
    <link rel="stylesheet" href="{{ assetManifest['main.css'] }}">
    <script src="{{ assetManifest['main.js'] }}"></script>
  </head>
  <body>
    <h1><img src="{{ assetManifest['assets/logo.svg'] }}" alt="Hello"></h1>
  </body>
</html>

src/styles/main.css:

body {
  background-image: url("~assets/background.svg");
}

src/scripts/main.js:

console.log(require('~assets/background.svg'))

gulp buildを実行すると次のファイルが出力されて、HTMLテンプレートはそれを読み込んでパスを解決する。

src/site/_data/assetManifest.json:

{
  "main.css": "/build/main.095d67a3.css",
  "main.js": "/build/main.3a6b57da.js",
  "assets/background.svg": "/build/assets/background.d41d8cd9.svg"
}

ただしこれだとsrc/assets/logo.svgがManifestに含まれておらず、EleventyをビルドしてもassetManifest['assets/logo.svg']の部分が空文字列になってしまう。これはwebpack側ではsrc/assets/logo.svgを読み込んでいないからだ。src/scripts/main.jssrc/styles/main.cssからファイルを指定するとwebpackのManifestに入りつつパス解決をしているが、Eleventyはその結果を参照しているだけなので、HTML側でファイルを指定していることはwebpackに関係がない。なのでHTML側だけで参照するファイルもManifestに含めるために、それらのファイルをあらかじめwebpackに読み込ませておく。

src/assets/_importer.js:

require.context('.', true)

webpack.config.js:

- entry: ['./scripts/main.js', './styles/main.css'],
+ entry: ['./assets/_importer.js', './scripts/main.js', './styles/main.css'],

これでsrc/assetsディレクトリに配置されたすべてのファイルがManifestに入るようになる。

しかしまだHTMLテンプレートに{{ assetManifest['assets/logo.svg'] }}"のように記述しないといけないのが冗長でめんどくさく、もう少し書きやすくしたい。NuxtJSではVueテンプレートが次のように変換される。

<template>
  <img src="~/assets/image.png">
</template>
<img src="/_nuxt/img/image.0c61159.png">

これを参考にEleventyでも同じようにパスが変換されるようにしてみた。

.eleventy.js:

const { JSDOM } = require('jsdom')
const importFresh = require('import-fresh')

const transformAssetAttrs = {
  video: ['src', 'poster'],
  source: ['src'],
  img: ['src'],
  image: ['xlink:href', 'href'],
  use: ['xlink:href', 'href'],
}

const transformAssetSelector = Object.entries(transformAssetAttrs)
  .map(([element, attrs]) => {
    return attrs.map((attr) => `${element}[${attr}]`).join(',')
  })
  .join(',')

module.exports = (eleventyConfig) => {
  eleventyConfig.addPassthroughCopy('src/site/build')

  eleventyConfig.addTransform('assetPath', (content, outputPath) => {
    if (!outputPath.endsWith('.html')) {
      return content
    }

    const assetManifest = importFresh('./src/site/_data/assetManifest.json')

    const dom = new JSDOM(content)
    dom.window.document
      .querySelectorAll(transformAssetSelector)
      .forEach((element) => {
        transformAssetAttrs[element.tagName.toLowerCase()].forEach((attr) => {
          const value = element.getAttribute(attr)
          if (value && value.startsWith('~assets/')) {
            const id = value.replace('~assets/', 'assets/')
            const resolved = assetManifest[id]
            if (!resolved) {
              throw new Error(`Can't resolve '${value}' for '${outputPath}'`)
            }
            element.setAttribute(attr, resolved)
          }
        })
      })
    return dom.serialize()
  })

  eleventyConfig.setUseGitIgnore(false)

  return {
    dir: {
      input: 'src/site',
      output: 'dist',
    },
  }
}

これによりHTMLテンプレートに次のように書いたパスは自動的に解決されるようになった。

<img src="~assets/logo.svg" alt="Hello">

存在しないファイルを指定するとエラーが出てくれるようにもなったので安心できる。

そういうわけでここまででビルドする方法に書いてきたが、実際にはwatchできないと使いものにならない。いい感じにwatchする方法についてもメモしておこうかと思ったが、解説量が膨大になりそうなのでリポジトリだけ置いておく。

_shifted/boilerplate-static at master · yuheiy/_shifted

「404 Not Found」は実装側の言葉であってユーザーに見せる言葉ではない

いわゆる404ページを作っていて、ふと「ページが見つかりません」という表現に違和感を覚えた。ユーザーになにの話をしているんだ?

URLに対応するリソースを見つけたり見つけられなかったりするのはあくまでサーバー、つまり実装の話で、そういった仕組みの話はどうでもいい。重要なのは実装を通してユーザーにどのような意味を感じさせたいかだ。

リンクをクリックしてページを開くとき、リンクは別の場所に移動するためのボタンに思える。移動先にはページ(またはその他のリソース)が存在していたりしなかったりして、ほぼ確実な存在を期待するが、まれにそうでない場合に出くわす。このときユーザーに関係があるのはページが存在しているか否かで、サーバーの処理内容に意識はない。「見つからなかった」と知らされてもわけがわからずトンチンカンな印象を受けてしまう。

「お探しのページは見つかりませんでした」と言っても同じ話だ。ページを探すのはサーバーの方で、ユーザーはリンクを選んだ時点で移動先を確定させている。強いて言うならユーザーが探すのはリンクであり、ページの先にあるものだ。

では「404 Not Found」をどのように表現するか。伝えるべきはページの存在についてだが、ややこしいのは404ページ自身もまたページであることだ。404ページが「ページが存在しない」と言うとき、「ページ」が指すのは「そこに存在するはずのページ」である。「この場所にあるはずのページは存在しません」とかだと冗長すぎるし、却って誤解を招きそうだ。

HTTPエラーページに意味を持たせよう」によると、以前Googleのエラーページには次のような文言があったらしい。

The page - www.google.com/dkjfhsd - does not exist.

The pageはなかなかいいと思える表現だ。エラーページが表示される瞬間にまさにユーザーの意識がある対象のページを指していて、かつ「404ページ自身が存在しない」という矛盾した表現になっていない。

しかし、訳しにくい。「そのページは存在しません」としても、「そのページ」がユーザー自身が意識していた「存在するはずのページ」を指しているとは理解されず、不自然に「その」と明言されていることで別のなにかを意図していると誤解されそうだ。「ページは存在しません」でもどのページを指しているのか一瞬理解しづらい気がする。

では「このページは存在しません」ではどうか。404ページ自身を指しているようにも取れるが、その場所に存在するはずの「存在しないページ」を指して「このページ」と呼んでいるように思えないだろうか。あるいはこれは訪問先の住所にある空っぽの土地に「売地」という看板が立っているようなイメージでも理解できるかもしれない。

そんな訳で作った404ページでは「このページは存在しません」を採用した。

ひと通り書いてみて想像してたほどうまく説明できなかったなと思った。「ページが見つかりません」的な表現はもはや記号化しているので今さらどうでもいい気もするが、それでも気持ち悪さはあったので書いてみた。