blog.takurinton.dev

wasmを使ったclientライブラリでstyleを書くことができるよにする

2026-04-06

こんにちは

今回はCSS-in-WASM(と勝手に呼んでいる謎の何か)のstyling systemを実装した話をします。

やったことを一言で言うと、css! というproc-macroを作って、グローバルな style.css を完全に削除しました。

なぜやったか

単純に面白そうだからです。

ちなみに真面目な理由だと、既存のグローバルなCSSファイルだとどのstyleがどのコンポーネントに対応しているかわからなくなってきていたので、コンポーネントと同じファイルにstyleを書けると嬉しいなというのもありました。

なお、グローバルなCSSファイルをそのまま読み込む構成は引き続き動きます。global_css! マクロもそういう用途で使えますし、Tailwind CSS のようなユーティリティファーストのライブラリをHTMLに読み込んでおいて、クラス名を直接 render! マクロに渡す使い方も問題なく動作します。今回の変更はあくまでこのブログの好みの問題で、アーキテクチャ上の制約ではありません。

設計

css! macroが返すのはクラス名の文字列で、styleの登録は副作用として行われます。

rust
let class = css!("
    color: red;
    &:hover { color: blue; }
    @media (max-width: 768px) { font-size: 14px; }
");
// class == "css-a1b2c3"

SSR時はthread-localなレジストリにCSSを積んでおいて、HTMLの<head>を組み立てるタイミングで<style>タグとして出力します。WASM時は<style id="css-a1b2c3">を動的に<head>に追加します。

クラス名のハッシュ化

クラス名はCSSテキストをFNV-1aでハッシュ化したものです。

rust
fn fnv1a_hash(s: &str) -> u32 {
    let mut hash: u32 = 2166136261;
    for byte in s.bytes() {
        hash ^= byte as u32;
        hash = hash.wrapping_mul(16777619);
    }
    hash
}

let class_name = format!("css-{:06x}", fnv1a_hash(&css_text) & 0x00FF_FFFF);

同じCSSテキストからは常に同じクラス名が生成されるので、SSRとWASMで結果が一致します。compile timeに計算しているので実行時コストはゼロです。

proc-macro内での#[cfg]分岐

css! macroの展開コードに #[cfg(target_arch = "wasm32")] を使って、SSRとWASMで異なるコードを生成します。proc-macro自体はホスト(native)で実行されるので、両方のコードをquoteで生成してcfgで囲むだけです。

rust
pub fn css_impl(input: TokenStream) -> TokenStream {
    // compile timeにCSSを解析してクラス名を決定
    let css_text = extract_css_text(input);
    let class_name = format!("css-{:06x}", fnv1a_hash(&css_text) & 0x00FF_FFFF);
    let full_css = generate_full_css(&class_name, &parse_css(&css_text));

    quote! {{
        const __NAME: &'static str = #class_name;
        const __CSS: &'static str = #full_css;

        // SSR時: thread-localレジストリに追加
        #[cfg(not(target_arch = "wasm32"))]
        crate::style::push(__NAME, __CSS);

        // WASM時: <head>に<style>を動的追加
        #[cfg(target_arch = "wasm32")]
        {
            use ::wasm_bindgen::JsCast as _;
            let __doc = ::web_sys::window().unwrap().document().unwrap();
            if __doc.get_element_by_id(__NAME).is_none() {
                let __el = __doc.create_element("style").unwrap();
                __el.set_id(__NAME);
                __el.set_inner_html(__CSS);
                if let Some(__head) = __doc.head() {
                    __head.append_child(__el.unchecked_ref::<::web_sys::Node>()).unwrap();
                }
            }
        }

        __NAME
    }}.into()
}

戻り値の型は &'static str ですが、SSRとWASMでコード自体は全然違います。render! macroと同じパターンです。

CSSの解析:ネスト構文の展開

& を使ったネスト構文を手書きのパーサーで展開しています。

入力:

css
color: red;
&:hover {
  color: blue;
}
& th,
& td {
  font-weight: bold;
}

出力:

css
.css-abc123 {
  color: red;
}
.css-abc123:hover {
  color: blue;
}
.css-abc123 th,
.css-abc123 td {
  font-weight: bold;
}

パーサーは文字配列をスキャンして、{ の深さで通常のプロパティかネストブロックかを判定します。正規表現は使わず、ブロック深度をカウントしながら進む状態機械で実装しました。

@media内のセレクタ展開でハマった

最初に詰まったのが @media の中にセレクタが入っているケースです。

css
@media (max-width: 768px) {
  code,
  pre {
    font-size: 0.8rem;
  }
}

これを単純に中身をクラスでラップする方式で処理すると、

css
/* ❌ 無効なCSS */
@media (max-width: 768px) {
  .css-abc123 {
    code,
    pre {
      font-size: 0.8rem;
    }
  }
}

になってしまいます。CSSでは @media の中でネストしたセレクタは書けません(Nesting Moduleが入るまでは)。

正しくはセレクタにプレフィックスを付ける必要があります。

css
/* ✅ 正しい形 */
@media (max-width: 768px) {
  .css-abc123 code,
  .css-abc123 pre {
    font-size: 0.8rem;
  }
}

generate_media_css の中でコンテンツに { が含まれているかを見て、含まれていればセレクタごとにプレフィックスを付ける処理を追加しました。

rust
fn generate_media_css(class_name: &str, query: &str, content: &str) -> String {
    let content = content.trim();
    if content.contains('{') {
        // セレクタ付きルール → 各セレクタにプレフィックスを付ける
        let mut rules = String::new();
        let mut remaining = content;
        while let Some(brace_pos) = remaining.find('{') {
            let selectors_str = remaining[..brace_pos].trim();
            remaining = &remaining[brace_pos + 1..];
            let close_pos = remaining.find('}').unwrap_or(remaining.len());
            let props = remaining[..close_pos].trim();
            let prefixed: Vec<String> = selectors_str
                .split(',')
                .map(|s| format!(".{} {}", class_name, s.trim()))
                .collect();
            rules.push_str(&format!("{} {{ {} }}", prefixed.join(", "), props));
            remaining = &remaining[close_pos + 1..];
        }
        format!("@media {} {{ {} }}", query, rules)
    } else {
        // 単純なプロパティ → クラスでラップ
        format!("@media {} {{ .{} {{ {} }} }}", query, class_name, content)
    }
}

global_css! マクロ

:root のCSS変数や html, body のリセットstyleはクラスでスコープ化できません。これは global_css! というマクロで対応しました。

rust
pub fn inject_global_styles() {
    global_css!(":root { --color-primary: #ff69b4; --font-mono: 'JetBrains Mono', monospace; }");
    global_css!("html, body { margin: 0; padding: 0; }");
    global_css!("a { font-weight: 700; color: var(--color-primary); }");
}

css! との違いはCSSを変換せずそのまま使うだけです。クラス名も返しません。

出力順序がまずかった

実装中に、グローバルstyleがコンポーネントstyleよりも後に出力されてしまって、CSS変数が未定義状態でコンポーネントstyleが適用されるという問題が起きました。

document.rs でHTMLを組み立てるとき、最初はコンポーネントのレンダリング中にstyleが登録されるので、collect_and_clear() を呼んで集めてから inject_global_styles() を呼んでいました。これだとコンポーネントCSSが先にレジストリに積まれてしまいます。

順序を入れ替えて、グローバルstyleを先にcollect、コンポーネントstyleを後にcollectする形にしました。

rust
// body_contentを先にレンダリング(この中でcss!が呼ばれてコンポーネントCSSが登録される)
let body_html = render_body_content(&props);
let component_css = crate::style::collect_and_clear();

// グローバルstyleを後から登録
crate::global_styles::inject_global_styles();
let global_css = crate::style::collect_and_clear();

// 出力は global → component の順
let style = format!("<style>{}\n{}</style>", global_css, component_css);

こうすると <style> の中が :root などのグローバル定義から始まって、コンポーネント固有のstyleが続く形になります。

クライアントサイドルーティング時のstyleロス

SPA遷移時に問題が起きました。apply_html<body> の中身だけを差し替えていたので、遷移先ページに固有の <style> タグが <head> に追加されませんでした。

WASM時は css! マクロが実行時に <style> を追加するはずなのに、SPA遷移後はDOMが既に構築済みなので css! が再実行されません。

sync_styles という関数を追加して、遷移先HTMLをパースしたあと <head><style> タグをマージするようにしました。

rust
fn sync_styles(doc: &Document, new_doc: &Document) {
    let head = match doc.head() {
        Some(h) => h,
        None => return,
    };

    let new_styles = new_doc.query_selector_all("style").unwrap();
    for i in 0..new_styles.length() {
        let new_style: Element = new_styles.item(i).unwrap().dyn_into().unwrap();
        let id = new_style.get_attribute("id");

        let already_exists = if let Some(ref id) = id {
            // ID付き(css!生成)はIDで重複チェック
            doc.get_element_by_id(id).is_some()
        } else {
            // IDなし(global_css!)はコンテンツで重複チェック
            let new_content = new_style.text_content().unwrap_or_default();
            let existing = doc.query_selector_all("head > style").unwrap();
            (0..existing.length()).any(|j| {
                existing.item(j)
                    .map(|el| el.text_content().unwrap_or_default() == new_content)
                    .unwrap_or(false)
            })
        };

        if !already_exists {
            let _ = head.append_child(&new_style);
        }
    }
}

css! が生成する <style> にはIDが振られているのでIDで高速チェックできます。グローバルstyleはIDがないのでコンテンツの一致で重複を判定します。

まとめ

styles/style.css を完全に削除して、全styleをコンポーネントと同じファイルの css! / global_css! に移行しました。

技術的に面白かった点は、proc-macroが compile time にCSSを解析してクラス名とフルCSSを決定してしまって、実行時はただのstring定数を扱うだけになっているところです。ランタイムコストがほぼありません。

詰まったのは @media 内のセレクタ展開と出力順序の制御とクライアント遷移時のstyle同期で、いずれもSSRとWASMで動作が違うこと、compile timeとruntimeで処理が分かれていることによる見落としが原因でした。