🪄

yew-style-in-rsで利用しているRustの黒魔術procマクロについて

2022/07/02に公開

はじめに

前回の記事、RustのWasmで仮想DOMなYewの紹介とyew-style-in-rsの使い方では、Yewの紹介とyew-style-in-rsという私が管理するスタイリングライブラリの紹介をしました。
実はこのyew-style-in-rsは黒魔術的なマクロを使っています、というのが今回の内容です。

この記事では、最初にStyled Componentについて説明をします。
次に、Styled Componentのうち、コンパイル時に実行できる部分について考えます。
最後に、その実装についてどのような黒魔術的マクロを使ったのか説明をします。

記事執筆時のRustのバージョンは1.62.0で、yewのバージョンは0.19.3です。

Styled Component

Yewはコンポーネントベースの仮想DOMのライブラリです。
Yewによって、Webの開発をコンポーネントベースに進めることができます。

ここでは2つのコンポーネントを例に、Styled Componentが解決する問題について説明します。

2つのコンポーネントと命名規則によるスタイル

次のような2つのコンポーネントを考えます。

button.rs
#[derive(PartialEq, Properties)]
pub struct Props {
    pub text: String,
}

#[function_component(ButtonComponent)]
pub fn button_component(props: &Props) -> Html {
    html! {
        <button>{&props.text}</button>
    }
}
toggle_button.rs
#[derive(PartialEq, Properties)]
pub struct Props {
    pub text: String,
    pub toggle: bool,
}

#[function_component(ToggleButtonComponent)]
pub fn toggle_button_component(props: &Props) -> Html {
    let class = if props.toggle {
        "toggle-button-on"
    } else {
        "toggle-button-off"
    };
    html! {
        <button {class}>{&props.text}</button>
    }
}

それぞれのボタンが、コンポーネントというまとまりとして実装できました。

残念ながらYewの仮想DOMの提供してくれる範囲にスタイルのコンポーネント化は含まれません。
このコンポーネントについてスタイルシートを当てるとすると、メンタルモデル的には次のような形でスタイルというものを考えるでしょう。

しかし、スタイルシートにはコンポーネントごとに名前空間を切ることができず、すべてグローバルとなるため、うまく実装しなければスタイルがコンフリクトします。

この問題を解消するためにCSSの命名規則を使うことなどが考えられます。

命名規則で管理するというのは大変でした。

Styled Componentによるコンポーネント固有の名前空間

ここでStyled Componentという考え方を持ち込みます。

例えば次のような疑似コードを書くと、コンポーネントごとに固有の名前空間でスタイルを実現できるようになります。

button.rs
#[derive(PartialEq, Properties)]
pub struct Props {
    pub text: String,
}

#[function_component(ButtonComponent)]
pub fn button_component(props: &Props) -> Html {
    let css = css! {r#"
        height: 1.5rem;
        font-size: 1rem;
        border: 1px solid black;
        border-radius: 0.25rem;
        background-color: #0044ff;
        color: white;
    "#};
    html! {
        <button class={css}>{&props.text}</button>
    }
}

コンポーネントの定義の中にCSSの文字列が埋め込まれました。

コンポーネントに固有の名前空間は、例えばコンポーネントごとにランダムに重複しないクラス名を与えることなどで可能となります。

例えば、Stylistというライブラリクレートの提供するStyled Componentは、実行時にhead要素にランダムなクラス名で名前空間を区切ったstyle要素を追加します。
これによって次のようなhtmlが生成されるイメージです。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>yew-style-in-rs</title>
    <style>
      .random-class-abcdefgh {
        height: 1.5rem;
        font-size: 1rem;
        border: 1px solid black;
        border-radius: 0.25rem;
        background-color: #0044ff;
        color: white;
      }
    </style>
  </head>
  <body>
    <button class="random-class-abcdefgh">
      <span>{"Click me!"}</span>
    </button>
  </body>
</html>

ランダムなクラス名を生成すること、そしてそれが他のコンポーネントと被らない一意な値となるようにすることなどの管理はすべてライブラリが裏側で行ってくれます。
これによって、コンポーネントの中にCSSを書くことでコンポーネント固有の名前空間によるCSSが実現できて、最初のメンタルモデルとマッチした形でスタイルを実装できます。

Styled Componentによる動的なスタイル

Styled Componentでは、さらに動的なスタイルも扱えます。
ToggleButtonComponentについて考えてみます。

Styled Componentを使う以前はクラス名を使ってスタイルを定義していました。

toggle_button.rs
#[derive(PartialEq, Properties)]
pub struct Props {
    pub text: String,
    pub toggle: bool,
}

#[function_component(ToggleButtonComponent)]
pub fn toggle_button_component(props: &Props) -> Html {
    let class = if props.toggle {
        "toggle-button-on"
    } else {
        "toggle-button-off"
    };
    html! {
        <button {class}>{&props.text}</button>
    }
}

これをStyled Componentによって次のような擬似コードのようにすることができます。

toggle_button.rs
#[derive(PartialEq, Properties)]
pub struct Props {
    pub text: String,
    pub toggle: bool,
}

#[function_component(ToggleButtonComponent)]
pub fn toggle_button_component(props: &Props) -> Html {
    let background_color = if props.toggle { "green" } else { "red" };
    let css = css!{format!(r#"
        height: 1.5rem;
        font-size: 1rem;
        border: 1px solid black;
        border-radius: 0.25rem;
        background-color: {background_color};
        color: white;
    "#)};
    html! {
        <button class={css}>{&props.text}</button>
    }
}

cssの文字列に埋め込む形で背景色を設定しています。
上記コードは実行時にbackground_colorの値によって別のランダムなクラス名が与えられ、別のスタイルとして設定されることになります。
実行結果は例えば次のhtmlの用になるでしょう。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>yew-style-in-rs</title>
    <style>
      .random-class-abcdefgh {
        height: 1.5rem;
        font-size: 1rem;
        border: 1px solid black;
        border-radius: 0.25rem;
        background-color: green;
        color: white;
      }
      .random-class-ijklmnop {
        height: 1.5rem;
        font-size: 1rem;
        border: 1px solid black;
        border-radius: 0.25rem;
        background-color: red;
        color: white;
      }
    </style>
  </head>
  <body>
    <button class="random-class-abcdefgh">
      <span>{"toggle on!"}</span>
    </button>
    <button class="random-class-ijklmnop">
      <span>{"toggle off!"}</span>
    </button>
  </body>
</html>

Styled Componentのうちコンパイル時実行可能な部分

Styled Componentは次の2つの事ができることを確認しました。

  • コンポーネント固有の名前空間を作ること
  • 動的なスタイルを扱うこと

ここでは前者をコンパイル時に実行することを考えます。

コンパイル時実行

前者のコンポーネント固有の名前空間を作ることは、動的なスタイルが含まれない場合、コンポーネントごとに固有のランダムなクラス名を割り振ることです。
これはコンパイルする際に各コンポーネントごとにランダムな文字列を割り振ったstyle.cssファイルを生成すれば、実行時にランダムな文字列を管理してheadにstyle要素を追加する必要がなくなります。

コンパイル時にCSSファイルへ書き出すと何が嬉しいかというと、CSSの文字列をWasmから取り除くことができることです。
実行時にheadにstyle要素を追加する場合、その大半が静的なCSSだとしても実行時に追加するためにその文字列をWasmの内部に保持する必要があります。
これはWasmのファイルサイズを大きくします。

これが、WasmとCSSファイルが別ファイルとして切り出されていた場合、例えばCSSを非同期で読み込むテクニックなどを使えば、CSSとWasmを同時に読み込むことができるようになります。

動的にスタイルを変更する部分については、依然として実行時にheadにstyle要素を追加する形でのスタイリングが必要になりますが、staticな部分のCSSについては全部コンパイル時にランダムなクラス名を割り振ってWasmからCSSファイルへ切り出してしまうことが有用であることがわかります。

yew-style-in-rsではstaticなCSSとdynamicなCSSを、それぞれcss!{}dyn css!{}で区別して定義することで、前者をstyle.cssなどのCSSファイルへ書き出すことができます。
次はこのyew-style-in-rsでの実装について見ていきます。

yew-style-in-rsの黒魔術的マクロ

Rustでコンパイル時になにかするといえば、マクロが思いつきます。
特に手続きマクロは様々なことができるので、今回のCSSをファイルに書き出すこともできなくはないでしょう。
ファイルシステムとやり取りするマクロというのはだいぶやばいですが。

今回作りたいマクロは、次の図のようにcss!{}を含むコードを、コンパイル時にcss!{}の部分をランダムに一意なクラス名に置き換えてコンパイルし、ファイルに置き換えたCSSの中身を配置することです。
先程の図をもう一度表示しておきます。

ここで制約として、Rustの手続きマクロはそれぞれが独立に動くというものがあります。

CSSファイルを書き出すにあたっては、css!{}を置き換えるランダムなクラス名が他のcss!{}のクラス名と被っていないことを確認する必要があり、さらにすべてのcss!{}が実行し終わったときにstyle.cssファイルにすべてのcss!{}の結果を集約して書き込む必要があります。

手続きマクロのstaticなグローバル変数を用意することで、一つのクレートをコンパイルする時に、各css!{}の内容を全体で共有する状態を持つことはできます。

しかし、次のようにコンポーネントをクレートに切り出した場合を考えてみます。

- app
- my_component_a
- my_component_b
- my_component_c
- ...

クレートのコンパイルごとに手続きマクロが別に起動されるので、staticなグローバル変数を利用した上でも、my_component_aのコンパイル時にmy_component_aの中で使われているcss!{}は状態を共有できるものの、その際に他のクレートを含めたすべてのcss!を集約することは不可能です。

そこで、すべてのクレートコンパイル時の状態の共有を実現するために、yew-style-in-rsでは手続きマクロにおいてファイルシステムに状態を格納するということを選択しました。
無法者ですね。

css!{}マクロを見つけたら、ファイルシステムにロックを取って、ランダムなクラス名でファイルを作成します。
そしてそのファイルの中にCSSの断片を格納します。
ランダムなクラス名が過去にファイルシステムに書き込まれているものとか重複しないように確認をする必要があります。

そしてさらに、各マクロのstaticな変数のDrop時に、すべてのCSS断片を集約してstyle.cssを生成します。

上記のステップによって、すべてコンパイルが終わったときには、すべてのcss!{}の内容を集約したstyle.cssが生成されます。

その他細々としたこと

これだけでは、依存するコンポーネントクレートを途中で減らした場合に、CSS断片が残り続けるという問題があります。
この手続マクロが走る際に、必要ないCSS断片ファイルを消すなどの仕組みもyew-style-in-rsには入っています。

また、staticな変数に対するDropを使うことで、css!{}が一通り処理されたあとで、style.cssに集約することを行っていますが、実はRustのデフォルトではstaticな変数はDropが呼ばれません。
そこでlibc::atexit()を使ってDrop時の処理を登録しています。

docs.rsにpublishする際に、出力用ディレクトリ以外に色々書き込んでいるためエラーで落ちます。
ドキュメントビルド時はdry-runのfeatureによってCSSファイルを書き出す機能を抑制して無理やり通しています。

おわりに

この記事ではyew-style-in-rsの手続きマクロで行っている手続きについて説明をしました。
手続きマクロのクレートのコンパイルごとに起動され、さらにマクロの記述箇所ごとに別々に実行されるという動作のなかで、無理やり状態を共有し集約するためにファイルシステムにグローバルな状態を書き込むということをやっていました。

なかなか無法者の黒魔術的マクロになっていますが、マクロで黒魔術をやってみた感想としては、あまりこういうことはやらないほうが良いかなと……。

Discussion