CSS in JSとしてVanilla-Extractを選んだ話と技術選定の記録の残し方

インクリメンタルに新しい技術を取り入れる方法では、VueからReactへ段階的に移行していったという話を紹介していました。
このReactの採用を決定してから大きな論点となったのは、ReactでCSS(スタイル)をどのように書くかについてです。

Reactのスタイリング方法には、デファクトと言えるものはありません。

CSSファイルとclassNameを使う方法、CSSファイルをimportするCSS Modules、JavaScriptでCSSを書くCSS in JSなどさまざまな方法があります。
スタイリングライブラリの選定は、表現力はそこまで大きく変わりませんが選択肢が多過ぎます。
そのため、VueやReactを選ぶといった技術選定よりも難しい部分があります。

KARTE Blocks(以下、Blocks)では、最終的にReactのスタイリングにvanilla-extractというCSS in JSの一種を採用しています。
この記事では、Blocksでなぜスタイリングにvanilla-extractを採用した理由について紹介します。
また、技術選定における意思決定の残し方と記録を残す意味について紹介します。

この記事は「KARTE Blocksリリースの裏側」という連載の7日目の記事です。全10回を予定しています。
これから毎日記事を更新していくため、更新をチェックしたい方は@KARTE_BlocksのTwitterアカウントをフォローしてください!

「KARTE Blocksリリースの裏側」の記事の一覧です。

  1. KARTE Blocksを支える技術
  2. インクリメンタルに新しい技術を取り入れる方法
  3. セカンドパーティコンテンツをもつサードパーティスクリプトの作り方
  4. AWSが落ちてもGCPに逃がすことで落ちないシステムを作る技術
  5. ユーザーが自ら理解・学習するためのテックタッチなアプローチ
  6. 爆速で価値あるプロダクトをリリースするためのチームビルディング
  7. CSS in JSとしてVanilla-Extractを選んだ話と技術選定の記録の残し方 ← イマココ
  8. 0→1のフェーズで複数のユーザー体験をつなぐUIデザインを考える|wagon|note
  9. リリース後に落ちないように、新規サービスで備えておいたこと
  10. KARTE Blocksにおけるポジショニングの考え方とその狙い
  11. 「KARTE Blocksリリースの裏側」の裏側 - 複数人で連載記事を書く方法

なぜスタイリングライブラリを選ぶ必要がある?

インクリメンタルに新しい技術を取り入れる方法では、VueからReactへ移行するのにルーティングを境界線としてVueとReactを共存する方法について紹介しました。
ルーティング単位でVueとReactは共存できますが、スタイルに関する情報は共有が難しいです。
なぜならVueの場合は単一ファイルコンポーネントの中にスタイルが書かれており、このスタイルは最終的にはJSファイルの中に埋め込まれます。
そのため、Vueの単一ファイルコンポーネントにかいたスタイルは基本的にはReactとは共有が難しいです。

また、Vueで書かれたスタイルを外部ファイル化(つまりCSSファイル)して、スタイルを共有するという手段もありますが、これもしませんでした。
なぜなら、Reactは新しい画面でまず試してみることが決まっており、元々共有する部分が少なかったためです。
また、共有しようとするとVueのコードも変更する手間がかかるため、ReactはReactでスタイルを書くことに決めました。

コンポーネントのベーシックな部分は、社内にはBaisuというCSSフレームワークがあるため、ここはすでにVueとReactで共有されていたのも関係しています。
Baisuは乱暴に言えば、社内で使われているBootstrapのようなCSSフレームワークで、class属性に指定できるグローバルなクラス名を提供します。
基本的にBaisuのスタイルを使えば、VueとReactのテイストは似たようなものになるため、これもVueとReactのコンポーネントでスタイルを共有しなかった理由です。
逆を言えば、Baisu部分はVueとReactで共有されていました。

一方で、ReactはReactで書くUIにおけるスタイリング方法を決める必要がありました。

CSSのスタイリング方法を選ぶ難しさについて

最初に紹介したようにReactにおけるCSSのスタイリング方法のデファクトはありません。
これは、Reactに限った話ではなくCSSの書き方自体が多種多様であるのも関係しています。

SassやLessといったCSSプリプロセッサ、BEMやOOCSSといったCSSの書き方のルールの幅が広いことからもわかります。
これは、CSSに足りない部分をプリコンパイルやルールで補っているからだと思います。

そして、現在もCSSの仕様は発展し続けています。
たとえば、現在策定中のCascade Layers(@layer)が利用できるようになったら、また違う形のスタイリングツールが登場する可能性はあります。

また、CSS Nesting Moduleも仕様策定が進み実装されれば、多くのスタイリングツールに影響を及ぼすでしょう。

まだまだ変化する状況において、どのスタイリング方法を選んでも、それがずっと正解であるということはないと考えています。
しかし、何かを選ばないと実装が進まないので、現在の重要だとする観点をもとにスタイリング方法を選定しました。
加えて、将来的スタイリング方法を変更する時に役立てるように、技術選定の記録を残すことにしました。

Reactでのスタイリングライブラリの選定

Reactでのスタイリングライブラリの選定時に重要視した観点は次のとおりです。

  • スコープを持っている
  • メンテナンス性が高い
  • ポータビリティ

最初の「スコープを持っている」は、コンパイル時などにクラス名が衝突しないようにハッシュをつけたりする機能を持っていることです。
この機能を持っていれば、意図せずグローバルなスタイルは定義されなくなるため安全です。
CSS in JSをはじめとする大体のライブラリはこの機能を持っています。

また、コンポーネント単位でスタイリングをすれば、自然とグローバルに余計なCSSを追加する動機が少なくなります。
(これはグローバルなCSSを減らすことで、スタイルの数とパフォーマンスの悪化を非線形的にするためです。)

次の「メンテナンス性が高い」は、そのままの意味ですが、メンテナンス性がよいかは主観が入ります。

最後の「ポータビリティ」は移植性についてですが、これは失敗した時やそのライブラリのメンテナンスが止まった時に抜け出せる方法があるかどうかという点です。
CSSを生成する独自言語ではない点やそのライブラリにパーサなどの移行に利用できるような仕組みがあるかが観点になります。

どの条件も重要ではありますが、いまいちライブラリ選定の決め手とはなりにくいものでした。

スコープはほとんどのCSS in JSが持っていおり、メンテナンス性が高いかどうかは主観的なものが入るため結局好みの問題が混じりそうです。
ポータビリティについては、現代的な実装をしてるライブラリならば、大体なんとかなるような作りになっています。
これはライブラリそのもののメンテナンス性を上げると、同時にポータビリティが高くなりやすいためです。

また、CSS in JSやCSSプリプロセッサなどいろいろなCSSの記述方法はありますが、最終的にはCSSとなるため表現力の限界はCSSの仕様で決まります。
そのため、スタイリングツールの機能は似たようなものとなって、単純に選ぶのが難しくなっていました。

実際に比較対象を探す場合は次のリポジトリが参考になります。

最初の段階で、CSSを直接利用する方法やSassを使う方法はスコープの問題があるため、選択しませんでした。
CSS Modulesも、今後のメンテナンス性に不安があるため選択しませんでした。
また、BlocksのReactはwebpackではなくViteを使って開発しているため、webpackのcss-loaderと関係が深いCSS Modulesは選択できませんでした。

そのため、候補はCSS in JSと呼ばれるスタイリングライブラリの中から選定していくことにしました。

触りながら観点を確かめる

観点として紹介していた「メンテナンス性が高い」かどうかは、実際に触ってみないと分からない部分も多いです。
そのため、候補となりそうなスタイリングライブラリを、作成中のReactの画面で実際に使うことで観点を満たせそうかを確認していきました。

CSS in JSのライブラリをみていき、その中で最初に候補として上がったのはEmotionでした。
Emotionは広く使われていて、styled-componentsと同じようにコンポーネントとしてスタイルもかけます。
広く使われていてノウハウもあるためメンテナンス性やポータビリティも問題なさそうです。
また、Emotionはテンプレートリテラルを使った String Styles と Object Styles のどちらもサポートしています。
String Stylesなら既存のCSSとほぼ同じため、学習コストはほとんどありません。

String Styles:

import { css } from '@emotion/css'
const color = 'darkgreen';
render(
  <div
    className={css`
      background-color: hotpink;
      &:hover {
        color: ${color};
      }
    `}
  >
    This has a hotpink background.
  </div>
)

Object Styles:

import { css } from '@emotion/css'
const color = 'darkgreen';
render(
  <div
    className={css({
        backgroundColor: 'hotpink',
        '&:hover': {
          color
        }
    })}
  >
    This has a hotpink background.
  </div>
)

もうひとつの候補として出てきたのは、vanilla-extractでした。
vanilla-extractはちょうどこの選定作業をしているタイミングで1.0.0がリリースされました。

vanilla-extractは、TypeScriptでスタイルを書くCSS in JS(TS)です。
.css.tsというファイルにスタイルを書かないといけないという制約やコンパイルが前提となっています。

vanilla-extractでは次のように、.css.tsというファイルにスタイルを定義します。

MyComponent.css.ts:

import { style } from '@vanilla-extract/css';
const color = 'darkgreen'
export const MyComponent = style({
  backgroundColor: 'hotpink',
  ':hover': {
    color
  },
});

このスタイルを利用する場合は、次のようにcss.tsをimportして、className属性に渡すだけです。
webpackやviteのビルド向けのプラグインが、ビルド時に.css.tsをCSSファイルへ変換し、それぞれのclassName属性にはハッシュ化されたクラス名が渡されます。

MyComponent.tsx:

import * as styles from "./MyComponent.css";
const color = 'darkgreen';
render(
  <div
    className={styles.MyComponent}
  >
    This has a hotpink background.
  </div>
)

実際にスタイルを定義するのはTypeScriptファイルですが、やっていることはCSS Modulesによく似ています。
これは、vanilla-extractCSS Modulesの後継を意図するような作りとなっていて、vanilla-extractはCSS Modules-in-TypeScriptと言えるフレームワークです。
またvanilla-extractの作者は元々CSS Modulesのメンバーです。

vanilla-extractをReactで作成中の画面に入れると、基本的な表現力は他のCSS in JSライブラリと特別な差はありませんでした。
また、vanilla-extractはTypeScriptでCSSを書くため、必然的に型チェックができるObject Stylesの記法しかサポートしていません。
TypeScriptでスタイルを書くため、スタイルの型チェックによって存在しないプロパティなどはコンパイルエラーにできます。

EmotionとVanilla-extractを比較する

Emotionvanilla-extractどちらも触ってみて、どちらの方法でも十分実装できることを理解しました。
しかし、両方を使うわけにはいかないので、どちらか一方に統一する必要があります。
どのスタイリング方法を選んで実装できますが、観点ごとにEmotionとvanilla-extractを比較しました。

Emotionvanilla-extractの機能的な比較は、次のリポジトリも参照してください。

スコープ

Emotionvanilla-extractどちらも生成されたクラスが被らないようにする機能を持っています。
そのため、どちらもスコープに関する問題はありません。

メンテナンス性

書き方

EmotionはString StylesとObject Stylesどちらもサポートしています。
一方でvanilla-extractは、Object Stylesのみのサポートです。

String StylesはただのCSS文字列を書くだけなので見慣れた記法ですが、基本的にはJSのテンプレートリテラルの中にCSS文字列を記述することで実現しています。
この記法はわかりやすいですが、エディターにプラグインなどを入れないとただの文字列となってシンタックスハイライトやコード補完が効かないという欠点があります。

import { css } from '@emotion/css'
const color = 'darkgreen';
// styleのheadに追加をして、対応するclass名を返すイメージ
const className = css`
  background-color: hotpink;
  &:hover {
    color: ${color};
  }
`

一方のObject Stylesは、ただのTypeScriptで書くコードなのでシンタックハイライトやコード補完も問題ありません。
また、TyepScriptで書くので型チェックもでき、存在しないCSSプロパティを書くとコンパイルが失敗します。

書き方に関しては、どちらもTypeScriptと相性のよいObject Stylesが使えるので、どちらでも問題なさそうです。
ただ、両方の書き方を混在はさせたくないため、特にLintを追加する必要なく統一できるvanilla-extractの方が楽でした。

TypeScript

インクリメンタルに新しい技術を取り入れる方法でも紹介していますが、BlocksのコードベースはTyepScriptになっています。
そのため、CSS in JSを使うならば、TypeScriptと相性のよい方が理想です。

Emotionとvanilla-extractはどちらもTypeScriptで書けるようになっています。

EmotionはTypeScriptでも書けるですが、vanilla-extractはTypeScritpじゃないと書けないという違いはあります。
vanilla-extractはCSS Variablescalcといった部分でも型を扱えるようになっています。

細かいところまで見ていくとvanilla-extractの方がTypeScriptサポートはよくできています。

ポータビリティ

Emotionはメジャーで、vanilla-extractリリースされたばかりでマイナーでした。

Emotionはメジャーであるため、Emotionをやめる場合も乗り換え手段が色々あります。
vanilla-extractはまだマイナーであるため、vanilla-extractをやめる場合の手段を確認する必要がありました。

vanilla-extractが扱うのはObject Stylesであるため、ツールから扱うのが難しくないただのオブジェクトです。
また、内部的にtransformCssでこのオブジェクトからCSSのコードを生成しています。
このことから、機械的に移行できるようなマイグレーションツールは何とか書けると考えました。

総合的なポータビリティはEmotionの方が高そうです。

その他の観点

パフォーマンス

Emotionは、JavaScriptでCSS オブジェクトモデル (CSSOM)を使って動的にスタイルをあてます。
一方のvanilla-extractは、ビルド時に定義したスタイルを元にCSSファイルを生成します。
そのため、スタイルはCSSファイルにビルドされ、実行時はCSSファイルをロードするだけです。

CSSファイルもコード分割で分解されるため、アプリケーション全部のCSSを一度に読み込むわけではありません。
動作的には、TSがコード分割されてchunkとなった後にvanilla-extractのプラグインによって.css.tsをCSSにするため、chunkごとのCSSファイルが生成されます。この辺はvanilla-extractのプラグインがやるため特に意識する必要はありませんでした。

実行時にCSSOMを使ってスタイルを操作するよりも、ビルド時に作成されたCSSファイルをロードする方がパフォーマンスがよいケースは多いです。
これは、実行時にCSSOMでスタイルを操作する場合は、Scriptingの中でスタイル操作をするため、Scriptingの時間が長くなるためです。
Scriptingが長くなると実際にUIが表示された操作できるまでの時間も長くなってしまうため、トータルのブロックキング時間が長くなる傾向があります。

もちろん、すべてのケースで静的なCSSをロードする方がよいわけではありませんが、ビルド時に解決できるのはvanilla-extractのよい点です。

vanilla-extractを選択する

上記の観点などを基に議論した結果、vanilla-extractを使うことにしました。
観点では正直どちらにするかという結論は出ませんでした。
これは、何度も書いていますが、おそらくどちらのスタイリングライブラリを使っても問題なく実装はできるためです。

最終的にvanilla-extractを選んだのはTypeScriptの親和性の高さと面白そうだからという興味本位です。

このスタイリングライブラリの選定は、1〜2週間ぐらい開発を進めながら、そこで両方とも使って実装してみることで比較していました。
決めた後、試しにEmotionで書いていた部分をvanilla-extractに書き直して、vanilla-extractを使っていくことにしました。
(この書き換え作業自体も表現力の比較の最終確認として利用できました)

vanilla-extractを利用してみての結果

vanilla-extractを使ってそのまま開発は進み、KARTE Blocksは正式リリースされています。
そのため、Reactを使っている画面のスタイリングはvanilla-extractを使って書かれています。

実際にチームでvanilla-extractを使ってみての意見をまとめてみました。
ポジティブな意見としては、TyeScriptとの親和性の高さやclassNameに指定するただの文字列として扱えるという点が多かったです。
ネガティブな意見としては、記述の手間がEmotioncssタグ関数などに比べて多いという話がありました。

いいところ

  • ただのTypeScriptのコードであること
    • エディターでのCode JumpやUsage Referencesなど普通のことが普通にできる
    • どのスタイルがどのコンポーネントで利用されているかが一発でわかる
  • 型がついた Class Name CSS Framework という感じで使える
    • styled component だと、小さなコンポーネントの命名に困るケースが多かった(実体はただのdivとスタイルなので、コンポーネントとしての名前がつけにくいことがあった)
    • また styled.div`` だと構造 + style 付きの component 書くのには物足りなさがあった
    • vanilla-extractならただのclassNameに入れるだけなので取り回しがよい
  • classNameを使うだけなので自由度がある(他のclassNameを使うものと組み合わせできる)
  • 存在しないプロパティを間違えてつけることがなさそうで、CSSを書くより楽だった

わるいところ

  • 学習コストはある
  • .css.ts ファイルと別ファイルにすることが規約になっている(これは好みが分かれる?)
  • 記述量が増えるので書き味が下がる

vanilla-extractで、いくつかの画面を実装してみた結果、特に機能的に不足した部分はありませんでした。
また、学習コストについては、Object Stylesの書き方の慣れと今後のドキュメントや人気の上昇によって解決されてくる問題だと考えています。
vanilla-extractはState of CSS 2021のCSS in JS部門で満足度が一位となったため、マイナーであるという懸念はだんだん解消されてくるかもしれません。

技術選定をした意思決定のログを残す

今回のCSS in JSの技術選定のように、なぜそれを選択したのかの記録を残すことは重要です。
なぜなら、人の記憶はあいまいであるため、なぜ導入されたのかがいつの間にか忘れられてしまうためです。
導入した理由がわからないと、導入したこと自体に意味があると考えてしまいます。導入して理由が不明だと取り除いたり置き換えたりする時の障壁となります。

そのため、なぜそれを導入したのかという記録を文章などの形で残すことは重要だと考えています。
この「KARTE Blocksリリースの裏側」という連載も、選定理由を説明する記事が多いため、その記録のひとつの形とも言えます。
特に、今回のようにあまり強い理由がない場合などは記憶から漏れやすいので、文章として残しておくことは大事です。

今回のCSS in JSとしてvanilla-extractを導入した意思決定のログは、社内のesaにArchitectureDecision Records(ADR)として記録されています。
ArchitectureDecision Records(ADR)とは、名前のとおりアーキテクチャをなぜ選んだのかという意思決定の記録のことです。

どのような形式で、どの程度詳細に、どこへ残すかはチームによって異なります。
BlocksでのCSS in JSフレームワーク選択のADRは、次のような形で社内のesaに残されていました。

BlocksでのCSS in JSフレームワーク選定

Status

  • accepted (承認)

Context

BlocksでReactを使った開発を新規で行うため、CSSでのスタイルの記述方法について意思決定が必要となった。
Vueとは異なり、Reactにはデファクトのスタイル記述方法、CSS in JSなどが決まっていないため、選択が必要となった

主要なCSS in JSのまとめ。

Decision

vanilla-extractをCSS in JSフレームワークとして採用する

Consequences

主な対象としてstyled-components形式のEmotionとCSS Modules-in-TypeScript形式のvanilla-extractが比較対象となった。

  • styled component/emotion
    • CSS in JSでメジャー
    • 最終生成物はJSで中にスタイルが入ってる
    • 欠点
      • 補完にはエディタのプラグインが必要
  • vanilla-extract
    • 1.0でたばかりなのでマイナー
    • CSS Modulesの悪いところは解消されてる
    • TypeScriptサポートとか、.css.ts に書かないといけないなどちょっと厳密
    • 最終生成物がCSSファイル (コンポーネントごとにCSSを切れる)
    • 欠点
      • ビルド前提の構造になっている(webpack, viteなどのプラグインは公式で提供されている)
      • メジャーじゃない

論点として次の点をあげた。

  • TypeScript と CSS どっちで書きたいか(Object StylesとTagged Templateが決まる)
    • → どちらでもいい
  • 一度書いたスタイルはVueと共有できることを目標にするかどうか
    • → 別途共通CSSファイルを作って使うのでどちらでもいい (注: これはBaisuやデザインシステムで用意するという別の話があったため)
  • パフォーマンス
    • 新規開発なので、そこまで論点にならなかった

議論では特にどちらに決定する有意な差が出なかった。

最終的に興味軸で vanilla-extractを使うことにした。

tacamy
なるほど💡 vanillaの方おもしろそう(個人の感想です)
slackのURL

スタイリングルール

  • .css.ts にスタイルを書かなければいいけない
  • Viteの場合は、style(object, debugId) でデバッグIDをつける
  • スタイルの合成は vanilla-extract側か、className の指定 どっちでするか
    • baisuのスタイルは className をつかうので、classNameに統一した方がわかりやすそう

感想

styled-component/emotionに比べたvanila-extraの良いところ

  • 型がついた class name css framework って感じで使える
  • classNameを使うだけなので自由度があるという意見

emotionに比べたvanila-extraの微妙なところ

  • globalStyleの扱いが少し難しい
  • 疑似要素はselectorで書くのを徹底させたほうが良い

ADRを残す意味

なぜArchitectureDecision Records(ADR)を残す必要があるのでしょうか?

ADRはデザインドキュメントなどとは違い、議論する材料ではなく、基本的には過去の記録です。
(もちろん議論した内容を書いて、それをADRにコピーすることはありますが、議論する場合は違う形になります。)

今回、vanilla-extractを使う強い理由はありませんでした。
そのため、vanilla-extractに強い必然性があった訳ではありません。(そのときのチームがTypeSriptを使おうという状態に引っ張られている可能性もありました)

このようなケースは後から見た時になぜその技術が使われているかは、コード上に残っていない可能性があります。
他のCSS in JSを使っていてもまったく問題はないでしょう。
そのなぜを記録するのがADRです。

捨てるためにドキュメントを書く

端的に言えば、ArchitectureDecision Records(ADR)は決めたものを捨てるために書くドキュメントだと考えています。
ADRはこれからの未来の話をするデザインドキュメントでもないし、Living Documentationのように現在を表すドキュメントでもありません。
ADRが扱うのは基本的に過去です。

この過去の記録が役に立つのは、その決めたことをやり直すケースです。

たとえば、vanilla-extractを別のスタイリングライブラリへ置き換えようとなった場合、
ADRがあることによってなぜこれが使われているのかということを記録が読めます。
その置き換えようというものは大体が負債と呼ばれるものであるため、それが置き換えていいものかどうかを知る資料としてADRは役に立ちます。
理由がわからないと多くの人は、無理に置き換えずに蓋をして放置してしまいます。

インクリメンタルに新しい技術を取り入れる方法でも紹介していたように、
新しいものを開発すればするほど古くなるものは出てきます。
そうした古くなったものは何かによって置き換えないと、新規の開発にも影響がでてきます。
ADRはこういった置き換えるというタスクを補助してくれます。

一方で、ADRが役に立つのは少し未来の話となってしまうため、意識して残さないとこういったドキュメントは残りにくいです。

Lightweight ADRという言葉があるように、厳密に残すよりも気軽に残る状態を目指すのがよいと思います。
GitHub Discussionを使ったり、Slackでの会話のURLを記録しておくなど、議論の内容が分かれば十分だと思います。
それに対して、他の選択肢は何があったのか、その結果なぜ選んだのかという補足を入れるだけでも十分役に立ちます。

こうしたドキュメントは形式化してしまうと形骸化してしまうため、形自体はそこまで重要ではありません。
たとえば、IssueやPull Requestなどになぜそうしたのかを書いておくのも十分重要な記録です。
他のチームにも共有するためにドキュメント化するなど、他の行動に紐づけるぐらいの方がよいのかもしれません。

そのため、ADRはADRという名前で呼ばれなくなっていくと思いますが、なぜそうしたのかを書く習慣に繋がるフレームワークの一種だと考えています。
ADRというドキュメントが重要なのではなく、なぜそうしたのかを記録することが重要なのです。

この記事が書けたのもADRという形でドキュメントが残っていたからなのは間違いありません。

おわりに

この記事は「KARTE Blocksリリースの裏側」という連載の7日目の記事です。全10回を予定しています。
これから毎日記事を更新していくため、更新をチェックしたい方は@KARTE_BlocksのTwitterアカウントをフォローしてください!

また「この記事が面白かった」などの感想や「KARTE Blocksリリースの裏側」の連載に対する感想などを #KARTE_Blocksのハッシュタグに書き込んでください!

最後に、KARTE Blocks自体の開発に興味がある!というエンジニア(インターンも!)を募集しています!
詳しくは弊社エンジニア採用ページ採用スライドをご覧ください!