Open22

Molcss: オリジナル Atomic CSS-in-JS の作成メモ

iMasanariiMasanari

オリジナルな Atomic CSS-in-JS を作成するためのメモ書き。
理想のスタイル定義を求めて自作します。

コメント・いいね等歓迎です。筆者のやる気になります。

iMasanariiMasanari

作成中でバグ等あるかもしれませんが、npmに公開しています。下記コマンドでインストール可能です。

npm install molcss

今のところ、viteにのみ対応しています。Webpackは対応中です。
Webpackはβ対応。一応動くはず。

iMasanariiMasanari

仕様

  • ゼロランタイムなCSS-in-JS
    • ランタイムCSSは今回のスコープ外
  • 重複するCSS定義は内部で積極的に再利用する(AtomicなCSS)
  • スタイルのマージが可能

記述方法と動作イメージ

Babel変換前

import { css, mergeStyle } from '...'

const a = css`
  color: black;
  padding: 1px;
  margin-top: 4px;
`

const b = css`
  color: black;
  padding: 2px;
  margin: 3px;
`

const c1 = mergeStyle(a, b)
const c2 = mergeStyle(b, a)

Babel変換後

/*
generated css
.c0 { color: black; }
.p1 { padding: 1px; }
.p2 { padding: 2px; }
.m3 { margin: 3px; }
.mt4 { margin-top: 4px; }
*/

import { mergeStyle } from '...'

const a = 'c0 p1 mt4'
const b = 'c0 p2 m3'
const c1 = mergeStyle(a, b) // 'c0 p2 m3'
const c2 = mergeStyle(b, a) // 'c0 p1 m3 mt4'
iMasanariiMasanari

成果物

  • クライアント用スクリプト (css, mergeClassをエクスポート)
  • バンドラー用プラグイン (まずはviteプラグインを想定)
    • vite
    • webpack
  • babelプラグイン(バンドラー用プラグインがない場合のfallback用)
iMasanariiMasanari

最近作成したtw-tagはある意味でプロトタイプ。Tailwind CSSをCSS in JS風に記載するライブラリ。

https://qiita.com/iMasanari/items/108f032baa4ce36bd087

違い:

  • tw-tagはCSSではなくTailwind CSSのクラス名で記述する
    • ↔ CSS in JSならクラス名を覚える必要がない
  • tw-tagはTailwind CSSを使用するため、(ビルド後の)クラス名が人間にとって読みやすい
    • ↔ CSS in JSならビルド時にクラス名が決定されるため、クラス名を短くできる
iMasanariiMasanari

Atomic CSS in JS固有の課題とその解決案

詳細度が同じスタイル定義がある場合、記載順によって適用されるスタイルが変わる可能性がある。Atomic CSS in JSの場合、スタイル定義を積極的に再利用するため発生しやすい。

例)

  • メディアクエリの違い
  • 擬似クラスの違い
  • shorthandによる記載方法の違い
iMasanariiMasanari

メディアクエリの違い

configファイルやオプション等で、使用するメディアクエリの値と順番を指定する。

// config
{ media: ['sm', 'md', 'lg'] }

// component
const style = css`
  /* ... */
  @media (--sm) { /* ... */ }
  @media (--md) { /* ... */ }
`

TODO: css`...`内で順番を破った場合の順番をどうするか(configの順か、css`...`記載順か)

iMasanariiMasanari

擬似クラスの違い

&:hover&:active、どちらを優先するか。
→ いったん、セレクターを辞書順ソートで決める方針で

iMasanariiMasanari

shorthandによる記載方法の違い

案1: longhandに展開し、そのクラス名を使用する

css`padding: 1px; padding-top: 2px;` // 'pr1 pb1 pl1 pt2'

欠点:

  • クラス名が長くなる
  • shorthandの展開ロジックが必要

案2: CSS出力順などでカバーする

CSS出力順を、shorthand → longhandの順でソート出力するようにすれば、スタイルが混ざることはない。
スタイル定義をマージする際は、shorthandなクラス名がある場合、出現済みのlonghandを無効化(上書き)することで対応可能。

欠点:

  • マージ処理が複雑になる

案2で進める予定

iMasanariiMasanari

上述の課題など残作業はたくさんあるが、いったんそれっぽい感じには出来てきている。

<header class="js-header bv1 bw1 bq3 Q2 bA0 bk1 bl0 bj0 bC0">
  <div class="bn1 y4 v0 bp0 bB0">
    <div class="aR1">
      <div class="av0 aw0"><a href="/" class="bh0a">Tech SANDBOX</a></div>
      <p class="av1 aw1 bm3">技術ブログ改め、Qiitaの下書き</p>
    </div>
    <a href="https://qiita.com/iMasanari" target="_blank" rel="noopener"
      class="be0 bb0 bc0 bd0 aQ0 g0 Q0 bo0 bk0 bl0 bj1 bo1b">
      Qiita
    </a>
    <a href="https://github.com/iMasanari" target="_blank" rel="noopener"
      class="be0 bb0 bc0 bd0 aQ0 g0 Q0 bo0 bk0 bl0 bj1 bo1b">
      GitHub
    </a>
  </div>
</header>
iMasanariiMasanari

【完了】ランタイムCSS案

/** @jsxImportSource molcss/react */

const dynamicStyle = (value: string) => css`
  color: ${value};
`

const Component = () =>
  <div css={dynamicStyle('red')} />

上記の形式で実装済み。
以下は、対応時のメモ書き。


今回のライブラリではビルド時(babel処理時)に最適化を行うため、実行時のCSS生成(emotionでいうcss`padding: ${num}px;`のような変数埋め込み)のサポート予定はない。

が、もしサポートするならという形で、案を書いていく。

記載方法

const Foo = () =>
  <div className={runtimeStyle({ transform: `translate(${x}px, ${y}px)` })} />

// staticなスタイルと合わせる場合、`mergeStyle`で合成する
mergeStyle(css`/* ... */`, runtimeStyle({ /* ... */ }))

css関数には手を加えない。

実装方法案

「Atomic CSS in JS固有の課題とその解決案」で記載した通り、CSSの記載順が重要である。つまり、ランタイムでCSSを追加して、期待通りにそのCSSを反映させるのは難しい。そのため、CSS変数を使用して下記の2段階で実装を行う。

  • runtimeなCSSにはCSS変数の定義を行う
  • staticなCSSにはそのCSS変数を使用したプロパティを記述する

例えば、下記のようなコードの場合、

runtimeStyle({ color: 'red' })

runtimeなCSSには--runtime-color: red;が、staticなCSSにはcolor: var(--runtime-color);が定義される。これによって、runtime時に生成されるCSSがどこに挿入されようとも、CSS記載順による影響は受けない。
あとは、mergeStyleがいい感じにやってくれる(はず)。

iMasanariiMasanari

@emotion/reactのように、jsxにcssプロパティを記載する場合に面白くなりそう。

  • Fooのようなユーザー定義コンポーネントの場合: runtimeなCSSにCSS変数を追加
  • divのような組み込みコンポーネントの場合: インラインスタイルにCSS変数を定義

jsxImportSourceを使用してcssプロパティ内にスタイルを書いてもらえば、上記の2パターンを自動で切り分けれるはず。
インラインスタイルに記述した場合、(たぶん)CSSOMの再構築は行われなくなりパフォーマンスが向上する。

とはいえReact専用ライブラリにする予定はないので、案だけ書き残して供養。

iMasanariiMasanari

css`padding: ${num}px;` のような形式もサポートするように対応中。
Reactの場合、このような感じにする予定。

export default ({ num }) => {
  const runtimeValue = useRuntimeValue()

  const style = css`
    padding: ${runtimeValue(`${num}px`)};
  `

  return <div className={style} />
}

useRuntimeValueを挟むことで、Reactのレンダリング(useInsertionEffect)に適応させる。

iMasanariiMasanari

関数オーバーライドすれば、埋め込みありなしで返り値の型を変更できた。
useRuntimeValue ではなく、型の違いで React 対応しても良さそう。

interface CssTagFunction {
  (template: TemplateStringsArray): string
  (template: TemplateStringsArray, ...substitutions: string[]): (ctx: RuntimeStyleContext) => string
}
const staticStyle = css`color: red;` // type: string

let foo = 'blue'
const runtimeStyle = css`color: ${foo}` // type: function
iMasanariiMasanari

実装完了。
staticなクラスはこれまで通り className propに、dynamicなクラスは css propに記載可能。

/** @jsxImportSource molcss/react */

const dynamicStyle = (value: string) => css`
  color: ${value};
`

const Component = () =>
  <div css={dynamicStyle('red')} />
iMasanariiMasanari

作成中だけど一通り機能は出来たのでnpmで公開。パッケージ名はmolcss
下記のコマンドでインストール可能。

npm install molcss

https://www.npmjs.com/package/molcss

iMasanariiMasanari

ブログにも反映完了。

https://blog.imasanari.dev/

ただし、vite-pluginではAstroには対応しきれなかった。(Astro側のバグな気がする)

  • devモードの場合CSS読み込みで404エラーが発生する(スタイルは反映される)
    • CSSは<style>埋め込みと<link>参照で(なぜか)2回読み込まれるが、molcssのCSSは仮想モジュールなので<link>参照で404エラー
  • Astroがv2.7.0であればastro buildは通るが、執筆時最新のv2.10.1ではエラーでビルドできない

だが、Tailwind CSSを使用していたときと比べてクラス名が短くなり、HTML / CSSが軽量化した。

いっぽう、VS Codeでcss`...`のシンタックスハイライトを行うvscode-styled-componentsはJavaScript、TypeScriptの拡張子のみの対応のため、.astroはハイライトされなかった。つらい。

iMasanariiMasanari

devモードの場合CSS読み込みで404エラーが発生する(スタイルは反映される)

力技で解決。

      // NOTE: Astro requests path with `.../�virtual:molcss/style.css`
      if (importee.endsWith(`/�${STYLE_PATH}`)) {
        return `\0${STYLE_PATH}`
      }

Astroがv2.7.0であればastro buildは通るが、執筆時最新のv2.10.1ではエラーでビルドできない

最新バージョンで再びビルド可能に

iMasanariiMasanari

Webpackプラグインの作成

LoaderとPluginのデータ共有が課題。
シングルトンパターンでなんとかなるっぽい?

https://stackoverflow.com/questions/46308248/webpack-share-data-between-custom-loader-and-plugin