Rust:WASM前端开发初探(Leptos)

Rust 编译到 WebAssembly 做前端已经不是概念验证了——Leptos 框架提供了接近 React/SolidJS 的开发体验,同时保持 Rust 的类型安全和性能优势。本文记录用 Leptos 开发一个简单 Web 应用的过程,重点看它的信号系统和组件模型。

Rust WASM 前端生态概览

目前 Rust WASM 前端框架主要有三个:

框架 特点 响应式模型
Yew 最早的 Rust 前端框架,类 React Virtual DOM
Dioxus 类 React,支持多平台(Web/Desktop/Mobile) Virtual DOM
Leptos 类 SolidJS,细粒度响应式 Signal(无 VDOM)

Leptos 选择了 SolidJS 路线——不用 Virtual DOM,通过 Signal 做细粒度更新。这意味着组件函数只执行一次(而非每次状态变化都重新执行),性能上限更高。

环境搭建

# 安装 trunk(WASM 打包工具)
cargo install trunk

# 安装 wasm target
rustup target add wasm32-unknown-unknown

# 创建项目
cargo new leptos-demo
cd leptos-demo

Cargo.toml

[package]
name = "leptos-demo"
version = "0.1.0"
edition = "2021"

[dependencies]
leptos = { version = "0.6", features = ["csr"] }  # csr = Client Side Rendering
console_error_panic_hook = "0.1"

项目根目录创建 index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>Leptos Demo</title>
    <link data-trunk rel="rust" data-wasm-opt="z"/>
</head>
<body></body>
</html>

信号系统(Signal)

信号是 Leptos 的核心。create_signal 返回一个 getter 和一个 setter:

use leptos::*;

#[component]
fn Counter() -> impl IntoView {
    // create_signal 返回 (ReadSignal<T>, WriteSignal<T>)
    let (count, set_count) = create_signal(0);

    view! {
        <div class="counter">
            // count() 读取当前值;每次 count 变化,只有这个 <span> 会更新
            <p>"当前计数: " {count}</p>
            <button on:click=move |_| set_count.update(|n| *n += 1)>
                "+1"
            </button>
            <button on:click=move |_| set_count.set(0)>
                "重置"
            </button>
        </div>
    }
}

关键概念:

  • countReadSignal<i32>——调用 count() 或在 view! 中使用时会自动追踪依赖
  • set_countWriteSignal<i32>——set() 替换值,update() 基于当前值修改
  • 与 React 不同,组件函数只执行一次。后续状态变化只更新绑定了信号的 DOM 节点

派生信号(Derived Signal)

let (count, set_count) = create_signal(0);
// 派生信号:依赖 count,count 变化时自动重新计算
let doubled = move || count() * 2;
let is_even = move || count() % 2 == 0;

view! {
    <p>"Count: " {count}</p>
    <p>"Doubled: " {doubled}</p>
    <p>"Is Even: " {is_even}</p>
}

派生信号就是闭包——不需要额外的 API。这一点比 React 的 useMemo 自然很多。

组件编写

use leptos::*;

// 组件参数用 struct-like 的方式定义
#[component]
fn TodoApp() -> impl IntoView {
    let (todos, set_todos) = create_signal(vec![
        Todo { id: 0, text: "学习 Leptos".to_string(), done: false },
        Todo { id: 1, text: "写博客".to_string(), done: false },
    ]);
    let (next_id, set_next_id) = create_signal(2u32);
    let (input, set_input) = create_signal(String::new());

    let add_todo = move |_| {
        let text = input().trim().to_string();
        if text.is_empty() { return; }
        set_todos.update(|todos| {
            todos.push(Todo {
                id: next_id(),
                text,
                done: false,
            });
        });
        set_next_id.update(|id| *id += 1);
        set_input.set(String::new());
    };

    let toggle_todo = move |id: u32| {
        set_todos.update(|todos| {
            if let Some(todo) = todos.iter_mut().find(|t| t.id == id) {
                todo.done = !todo.done;
            }
        });
    };

    let remaining = move || todos().iter().filter(|t| !t.done).count();

    view! {
        <div class="todo-app">
            <h1>"Todo List"</h1>
            <div class="input-row">
                <input
                    type="text"
                    prop:value=input
                    on:input=move |ev| set_input.set(event_target_value(&ev))
                    placeholder="输入新任务..."
                />
                <button on:click=add_todo>"添加"</button>
            </div>
            <ul>
                <For
                    each=todos
                    key=|todo| todo.id
                    children=move |todo| {
                        let id = todo.id;
                        view! {
                            <li class:done=todo.done>
                                <input
                                    type="checkbox"
                                    prop:checked=todo.done
                                    on:change=move |_| toggle_todo(id)
                                />
                                <span>{todo.text.clone()}</span>
                            </li>
                        }
                    }
                />
            </ul>
            <p>{remaining} " 项待完成"</p>
        </div>
    }
}

#[derive(Clone, Debug)]
struct Todo {
    id: u32,
    text: String,
    done: bool,
}

<For> 组件是 Leptos 的列表渲染方案,类似 SolidJS 的 <For>key 用于高效 diff。

事件处理

// 基本事件
<button on:click=move |_| { /* ... */ }>"Click"</button>

// 带 event 对象
<input on:input=move |ev| {
    let value = event_target_value(&ev);
    set_input.set(value);
}/>

// 键盘事件
<input on:keydown=move |ev: web_sys::KeyboardEvent| {
    if ev.key() == "Enter" {
        add_todo(ev);
    }
}/>

// 阻止默认行为
<form on:submit=move |ev| {
    ev.prevent_default();
    // ...
}/>

事件类型来自 web_sys crate,和浏览器原生 API 一一对应。Rust 的类型系统确保你不会拼错事件属性名。

与 JavaScript 互操作

通过 wasm-bindgen 调用 JS API:

use wasm_bindgen::prelude::*;

// 调用 JS 的 alert
#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

// 调用 console.log
#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

// 在组件中使用
#[component]
fn App() -> impl IntoView {
    let greet = move |_| {
        alert("Hello from Rust!");
        log("Button clicked");
    };

    view! {
        <button on:click=greet>"Greet"</button>
    }
}

也可以用 js_sys crate 直接操作 JS 对象,或通过 web_sys 访问所有 Web API(DOM、fetch、WebSocket 等)。

SSR 支持

Leptos 原生支持 SSR(Server-Side Rendering),用 Actix-Web 或 Axum 作为后端:

[dependencies]
leptos = { version = "0.6", features = ["ssr"] }
leptos_axum = "0.6"
axum = "0.7"
tokio = { version = "1", features = ["full"] }

SSR 模式下,组件在服务端渲染为 HTML 字符串发送给客户端,然后在客户端"水合"(hydration)恢复交互。这和 Next.js/Nuxt 的思路一样,但全程用 Rust 编写。

开发与构建

# 开发模式(热重载)
trunk serve

# 生产构建
trunk build --release

# 输出在 dist/ 目录
ls dist/

trunk 会自动编译 Rust 到 WASM、处理资源文件、生成 HTML。生产构建默认会做 wasm-opt 优化,通常能把 WASM 文件压到几百 KB。

总结

Leptos 的开发体验比预期好很多。细粒度响应式避免了 VDOM 的开销,view! 宏的语法和 JSX 相似度很高,上手不难。Rust 的类型系统在前端场景下也发挥了作用——状态类型不对、事件属性拼错都能在编译期抓住。目前的主要限制是生态还不够丰富(组件库少),以及 WASM 的初始加载体积比纯 JS 大。适合对性能有要求的、或者团队本身就用 Rust 的场景。