CSS-in-JSを作った話
モチベーション
ReactのCSS選定はVueなどに比べ選択肢が非常に多く、選定が難しいところが課題だと思っていた。
自分で自炊したCSS-in-JSでストレスフリーでやっていきたい、そんな思いがあった中、
ゼロランタイムが成熟し始めてきていたのでそろそろやるか、という意気込みで開始した。
UIライブラリでもCSS-in-JSでも良かったが、ReactRSC以来パフォーマンスを気に掛ける必要があり実行時に解決されるEmotionやStyled-JSXはTailwindと通常のCSSの進化に淘汰されてしまったことを残念に思っていた。
興味のあったCSS-in-JS
vanilla-extract・StyleX・Linaria・Kuma-UI
作成するにあたって
・ほぼゼロランタイム
・機能を沢山作るより最低限のapiの実装で行きたい
・設定を煩わしくしたくないのでバンドラー専用プラグインを作りたくない
・補完が効く・リンターが使えることが前提
・React19 & Next.jsサーバーコンポーネントに対応させる
・JSXを直接コンパイルできる
・理想的にはコンパイル時間が速い
コンセプト・特徴
通常のCSS ModuleやCSS in JSはバンドラやcss-loaderを通してバンドラーのビルドに結合されていますが、Plumeriaはビルドに結合していないためCLIでコンパイルを行います。
ビルドを実行する前にスタイルのデバッグのサイクルを事前に回せます、またビルドパイプラインに繋げることも出来るので当然ビルドが走る前にリンティングや型チェックが実行されるためビルド時エラーや本番環境のスタイルの壊れが発生しない特徴があります(詳細はドキュメント)。
Plumeriaの機能
import { css, cx, rx } from '@plumeria/core'
export const styles = css.create({
header_main: {
position: 'absolute',
top: 0,
zIndex: 1,
width: '100%',
},
})
cssがNode.js APIのfsの様にクラスオブジェクトで、チェーンから以下の4つのAPIがあります。
- css.create: クラスネームを返り値とするオブジェクトを作成します。
- css.global: グローバルスタイルを直接オブジェクトで定義します。
- css.defineThemeVars: CSS変数として定義できるthemeを:rootに作成します。
- css.keyframes hashを返り値とする@keyframesのオブジェクトを作成します。
cx・クラスネームや擬似クラス・要素を結合します。
<div className={cx(stylex.box, state ? styles.color1 : styles.color2)}>
次の様な使い方もできます。
[cx(css.pseudo.hover, css.pseudo.after)]: {...}
インストール
Viteではデフォルトで動きますが今回はNext.jsを使うことを前提に進めます。
パッケージマネージャは各自インストール:
pnpm i @plumeria/core
pnpm i -D @plumeria/compiler @plumeria/next
Next.jsは開発環境でもSSRを行うため、ServerComponentsで動作させるためにlayout.tsxに@plumeria/nextを設定していきます。
import { ServerCSS } from '@plumeria/next'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<head>
<ServerCSS />
</head>
<body>
<main>{children}</main>
</body>
</html>
)
}
メディアクエリ
使い方はcss.media.max()でwidthかheightかを指定し数値を入れます。
関数なので配列で囲んでセレクタとして使用します。
import { css, cx } from '@plumeria/core'
export const styles = css.create({
header_main: {
[css.media.max('width: 900px')]: {
position: 'absolute',
top: 0,
zIndex: 1,
width: '100%',
},
},
})
擬似要素(クラス)
hoverやafterなどの擬似クラス、要素はセレクターのネストに制限を設けておりメディアクエリの中に1回だけ擬似要素は指定できますが、擬似要素の中にはメディアクエリをネストできないようにしています。コンパイラはこの挙動をよしなに捌きますが、これを型レベルで許してしまうと無限ネストになってしまうため、意図的に制限しています。
import { css, cx } from '@plumeria/core'
export const styles = css.create({
link_container: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 60,
height: 40,
[css.pseudo.hover]: {
color: '#515151',
textDecoration: 'underline',
},
},
})
CLI
npx css
通常のコンパイルで速度が一番速いです。
npx css --log
通常のコンパイルに加えてログを残します。
npx css --type-check
コンパイルを実行する直前にタイプチェックを実行します。
これをビルドパイプラインに組み込むことで型安全性を未然に検知できます。
その後にESLint Pluginを作成した
これだけだと型チェックがCSSの値に効かなくて、誤字ったときに間違ってコンパイルされてしまいます。そこでstylexjsのように自社で作るeslint-pluginを考えましたが、vanilla-extractやpigment-cssでも使えるように外部向けに作りました。ただcss.createのスコープに限定すれば余計なeslintignoreを書かなくて済むのはいいのですが、汎用性がなくなってしまうため外部向けにも使える様にしました。
Linter
-
sort-properties
recess式のソート(stylelint-config-recess-orderからリストをフォークで頂いています) -
validate-values
値の検証を行いエラーや警告を出します。1ヶ月ほど掛けて作成しました。
非推奨と実験機能を除くプロパティが全てリストされてあります。 -
no-unused-keys
関数の中にある呼び出しされていないキーに警告を出します。
これで使ってないキーを消すことが簡単になります。
副産物
zss-engineというシステムバックエンド寄りのライブラリを作成しました。
Zero-runtime Style Sheet Engineの略です。
これはもともとinternalとして切り離していたフォルダが外部向けに使えるように公開しています。
transpilerやinjectCSSなどの誰でもCSS in JSが作成できる関数が入っています。
今後の課題
- メンテナーが自分一人だけ(理想的には是非メンバーを追加して行きたい)
- Next.jsのSCの挙動が特殊なため今後追従して開発しなければならない
- MetaやVercel、Googleがバックについている訳ではないため認知されないからと落ち込まないこと
自分で使ってみた所感
ESLintで型チェックをするのが良いです、ソートも機能するためcssをstylelintでソートしているようにセーブ時に並び替えられるので使っていてストレスを感じません。
CSS Modulesよりパフォーマンスが出るので仕事でも使うかも。
作っていて困ったことと考えたこと
linaria
に触発されて亜種としてplumeria
という名前でオブジェクトで書けるCSS in JSを作りました。空いていたcinerariaと迷いましたがこちらは縁起が悪い可能性があるということで見送り、plumeriaに決定。
ライブラリ自体の命名が難しく2回くらい引っ越してプルメリアという名前に決まりました。
機能の実装より機能を削ることを検討することが多く必要ない機能をとにかく削りました。
その他
例えばclassNameはスニペットを展開する時に文字列展開をしますがオブジェクト展開するようにVSCodeを設定できます、これはCSS Modulesやオブジェクト系のCSS in JSを使う上で必須の設定になります。
{
// Place your snippets for javascript here. Each snippet is defined under a snippet name and has a prefix, body and
// description. The prefix is what is used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders. Placeholders with the
// same ids are connected.
// Example:
// "Print to console": {
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"React className": {
"prefix": "cn",
"body": "className={$1}",
"description": "React className with curly braces",
}
}
もし、あった方がいい機能や、消した方がいい機能があれば随時コメントで教えていただければ、メンテナーが時間を許す限りの範囲で実現するので宜しくお願いします。
Discussion