基于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

相关推荐
桂月二二22 分钟前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062062 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb2 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角2 小时前
CSS 颜色
前端·css
浪浪山小白兔3 小时前
HTML5 新表单属性详解
前端·html·html5
lee5763 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579653 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
limit for me4 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架
浏览器爱好者4 小时前
如何构建一个简单的React应用?
前端·react.js·前端框架
qq_392794484 小时前
前端缓存策略:强缓存与协商缓存深度剖析
前端·缓存