基于trunk、yew构建web开发脚手架

trunk 构建、打包 rust wasm 程序;yewweb 前端开发库;

项目仓库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

安装yewweb 开发工具,类似 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 中所有的链接资源必遵循三个原则:

  1. 必须声明有效的 html link标记。
  2. 必须有属性data-trunk
  3. 必须有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暂时没有提供方案处理每个页面的样式,只能以外部引入的方式处理。持续关注,有关的提案还在讨论中。

脚本资源加载

脚本资源加载有三个原则:

  1. 必须声明为有效的 htmlscript标记
  2. 必须有属性 data-trunk
  3. 必须有属性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 构建过程

  1. 读取并解析index.html文件
  2. 生成所有资源编译计划
  3. 并行构建所有资源
  4. 构建完成将资源写入暂存目录
  5. 将 html 文件写入暂存目录
  6. 将 dist 目录内容替换为暂存目录的内容

未来可能发生变化。

material_yew ui 库

适配yew设计的 Material Design 风格的 UI 库。

material_yew;

引入发现不兼容,这里先占个坑位。

使用原生的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::NotFound404 页面。在这里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_withhook 接受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可用

Requestgloo_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 它接受一个实现了FnMuttrait 的闭包函数的引用,闭包参数为JsValue类型的数据。

来定义这个回调闭包函数,通过 Closure::wrap()创建一个闭包,并转换为实现了FnMuttrait 的 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

相关推荐
迷雾漫步者1 小时前
Flutter组件————FloatingActionButton
前端·flutter·dart
向前看-2 小时前
验证码机制
前端·后端
燃先生._.3 小时前
Day-03 Vue(生命周期、生命周期钩子八个函数、工程化开发和脚手架、组件化开发、根组件、局部注册和全局注册的步骤)
前端·javascript·vue.js
高山我梦口香糖4 小时前
[react]searchParams转普通对象
开发语言·前端·javascript
m0_748235244 小时前
前端实现获取后端返回的文件流并下载
前端·状态模式
m0_748240254 小时前
前端如何检测用户登录状态是否过期
前端
black^sugar4 小时前
纯前端实现更新检测
开发语言·前端·javascript
寻找沙漠的人5 小时前
前端知识补充—CSS
前端·css
GISer_Jing5 小时前
2025前端面试热门题目——计算机网络篇
前端·计算机网络·面试