このyuhei blogはおしまいになりました
半年くらい前からは新しく作った自分のサイトに書いている。たぶんここはもう更新しないと思います。ありがとうはてなブログ。
EmotionとSvelteを組み合わせる場合の設定とうまくいかない部分とか
CSS in JSライブラリのEmotionにはReact版とFramework agnostic版がある。なのでReact以外のビューライブラリと組み合わせたりバニラJSでも使ったりできるけど、Babelプラグインが実質必須なのでSingle File Componentsみたいなスタイルとは相性が悪い。
<script> import { css } from "emotion"; </script> <h1 class={css({ color: "pink" })}>Hello!</h1>
EmotionのBabelプラグインはコードの最適化に加えてCSSのソースマップ生成を行う。Emotionが生成するクラス名はランダムなハッシュ値になって可読性がないので、デバッグするにはソースマップがかなり重要になる。Babelプラグインが正しく機能すれば開発者ツールで検出した宣言ブロックからソースコード内の定義元が参照できるようになる。
一方でSvelteのコンポーネントはそのままではBabelでパースできないので、このBabelプラグインを使うには次のいずれかの方法を取るしかない:
- svelte-loaderあるいはrollup-plugin-svelteによってJavaScriptに変換された後にBabelを適用する
- Svelte Preprocessによって
script
要素内のみにBabelを適用する
1の場合、JavaScriptにコンパイルされた後のソースコードを基準にしてソースマップを生成するので、ユーザーが実際に編集するファイルと別のものを参照してしまう。
2の場合、script
要素内のコードについては正しくソースマップが生成されるが、テンプレートにはBabelが適用されないので、テンプレートに直接記述した定義にはソースマップが生成されない。この場合テンプレート内の最適化も行われない。
またいずれにしてもSvelteコンポーネントの外でスタイルを定義してimport
すればソースマップは正しく生成される。
import { css } from "emotion"; export const hello = css({ color: "pink" });
<script> import { hello } from "./styles"; </script> <h1 class={hello}>Hello!</h1>
しかしこの使い方だとインラインスタイル風に書きたいというモチベーションが満たせないので自分としてはあまり意味がない。同じ理由でscript
要素内にすべて定義してしまうのも無い。
そのためどちらかというと1の方がマシという結論。ソースマップがまったく無いよりは良いし、最適化も漏れなく適用させたい。webpack.config.js
はこんな感じ:
module: { rules: [ ... { test: /\.svelte$/, use: [ { loader: "babel-loader", options: { plugins: ["emotion"], }, }, { loader: "svelte-loader", }, ], }, ... ], },
またVS Codeでは、Babel JavaScriptやvscode-styled-componentsをインストールすることでTemplate literals内に記述したCSSがシンタックスハイライトされたり補完が効くようになったりするが、Svelteコンポーネントのテンプレート中ではそれが有効にならない。script
要素内では動く。
代わりにObject Stylesで記述すると特別なプラグインを足さずともまともに書けるようになるし、Emotionの型定義ファイルからプロパティ名や値を補完してくれる。最低限Svelte for VS Codeは必要。
次世代インラインスタイル
ユーティリティファーストCSSは実質的にインラインスタイルであり、本来はインラインスタイル的な記述ができる方がより望ましいように思えるが、おもに次のような制約によりユーティリティファーストとして体を成している:
- インラインスタイルでは、疑似クラス・疑似要素・子孫セレクタ・
@media
をはじめとする@-規則などが利用できない - インラインスタイルは多くのエディタでうまく補完されない
- デザイントークンを参照できない(あるいはしづらい)
一方でいくつかのCSS in JSライブラリは別のアプローチを選択することでこれらの制約を回避しており、また比較的新しいCSSネイティブの機能の利用によっても解決できるようになってきた。ユーティリティファーストを完全に置き換えられるわけではないにしても、それらの代替案を検討できる場面もあるだろう。
CSS in JSのアプローチ
たとえばEmotionでは、通常のCSSファイル内と同じように宣言ブロックの中身をcss
関数の引数として記述することで対応するクラス名が生成されるAPIになっている:
import { css, cx } from 'emotion' const color = 'white' render( <div className={css` padding: 32px; background-color: hotpink; font-size: 24px; border-radius: 4px; &:hover { color: ${color}; } `} > Hover to change color. </div> )
Sassのように&
によって疑似クラスを表現できて、JavaScriptのテンプレートリテラルなのでスコープ内の変数も参照できる。メディアクエリも書ける。いわばフルスペックなインラインスタイルだ。
styled-componentsにあるcss
propを使っても同じようなことができる。
<div css={` background: papayawhip; color: ${props => props.theme.colors.text}; `} />
この記述はBabelプラグインによって次のように変換される:
import styled from 'styled-components'; const StyledDiv = styled.div` background: papayawhip; color: ${props => props.theme.colors.text}; ` <StyledDiv />
styled
関数に渡したスタイル宣言から自動的にstyle
要素が生成されてページに挿入される。
Emotionやstyled-componentsはランタイムとして実行されるためパフォーマンス上のオーバーヘッドがあるが、スタイル宣言をプリコンパイルしてランタイムコストなしで利用できるLinariaというライブラリもある。APIは基本的にEmotionやstyled-componentsと変わらないが、プリコンパイル時に評価しきれない表現を使えないトレードオフはある(ある程度はJavaScriptを評価してくれる)。
import { css } from 'linaria'; import { modularScale, hiDPI } from 'polished'; import fonts from './fonts'; <h1 className={css` text-transform: uppercase; font-family: ${fonts.heading}; font-size: ${modularScale(2)}; ${hiDPI(1.5)} { font-size: ${modularScale(2.5)}; } `} > Hello world </h1>
難点として、この手のライブラリにつきものなのがデバッグの煩わしさであり、クラス名はハッシュ値として生成されるため可読性がなく、ソースマップもサポートされていないような場合がある。
styled-componentsではクラス名に、コンポーネントと対応するハッシュ値に加えてコンポーネントに紐づいている変数名をもとにしたApp___StyledDiv-mo47nu-0
のような値を付与しているが、ソースマップは今のところサポートされていない。EmotionとLinariaではクラス名はハッシュ値のままになるがソースマップがサポートされている。
これらのためのシンタックスハイライトや補完は、主要なエディタにはプラグインとしてコミュニティによって提供されている。構文はどれもstyled-componentsと変わらないので同一のプラグインで用が足りる。
しかしそれでもこのような周辺ツールの開発にはそれなりのリソースが費やされており、stylelintとの統合なども含めて、独自性の高いアプローチを実現するためには膨大な労力が必要になってしまう。このような問題に対してSvelteは、もとあるものをできるだけそのままにした「十分な」やり方を提供することをあえて選択している。
カスタムプロパティによる表現力の拡張
インラインスタイルでは疑似クラスや疑似要素などを直接宣言することはできないが、カスタムプロパティを利用すれば間接的にそれが実現できる。カスタムプロパティの値はカスケードされるため、インラインスタイルからカスケードされ得るあらゆる宣言はすべてカスタムプロパティとしてインラインスタイルから挿入できる。
たとえば:hover
に対応するcolor
プロパティは次のように表現できる:
a { color: var(--color); } a:hover { color: var(--hover-color); }
<a href="/hello" style=" --color: dodgerblue; --hover-color: mediumblue; " > Hello </a>
このように「インラインスタイルで表現できない宣言」の値を「インラインスタイルから挿入できる仕組み」を実装しておくと、本来インラインスタイルではできなかったはずのスタイリングが実現可能になる。すべての要素に対してこの手法を適用すると、ユーティリティーファーストCSSと同じようにほぼセレクタを書かずに開発していけるようになる。たとえば次のようにすると、メディアクエリごとのdisplay
プロパティの値がインラインスタイルで指定できるようになる:
* { --display: initial; display: var(--display, revert); @media (min-width: 45em) { --md--display: initial; display: var(--md--display, var(--display, revert)); } @media (min-width: 60em) { --lg--display: initial; display: var(--lg--display, var(--md--display, var(--display, revert))); } }
<div style=" --display: none; --md--display: block; --lg--display: inline-block; " > Hello </div>
通常カスタムプロパティは継承されるが、initial
キーワードを指定すると継承されなくなる。これはguaranteed-invalid valueと呼ばれるカスタムプロパティに固有の仕様である。その上で指定しているrevert
キーワード(実装はまだ十分ではない)は、値をユーザーエージェントスタイルシートのデフォルトスタイルにフォールバックする役割がある。これによって、インラインスタイルが指定されている場合にはその値が利用されて、指定がない場合はデフォルトスタイルのままになる挙動が実装できる。
しかしエディタでの入力はあまり快適ではない。利用されるカスタムプロパティの解析しづらさも含めて改善は難しそうに思える。あるいは.tsx
ファイルではなんとかなる可能性があるかもしれない。
デザイントークンの管理と適用
Sassの変数として管理されているようなカラーコードや余白のサイズなど、スタイル上で利用する値のセットをデザインシステムの文脈ではデザイントークンと呼ぶ。従来CSSには変数の機能がなかったのでデザイントークンの管理にはSassなどのツールが必要とされていたが、IEの後の世界にはCSSネイティブの機能としてカスタムプロパティがあるので単にそれを利用すれば良い。
:root { --color-blue: hsl(240, 100%, 27%); }
<div style="color: var(--color-blue);">Hello</div>
ユーティリティファーストCSSではその性質上、あらかじめデザイントークンが決まっていなければユーティリティファーストな開発を行えない。インブラウザデザインのような制作方法であればそうした実装上の細かい制約を意識しながら作っていけるかもしれないが、そうでなくデザインファイルなどをもとにして実装していく場合では最初に値だけを予測して設定するのはかなり難しい。
CSSでは、最初は値がハードコーディングされた状態から始まり、必要に応じて後から共通化していくのが現実的。これは値だけに限らず、そもそもユーティリティファーストCSSというアプローチ自体が「決定を遅延させる」考え方だと言える。しかしながら、ユーティリティファーストなアプローチを取るためにはこのデザイントークンだけは先に決まっていなければならないジレンマがある。このあらかじめの設定を意味のある制約だと言う人もいるが、少なくとも「決定を遅延させる」指向とで議論を分けるべきだろう。
CSSのユーティリティクラスと「関心の分離」——いかにしてユーティリティファーストにたどり着いたか(翻訳)
Tailwind CSS作者のAdam Wathan氏による「CSS Utility Classes and "Separation of Concerns"」の日本語訳です。翻訳に当たって原著者の許諾を得ています。
2021年10月29日に全文再翻訳しました。
この数年の間で、私のCSSの書き方は、非常に「セマンティック」なアプローチから「ファクショナル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(この場合は著者略歴(author bio)カード)のマークアップをする。
<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. コンテンツに基づいた説明的なクラスを1、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 Author Bio, nested selectors by Adam Wathan (@adamwathan) on CodePen.
このアプローチは理解しやすく、筋が通っていると思ったので、しばらくはこのようにHTMLとCSSを書いていました。
しかし、そのうちなにか違和感を覚え始めます。
「関心を分離」しても、CSSとHTMLは明らかに結びついていたのです。ほとんどのCSSがマークアップと合わせ鏡のようでした。入れ子になったCSSセレクタに、HTMLの構造がそのまま反映されてしまっていました。
マークアップはスタイルの規定について関心を持ちませんでしたが、CSSはマークアップの構造に関心を持っていました。
結局のところ、関心は分離できていなかったのでしょう。
第二段階 スタイルを構造から切り離す
この結びつきを切り離す方法を探し回った結果、行き着いたのは、マークアップにより多くのクラスを追加して、直接要素を選択できるようにする――セレクタの詳細度を低く保ち、CSSを特定のDOM構造に依存させないようにするという解決策でした。
こうした考え方を提唱する方法論として、最も有名なのがBlock Element Modifier――略して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; }
これはかなりの進歩だと感じました。マークアップは「セマンティック」なままで、スタイルを規定していません。CSSはマークアップの構造から切り離されているように思えますし、加えて、セレクタの不用意な詳細度に悩まされずに済みます。
しかし、私はジレンマに陥るのです。
似たようなコンポーネントの扱い
サイトの新しい機能として、記事の概要をカードレイアウトで表示する機能を追加するとしましょう。
記事概要(article preview)カードの中には、上部に幅いっぱいの画像が、下部に余白を伴うコンテンツセクションが含まれます。太字のタイトルと、小さく本文テキストもあります。
これが、著者略歴とまったく同じ見た目だとします。
あくまで関心は分離されたままにしつつ、どのように対処するのが最適でしょうか?
記事概要に.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; }
@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の重複もなくせますが、しかし、これでは「関心の混合」ではないでしょうか?
この瞬間から、これらのコンテンツ両方をメディアカードとしてスタイリングするという知識がマークアップに含まれてしまっています。ではもし、記事概要コンポーネントの見た目を変更せずに、著者略歴コンポーネントの見た目を変更したくなった場合にはどうすればよいでしょうか?
これまでは、スタイルシートを開いて、ふたつのコンポーネントのどちらかに新しいスタイルを適用するだけでした。それが今では、HTMLを編集しなければならなくなってしまったのです! なんということでしょう!
しかし、別の側面からも考えてみます。
もし新しい種類のコンテンツを追加することになって、それにもまた同じスタイリングが必要だとすればどうしましょう?
「セマンティック」なアプローチでは、まずHTMLを記述し、コンテンツ固有のクラスをスタイリングのための「フック」としていくつか追加し、スタイルシートを開き、新しい種類のコンテンツのためのCSSコンポーネントを作成し、そして共通のスタイルを複製するか、@extend
やMixinを使って割り当てます。
コンテンツに依存しない.media-card
クラスでは、記述するのは新しいHTMLのみで、スタイルシートを開く必要はまったくありません。
もし本当に「関心の混合」をしているなら、複数の箇所に変更を加える必要が出てくるのではないでしょうか?
「関心の分離」は論証上の誤り
HTMLとCSSの関係性について「関心の分離」の観点から考えると、白黒は非常にはっきりしています。
「関心の分離」ができている(よい!)か、できていない(悪い!)かだけです。
しかしこれは、HTMLとCSSについて考える上では正しい方法ではありません。
代わりに、「依存の方向」について考えてみましょう。
HTMLとCSSにはふたつの書き方があります。
「関心の分離」
HTMLに依存するCSSコンテンツに基づいたクラス名(
.author-bio
など)を付与することで、CSSがHTMLに依存するように見なせます。HTMLは依存していません。どのような見た目になるかは意識せず、
.author-bio
のようなHTML自身が制御できるフックを公開しているだけです。一方、CSSは依存しています。HTMLがどのようなクラスを公開しているのかを知った上で、それらを介してHTMLにスタイルを設定する必要があります。
このモデルでは、HTMLのスタイル変更が可能になる代わりに、CSSは再利用できません。
「関心の混合」
CSSに依存するHTMLUIの繰り返しのパターン(
.media-card
など)にちなんで、コンテンツにとらわれないようにクラスを命名することで、HTMLがCSSに依存するように見なせます。CSSは依存していません。どのようなコンテンツに適用されるかは意識せず、マークアップに適用できる一連のブロックを公開しているだけです。
HTMLは依存しています。CSSから提供されたクラスを利用しているので、目的とするデザインを実現するためには、どのようなクラスが存在するかを知った上で、必要に応じてそれらを組み合わせる必要があります。
このモデルでは、CSSには再利用性がありますが、HTMLのスタイル変更はできません。
CSS Zen Gardenが最初のアプローチを取る一方で、BootstrapやBulmaのようなUIフレームワークはふたつ目のアプローチを取ります。
本質的にはどちらも間違っていません。特定の状況下において、なにがより重要であるかに基づいて判断される問題です。
あなたが取り組んでいるプロジェクトでは、スタイル変更できるHTMLと再利用性のあるCSSのどちらに価値があるでしょうか?
再利用性の選択
転機が訪れたのは、ニコラス゠ギャラガー氏の「HTMLのセマンティクスとフロントエンドアーキテクチャ」を読んだときでした。
彼の指摘のすべてをここで繰り返すつもりはありませんが、そのブログ記事を読んで確信したのは、私が手がけているようなプロジェクトでは、CSSの再利用性に舵を切ることが明らかに正しい選択だということです。
第三段階 コンテンツに依存しない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
と呼ぶことにして、.stacked-form__
という接頭辞を取り払ってしまうのはどうでしょう?
<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を構成してみてはどうでしょうか?
+ <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
の外側で.stacked-form__footer
を再利用することはできないので、ヘッダーの内側にも新しいサブコンポーネントを作成することになるかもしれません。
<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)の一方を左揃えに、もう一方を右揃えにしたいとすればどうでしょう? .actions-list--left
と.actions-list--right
モディファイアを作るのでしょうか?
第四段階 コンテンツに依存しないコンポーネント+ユーティリティクラス
絶え間なくこのようなコンポーネントの名前を考え続けていると疲れ果ててしまいます。
.actions-list--left
のようなモディファイアを作るということは、ひとつのCSSプロパティを割り当てるためだけにひとつのコンポーネントを新しく作るということです。名前にleft
と含まれている以上、「セマンティック」であると惑わされることもないでしょう。
もし、別のコンポーネントでも左揃えと右揃えのモディファイアが必要になれば、同様に新しいモディファイアを作成するのでしょうか?
こうして、.stacked-form__footer
と.header-bar__actions
を廃止して、ただひとつの.actions-list
と置き換える判断をしたときに直面した問題に戻ってきます。
重複よりもコンポジションを選びます。
では、アクションリストのひとつは左揃えに、もうひとつは右揃えにしたいとき、コンポジションを使えばどのように問題を解決できるでしょうか?
配置ユーティリティ
コンポジションを用いてこの問題を解決するには、再利用可能で、かつ必要な効果を得られるクラスを追加しなければいけません。
モディファイアのことはすでに.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」という表現を見て不安になるかもしれません。しかし確認しておきたいのは、もうしばらく前から、視覚的なパターンにちなんだコンポーネント名を使っているということです。
.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
コンポーネントを作らずに、元々の問題を解決する方法はほかにないでしょうか?
思い返してみると、このコンポーネントを作ったのは、ふたつのボタンの間に少しのマージンを追加するためでした。.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は小さくなり、クラスの再利用性は高まりました。
第五段階 ユーティリティファースト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
ユーティリティのほうが再利用しやすいでしょう。
トップの画像はどうでしょう? .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のすべてが複雑性の元凶なのです。CSSを増やしてもCSSがシンプルになることは決してありません。
代わりに、既存のクラスを適用してスタイルを設定できれば、真っ白なキャンバスの問題はたちまち解消されることになります。
テキスト色の暗さを少し和らげたいですか? .text-dark-soft
クラスを追加しましょう。
フォントサイズを少し小さくする必要がありますか? .text-sm
クラスを使いましょう。
プロジェクトに携わる全員が、選定されたオプションの中からスタイルを決めることで、プロジェクトの規模とともにCSSが直線的に増大してしまう事態を回避できるだけでなく、自ずと一貫性も保たれます。
それでもコンポーネントは作るべきです
ほかのファンクショナルCSSの熱心な支持者と少し違うのは、私は、ユーティリティだけで作るべきだとは考えていないという点です。
ユーティリティベースのフレームワークとして人気のTachyonsなどを見ると、ボタンのスタイルでさえも純粋なユーティリティから作られているのがわかります(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
: ホバーの際には背景色を明るい紫にする
このような同じクラスの組み合わせからなるボタンが複数必要な場合、Tachyonsでは、CSSではなくテンプレートを通して抽象化することが推奨されています。
たとえば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コンポーネントを作成したほうが役立つ場面も多いと私は考えています。
私が手がけているようなプロジェクトでは、サイトにある小さなウィジェットをすべてテンプレート化するよりも、新しい.btn-purple
クラスを作って、これら7つのユーティリティをバンドルしたほうがたいてい簡単です。
それでも、最初はユーティリティで作ります
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-sm
かtext-xs
か?
py-3
とpy-4
のどちらを使うべきか?
text-dark-soft
とtext-dark-faint
のどちらにしたいのか?
なんでも好きな値を選択することはできず、選定されたリストの中から選ばなければなりません。
380色の文字色ではなく、10色や12色に制限されます。
ユーティリティファーストで作業することは、最初は、コンポーネントファーストよりも直感的ではないかもしれませんが、より一貫したデザインになると経験上言えます。
どこから始めるか
このアプローチに興味を持たれた方は、次のフレームワークについて調べてみるのがいいでしょう。
また最近、私は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-bottom
やpadding
などのユーティリティクラスは本当に必要になるタイミングまで作らない。
もうひとつのやり方は、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-content
とalign-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の目的はスタイル記述順の制御であり、同じ詳細度の宣言はより後のレイヤーによって上書きされる仕組み。もっともレイアウトプリミティブでは全称セレクタを多用するので、詳細度が高まっていてあまりうまくは機能させられないが……。はっきり区別させる意味では役に立つ。
各レイアウトプリミティブのユースケースの例示
よくある「目に見える」コンポーネントカタログと違って、レイアウトプリミティブのユースケースは最初は少し想像しづらい。その汎用性ゆえに抽象的で、具体例が欲しくなる。その解決のためにパターンごとのユースケースを掲載したスタイルガイドを作成した。
_shifted/1-objects.pug at master · yuheiy/_shifted
利用方法はこれらの例から学習できるだろう。
プロジェクトのセットアップ
これまで紹介してきたレイアウトプリミティブの実装はひとつのリポジトリに集約させていて、それらがあらかじめ用意された状態で新しいプロジェクトをはじめられるようにしてある。基本的なパターンを繰り返し実装し直す手間を省いて、個別の問題により集中できるようにする狙いがある。
_shifted/boilerplate-static/app/assets/objects at master · yuheiy/_shifted
カスタムプロパティを用いずに汎用化するのが困難なパターンや、利用頻度が少ないパターンは含んでいない。
ちなみにCSS以外の開発環境構築についても汎用化できないか長らく考えていて、これについてもまたいつか書きたい。
宣伝
この記事では自分なりのEvery Layoutの応用について書いたが、その根底には原著が伝えるもとの意図がなければ成立しない。しかしながらEvery Layoutは英語であり有料コンテンツであるためになかなか紹介しづらく、また読んでもらうハードルも高く、非常にやりきれない思いになっていた。
そうしたところで偶然、編集者の岡本さんにお声がけいただき、Every Layoutを日本語訳して出版する事の運びとなった。友人の横内さんとも一緒に。
#dist30 でこのLTをされた @_yuheiy さんと @8845musign さんの共同監訳で、LTで取り上げられているEvery Layout @layoutplusplus の電子書籍を日本語版を刊行(紙+電子)することになりました。凡百のHTML+CSS本と違う中級者向けのCSS本を目指します。お楽しみに :-)https://t.co/Szi3lm1fOC
— 岡本淳 ⛺️ (@orange_juno) 2020年2月21日
原著の内容をなんとかうまく伝えられるよう精一杯やりますので、みなさん何卒よろしくお願いします。
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個紹介されている。
レイアウトプリミティブの特徴は、パターンの役割がとにかく純粋であること。責務を混合させずに独立させることによって、かなり広範囲の問題に対してパターンが適用できるようになっている。
それぞれのパターンは相互に組み合わせて利用する前提で設計されている。たとえばダイアログは次のような構成で実装できる。
登録フォームならこんな感じに。
あるいは講演でのスライド。
いずれも出典は「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 ... }
useState()
の引数に渡した関数は初回レンダー時のみ実行され、後続のレンダーでは無視される。
react-useにあるuseMeasure()
の実装を見ていて気づいた。