原创作者:庄晓立(LIIGO)
原创时间:2025年2月8日(首次发布时间)
原创链接:https://blog.csdn.net/liigo/article/details/145520637
版权所有,转载请注明出处。
关键词:Sycamore, Tauri, Dioxus, Leptos, Rust, WebAssembly, Reactive, JSX, React, Web
前言
Tauri 2.0发布于2024年10月2日,Sycamore 0.9发布于2024年11月1日。二者在近期双双发布重大版本升级,是我(LIIGO)这次想体验他们的主要动机。Tauri自2022年发布v1.0之后就早已火出天际,而Sycamore自2022发布v0.8之后沉寂了两年之久,如今各自凤凰涅槃,他们的组合体会擦出怎样的火花?
这是上一篇体验Tauri 2.0的姊妹篇。本文重点是体验Sycamore 0.9。
关于Sycamore
Sycamore, a library that makes it effortless to write performant user interfaces using the power of fine-grained reactivity. Sycamore uses open web standards such as WebAssembly to run your Rust code on the web, an environment that has been traditionally dominated by JavaScript.
Sycamore库提供细粒度响应式能力,可轻松编写高性能UI应用。Sycamore使用WEB标准技术WebAssembly等,将你的Rust代码运行在WEB中(传统上此类应用由JavaScript主导)。
Sycamore使用类似于JSX的声明式DSL语言描述UI,并提供组件、事件、路由、双向数据绑定等功能。
Sycamore和Tauri的关系
Tauri负责整个GUI应用框架,Sycamore在框架中负责前端UI渲染。Sycamore也可以脱离Tauri独立工作,用于开发基于WASM的WEB前端应用。
创建App
本节内容主要摘抄自同系列文章第一篇《初次体验Tauri和Sycamore (1)》。
Tauri使用两个命令行工具 (create-tauri-app, tauri-cli) 创建和编译打包App。首先要安装这两个CLI:
cargo install create-tauri-app --locked
cargo install tauri-cli --version "^2.0.0" --locked
create-tauri-app, tauri-cli 从Rust源码编译(cargo install)都相当耗时(均依赖数百个crates)。我还是建议自行下载编译后版本放到cargo bin目录。我给他们提了建议,今后会推荐使用 cargo binstall
下载编译好的二进制CLI,而不是使用cargo install
从源码开始编译。
这两个CLI也有都对应的npm包:create-tauri-app, @tauri-apps/cli,二者都是间接调用Rust编译好的可执行文件。供TS/JS前端使用。
执行如下命令开始创建Tauri app:cargo create-tauri-app
。CLI会逐步引导你输入或选择如下信息:
- 项目名称(Project name),默认是"tauri-app"
- Identifier 默认是"com.tauri-app.app"
- 前端语言,可选 Rust, TS/JS, .Net
- UI模板,视前端语言而定
- Rust UI模板:可选 Vanilla, Yew, Leptos, Sycamore, Dioxus
- TS/JS UI模板:可选 Vanilla, Vue, Svelte, React, Solid, Angular, Preact
- .Net UI模板:可选 Blazor
选TS/JS的UI模板前还需要选择包管理器:pnpm, yarn, npm, deno, bun
因为这次我(Liigo)想体验Tauri+Sycamore,因而前端语言选Rust,UI模板选Sycamore。
目录结构
Tauri+Sycamore App目录结构:
├─ public/
├─ src/
│ ├─ app.rs
│ └─ main.crs
├─ src-tauri/
│ ├─ ...
│ ├─ Cargo.toml
│ └─ tauri.conf.json
├─ .gitignore
├─ .taurignore
├─ Cargo.toml
├─ index.html
├─ README.md
├─ styles.css
└─ Trunk.toml
Tauri源码目录对前端和后端代码进行了隔离。后端代码使用src-tauri/
子目录;前端代码使用除此之外的其他文件和子目录。
编译打包
本节内容主要摘抄自同系列文章第一篇《初次体验Tauri和Sycamore (1)》。
开发版
cargo tauri dev
编译完成后自动启动App,弹出GUI主窗口。允许开发者在App运行过程中修改前端源代码,Tauri(或者说Trunk)会自动编译,并刷新App窗口内容,但是App并不会中途退出或重启(原理:Trunk通过WebSocket向开发版App推送重新加载UI的指令;Dioxus虽然没用Trunk但也实现了类似机制)。
它会检查Trunk是否存在,不存在的话会自动下载源码并编译。但是在Windows下编译Trunk很可能会碰到如下问题(间接依赖openssl开发者库):
It looks like you're compiling for MSVC but we couldn't detect an OpenSSLinstallation. If there isn't one installed then you can try the rust-opensslREADME for more information about how to download precompiled binaries ofOpenSSL:https://github.com/sfackler/rust-openssl#windowsnote: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
warning: build failed, waiting for other jobs to finish...
error: failed to compile `trunk v0.21.2`, intermediate artifacts can be found at `E:\tmp\RUST_DIR\CARGO_TARGET_DIR`.
To reuse those artifacts with a future compilation, set the environment variable `CARGO_TARGET_DIR` to that path.
我找到的解决办法是,去github的trunk官方仓库下载编译好的trunk.exe,丢进cargo bin目录即可(或任意PATH目录均可)。
如果cargo tauri dev
过程中看到如下提示时只需耐心等待:
Warn Waiting for your frontend dev server to start on http://localhost:1420/…
这是因为Trunk先启动了(WEB服务监听1420端口),等待APP主动连接。但是编译App需要时间,等它编译完并启动后才能连上。
通过App窗口右键菜单"检查"可以打开devtools。在App运行过程中,还可以在浏览器中打开 http://localhost:1420/ ,网页外观和功能跟App窗口是一样的(可视为App的另一个实例)。
发行版
cargo tauri build
编译App并打包为安装包。
如果编译过程中提示正在下载Wix但失败(Github国内连接不稳定):
Downloading https://github.com/wixtoolset/wix3/releases/download/wix3141rtm/wix314-binaries.zip
你可以通过其他方法手动下载此连接,解压到如下目录:C:\Users\liigo\AppData\Local\tauri\WixTools314\
(里面有一堆exe等文件)。
此方法是我(LIIGO)从 Tauri仓库源码 里扒出来的。实证管用。
同理,如果NSIS也下载不了,可以用类似的办法手动下载解压到目录C:\Users\liigo\AppData\Local\tauri\NSIS
。但是我没用这个方法。因为我觉得,既然已经有Wix用来生成MSI安装包,就没必要再下载NSIS用来生成另一种安装包。我研究了一下,将配置文件tauri.conf.json
里面的bundle.targets
改为"msi"(原来是"all")即可禁用NSIS等。
文件大小
Tauri+Sycamore app发行版编译后是一个可独立运行的图形用户界面(GUI)exe,其内部整合了wasm/css/图片等文件,没有其他外部依赖。exe文件大小是10.3MB,对应的安装包msi文件大小是3.6MB(安装后也只有那个exe和一个用于卸载的快捷方式文件(指向系统文件msiexec.exe /x
))。Sycamore生成的wasm文件大小为750KB(已包含在exe中)。App启动时有大约一两秒的窗口白屏。
作为对比,再看一下Tauri+Dioxus app的数据:exe大小10.6MB,msi大小3.9MB,wasm文件大小为1.3MB(debug版33MB或25MB),也有一两秒的启动白屏。大同小异吧。我(LIIGO)暂且认为这是Tauri App (Hello world)的平均水平。
这样的文件大小应该很香吧。最起码比Electron app香多了。
1MB的wasm文件,用在普通网站上,网络传输加载延迟是一个较大的负担,但是对Tauri app这种桌面应用而言,就是本地加载啊,性能没得说。况且Tauri还应用了"localhost free"技术,直接注入Webview,连本地WEB传输步骤也省了。
Instead of relying on localhost server that expose your frontend to all other processes we use native webview apis to inject the assets right before the webview requests hit the network stack. – FabianLars
资源占用
Tauri+Sycamore app发行版启动后,内部加载3到6个Webview2进程,连同exe合计占用内存60到90MB。
无操作时CPU占用率为0%;在app窗口上移动鼠标时,CPU占用率逐步上升到10%甚至更多。这个问题是不是需要改善呀。
20241230 LIIGO补记:在VsCode中打开Tauri+Sycamore app项目源码,VsCode大概占用2.5GB内存甚至更多。
Tauri+Dioxus app的表现与之类似。
前端代码全览
以下是Tauri CLI生成的前端核心文件src\app.rs
全部代码,先整体感受一下Sycamore。
其主体代码定义了一个App组件(App
函数)。App组件内部负责描述UI、管理状态、处理事件。
use serde::{Deserialize, Serialize};
use sycamore::futures::spawn_local_scoped;
use sycamore::prelude::*;
use sycamore::web::events::SubmitEvent;
use wasm_bindgen::prelude::*;#[wasm_bindgen]
extern "C" {#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])]async fn invoke(cmd: &str, args: JsValue) -> JsValue;
}#[derive(Serialize, Deserialize)]
struct GreetArgs<'a> {name: &'a str,
}#[component]
pub fn App() -> View {let name = create_signal(String::new());let greet_msg = create_signal(String::new());let greet = move |e: SubmitEvent| {e.prevent_default();spawn_local_scoped(async move {// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/let args = serde_wasm_bindgen::to_value(&GreetArgs {name: &name.get_clone()}).unwrap();let new_msg = invoke("greet", args).await;greet_msg.set(new_msg.as_string().unwrap());})};view! {main(class="container") {h1 {"Welcome to Tauri + Sycamore"}div(class="row") {a(href="https://tauri.app", target="_blank") {img(src="public/tauri.svg", class="logo tauri", alt="Tauri logo")}a(href="https://sycamore.dev", target="_blank") {img(src="public/sycamore.svg", class="logo sycamore", alt="Sycamore logo")}}p {"Click on the Tauri and Sycamore logos to learn more."}form(class="row", on:submit=greet) {input(id="greet-input", bind:value=name, placeholder="Enter a name...")button(r#type="submit") {"Greet"}}p {(greet_msg)}}}
}
声明式UI
Sycamore使用view!()
宏描述UI,大致类似于React系的JSX语言。
语法上相对比较陌生。虽然本质上还是写HTML,但并未直接采用HTML或JSX语法,也未直接采用Rust语法,它最终呈现给我们的是一种糅合了函数调用和花括号子块的形式(form(attr1, attr2) { sub-nodes }
)。内部支持//
和/* */
注释。
view! {h1 {"Welcome to Tauri + Sycamore"}form(class="row", on:submit=greet) {input(id="greet-input", bind:value=name, placeholder="Enter a name...")button(r#type="submit") {"Greet"}}p {(greet_msg)}
}
在前端声明式UI语法上,不同的作者有不同的喜好倾向。LIIGO本人的感受是,Leptos更像JSX,Dioxus更像Rust,Sycamore则介于二者中间。主要区别是风格口味,暂且不谈孰好孰坏吧。总之它们都可以统一归结为“类JSX”或者“神似JSX”学派。
组件
在Sycamore中创建定义组件是非常简单的。组件有几个特征:普通的函数,添加#[component]
标注,返回值类型固定为View
,函数体主体是view!
宏调用,里面是类JSX语法。组件函数内部view!
前面,可以定义状态变量,可以写Rust代码;view!
内部也可以嵌入Rust表达式;参见后文状态管理一节。
#[component]
pub fn Hello() -> View {view! {div {"hello"}}
}
我照猫画虎把前面app.rs
里面的一部分UI提取为一个独立组件,welcome组件:
#[component]
fn welcome() -> View {view! {h1 {"Welcome to Tauri + Sycamore"}div(class="row") {a(href="https://tauri.app", target="_blank") {img(src="public/tauri.svg", class="logo tauri", alt="Tauri logo")}a(href="https://sycamore.dev", target="_blank") {img(src="public/sycamore.svg", class="logo sycamore", alt="Sycamore logo")}}p {"Click on the Tauri and Sycamore logos to learn more."}}
}
调用端语法示例:
#[component]
pub fn App() -> View {view! {self::welcome {}}
}
welcome
前面必须加上self::
前缀,否则编译报错:
error[E0425]: cannot find function
welcome
in modulesycamore::rt::tags
看样子它固定去sycamore::rt::tags
module里加载组件,无视welcome就在当前module里。希望后续改进。
组件当然也可以有属性(Attributes),附官方示例定义代码和调用代码。
#[component(inline_props)]
fn Button(// `html` means that we are spreading onto an HTML element.// The other possible value is `svg`.//// `button` means that we are spreading onto a `<button>` element.#[prop(attributes(html, button))]attributes: Attributes,// We can still accept children.children: Children,// We can still accept other props besides `attributes`.other_prop: i32,
) -> View {view! {// The spread (`..xyz`) syntax applies all the attributes onto the element.button(..attributes)}
}
view! {Button(// Pass as many HTML attributes/events as you want.id="my-button",class="btn btn-primary",on:click=|_| {}// You can still pass in regular props as well.other_prop=123,) {// Children still gets passed into the `children` prop."Click me"}
}
状态管理
使用create_signal
等函数创建状态对象,例如:
let name = create_signal(String::new());
let greet_msg = create_signal(String::new());
显示状态对象的值(小括号括住):
view! {p {(greet_msg)}
}
文本内插值:"ok " (greet_msg) " yes"
。状态值必须写在常量文本双引号外面,严格来说是伪内插值;Leptos也类似(小括号改为花括号);Dioxus支持"ok {greet_msg} yes"
真内插值。
更新状态对象的值(Signal<T>::set
):
greet_msg.set(new_value);
双向绑定到组件属性(属性value
⇄状态name
),语法:bind:属性=状态
,示例:
view! {input(id="greet-input", bind:value=name)
}
事件
以下示例代码处理FORM组件的submit事件,其中事件处理函数greet是一个闭包函数(有一个event参数,类型取决于具体事件):
let greet = move |e: SubmitEvent| {// ...
};view! {form(class="row", on:submit=greet) {// ...}
}
事件处理函数通常是被写成组件函数内部的闭包函数形式,这是因为事件处理函数内往往需要访问状态对象(Signal<T>
),而状态对象往往是组件函数内部的局部变量,在组件函数内部定义的闭包函数访问组件函数内的局部变量是很方便的(自动捕获闭包外部变量)。
但是这种形式把逻辑代码和UI代码放在一个组件函数里,很容易导致大函数缝合怪,一大坨事件处理代码 + 一大坨UI描述代码(view!{}
),互相喧宾夺主。我感觉组件函数的主体应该是view!{}
(加上少量状态对象定义代码),理想情况下应该把事件处理代码隔离出去。
事件处理代码当然也可以写成独立函数的形式(独立于组件函数),我(LIIGO)专门做了一些尝试并且也成功了(不知道能不能作为最佳实践)。首先要解决Signal对象不在当前作用域的问题,需要通过参数传递Signal,可是有了Signal参数这函数就不可能符合事件处理函数的原型(有且只有一个Event参数)。我的做法是让这个函数执行后再返回符合事件处理函数原型的闭包函数。代码如下,mygreet()为独立函数,调用mygreet()后返回事件处理函数:
#[component]
pub fn App() -> View {let name = create_signal(String::from("LIIGO"));let greet_msg = create_signal(String::new());view! {form(class="row", on:submit=mygreet(name, greet_msg)) {// ...}}
}fn mygreet(name: Signal<String>, greet_msg: Signal<String>) -> impl Fn(SubmitEvent) {move |e| {let new_msg = invoke("greet", args).await;greet_msg.set(new_msg.as_string().unwrap());}
}
整改后的app.rs
以下是我整改后的app.rs文件全部代码,主要是拆解、抽象、隔离,但保持原有功能不变。
use serde::{Deserialize, Serialize};
use sycamore::futures::spawn_local_scoped;
use sycamore::prelude::*;
use sycamore::web::events::SubmitEvent;
use wasm_bindgen::prelude::*;#[wasm_bindgen]
extern "C" {#[wasm_bindgen(js_namespace = ["window", "__TAURI__", "core"])]async fn invoke(cmd: &str, args: JsValue) -> JsValue;
}#[derive(Serialize, Deserialize)]
struct GreetArgs<'a> {name: &'a str,
}#[component]
pub fn App() -> View {let name = create_signal(String::from("LIIGO"));let greet_msg = create_signal(String::new());view! {main(class="container") {self::welcome {}form(class="row", on:submit=mygreet(name, greet_msg)) {input(id="greet-input", bind:value=name, placeholder="Enter a name...")button(r#type="submit") {"Greet"}}p {(greet_msg)}}}
}fn mygreet(name: Signal<String>, greet_msg: Signal<String>) -> impl Fn(SubmitEvent) {move |e| {print!("{}", name.get_clone());e.prevent_default();spawn_local_scoped(async move {// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/let args = serde_wasm_bindgen::to_value(&GreetArgs {name: &name.get_clone()}).unwrap();let new_msg = invoke("greet", args).await;greet_msg.set(new_msg.as_string().unwrap());})}
}#[component]
fn welcome() -> View {view! {h1 {"Welcome to Tauri + Sycamore"}div(class="row") {a(href="https://tauri.app", target="_blank") {img(src="public/tauri.svg", class="logo tauri", alt="Tauri logo")}a(href="https://sycamore.dev", target="_blank") {img(src="public/sycamore.svg", class="logo sycamore", alt="Sycamore logo")}}p {"Click on the Tauri and Sycamore logos to learn more."}}
}
Sycamore / Leptos / Dioxus
这三者有诸多相似性:
- 都具备响应式WEB功能
- 都提供类似于JSX的前端UI描述语言
- 都支持UI组件化
- 都是用Rust编程语言开发的开源项目
- 都支持编译为WebAssembly(WASM)在浏览器内运行
- 都可以作为Rust前端被集成进Tauri 2.0 App
当然它们也都可以脱离Tauri各自独立开发WEB应用(其中Dioxus还可以开发桌面应用和移动应用)。
三者之中目前只有Dioxus支持热加载(Hot Module Reloading, HMR),修改UI代码后无需重新编译即时预览结果,开发体验更佳。