CSSをパースしよう

ここでは、自作のCSS in JSライブラリを作るにあたりCSSをパースする必要が出てきたため、CSSのパースを実装したことについて記載しています。

モチベーション

以前、 zheleznaya というフロントエンドライブラリを作りました。実用性には欠けるものですが、自分で作ったツールというのはかわいいものです。

ところで、このライブラリはReactのようにJSXを使って書くことができます。しかし、スタイルを当てるには、一般的なcssを書いてアプリケーションでclassを指定するか、直接styleを書くかの2択しかありませんでした。そこで、CSS in JSができる何らかの仕組みを取り入れよう、と考えたわけです。

どんなライブラリにするか?

パッと思いつくのはstyled-componentsのようなライブラリでした。

大昔にhyperappのstyled-componentのようなライブラリ( hyper-styled )を作ったことがあったので、何となく作り方がイメージできました。当時は、SCSSのようなネストされたスタイルを扱えず、 &::before などの擬似クラスに対応していただけでした。これを正規表現で取り出していたので、まあなんというかちゃんとしたユースケースには対応できない代物だったわけです。そのような中途半端なライブラリにするのは嫌でした。

今回は以下のように方針を決めました。

  • styled-components みたいなライブラリとする
  • ある程度、styled-componentsのような形でネストされたスタイルにも対応する
  • 速度は無視してもよい
  • 外部ライブラリは使わない

作る

まずは、CSSをパースするところから始めました。一文字一文字読んで、大まかにパースする、という方針です。対応するのは、通常のスタイル、ネストされたスタイル、メディアクエリの3種類としました。以下のようなCSSをパースできるものとしています。

display: flex;
justify-content: center;

div.app {
  color: red;

  h1.title {
    font-size: 3em;
  }
}

@media (min-width: 1024px) {
  div.app {
    ...
  }
}

これを紐解いて、ネストしたスタイルを分解した上でユニークなセレクターをつけることでブラウザー上で利用できるスタイルに変換をかけられます。

ちなみに、大まかにパースする、というのは、要は display: flex とか justify-content: center; とか一個いっこのスタイルには注目せず、まとまりをブロックとして扱える程度にパースするということです。 そう考えると、上記のスタイル定義は以下のような形にパースできます。

display: flex;
justify-content: center;

div.app {
  // このブロックの中に注目すると、このブロックの中も一つのスタイル定義と見ることができる。
  color: red;

  h1.title {
    // この中もスタイル定義としてパースできる
    font-size: 3em;
  }
}

// つまり、以下のようなブロックを取り出すことができ、これを再度パースできる
color: red;
h1.title {
  font-size: 3em;
}

@media (min-width: 1024px) {
  // このブロックの中は、やはり上の例と同様に一つのスタイル定義としてパースできる
  color: blue;
  div.app {
    // この中もスタイル定義としてパースできる
    ...
  }
}

なるほど。つまり、ベタ書きされたスタイルと <selector> { ... }, @media(<style>) { ... } をうまく抽出する処理を書き、{ ... } の内部を再帰的にパースすれば、きれいにパースできそうですね。

書きました。 パーサーと言いながら、パースして変換までかけてしまっているので、微妙な感じですね。

やっていることは、メディアクエリの中身をパースして取り出す、取り出したものを再帰的にパースする、ネストされたスタイルの中身をパースして取り出す、再帰的にパースする、パースが完了した部分を削除して、残ったものがベタ書きされたスタイルと判定する。という感じです。随分乱暴ですが、これでも割と動きます。ただ、例えば、@keyframesに対応するには、それ用のパース処理を書き直さなければならず、中の実装は似たような感じなのに共通化が難しいといった問題があり、微妙だなと感じていました。

パース方法を変える

文字を一文字ずつ読んでやるやり方では、なかなかうまく実装できませんでした。(もう一回作り直せばもう少しうまく作れる気はしますが)

styled-componentsがCSS変換くんとして利用しているライブラリ stylis ではこのような実装となっており、まあ、自分には作れる気がしませんでした。

ところで、テキストをパースして何か別の形に変換するというのはプログラミング言語も同じですね。プログラミング言語だと コード -> トークン -> Ast -> (機械語, 別の形のコード) のような流れで処理が行われたりします。TypeScriptをJavaScriptに変換するというのも、TypeScriptコード -> トークン -> Ast -> JavaScriptコード という流れで処理が行われています(たぶん)。

もとのパース処理だとトークン分割処理を端折って、Astを直接作って、別の形のCSSに変換するまでをCSSParserが担っていたわけです。Astの構築が微妙だったので、煩雑な実装になってしまったわけですね。このAstを構築する処理をもっといい感じにやれる方法があります。

BNF記法を使います。

BNF記法とは、以下のような人間に読みやすく作られた文法を定義するためのメタ言語です(ググりました)。

記法は色々ある(方言が色々あります)と思うのですが、正しく記述することは重要ではない(ただの設計書なので)ので、オレオレ記法でいきます。以下のようなものがBNF記法で記述されたCSSの定義の一部です。

// StyleはLocalStyle, NestedStyle, MediaStyle, KeyframesStyleのいずれかが0個以上並んでいる。
Style ::= (LocalStyle | NestedStyle | MediaStyle | KeyframesStyle)*

// LocalStyleとは Identifier、コロン、Identifiers、セミコロンが並んだものを指す
LocalStyle ::= Identifier ":" Identifiers ";"

// Identifierは正規表現で .... と表すことができる文字列である。
Identifier ::= /a-zA-Z0-9.../

// IdentifiersはIdentifierが一つ以上スペースをあけて連なるものである
Identifiers ::= Identifier | (Identifier " " Identifiers)

// NestedStyleはSelectorとBlockが連なるものである
NestedStyle ::= Selector Block

// Blockは { } に囲まれてAnyが0個以上並んでいる
Block ::= "{" Any* "}"

// AnyはLocalStyleかNestedStyleである
Any ::= LocalStyle | NestedStyle

...

とまあ、こんな感じで書けます。あとは、一つ一つの定義をパースしてAstを吐き出す関数を実装していくだけでよろしい。どう実装するかはアレですが、以下のように書けるでしょう。

function parseIdentifier(text: string): Ast {
  const result = text.match(/よい正規表現/);
  return {
    type: "Identifier",
    value: result[0]
  }
}

function parseLocalStyle(text: string): Ast {
  const styleNameAst = parseIdentifier(text);
  // identifierの次の文字が ":" であることをチェック
  // ":" より後ろの文字列を perseIdentifiers でパースする
  // Identifiersの次の文字が ";" であることをチェック
  return {
    type: "LocalStyle",
    name: styleNameAst,
    values: parseIdentifiersResult,
  }
}

こんな感じです。パースに失敗したらどうするんじゃい、とか、今文字列のどの位置を見ているのかわからんのでは?など色々と思うところはあると思いますが、その辺は個別に工夫するポイントだと思います。ちなみに、私は以下のような形とすることにしました。

type ParsedResult<T extends Ast> = {
  ast?: T;
  remaining: string;
}

要は、パースできればastにAstをセットして、remainingにパース後の残った文字列をセットします。パースできなければ、remainingに引数で渡されたテキストをそのまま渡す感じです。パース後の残った文字列ですからね。 このへんなどはわかりやすいのではないかと思います。 パースできなかった場合は引数をそのまま返す、というのは、バックトラック法を実現するための一つのアイデアだと思います。

"@media" で始まる文字列かをチェック。次に "(" がくるかをチェック。次に、Identifierがパースできるかチェック・・・といった感じで実装しています。ですが、以前の実装と比べると、随分わかりやすいのではないでしょうか。また、記述を見るだけで、この実装だと、正しくメディアクエリをパースできないことにもすぐに気づけるはずです。メディアクエリに出てくる media-type が存在しないものとして、実装しています。BNFにもmedia-typeの存在はどこにも記載されていません。個人的にはあまり使わないからいいか、ということで落としています。実装がよりシンプルになるので、よいのです。

まとめ

CSSをパースすることができるような気がしてきたのではないでしょうか。この方法がいいかは知りませんが(多分無駄な処理が多くあるので遅いのでしょう)、自前でパーサーを書く必要が出てきたらまずは使える方法だと覚えておくとよいでしょう。 ぜひ、使ってみてください。

以上です。よろしくお願いいたします。