构建完整基于 rust 的 web 应用,使用yew
框架
trunk
构建、打包、发布 wasm web 应用
安装后会作为一个系统命令,默认有两个特性开启
rustls
- 客户端与服务端通信的 tls 库update_check
- 用于应用启动时启动更新检查,应用有更新时提示用户更新。native-tls
需要指定开启,使用系统原生的 tls 用于客户端;使用 openssl 用于服务端
$> cargo install --locked trunk
创建一个 rust web 项目cargo new rust-yew-web
,项目根目录下创建index.html
没有任何内容,尝试一下trunk build
,可以看到dist
目录
$> trunk buil
安装yew
开始 web 程序
用于创建使用 webAssembly 的多线程 web 应用框架。
$> cargo add yew --features "csr"
yew
不会默认指定特性,我们做的是 web 端开发,所以指定开启csr
。其他的还包括 ssr - 服务端渲染;hydration - 混合开发,支持客户端、服务端渲染。
在src/main.rs
定义方法app
渲染 html,html!
宏可以定义类似 jsx 语法的视图结构。
use yew::prelude::*;
#[function_component(App)]
fn app() -> Html {
html! {
<h1>{"hello, trunk/yew!"}</h1>
}
}
fn main() {
yew::Renderer::<App>::new().render();
}
#[function_component(App)]
属性宏使一个普通的 rust 函数变成了一个函数组件,这个组件必须返回 html.#[function_component]
接受一个组件名称,这里我们定义的是App
函数组件可以接受一个参数props: &Props
用于组件之间传递数据。它通常是一个没有状态的静态组件。
启动,通过trunk serve --open
启动一个服务,直接打开浏览器
$> trunk serve --open
可以看到浏览器输出,修改内容会重新编译,实时刷新页面。
可以在Trunk.toml
中配置启动服务的地址、端口
# The address to serve on LAN.
address = "127.0.0.1"
# The address to serve on WAN.
# address = "0.0.0.0"
# The port to serve on.
port = 8000
组件语法,使用注意项
可以注意到组件中<h1>{"hello, trunk/yew!"}</h1>
文本字符展示需要使用{}
括起来。
-
组件只能有一个根节点,节点必须是闭合的。如果不需要渲染根节点,可以使用
<></>
,使用block
也行html! { <> <h1>{"hello, trunk/yew!"}</h1> <h2>{"good!"}</h2> </> }
-
循环渲染,渲染的每一个节点业务需要返回
html!
。最后通过collect
消费掉。// 需要渲染的字段 let names = ["admin", "test", "hboot"]; // 渲染片段 let names = names .iter() .map(|name| { html! { <p>{format!("{name}")}</p> } }) .collect::<Html>();
在组件的
html
返回中使用,通过{}
,可以看到浏览器中的输出。html! { <> <h1>{"hello, trunk/yew!"}</h1> <h2>{"good!"}</h2> {names} </> }
也可以直接在
html! {}
中直接使用循环渲染。这里可以使用另一种语法{for ...}
来替代消费.collect()
// 渲染片段 let names = names.iter().map(|name| { html! { <p>{format!("{name}")}</p> } }); // .collect::<Html>(); html! { <> {for names} </> }
-
属性绑定,给节点绑定动态的 class,通过
{}
。这里只演示了变量绑定,动态的则需要hook
声明。let active = "active"; html! { <> <h2 class={active}>{"good!"}</h2> </> }
-
条件判断,通过
if
判断let bool = true; html! { <> {if bool{ html!{ <span>{"yes"}</span> } }else{ html!{ <span>{"no"}</span> } }} </> }
在未来更新后,内部的
html!
可能就不需要了,现在仍需要加上。表明类型是 html。
关于组件 - Component
trait
上面我们实现一个组件App
,通过#[function_component]
属性宏转变 rust 函数为一个组件。也可以通实现Component
trait,来实现组件的功能。
重新创建一个模块src/user.rs
,创建一写关于个人信息的组件
use yew::prelude::*;
// 定义用户结构体
pub struct User {
name: String,
}
impl Component for User {
type Message = ();
type Properties = ();
fn create(ctx: &Context<Self>) -> Self {
Self {
name: "hboot".to_string(),
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div class="user-center">
<h3>{self.name.clone()}</h3>
</div>
}
}
}
Component
作为一个 trait,只要实现了就可以作为一个函数组件渲染到视图中。必须要实现的方法create
和view
,两个必须要声明的类型Message\Properties
。还有一些其他的方法
-
create
在组件创建后调用,用于初始化。 -
view
定义组件视图,语法类似于 jsx。create
方法跟随 view 方法的调用;view 方法不总是跟随update
和changed
方法,内部做了一些渲染优化。 -
type Message: 'static
用来声明消息类型,使得组件变成动态组件、可交互。它通过枚举来定义消息类型。 -
type Properties: Properties
定义组件的属性,它接受来自上文context
的消息,不一定是父组件。触发组件的重新渲染。 -
update
可选,交互式消息触发时的钩子函数,在这里处理逻辑。返回bool
来定义是否触发组件更新。 -
changed
可选,定义属性变更时的钩子函数。返回bool
来定义是否触发组件更新。 -
rendered
可选,在组件渲染完成,还未更新到页面时调用。在view
之后 -
destroy
可选,在组件销毁卸载之前调用。 -
prepare_state
可选,在服务端渲染后,组件被渲染前调用。
在main.rs
中导入使用,可以看到页面上已经出现了展示内容。
mod user;
// 使用组件
use user::User;
fn app() -> Html {
// ...
html! {
<>
<User />
</>
}
}
添加一个事件,通过点击 button 来改变当前的用户名。就需要实现update
方法来处理交互消息
// 定义消息类型
pub enum Msg {
UpdateName,
}
// ...
impl Component for User {
// ...
fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::UpdateName => {
self.name = "admin".to_string();
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div class="user-center">
<button οnclick={ctx.link().callback(|_| Msg::UpdateName )}>{"更新"}</button>
<h3>{self.name.clone()}</h3>
</div>
}
}
}
在update
方法中,通过接收到的msg
消息类型来匹配需要执行的逻辑。当然可以将处理逻辑抽离提供一个方法进行调用。在view
中添加了一个 button 作为按钮触发点击事件,通过onclick
监听点击事件,ctx.link().callback()
来发起一个事件,这有点像 react 的 redux。
数据传递,单项数据流
首先要接收来自父组件的数据,我们定义一个Props
类型,props 需要过程宏derive
来实现Properties \ PartialEq
,然后定义type Properties = Props
#[derive(Properties, PartialEq)]
pub struct Props {
pub age: i32,
}
impl Component for User {
// ...
type Properties = Props;
//...
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div class="user-center">
<h3>{format!("姓名:{}",self.name.to_string())}</h3>
<h4>{format!("年龄:{}",ctx.props().age)}</h4>
</div>
}
}
}
定义完Properties
,就需要在用到组件的地方增加传参。默认是必传的
html! {
<>
<User age={30} />
</>
}
通过属性宏来设置属性的状态,这样就可以不必传:
#[prop_or_default]
默认初始化值。#[prop_or(value)]
使用默认值value
指定默认值。#[prop_or_else(fn)]
指定初始化值函数,没有传值时会调用。函数签名FnMut()-> T
#[derive(Properties, PartialEq)]
pub struct Props {
#[prop_or(28)]
pub age: i32,
}
这样就可以不必传了。我们可以在父组件使用props!
宏来定义子组件的 props,然后传给子组件,为了标识 props,我们把 user 中的 props 改名为UserProps
在main.rs
:
use yew::props;
// ...
use user::{User, UserProps};
fn app() -> Html {
// ...
let user_props = props! {
UserProps {
age:30
}
};
html! {
<>
<User ..user_props />
</>
}
}
可以看到props!
宏可以接受多个 props。定义完之后通过..user_props
绑定到子组件上。注意是两个点..
。
两个定义 props 字段类型的建议:
- 不要使用
String
类型,而是使用&str
。因为 String 类型的复制消耗大 - 不要使用内部可变性的智能指针,这会导致组件不知掉什么时候需要更新。
子组件更新父组件状态
子组件通过事件回调的方式更新父组件的状态。我们定义父组件更新age
的方法,然后在子组件触发调用更新
在main.rs
,因为需要更新 age,所以需要声明 age 为父组件的一个状态数据,在函数组件中使用use_state
定义。然后声明更新方法update_age
提供给子组件调用。
let age = use_state(|| 30);
// 点击更新年龄
let update_age: Callback<()> = {
let age = age.clone();
Callback::from(move |_| age.set(26))
};
html! {
<>
// ...
<User age={*age} on_update_age={update_age} />
</>
}
因为 props 的不可变性,我们使用props!
创建的传参改为传统方式。
*age
解引用获取指向的值。通过age.set()
方法更新值。子组件 props 增加接受回调函数,我们这个回调不接受参数,所以给一个()
在user.rs
中:
pub enum Msg {
// ...
UpdateAge,
}
#[derive(Properties, PartialEq)]
pub struct UserProps {
// ...
pub on_update_age: Callback<()>,
}
impl Component for User {
// ...
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
// ...
Msg::UpdateAge => {
ctx.props().on_update_age.emit(());
false
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div class="user-center">
// ...
<button οnclick={ctx.link().callback(|_| Msg::UpdateAge )}>{"更新age"}</button>
<h4>{format!("年龄:{}",ctx.props().age)}</h4>
</div>
}
}
}
首先增加了一个 msgUpdateAge
,有 button 触发点击回调Msg::UpdateAge
,在update
中匹配消息类型,调用来自父组件的回调方法ctx.props().on_update_age.emit(())
,不是直接调用哦,而是通过.emit()
可以传值,因为我们不需要,所以给一个元组。
组件内部嵌套html
上面子组件更新父组件的状态感觉很费劲,既然状态在父组件的我们可以通过在子组件调用时嵌套 html 的方式增加子组件视图。
在main.rs
中
let update_age = {
let age = age.clone();
Callback::from(move |_| age.set(26))
};
html! {
<>
<User>
<button οnclick={update_age}>{"更新age"}</button>
<h4>{format!("年龄:{}",*age)}</h4>
</User>
</>
}
在父组件渲染就没有那么多的弯弯绕绕。
修改子组件user.rs
,移除掉之前的回调函数的定义。增加 props 类型children:Html
,就可以直接在渲染时访问。
#[derive(Properties, PartialEq)]
pub struct UserProps {
// ...
pub children: Html,
}
impl Component for User {
fn view(&self, ctx: &Context<Self>) -> Html {
html! {
<div class="user-center">
// ...
{ctx.props().children.clone()}
</div>
}
}
}
组件hooks
上面已经使用了一个 hookuse_state
用于管理数据状态。这样类比 react,不就是函数组件和类组件的区别吗,感觉就容易上手很多。
可以使用已经定义好的 hook,还可以自定义 hook。
yew hook 文档
-
use_state
管理函数组件的数据状态,只要设置新的值,就会触发组件重新渲染。 -
use_state_eq
设置新值,需要比较旧值是否相等。不相等才出发组件渲染,定义的数据结构需要实现PartialEq
trait -
use_effect
副作用钩子函数,在组件渲染完成后调用。use log::{info, Level}; #[function_component(App)] fn app() -> Html { // ... use_effect(move || { // 渲染完成执行 info!("render!") }); // 点击更新年龄 let update_age = { let age = age.clone(); Callback::from(move |_| age.set(30)) }; // ... }
我们点击视图中的更新年龄,会一直调用
update_age
,虽然值没有发生变化,但是组件仍会重新渲染。为了防止不必要的重复渲染,可以声明变量使用
use_state_eq
// let age = use_state(|| 30); let age = use_state_eq(|| 30);
我们再次测试点击更新,发现没有执行副作用函数
use_effect
的逻辑。还有另一种方式就是use_effect_with
只有它接收的依赖变量发生变化时才触发调用。 -
use_effect_with
同use_effect
,接受依赖,依赖变更时,才触发。let age = use_state(|| 30); //... let with_age = age.clone(); use_effect_with(with_age, move |_| { // 渲染完成执行 info!("dep render!") });
点击更新时,没有触发
use_effect_with
钩子输出,初始时触发。 -
use_force_update
手动强制重新渲染组件 -
use_memo
优化计算,只有在依赖项发生变更时才会重新执行计算。 -
use_callback
优化渲染,使得子组件不受父组件数据状态变化而重新渲染。 -
use_context
捕获上下文的值,跨组件共享数据。 -
use_mut_ref
获取值的可变引用,但不会引起组件重新渲染。 -
use_node_ref
用来访问 DOM 元素,ref
绑定 -
use_reducer
共享计算逻辑,可以在不同的组件内通过触发action
来调用处理函数
和 react 的 redux 的类似,定义数据结构必须实现Reducible
trait定一个
UserInfo
结构,存储用户个人信息,定一个 actionUserInfoAction
触发 age 的值更新。实现Reducible
trait,定义type Action = UserInfoAction
.pub struct UserInfo { pub age: i32, } // 定义操作的 action pub enum UserInfoAction { UpdateAge(i32), } impl Default for UserInfo { fn default() -> Self { Self { age: 28 } } } // action reducer impl Reducible for UserInfo { type Action = UserInfoAction; fn reduce(self: Rc<Self>, action: Self::Action) -> Rc<Self> { match action { UserInfoAction::UpdateAge(age) => { info!("update age --- {age}"); Self { age }.into() } } } }
通过
reduce
方法接收分发过来的 action,并增加处理逻辑。这里使用了Rc
引用计数,维护多引用值的可变性。然后在
main.rs
引入使用,使用use_reducer
,UserInfo::default
初始化状态.dispatch
方法分发事件。use std::rc::Rc; mod userInfo; use userInfo::{UserInfo, UserInfoAction}; #[function_component(App)] fn app() -> Html { // ... let age = use_state(|| 30); let user_info = use_reducer(UserInfo::default); // 点击更新年龄 let update_age = { let age = age.clone(); let user_info = user_info.clone(); let cb1 = Rc::new(Callback::from(move |_| age.set(30))); let cb2 = Rc::new(Callback::from(move |_| { user_info.dispatch(UserInfoAction::UpdateAge(30)) })); // Callback::from(move |_| { cb1.emit(()); cb2.emit(()); }) }; // ... }
在一个方法中同时处理多个事件时,通过
Rc<T>
引用计数确保Callback::from
闭包调用的所有权。在第三个Callback::from
通过emit()
方法同时触发多个事件。 -
use_reducer_eq
设置值时,比较新旧值是否相等。
其他
-
rust 本身不支持解析 css 样式,只能通过外部样式来调整。给节点增加类或 id。
html! { <h1 class="info">{"hello, trunk/yew!"}</h1> }
再利用
trunk
的功能,加载外部样式。也可以直接通过
style
属性,你要是直接写一串字符串 css 样式,绑定到 style 也行,就是不好维护html! { <h1 class="info" style="color:red;">{"hello, trunk/yew!"}</h1> }
需要动态的 class 设置,只能通过
classes!()
宏管理,动态添加、移除、切换,参数可以是 list、字符串、实现了Into<Classes>
的类型新增一个
is_active
状态,通过 button 点击事件更新值,再通过条件语句判断增加 class。Classess
用于声明和管理类属性。let mut class = Classes::from("info"); if *is_active { class.push("active") }; html! { <> <button οnclick={change_active}>{"active"}</button> <h1 class={class} style="color:red;">{"hello, trunk/yew!"}</h1> </> }
-
html!
宏那边定义的节点元素是小写的,如果需要使用大写,则可以这样html! { <@{"myBook"}> </@> }
,也可以用于动态 tag 标签设置 -
动态设置属性的,通过
Some(None)
定义字段,如果设置为None
,这个属性则不会被设置。 -
阻止事件冒泡,通过事件的回调参数
event
,可以调用event.prevent_default().stopPropagation()
阻止默认事件及事件冒泡。
相关的开发crate
开发时,需要有一些工具库帮助我们进行开发,方便我们调试、验证逻辑;
-
console_error_panic_hook
上一篇文章已经讲过了,可以帮助我们在浏览器控制台输出错误的具体信息 -
console_log
在浏览器控制台输出信息
安装依赖:
$> cargo add log console_log
在main.rs
use log::{info, Level};
fn main() {
let _ = console_log::init_with_level(Level::Debug);
info!("render web page");
yew::Renderer::<App>::new().render();
}
-
wasm-bindgen
用于前端与 js 交互的桥梁。上一篇文章里写过。 -
js-sys
基于wasm-bindgen
提供的 js 全局 Api 对象,及属性绑定。例如 Array、Object、Reflect 等
依赖安装:cargo add js-sys
新建一个数组,添加一个值,并取值
use js_sys::Array; use wasm_bindgen::prelude::*; fn main() { // ... let arr = Array::new(); arr.push(&JsValue::from(10)); info!("{:?}", arr.get(0).as_f64().unwrap()); }
js 所有 api 调用传入的数据类型,都需要是
JsValue
,它定义在wasm_bindgen
中; -
web-sys
基于wasm-bindgen
提供的原始浏览器提供的接口,例如 DOM、WebGL、CSSOM 等。
安装依赖:$> cargo add web-sys
为了不影响打包速度,我们将需要用到的 api 特性列在依赖特性中.这里看有哪些可以导入使用的对象
[dependencies.web-sys] version = "0.3.69" features = ["Window"]
这样我们就可以使用到
Window
对象下的所有方法、属性。使用Window.alert()
use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn alert() { let window = web_sys::window().unwrap(); window.alert_with_message("alert!!").unwrap(); }
然后在事件回调函数中调用
alert()
,想要弹出自定义消息,需要调用alert_with_message
,具体 API 有哪些可以用的方法,查看文档。
gloo
是一个工具包,上面的js-sys
和web-sys
都是底层的 api 封装,理解和使用都很困难,而gloo
正是为了解决这个困难,提供简单易用的 api。
简单介绍了trunk
打包工具,以及 web 库yew
的概念知识,基本使用。下一篇则构建一个可以用于开发的脚手架。