Romira's develop blog

【Rust】ブログサイトをyewからLeptosで再実装しました

Cover image of 【Rust】ブログサイトをyewからLeptosで再実装しました

概要

本ブログサイトはRust言語とフロントエンドフレームワークのYewで実装しています。
詳しくは以下の記事に書いています。

しかしYewにはいくつかの課題があったため、Leptosフレームワークで再実装することにしました。

本ブログでは、Yewでブログサイトを実装したときの課題と、なぜ移行先にLeptosを選択したのかを紹介します。

Yewの課題

Yewには以下のような課題がありました。

  1. Metaタグを変更する機能がフレームワークにない
  2. 生HTMLな文字列からYewのNodeに変換するとエラーが出る
  3. コンパイルが遅く、開発体験が悪い

1.について、SEOのためにメタディスクリプションやOGPタグを記述したいところですが、Yewにはbody内の要素をレンダリングする機能しかありません。
本サイトにはMetaタグを記述していますが、どのように実装しているか軽く説明します。

YewのSSRモードはServerRendererにurlとqueryを渡すとrouteに応じたレンダリングを行い、body内のHTMLがStringとして返ってきます。

let renderer = Yew::ServerRenderer::<ServerApp>::with_props(move || ServerAppProps {
        url: path.into(),
        queries,
    });

それを以下のようなテンプレートHTMLのbodyタグ内に埋め込んでレスポンスを返しています。

<!DOCTYPE html>
<html lang="ja">

<head>
   <!-- 全ページ共通のscriptタグやlinkタグ -->
</head>
<body>
</body>
</html>

同じようにmetaタグのHTMLをrouteに応じて生成し、それをheadタグ内に埋め込んでいます。
以下は実装の抜粋です。

let route = Route::from_str(&path);
let meta = match &route {
        Ok(Route::Article { id }) => {
            article_controller::article_meta_tag(&id, &path, false).await
        }
        Ok(Route::Preview { id }) => {
            article_controller::article_meta_tag(&id, &path, true).await
        }
        Ok(Route::Home) => {
            article_controller::home_meta_tag(&path)
        }
        _ => Ok("".to_string()),
    };

しかしこの実装にはいくつかの問題があります。

まず、ルーティングを二重管理しなければならない点です。

bodyレンダリングのためのルーティングはYewが担っているためYewの外からはどのRouteに該当するか知る手段はありません。

そのためRouteに応じたmetaタグを生成するためには自分でpathを解析する必要があります。
しかしYewでもRouteを定義してるので完全に無駄ですし、ルート追加時の実装漏れが発生する可能性があります。

2つ目は、同じAPIを2度叩いている点です。

記事ページではmetaタグのために記事取得APIを叩き、さらにYewコンポーネント内で記事表示のために記事取得APIを叩いています。

本来1回で良いはずのリクエストを2回送信すると当然レスポンスレイテンシが遅くなりますし、Newt APIのリクエスト数には制限があるのでなるべくコール回数は減らしておきたいです(とはいえ十分な回数の制限ではありますが)。

2.について、NewtのAPIは記事コンテンツがHTMLで返ってくるためそれをYewで扱えるコンポーネント(VNode)に変換する必要があります。

https://docs.rs/yew/latest/yew/virtual_dom/enum.VNode.html#method.from_html_unchecked

しかしこれを実行するとブラウザのコンソールにwasmがパニックしたエラーが出てしまっていました。

この影響により、ダークテーマ切替ボタンをはじめとするUIコンポーネントの操作不能状態が発生していました。

ブログサイトなのでコンテンツの表示さえ出来ていれば良かったので放置していましたが、コンソールエラーがあるとPageSpeed Insightsで怒られるのでエラーがない状態が良いのは間違いないです。

3.について、Yewコンポーネントは以下のようにhtml!マクロにHTMLなStringを記述するスタイルです。

#[function_component]
fn App() -> Html {
    let counter = use_state(|| 0);
    let onclick = {
        let counter = counter.clone();
        move |_| {
            let value = *counter + 1;
            counter.set(value);
        }
    };

    html! {
        <div>
            <button {onclick}>{ "+1" }</button>
            <p>{ *counter }</p>
        </div>
    }
}

このhtml!マクロ内はcargo fmtrust-analyzerの対象外です。

つまりコード整形の支援も受けられず、入力補完もありません。

※どうやら現在はVSCodeの拡張機能もあるようです。GitHub Copilotもあるのでもはやこれはそこまで問題ではないのかもしれません。

またYew SSRモードの場合はフロントエンド用のwasmを生成するビルドとサーバー用のバイナリを生成するビルドの2ステップを行う必要があります。

Rustのビルドは非常に遅いのでそれが2回もあるのは非常に開発速度を遅くします。
当時はhot reloadも対応していなかったため、CSSの細かな変更でも毎回ビルド待ちになります。

その他、Yewの最新ver0.21.0リリースから1年以上経過しており、リリースが滞っていることもあり、他のフレームワークに乗り換えることを決めました。

移行先のフレームワーク選定

以下の要件を満たすフレームワークを探しました。

  1. SSRができること
  2. 標準機能でmetaタグを動的に付与できること
  3. 他のフレームワーク(axumなど)との依存が少ないこと
  4. 他のフレームワークに依存していたとしてもこちらである程度のカスタマイズが可能であること
  5. コンポーネントの記述にある程度、ツールの支援が受けられること(formatterやコード補完など)

上に行くほど優先度が高く、下はあればありがたいという感じです。

いくつかのフレームワークを見ていくと、DioxusLeptos が上記の要件を大方満たせそうでした。

なのでこの2つを中心に機能や使い勝手を比較していくことします。

Dioxus については以下の記事でも紹介しています。

ざっくりとした比較表を以下に示します。
またDioxusはFullstack modeで動作させるものとして比較しています。

SSR Metaタグ 他のフレームワークへの依存 周辺ツール
Dioxus axumに依存 formatter等はないがHTMLタグがstructとして定義されているため補完が効く
Leptos axum or actix-webに依存しているが選択可 Leptosfmtというformatterがある

SSRは最低条件として選定しているためどちらも対応しています。

Metaタグの動的設定もどちらも対応しています。

DioxusのリポジトリにMetaタグを設定するexampleがあります。

以下抜粋

fn app() -> Element {
    rsx! {
        // You can use the Meta component to render a meta tag into the head of the page
        // Meta tags are useful to provide information about the page to search engines and social media sites
        // This example sets up meta tags for the open graph protocol for social media previews
        document::Meta {
            property: "og:title",
            content: "My Site",
        }
        document::Meta {
            property: "og:type",
            content: "website",
        }
        document::Meta {
            property: "og:url",
            content: "https://www.example.com",
        }
        document::Meta {
            property: "og:image",
            content: "https://example.com/image.jpg",
        }
        document::Meta {
            name: "description",
            content: "My Site is a site",
        }
    }
}

Leptosでは以下のように指定します。

#[component]
pub(crate) fn Meta() -> impl IntoView {
    view! {
        <Title text="Title" />
        <Meta name="description" content="description" />
        <Meta name="keywords" content="keywords" />
        <Meta name="date" content="2024-01-01" />
    }
}
LeptosのMetaタグ設定には実用上問題はないがバグがあることを確認しています。

なぜかMetaタグの値や定義がおかしくなるというバグに遭遇しています

2f355089-e916-43ac-9bc0-0cc854f3ffef.png

しかし、curlでサーバーレスポンスを確認すると正しくレンダリングされているため、SNSのサムネイル生成やクローラからのアクセスでは問題ないと判断しています。

$ curl https://blog.romira.dev/articles/67602c172e1a9fe4e94472af
...一部抜粋
<title>Windows・WSLの開発環境をansibleで管理しているという話</title><!--HEAD-->
<link id="Leptos" rel="stylesheet" href="https://cdn.blog.romira.dev/pkg/blog-romira-dev.FVjoSFm3wWex-HE1Ad0b_g.css">
<link
    href="https://blog-romira.imgix.net/4874cb12-6e50-4aa3-a1f5-541de4ae184c/icon.JPG?w=32&amp;h=32&amp;auto=format&amp;fit=crop&amp;mask=ellipse&amp;q=75"
    rel="icon">
<meta name="description"
    content="WSLに限らず、私はWindows・WSL・Macを今すぐ初期化してもワンクリックで普段の環境を復元できることが理想だと考えて、なるべくそれが実現できるように環境のコード化を行っています。">
<meta name="keywords" content="Develop, Developers">
<meta name="date" content="2025-01-23T23:08:34.309+09:00">
<meta name="creation_date" content="2024-12-18T21:15:14.999+09:00">
<meta property="og:sitename" content="Romira's develop blog">
<meta property="og:title" content="Windows・WSLの開発環境をansibleで管理しているという話">
<meta property="og:description"
    content="WSLに限らず、私はWindows・WSL・Macを今すぐ初期化してもワンクリックで普段の環境を復元できることが理想だと考えて、なるべくそれが 実現できるように環境のコード化を行っています。">
<meta property="og:image"
    content="https://blog-romira.imgix.net/95424e09-0b44-4165-a5d9-498fdad10553/Windows%E3%83%BBWSL%E3%81%AE%E9%96%8B%E7%99%BA%E7%92%B0%E5%A2%83%E3%82%92ansible%E3%81%A7%E7%AE%A1%E7%90%86%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%A8%E3%81%84%E3%81%86%E8%A9%B1.jpg?fit=crop&amp;w=1200&amp;h=630&amp;q=75&amp;auto=format%2Ccompress%2Cenhance">
<meta property="og:type" content="article">
<meta property="og:url" content="https://blog.romira.dev/articles/67602c172e1a9fe4e94472af">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Windows・WSLの開発環境をansibleで管理しているという話">
<meta name="twitter:description"
    content="WSLに限らず、私はWindows・WSL・Macを今すぐ初期化してもワンクリックで普段の環境を復元できることが理想だと 考えて、なるべくそれが実現できるように環境のコード化を行っています。">
<meta name="twitter:image"
    content="https://blog-romira.imgix.net/95424e09-0b44-4165-a5d9-498fdad10553/Windows%E3%83%BBWSL%E3%81%AE%E9%96%8B%E7%99%BA%E7%92%B0%E5%A2%83%E3%82%92ansible%E3%81%A7%E7%AE%A1%E7%90%86%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B%E3%81%A8%E3%81%84%E3%81%86%E8%A9%B1.jpg?fit=crop&amp;w=1200&amp;h=630&amp;q=75&amp;auto=format%2Ccompress%2Cenhance">
<meta name="twitter:creator" content="@Romira915">
</head>

他のフレームワークへの依存について

両者ともWebフレームワークへの依存があります。Dioxusはaxum、Leptosの場合はactix-webとaxumどちらかを選択することができます。

以下はdx newで生成したプロジェクトmain.rsを抜粋したコードですが、完全にaxumを隠蔽していて開発者からカスタムすることができなくなっていそうです。

ドキュメント等を隅々まで見たわけではないですがConfigを渡すlaunch関数はあるものの、axumのコアな部分、axumのRouterにカスタムサービスを渡すなどの操作はできなさそうでした。

ただしStateを渡すことはできそうです。

https://docs.rs/dioxus/0.6.3/dioxus/struct.LaunchBuilder.html#impl-LaunchBuilder-1

use dioxus::prelude::*;

#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
enum Route {
    #[layout(Navbar)]
    #[route("/")]
    Home {},
    #[route("/blog/:id")]
    Blog { id: i32 },
}

fn main() {
    dioxus::launch(App);
}

#[component]
fn App() -> Element {
    rsx! {
        document::Link { rel: "icon", href: FAVICON }
        document::Link { rel: "stylesheet", href: MAIN_CSS }
        document::Link { rel: "stylesheet", href: TAILWIND_CSS }
        Router::<Route> {}
    }
}

/// Echo the user input on the server.
#[server(EchoServer)]
async fn echo_server(input: String) -> Result<String, ServerFnError> {
    Ok(input)
}

と、ここまで書いて本当にないのかと調べていたところ、axumを露出させる方法があるようです。

https://dioxuslabs.com/learn/0.6/guide/backend/#calling-the-server-function

上記のページに従ってdx newしたプロジェクトを以下のように変更を加えると、新たなエンドポイントを追加することができました。

fn main() {
    #[cfg(feature = "server")]
    tokio::runtime::Runtime::new()
        .unwrap()
        .block_on(launch_server());
    #[cfg(not(feature = "server"))]
    dioxus::launch(App);
}

#[cfg(feature = "server")]
async fn launch_server() {
    use axum::{response::Html, routing::get, Router};

    async fn handler() -> Html<&'static str> {
        Html("<h1>Hello, World!</h1>")
    }

    // Connect to dioxus' logging infrastructure
    dioxus::logger::initialize_default();

    // Connect to the IP and PORT env vars passed by the Dioxus CLI (or your dockerfile)
    let socket_addr = dioxus_cli_config::fullstack_address_or_localhost();

    // Build a custom axum router
    let router = axum::Router::new()
        .route("/hello", get(handler))
        .serve_dioxus_application(ServeConfigBuilder::new(), App)
        .into_make_service();

    // And launch it!
    let listener = tokio::net::TcpListener::bind(socket_addr).await.unwrap();
    axum::serve(listener, router).await.unwrap();
}

Leptosでは以下のように設定できます。

#[tokio::main]
async fn main() {
    let conf = get_configuration(None).unwrap();
    let addr = conf.Leptos_options.site_addr;
    let Leptos_options = conf.Leptos_options;
    let app_state = AppState::new(Leptos_options.clone());
    // Generate the list of routes in your Leptos App
    let routes = generate_route_list(App);

    let app = Router::new()
        .Leptos_routes_with_context(
            &app_state,
            routes,
            {
                let app_state = app_state.clone();
                move || provide_context(app_state.clone())
            },
            {
                let Leptos_options = Leptos_options.clone();
                move || shell(Leptos_options.clone())
            },
        )
        .fallback(Leptos_axum::file_and_error_handler::<AppState, _>(shell))
        .with_state(app_state)

    // run our app with hyper
    // `axum::Server` is a re-export of `hyper::Server`
    let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
    axum::serve(listener, app.into_make_service())
        .await
        .unwrap();
}

どちらもaxum::RouterにTraitを実装して拡張している形です。

axum::Routerを直接操作できると何がありがたいかというと、共通の処理を行うサービスをLayerに追加できます。

具体的にはNew Relicでトランザクションで観測可能にするためのスパンを生成するLayerを追加しています。

実際のコードは以下です。

https://github.com/Romira915/blog-romira-dev-Leptos/blob/v0.14.8/server/src/main.rs#L65

axumとNew Relic(OpenTelemetry)を連携する方法については以下の記事で紹介しています

周辺ツールについて、今回は特にformatに関して話します。

Leptosでは以下のようにview!マクロ内にHTMLを直接書くという点においてはYewとスタイルが似ています。

#[component]
pub fn SimpleCounter(
    /// The starting value for the counter
    initial_value: i32,
    /// The change that should be applied each time the button is clicked.
    step: i32,
) -> impl IntoView {
    let (value, set_value) = signal(initial_value);

    view! {
        <div>
            <button on:click=move |_| set_value.set(0)>"Clear"</button>
            <button on:click=move |_| *set_value.write() -= step>"-1"</button>
            <span>"Value: " {value} "!"</span>
            <button on:click=move |_| set_value.update(|value| *value += step)>"+1"</button>
        </div>
    }
}

マクロ内はrustfmtが効かないため本来は自分でフォーマットするしかないのですが、LeptosではLeptosfmtというツールが提供されており、マクロ内のフォーマットを整えることができます。

またHTMLタグが全て関数として定義されているためrust-analyzerの恩恵を受けることもできます。

一方でDioxusは以下のようなstructを定義するようなスタイルで記述します。

pub fn App() -> Element {
    let mut count = use_signal(|| 0);

    rsx! {
        h1 { "High-Five counter: {count}" }
        button { onclick: move |_| count += 1, "Up high!" }
        button { onclick: move |_| count -= 1, "Down low!" }
    }
}

こちらは全てstructとして定義されているためrust-analyzerの恩恵を受けることができますが、記法が独特なため、慣れるまで時間がかかりました。

ここまででどちらのフレームワークも移行先としての要件はある程度満たしていそうです。

しかし調査段階ではDioxusがMetaタグの設定に対応していなかった(?)ことやaxumをカスタムする方法のドキュメントがあまりなく、難しいと判断して今回は移行先としてLeptosを選択しました。

あとは実装するだけで、1~2週間で実装完了できました。

移行に合わせてデザインをいい感じにしてみたり、コードハイライトできるようにしてみたりしました。

Leptosで開発する上でのTipsなどはまた別の記事で紹介したいと思います。

まとめ

今回は本ブログサイトをYewからLeptosに移行したことについて紹介しました。

本ブログのリポジトリは以下で公開しているのでLeptosを使う機会があれば参考にしてみてください。