Topcoat 是一個模組化、開箱即用的 Rust 框架,用於建構全端、反應式網頁應用程式。它優先考量簡潔性與生產力。Topcoat 完全在伺服器端渲染。反應性則是透過在伺服器渲染 HTML 片段,並將「反應指令」作為中繼資料附加(概念類似 HTMX)。

以下是使用 Topcoat 建立的「Hello world」應用程式:

#[tokio::main]
async fn main() {
    topcoat::start(Router::builder().discover().build()).await.unwrap();
}

#[page("/")]
async fn home() -> Result {
    view! {
        <!DOCTYPE html>
        <html>
            <head>
                <title>"Hello world"</title>
                topcoat::dev::script()
            </head>
            <body>
                hello(name: "World")
            </body>
        </html>
    }
}

#[component]
async fn hello(name: &str) -> Result {
    view! {
        <h1>"Hello, " (name) "!"</h1>
    }
}

入門指南

動機

在深入探討技術細節之前,值得先討論 Topcoat 背後的動機。如果你曾觀看我的 TokioConf 演講(那場我預測 Rust 將成為前三大綠地開發語言的演講),那麼這些都不會讓你感到意外。

三年前,如果我告訴你 Rust 會是建立網頁應用程式的優秀語言,你大概會覺得我瘋了,而且有理有據。畢竟,網頁應用程式通常不是性能敏感的應用程式。因此,適合的工具應該是能讓你快速交付的工具。性能只是額外的好處。不出所料,最豐富的網頁應用程式生態系統存在於強調生產力的語言中,例如 JavaScript、Ruby 和 PHP。

然而,AI 徹底改變了這個計算方式。AI 消除了學習障礙和生產力差距。現代 AI 編碼工具在建構東西所需時間上的差異,主要是取決於可用函式庫的集合,而非程式語言。即使是操作者的特定專業知識也變得較不重要。我曾見過從未寫過 Rust 的資深軟體工程師,利用 AI 工具從第一天起就用 Rust 進行建構。我指的不是氛圍編碼,而是利用他們的一般工程經驗,與 AI 工具互動,在學習語言的同時取得進展。

現在,我認為 Rust 是一門優秀的語言,它具有一些非常優良的特性(速度和可靠性)。然而,這並不意味著我認為如果你已經擁有一個運作良好的技術堆疊,且不需要 Rust 的性能和可靠性,你就應該切換到 Rust(好吧,我確實認為你應該如此,但我也知道這是出於我對 Rust 的熱愛,而非實用建議)。然而,許多組織採用 Rust 是因為他們需要解決需要高性能和可靠語言的問題。已經採用 Rust 的組織有很多理由繼續使用 Rust 來建構更高階的應用程式,不是因為他們需要性能,而是因為他們已經擁有圍繞該語言的內部基礎設施(函式庫、建構系統、流程等),而最小化組織內的程式語言和工具數量確實能提升生產力。

因此,現在我們需要的是能讓我們用 Rust 高效建構網頁應用程式的豐富函式庫生態系統。這正是我一直在努力建構的。我從 Toasty 開始,這是一個 Rust 的 ORM,因為它可能是最困難的元件。Toasty 自 2026 年 4 月起就已準備好使用。路線圖的下一步是一個網頁框架,而這正是 Topcoat 登場之處。

去年年底,我遇到了 Julien Scholz,並對他建構優秀 Rust 網頁應用程式框架的品味和熱情印象深刻,因此我說服他花時間建構一個:Topcoat

無需 WebAssembly 的反應性

LeptosDioxus 這樣的框架非常適合使用 Rust 建立高度互動的網頁應用程式,它們將你的程式碼編譯成 WebAssembly 並在瀏覽器中執行。然而,許多應用程式並不需要這種等級的互動性。對於這些使用情境,編譯到不同的目標、擔心套件大小和分割,以及在客戶端/伺服器邊界序列化資料,都會成為負擔。Topcoat 採用更簡單的方法。所有標記都在伺服器端渲染,因此元件可以是 async、安全地存取資料庫或驗證使用者權限。為了加入反應性,它使用巨集將完全型別檢查的 Rust 運算式子集跨編譯成 JavaScript,讓你可以在不接觸 WebAssembly 的情況下繼續使用 Rust。在這個範例中,<button> 在點擊時會顯示下方的 <p> 標籤:

view! {
    // 宣告一個客戶端狀態變數:
    signal open = false;
    
    <button
        // 將 Rust 閉包設定為此按鈕的「點擊」處理程式。
        // $(...) 內的程式碼會在瀏覽器中以 JavaScript 執行:
        @click=$(|_e| open.set(!open.get()))
    >
        "What is Topcoat?"
    </button>
    
    // `hidden` 屬性會追蹤 `open` 的值,並在每次按下按鈕時更新
    <p :hidden=$(!open.get())>"A fullstack Rust framework."</p>
}

切換邏輯完全在瀏覽器中執行,不需要伺服器往返。

Topcoat 也可以在客戶端狀態變更時,在伺服器端重新渲染 UI 的整個部分,並只替換頁面中必要的部分。在這裡,搜尋結果會隨著使用者在搜尋 <input> 欄位中輸入而更新:

#[component]
async fn search() -> Result {
    view! {
        signal query = String::new();

        // 將目前文字輸入寫入 `query` 訊號:
        <input @input=$(|e: Event| query.set(e.target.value))>

        // 隨著使用者輸入而更新。
        search_results(query: $(query.get()))
    }
}

// Shards 是一種特殊型別的元件,會從你的路由器公開 API 端點。
#[shard]
async fn search_results(cx: &Cx, query: String) -> Result {
    // 此函式在伺服器上執行。它可以非同步存取資料庫。
    view! {
        <ul>
            for product in search_products(cx, &query).await? {
                <li>(product.name)</li>
            }
        </ul>
    }
}

客戶端反應性系統仍處於開發早期階段,並有一些限制。我們未來有許多想法來改善它。同時,你也可以使用 HTMXAlpine.js 整合。

建構網頁介面遠比渲染 HTML 複雜。網頁應用程式需要載入自訂字型、樣式表、圖片等。Topcoat 提供完整的資源管線,使用 asset 巨集:

const FERRIS: Asset = asset!("./ferris.png");

view! { <img src=(FERRIS)> }

在建置時,Topcoat CLI 會收集或下載所有資源,並將它們儲存在單一目錄中。當你的應用程式執行時,它們會使用內容雜湊來提供,以最佳化瀏覽器快取。

每個好的設計都需要自己的字型和圖示。FontsourceIconify 提供大量可供你使用的免費字型和圖示庫。這些可以輕鬆地包含在你的 Topcoat 應用程式中:

// 從 Fontsource 載入「Roboto」網頁字型。
const ROBOTO: Font = fontsource_font!(ROBOTO);

// 建立包含「feather」圖示集的 Rust 模組。
iconify::include!("feather");

想出一個有凝聚力的設計系統及其所有元件可能具有挑戰性。元件函式庫可以讓你快速上手,但當你的設計變更時,往往會變得缺乏彈性。受到 shadcn/ui 的啟發,Topcoat 的內建元件函式庫基於 Tailwind,並將現成的元件直接複製到你的原始碼目錄中。這意味著你可以修改任何需要的東西,並讓設計真正屬於你自己:

#[component]
async fn delete_card() -> Result {
    view! {
        card(
            card_header(
                card_title("Delete workspace")
                card_description("This permanently removes the workspace and all of its data.")
            )
            card_footer(
                attrs: attributes! { class="justify-end" },
                button(variant: ButtonVariant::Ghost, "Cancel")
                button(variant: ButtonVariant::Destructive, "Delete workspace")
            )
        )
    }
}

行為局部性作為指導原則

人類和 AI 都更擅長在小範圍的程式碼區域內進行推理。Topcoat 從根本上架構設計,讓你可以保持邏輯的局部性和可組合性。我們鼓勵,例如,讓元件自行進行資料擷取,而不是將資料作為元件引數向下傳遞:

#[component]
async fn user_profile(cx: &Cx, user_id: &str) -> Result {
    // 只有此元件知道它需要什麼使用者資料。
    let user = load_user(cx, user_id).await?;
    view! {
        <h1>(user.name)</h1>
        ...
    }
}

為避免多次擷取資料,Topcoat 具有內建的請求層級記憶化,靈感來自 React 的 cache

#[memoize]
async fn load_user(cx: &Cx, user_id: &str) -> Result<User> {
    // 此資料庫呼叫每個唯一的 `user_id` 只執行一次。
    db(cx).load_user_by_id(user_id).await
}

這個原則可以進一步延伸到驗證。與其依賴可能或可能不會執行的程式碼庫中完全不同的中介軟體,你可以直接在元件中保護你的資料:

async fn require_auth(cx: &Cx) -> Result<User> {
    if let Some(Session { user_id }) = current_session(cx).await? {
        Ok(load_user(cx, user_id).await?)
    } else {
        // 資料保持秘密,重新導向至登入。
        Err(redirect("/login").into())
    }
}

#[component]
async fn user_profile(cx: &Cx) -> Result {
    // `user_profile` 會在使用者未登入時自我保護!
    let user = require_auth(cx).await?;
    view! {
        <h1>(user.name)</h1>
        ...
    }
}

就像 React 中的 hooks(但沒有令人畏懼的 hooks 規則),函式透過傳遞請求內容(cx)來很好地組合。

Axum 呢

Axum 是另一個託管在 tokio-rs 上的 crate,用於建構伺服器應用程式。正因如此,我想特別說明 Topcoat 和 Axum 涵蓋截然不同的使用情境。事實上,我預期許多 Topcoat 使用者最終也會在他們的專案中使用 Axum。Axum 是一個較低階的 HTTP 路由器,讓建立 HTTP API 端點變得容易。你也可以使用 Axum 來建構反應式網頁應用程式,但這需要更多的樣板程式碼和設定。Topcoat 旨在移除這些樣板程式碼,但如果你只想建立那些較低階的 HTTP 端點,你仍然應該選擇 Axum

接下來

這只是 Topcoat 的第一版發行。我們的路線圖上有許多內容,包括與 Toasty 的更緊密整合、驗證、電子郵件等。你可以在 Readme 中了解一些相關資訊。

同時,Topcoat 現在就可以使用,如果你需要資料庫,請引入 Toasty。歡迎試用,並向我們提出問題和/或回饋。我們都在 Tokio Discord 的 #topcoat 頻道。

祝你建構愉快。