styled-componentsの仕組みについての覚え書き
Photo by Brandi Redd on Unsplash
弊社では現在UIコンポーネントのスタイルを当てるために、CSS-in-JSライブラリのひとつであるstyled-componentsを使っています。
styled-componentsで高度な使い方をしたときにスタイルが意図した優先度で当たらない現象の調査のためにstyled-componentsの仕組みを調べたので、覚え書きとして残しておきます。
前提知識
styled-componentsについて:
- CSS-in-JS はスタイル定義をJavaScript内に書く手法の総称です。スタイルのコンポーネント間の結合を下げ、単一コンポーネント内の振舞いとスタイルの凝集を上げる狙いがあり、特に振舞いに依存して動的にスタイルが変わるようなケースでJSとスタイル定義をシームレスに結合させることができます。
- 実際の実現方法はライブラリによってまちまちです。
- styled-componentsは比較的オーソドックスなReact向けのCSS-in-JSライブラリのひとつで、template string literalとしてCSSを記述します。内部では当該CSSをスタイルシートに転記し、自動生成されたクラス名でそれらを参照するようになっています。
- 他のCSS-in-JSライブラリとの比較は本稿の主眼ではないため行いません。
ルールの優先度について:
- CSSのカスケーディングルールでは、セレクタの詳細度が高いルールが優先されます。 (important など他の要因については本稿の関心ではないため省略します)
- → styled-componentsの場合、デフォルトではCSSに対応するクラスが生成され、そこにスタイルが当てられます。そのため詳細度は (0, 1, 0) です。
- → 明示的に詳細度を上げることは可能です (`&&` により同じクラスを2回宣言するなど)
- 詳細度が同じ場合、後方で宣言されたルールが優先されます。
- → styled-componentsの場合に宣言順がどのように決まるかがこの記事の主な関心です
大まかな流れ
- 全てのスタイルを入れるための単一のスタイルシート (styleタグ) を作成する。
- 生成されたCSSのハッシュ値をとり、クラス名とする。コンポーネントには当該クラス名を付与する。もしそのクラス名に対応するルールセットが定義済みでなければ、上記のstyleタグに追記する。
- styled component間に継承関係があるときは継承元のルールセットを先に挿入する。
styleタグの生成と操作
styled-componentsにおいてはstyleタグはページにつき1つでよいため、以下のように管理します。
- ブラウザではグローバル変数に入れて管理する。
- SSR時はReact Contextに入れて管理する。 (styled-componentsをSSRで使うときはサーバー側のコードで対応が必要)
- ただし、streaming SSRの場合は逐次的に生成するため、styleタグを次々に生成していく
ブラウザではstyleタグをDOM APIで挿入しますが、その操作には2つの方法が実装されています:
SSR時はstyleタグをJavaScript内でテキストデータとして保持しておきます (これを実際にHTMLのstyleタグとして実現するのは呼び出し側の責務です)
ルールのグループ化
後に述べるように、styled-componentsでは各コンポーネントのルールはスタイルシート内で連続した位置に挿入されます。このためにstyleタグの操作APIをさらにラップしたGroupedTagが実装され、使われています。
Display nameの決定
Display nameはReactのコンポーネントが持つ属性で、デバッグやテストのためにVDOMを表示する必要があるときに使われます。これは次項のComponent IDの生成に関わってくるためここで説明します。
display Nameは以下のようにして明示することができます。
const StyledButton = styled(Button).withConfig({
displayName: "StyledButton",
})`
color: red;
`;
またbabel-plugin-styled-componentsやstyled-components/macroを使っている場合は、デフォルトではdisplayNameが自動的に決定され、上記のようなコードが挿入されます。このとき、デフォルトでは変数名とファイル名に基づいた名前が採用されます。
もし利用側コードで明示されなかった場合は、元となったコンポーネントの名前に基づいたdisplayNameが付与されます。
Component IDの生成
Component IDは以下の目的で使われます。
- 他のコンポーネントのCSS内でセレクタとして利用するため。
- この次に説明するCSS IDとCSSの管理のため。
Component IDの生成 (実行時)
displayNameと異なり、Component IDは一意である必要があります。そこでComponent IDはdisplayNameをもとに以下のように生成されます。
まず、Component IDのもととなるベース名を決定します。これは、呼び出し側でdisplayNameが明示されていた場合はその名前、もし明示されていなかった場合は sc
が使われます。
次に、ベース名ごとに管理されているカウンタをインクリメントして取得します。これらのデータのハッシュを取ってベース名に加えることで、ベース名が重複しても一意な結果が得られるようになっています。
これにより以下の条件下では安定した(再現性のある)結果が得られると考えられます。
- コンポーネントを含むモジュールがリロードされることがない。
- かつ、displayNameの重複がない。
逆に、コンポーネントがリロードされると新しいIDが付与される点には注意が必要です。
Component IDの生成 (ビルド時)
ここまでの仕組みだと、SSRで使うには再現性が不十分であるとも考えられます。そこでstyled-componentsにはComponent IDを直接指定するためのAPIが存在しています。
const StyledButton = styled(Button).withConfig({
displayName: "StyledButton",
// Component IDを直接指定 (displayNameがある場合は自動的にdisplayNameと連結される)
componentId: "sc-3cf67a-0",
})`
color: red;
`;
これは通常、手書きするのではなくbabel-plugin-styled-componentsによって生成されます。
babel-plugin-styled-componentsが生成するcomponentIdには以下の情報が含まれています。
- 当該ソースファイルのハッシュ値
- 当該ソースファイル内のstyled componentsの出現順
これにより唯一性とリロードの反映を保証しつつ、クライアントとサーバーでの結果の一貫性も実現しています。
CSS IDの生成
styled-componentsでは単一のコンポーネントでも (カスケードを使わずに) 状態に応じて異なるCSSを生成して当てることができるようになっています。
const MyButton = styled.button<{ $theme: Theme }>`
color: ${(p) => p.$theme.color };
`;
そのため、Component IDとは別に、生成されたCSSごとのIDという概念が存在します (これには良い名前がないのですが、本稿ではCSS IDと呼んでいます)
これにより、コンポーネントが動的なCSS生成を行っている場合は当該要素には2つのクラス (Component IDとCSS ID) が付与されます。
CSS IDはComponent IDをベースに、CSSの動的に生成された部分の情報を加えてハッシュ値を取ることで算出されます。
ルールセットの挿入
生成されたルールセットはinsertRulesメソッドを経由してスタイルシートに挿入されます。ここで、Component IDをグループIDとして使用しています。
これは以下を行っています。
- 当該ルールセット (CSS ID) がすでに存在していれば、何もしない。
- もしこのComponent IDに対応するグループがすでに存在していれば、そのグループ内の末尾にルールセットを挿入する。
- もしこのComponent IDに対応するグループがなければ、ルールセットをスタイルシートの末尾に挿入する。 (グループが作成される)
同じComponent IDに対応するCSS IDが複数個同時に使われることはまずないので、グループ内での挿入順が問題になることはまずありません。そこで、グループ (Component ID) の挿入順がどうなるかが問題の主眼になります。
CSS Templateの評価はいつ行われるか
styled-componentsのCSSは実際に評価されたタイミングでスタイルシートに挿入されるのでした。
その評価は当該styled componentのrender内で行われます。このコンポーネントがやっていることは大まかには以下のようになっています。
function WrappedComponent() {
// 現在のpropsをもとにCSSを計算し、クラス名を取り出す
const generatedClassNames = injectStyles(WrappedComponent, props);
// classNameを上書きして元のコンポーネントを呼び出す
return (
<WrappedComponent.Original
{...props}
className={`${generatedClassNames} ${props.className}`}
/>
);
}
オリジナルのコンポーネントに渡すクラス名の計算に必要なので、オリジナルのコンポーネントが呼び出されるよりも前に呼ばれることになる点が注目に値します。
もしあるコンポーネントをstyled-componentsで二重にラップした場合、コンポーネントツリー上では祖先から順に評価されます。これは、styled-componentsの継承関係としては逆順にあたり、継承関係上の子孫から順に評価されることになります。
このままでは以下のようなコードがうまく動きません。
const MyButton1 = styled.button`
display: block;
color: red;
`;
// MyButton1を継承し、特定のスタイルを上書きする
const MyButton2 = styled(MyButton1)`
color: blue;
`;
もし上のようなルールで評価されたとすると、生成されるスタイルシートは以下のようになります。
/* MyButton2 由来のルールセット */
.f4d3ee {
color: blue;
}
/* MyButton1 由来のルールセット */
.d236f8 {
display: block;
color: red;
}
これでは宣言順で後ろにあるMyButton1のルールが勝ってしまい、意図に反します。もちろん明示的に詳細度を上げれば解決はできますが、詳細度を意識しながらスタイリングをすることになってしまい、抽象化を阻害します。
そこで、styled-componentsを使ってコンポーネントを多重にラップした場合、継承元のスタイルを先に評価するという特別ルールが存在します。これによりスタイルシートは以下のようになります。
/* MyButton1 由来のルールセット */
.d236f8 {
display: block;
color: red;
}
/* MyButton2 由来のルールセット */
.f4d3ee {
color: blue;
}
継承がうまくいかない例
先ほど説明した継承例は、styled-componentsが理解できる形で継承を行ったときにはうまくいきますが、間に別のコンポーネントが挟まっているとうまくいきません。
const MyButton1Base = styled.button`
display: block;
color: red;
`;
// MyButton1Baseをラップしたコンポーネント
const MyButton1 = (props) => {
return <MyButton1Base {...props} />;
};
// MyButton1を継承し、特定のスタイルを上書きする
const MyButton2 = styled(MyButton1)`
color: blue;
`;
この場合、MyButton2のルールはMyButton1Baseのルールに負けてしまいます。
なお、このように書いた場合はasの代わりにforwardedAsを使う必要があるなど別の困難も発生します。
宣伝
こちらの記事もよろしくお願いします。