trunk
构建、打包 rust wasm 程序;yew
web 前端开发库;
项目仓库yew-web
trunk
之前已经简单介绍了trunk
,全局安装:
rs
$> cargo install --locked trunk
常用命令:
trunk build
基于wasm-bindgen
构建 wasm 程序。trunk watch
检测文件系统,更改时触发新的构建trunk serve
和 trunk watch 动作一致,创建一个 web 服务trunk clean
清理之前的构建trunk config show
显示当前 trunk 的配置trunk tools show
打印出项目中 trunk 需要的工具
新增配置文件Trunk.toml
,可以通过trunk tools show
查看需要的工具,它们的下载的地址。包括sass\tailwindcss\wasm-bindgen\wasm-opt
安装yew
web 开发工具,类似 react 设计;安装之前文章介绍的gloo
,它是js_sys\web_sys
的二次分装,简化对于 web api、js 语法的使用。还有链接 web 前端不可或缺的wasm-bindgen
toml
[dependencies]
gloo = "0.11.0"
wasm-bindgen = "0.2.92"
yew = { version = "0.21.0", features = ['csr'] }
正常的页面开发,路由必不可少,用以跳转不同的页面。基于yew
的路由库yew-router
安装:
sh
$> cargo add yew-router
在代码中,我们默认就只使用函数组件一种方式书写#[function_component]
。
文件目录介绍:
public
静态资源目录,打包时直接拷贝到编译目录下。src/assets
资源目录,图片、css 等src/routes
路由src/stores
共享数据src/views
视图文件定义src/main.rs
入口文件,根文件。src/app.rs
主视图文件
所有的模块定义,我们在其根目录下定义
mod.rs
文件统一声明导出,然后在main.rs
中声明为全局的 crate,这样任何目录下想要访问其他目录则可以通过根包导入使用。
在main.rs
,导入了src/routes
路由根包;导入了src/views
视图文件根包;还有主视图文件app.rs
rs
mod app;
mod routes;
mod views;
//
use app::App;
fn main() {
yew::Renderer::<App>::new().render();
}
然后在app.rs
中,导入了主路由文件route
,组件BrowserRouter
做为根路由器,会注册一个路由上下文,可在全局所有组件内访问。
rs
use yew::prelude::*;
use yew_router::prelude::*;
// 主路由文件
use crate::routes::route::{switch, Route};
#[function_component]
pub fn App() -> Html {
html! {
<BrowserRouter>
<Switch<Route> render={switch} />
</BrowserRouter>
}
}
链接资源加载
trunk
中所有的链接资源必遵循三个原则:
- 必须声明有效的 html
link
标记。 - 必须有属性
data-trunk
- 必须有
res={type}
属性,type
则为其支持的资源类型
trunk
目前支持的资源 type - rust 、sass/scss 、css 、tailwind-css、icon、inline 、copy-file、copy-dir
我们将public
直接复制整个目录到打包目录中。
html
<link data-trunk rel="copy-dir" href="public" />
trunk
使用dart-sass
来打包编译scss
文件,我们在assets/base.scss
定义样式,然后在index.html
加载
html
<link data-trunk rel="scss" href="/src/assets/base.scss" />
base.scss
作为基础样式资源,其他模块的样式文件,可以全部导入到这这个文件.
可以互相依赖,trunk
暂时没有提供方案处理每个页面的样式,只能以外部引入的方式处理。持续关注,有关的提案还在讨论中。
脚本资源加载
脚本资源加载有三个原则:
- 必须声明为有效的 html
script
标记 - 必须有属性 data-trunk
- 必须有属性
src
,指向脚本文件
这允许我们可以直接导入一些js
脚本,trunk 会复制到 dist 目录中,就可以在程序中使用它们。通过哈希处理以便进行缓存。
服务基本路径baseURI
可以在Trunk.toml
中配置服务的基本路径,
rs
[build]
# public_url = "/hboot/"
public_url = ""
如果配置了基本路由,那我们在程序里的路径处理就会多一个前缀/hboot/
,想要在程序里访问这个路径,则需要配置index.html
在<head>
增加:
html
<base data-trunk-public-url />
这对于路由管理非常重要,不然就匹配不到设置的路径。在运行程序中可以通过document.baseURI
访问。
trunk
构建过程
- 读取并解析
index.html
文件 - 生成所有资源编译计划
- 并行构建所有资源
- 构建完成将资源写入暂存目录
- 将 html 文件写入暂存目录
- 将 dist 目录内容替换为暂存目录的内容
未来可能发生变化。
material_yew
ui 库
适配yew
设计的 Material Design 风格的 UI 库。
引入发现不兼容,这里先占个坑位。
使用原生的input \ button
组件构建页面,再加一点样式。
rs
use std::ops::Deref;
use web_sys::{Event, HtmlInputElement};
use yew::prelude::*;
#[function_component]
pub fn App() -> Html {
let name = use_state(|| "admin".to_string());
let update_name = {
let name = name.clone();
Callback::from(move |event: Event| {
let input = event.target_dyn_into::<HtmlInputElement>();
if let Some(input) = input {
name.set(input.value())
}
})
};
return html! {
<input class="hb-input" value={name.clone().deref().to_string()} οnchange={update_name} required={true} name={"name"} type={"text"} placeholder={"用户名"} />
}
}
yew-router
定义路由
在src/routes/route.rs
定义主路由
rs
use yew::prelude::*;
use yew_router::prelude::*;
// 路由配对的视图文件
use crate::views::main;
use crate::views::not_found;
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Main,
#[not_found]
#[at("/404")]
NotFound,
}
// 作为Switch 组件的属性render绑定;回调处理匹配的路由应该渲染什么
pub fn switch(routes: Route) -> Html {
match routes {
Route::Main => html! {<main::App />},
Route::NotFound => html! {<not_found::App />},
}
}
定义枚举值Route
,再与组件<Switch />
配对,组件通过路径查找将其传递给render
回调,在回调中决定渲染什么。如果没有匹配的路由,则会匹配到具有#[not_found]
属性的路径。若未指定则什么都不渲染。
在src/app.rs
导入路由,上面已经贴过代码<Switch<Route> render={switch} />
在src/views
定义了main
以及not_found
视图文件,启动访问http://127.0.0.1:8080/
use_navigator
获取路由导航对象
从一个页面跳转到另一个页面,通过use_navigator
获取导航对象。
rs
use crate::routes::route::Route;
#[function_component]
pub fn App() -> Html {
let navigator = use_navigator().unwrap();
let handle_logout = {
Callback::from(move |_| {
navigator.push(&Route::Login);
})
};
}
包含了常用跳转路由的方法push \ go \ back \ forward
等。
除了手动跳转路由,通过Link
组件点击触发路由跳转。比如我们来增加左侧的菜单,切换不同的视图。
rs
// ...
#[function_component]
pub fn App() -> Html {
html! {
<div class="hb-nav-menu">
<div class="nav-menu">
<Link<MainRoute> classes={"menu-item"} to={MainRoute::Home}>{"首页"}</Link<MainRoute>>
<Link<MainRoute> classes={"menu-item"} to={MainRoute::User}>{"用户"}</Link<MainRoute>>
</div>
</div>
}
}
嵌套路由
我们使用Redirect
重定向路由,比如访问/
重定向到/main
rs
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/")]
Home
// ...
}
pub fn switch(routes: Route) -> Html {
// log!(JsValue::from(routes));
match routes {
Route::Home => html! {<Redirect<MainRoute> to={MainRoute::Home}/>},
// ...
}
}
嵌套路由,之前在根组件中使用Switch
处理路由,我们创建/routes/main.rs
处理带有顶部导航栏、左侧菜单的路由.
rs
// ...other
#[derive(Clone, Routable, PartialEq)]
pub enum MainRoute {
#[at("/main")]
Home,
#[at("/main/user")]
User,
#[not_found]
#[at("/main/404")]
NotFound,
}
pub fn main_switch(routes: MainRoute) -> Html {
match routes {
MainRoute::Home => html! {<h1>{"首页"}</h1>},
MainRoute::User => html! {<user::App />},
MainRoute::NotFound => html! {<Redirect<Route> to={Route::NotFound} />},
}
}
同样的,在这个子路由中也存在NotFound
,匹配不到时我们重定向到根路由Route::NotFound
404 页面。在这里Home \ User
就代表了左侧的两个菜单。
在根路由中/routes/route.rs
,定义了/main
和/main/*
的匹配路由,它们都指向渲染main::App
组件
rs
#[derive(Clone, Routable, PartialEq)]
pub enum Route {
#[at("/main")]
MainRoot,
#[at("/main/*")]
Main,
// ...
}
pub fn switch(routes: Route) -> Html {
// log!(JsValue::from(routes));
match routes {
// ...
Route::Main | Route::MainRoot => html! {<main::App />}
}
}
我们需要在main::App
页面中使用Switch
分发路由
rs
// ...
use crate::routes::main::{main_switch, MainRoute};
#[function_component]
pub fn App() -> Html {
html! {
<div class="hb-main">
<header::App></header::App>
<div class="hb-main-layout">
<nav_menu::App></nav_menu::App>
<div class="hb-main-content">
<Switch<MainRoute> render={main_switch} />
</div>
</div>
</div>
}
}
use_route
当前路由信息
use_location
获取当前路由信息,比use_route
信息多一点,同History::location
。
当路由发生变化时,当前组件会重新渲染。
之前加了左侧菜单,需要处理下点击时添加对活动菜单的样式,增加类active
rs
#[function_component]
pub fn App() -> Html {
// ...
html! {
<div class="hb-nav-menu">
<div class="nav-menu">
<Link<MainRoute> classes={is_active(MainRoute::Home)} to={MainRoute::Home}>{"首页"}</Link<MainRoute>>
<Link<MainRoute> classes={is_active(MainRoute::User)} to={MainRoute::User}>{"用户"}</Link<MainRoute>>
</div>
</div>
}
}
Link
组件接受classes
定义类,我们定义一个is_active
方法去处理当前的链接是否处于活动状态。参数为当前渲染的路由枚举值。
rs
let is_active = |route: MainRoute| {
let mut class = classes!("menu-item");
let menu = active_menu.clone();
if route == *menu {
class.push("active");
}
class
};
在is_active
中,默认每个链接都有menu-item
,然后通过active_menu
变量判断是否需要追加类active
定义active_menu
,通过路由的变化,来变更活动的路由值。使用了use_effect_with
hook 接受route
作为依赖,变更时触发调用,然后通过active_menu_set
更新值。
rs
// 当前路由
let route: Option<_> = use_route::<MainRoute>();
let active_menu = use_state(|| MainRoute::Home);
let active_menu_set = active_menu.clone();
use_effect_with(route.clone(), move |_| {
// ..
let route = route.clone();
if let Some(active_route) = route {
active_menu_set.set(active_route);
}
});
跨组件数据交互
父子组件之间可以通过 props 进行数据传递。跨组件如果采用这种一层层传就很冗余,更加麻烦不好管理。
通过使用到数据的组件跟组件上挂载上下文 context,然后子组件消耗使用。通过ContextProvider
包裹根部元素
将所有的数据模块放在目录/src/stores
,新建了一个app.rs
定义数据App
rs
#[derive(Clone, Debug, PartialEq)]
pub struct App {
pub name: String,
}
在视图主文件中app.rs
引入并初始化数据,使用ContextProvider
传递。
rs
#[function_component]
pub fn App() -> Html {
let app = use_state(|| app::App {
name: "yew-web".to_string(),
});
html! {
<BrowserRouter>
<ContextProvider<app::App> context={(*app).clone()} >
<Switch<Route> render={switch} />
</ContextProvider<app::App>>
</BrowserRouter>
}
}
在所有的子孙组件通过use_context
钩子函数获取消费使用。
rs
use yew::prelude::*;
use crate::stores::app;
#[function_component]
pub fn App() -> Html {
let context = use_context::<app::App>().unwrap();
html! {
<div class="user">
<h2>{"个人中心"}</h2>
<p>{"消费来自根部组件的数据:"}{context.name}</p>
</div>
}
}
跨组件数据更新
除了消费使用,可能还需要更新,我们在顶部栏加一个按钮处理更新。只有数据来源都在同一个地方,那么我们通过use_reducer
来更新数据。数据变更后,根组件触发重新渲染,再向下传递。
在/stores/app
定义AppProvider
,并初始化数据,这里通过使用use_reducer
初始化了数据,向下传递的app
是带有dispatch
方法的,可以用来更新数据。
rs
// 自定义上下文数据类型
pub type AppContext = UseReducerHandle<App>;
#[derive(Properties, Debug, PartialEq)]
pub struct AppProviderProps {
#[prop_or_default]
pub children: Html,
}
#[function_component]
pub fn AppProvider(props: &AppProviderProps) -> Html {
let app = use_reducer(|| App {
name: "yew-web".to_string(),
});
html! {
<ContextProvider<AppContext> context={app}>
{props.children.clone()}
</ContextProvider<AppContext>>
}
}
我们向下传递的 context 是使用use_reducer
创建的,对于ContextProvider
需要的类型,通过自定义类型AppContext
。在子孙组件使用,则需导入使用app::AppContext
修改视图主文件,可以直接使用app::AppProvider
包裹根组件:
rs
#[function_component]
pub fn App() -> Html {
// let app = use_reducer(|| app::App {
// name: "yew-web".to_string(),
// });
html! {
<BrowserRouter>
// <ContextProvider<app::App> context={(*app).clone()} >
// <Switch<Route> render={switch} />
// </ContextProvider<app::App>>
<app::AppProvider>
<Switch<Route> render={switch} />
</app::AppProvider>
</BrowserRouter>
}
}
网络请求
网络请求必不可少,请求后端数据完成页面渲染。
通过现有封装的依赖库完成数据请求,包括:
gloo-net
http 请求库serde
高效的数据序列化、反序列化框架wasm-bindgen-futures
提供了在 rust 和 js 之间的异步交互能力
安装后测试请求数据并渲染到页面
定义了接口响应数据结构TopicResponse
,主体数据结构Topic
:
rs
// 数据主体
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct Topic {
pub id: String,
pub title: String,
pub top: bool,
pub visit_count: i32,
pub content: String,
pub create_at: String,
}
// 请求响应
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct TopicResponse {
pub success: bool,
pub data: Vec<Topic>,
}
发起请求,使用use_effect_with
接受依赖变更时触发,这里我们这调用一次:
rs
use gloo_net::http::Request;
use wasm_bindgen_futures::spawn_local;
#[function_component]
pub fn App() -> Html {
let data = use_state(|| vec![]);
let data_set = data.clone();
use_effect_with((), move |_| {
spawn_local(async move {
let data: TopicResponse = Request::get("https://****/api/v1/topics")
.send()
.await
.unwrap()
.json()
.await
.unwrap();
data_set.set(data.data);
});
});
// ...
}
在这里使用到了新语法async...await...
, 它可以让我们以同步的方式书写异步代码。通过async
声明的块、函数或闭包,会返回一个Future
类型,它不会阻塞当前线程的执行,当它处于.await
时事件循环会将控制权交给其他任务。在它被挂起的时候,他执行的上下文也会被保存。
注意 rsut 的版本,
async...await
在稳定本^1.39
可用
Request
是gloo_net
提供的请求模块,它是原生fetch
的包装,以便我们更方便的调用。
我们在这里调用没有传参数,我们可以定义接口需要的请求参数,首先定义请求数据结构:
rs
/**
* 请求参数
*/
#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct TopicRequest {
pub page: i32,
pub tab: String,
pub limit: i32,
pub mdrender: bool,
}
然后在视图文件中,创建请求数据实例,我们让use_effect_with
依赖实例,当参数变化时,重新发出请求
rs
#[function_component]
pub fn App() -> Html {
// req
let params = use_state(|| TopicRequest {
page: 1,
tab: "good".to_string(),
limit: 10,
mdrender: false,
});
use_effect_with(params.clone(), move |req_params| {
let req = req_params.clone();
spawn_local(async move {
let page = req.page.to_string();
let limit = req.limit.to_string();
let tab = req.tab.to_string();
let mdrender = req.mdrender.to_string();
let query = vec![
("page", &page),
("limit", &limit),
("tab", &tab),
("mdrender", &mdrender),
];
let data: TopicResponse = Request::get("https://****/api/v1/topics")
.query(query)
.send()
.await
.unwrap()
.json()
.await
.unwrap();
data_set.set(data.data);
});
});
// ...
}
Request::get
调用返回gloo_net::http::RequestBuilder
实例,通过query()
方法添加请求参数,暂时需要挨个组装一个下参数,没有找到直接转换的方法。如果是post
可以使用body()
然后在页面上增加按钮,切换上一页、下一页。增加事件监听:
rs
let update_params = params.clone();
let handle_jump_next = {
let req_set = update_params.clone();
Callback::from(move |_| {
req_set.set(TopicRequest {
page: update_params.page + 1,
..(*update_params).clone()
});
})
};
我们只更新了page
参数,其他值不做修改,通过*update_params
解引用获取到值。..
指定剩余未显示设置值的字段与给定实例相同的值.
在所有的代码逻辑中没有处理接口请求失败、或者响应数据错误的问题。这里假设我们一切都请求正常,通过data
去渲染视图:
rs
html! {
<div class="user">
<ul class="list">
{data.iter().map(move |item|{
html! {
<li key={format!("{}", item.id)}>
<p>{format!("{}", item.title)}</p>
</li>
}
}).collect::<Html>()}
</ul>
</div>
}
使用web_sys
Request 请求
gloo-net
是基于web_sys
的底层 fetch API 的二次封装。它是基于 rust 的异步特性构建的,使用async/await
处理异步请求,可以跨平台,不止处理 web 平台。
相比于gloo-net
,web_sys
更倾向 web 平台,它提供了浏览器原生 API 的抽象。在 web Assembly 环境中于 web Api 交互。
通过使用web_sys
提供的原生 fetch 处理异步请求,开发上面列表的详情页面detail
在列表页面数据各项增加点击事件,事件处理并跳转至详情页面
rs
let info = item.clone();
let navigator = navigator.clone();
let view_detail = Callback::from(move |_| {
let id = info.id.clone();
navigator.push(&MainRoute::CNodeDetail { id: id });
});
html! {
<li key={format!("{}", &item.id)} οnclick={view_detail}>
<p>{format!("{}", &item.title)}</p>
</li>
}
在详情页面获取到传过来的参数id
,然后发起请求获取当前文章的详情信息,
先看一下 js 原生 api fetch 的请求示例
js
fetch("https://****/api/v1/topic/*id")
.then((res) => {
return res.json();
})
.then((res) => {
// 接口响应
console.log(res);
});
使用web_sys
提供的 api 调用的步骤基本一致,我们通过提供的window()
函数获取到 js 全局Window
对象,然后调用请求方法fetch_with_str()
,还有另一个fetch_with_request()
参数需要使用Request
初始化构造请求参数。
详情页面需要列表点击传过来的参数id
,使用use_effect_with
增加依赖项,为了保证回调函数cb
不被清理,保证它存在一个很长的生命周期,使用了forget()
方法。
rs
use web_sys::{window};
#[function_component]
pub fn App(props: &DetailProps) -> Html {
// ...
use_effect_with(query_id, move |query_id| {
let url = format!("https://****/api/v1/topic/{}", *(query_id.clone()));
info!("{}", url);
let window = window().unwrap();
let _ = window.fetch_with_str(&url).then(&cb);
|| cb.forget()
});
// ...
}
看一下方法fetch_with_str
签名fn fetch_with_str(&self, input: &str) -> Promise
,传参为接口地址 url,响应一个Promise
对象,通过Promise
的方法来解析响应数据。这里我们调用了then()
方法接受响应。
看下Promise
的 then 方法的签名fn then(&self, cb: &Closure<dyn FnMut(JsValue)>) -> Promise
它接受一个实现了FnMut
trait 的闭包函数的引用,闭包参数为JsValue
类型的数据。
来定义这个回调闭包函数,通过 Closure::wrap()
创建一个闭包,并转换为实现了FnMut
trait 的 trait 对象;使用了智能指针Box::new
来创建Box
类型的实例,因为响应数据不知道其大小、也不确定其生命周期。
rs
let cb = Closure::wrap(Box::new(move |value: JsValue| {
let res = value.dyn_into::<Response>().unwrap();
let json = res.json().unwrap();
let _ = json.then(&get_data);
}) as Box<dyn FnMut(JsValue)>);
根据 js 原生 fetch 调用,第一次的then
方法返回的是一个Response
类型,我们需要把JsValue
转换为Response
类型,通过JsCast::dyn_into()
方法处理 js 与 rust 之间的数据转换。然后就可以调用json()
方法了,解析响应并再次通过then()
方法处理响应数据
这里接收到的就是我们接口具体的数据响应了,定义数据结构:
rs
#[derive(Deserialize, Debug, Clone, PartialEq)]
pub struct DetailResponse {
pub success: bool,
pub data: Detail,
}
定义回调闭包函数get_data
,这里针对 JsValue 数据转 rust 数据结构就需要序列化库serde
,之前已经安装过了,想要把 JsValue 转换为serde
还需要转换工具gloo_utils::format::JsValueSerdeExt
,它提供了into_serde/from_serde
用于互相转换
rs
let get_data = Closure::wrap(Box::new(move |value: JsValue| {
let data = value.into_serde::<DetailResponse>().unwrap();
if data.success {
data_set.set(data.data);
};
}) as Box<dyn FnMut(JsValue)>);
将 JsValue 转换为 rust 数据结构后,就可以取响应数据进行页面渲染了。
这样对比还是使用前一种二次封装的库更好,写法更优雅;更容易理解。毕竟已经习惯使用async..await
了