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

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

码农世界 2024-05-23 前端 71 次浏览 0个评论

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

项目仓库yew-web

trunk

之前已经简单介绍了trunk,全局安装:

$> 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

    [dependencies]
    gloo = "0.11.0"
    wasm-bindgen = "0.2.92"
    yew = { version = "0.21.0", features = ['csr'] }
    

    正常的页面开发,路由必不可少,用以跳转不同的页面。基于yew的路由库yew-router

    安装:

    $> 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

      mod app;
      mod routes;
      mod views;
      //
      use app::App;
      fn main() {
          yew::Renderer::::new().render();
      }
      

      然后在app.rs中,导入了主路由文件route,组件BrowserRouter做为根路由器,会注册一个路由上下文,可在全局所有组件内访问。

      use yew::prelude::*;
      use yew_router::prelude::*;
      // 主路由文件
      use crate::routes::route::{switch, Route};
      #[function_component]
      pub fn App() -> Html {
          html! {
              
                   render={switch} />
              
          }
      }
      

      链接资源加载

      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直接复制整个目录到打包目录中。

      
      

      trunk 使用dart-sass来打包编译scss文件,我们在assets/base.scss定义样式,然后在index.html加载

      
      

      base.scss作为基础样式资源,其他模块的样式文件,可以全部导入到这这个文件.

      可以互相依赖,trunk暂时没有提供方案处理每个页面的样式,只能以外部引入的方式处理。持续关注,有关的提案还在讨论中。

      脚本资源加载

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

      1. 必须声明为有效的 htmlscript标记
      2. 必须有属性 data-trunk
      3. 必须有属性src,指向脚本文件

      这允许我们可以直接导入一些js脚本,trunk 会复制到 dist 目录中,就可以在程序中使用它们。通过哈希处理以便进行缓存。

      服务基本路径baseURI

      可以在Trunk.toml中配置服务的基本路径,

      [build]
      # public_url = "/hboot/"
      public_url = ""
      

      如果配置了基本路由,那我们在程序里的路径处理就会多一个前缀/hboot/,想要在程序里访问这个路径,则需要配置index.html 在增加:

      
      

      这对于路由管理非常重要,不然就匹配不到设置的路径。在运行程序中可以通过document.baseURI访问。

      trunk 构建过程

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

      未来可能发生变化。

      material_yew ui 库

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

      material_yew;

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

      使用原生的input \ button组件构建页面,再加一点样式。

      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::();
                  if let Some(input) = input {
                      name.set(input.value())
                  }
              })
          };
          return html! {
              
          }
      }
      

      yew-router 定义路由

      在src/routes/route.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! {},
              Route::NotFound => html! {},
          }
      }
      

      定义枚举值Route,再与组件配对,组件通过路径查找将其传递给render回调,在回调中决定渲染什么。如果没有匹配的路由,则会匹配到具有#[not_found]属性的路径。若未指定则什么都不渲染。

      在src/app.rs导入路由,上面已经贴过代码 render={switch} />

      在src/views定义了main以及not_found视图文件,启动访问http://127.0.0.1:8080/

      use_navigator 获取路由导航对象

      从一个页面跳转到另一个页面,通过use_navigator获取导航对象。

      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组件点击触发路由跳转。比如我们来增加左侧的菜单,切换不同的视图。

      // ...
      #[function_component]
      pub fn App() -> Html {
          html! {
              
                  
                       classes={"menu-item"} to={MainRoute::Home}>{"首页"}>
                       classes={"menu-item"} to={MainRoute::User}>{"用户"}>
                  
              
          }
      }
      

      嵌套路由

      我们使用Redirect重定向路由,比如访问/重定向到/main

      #[derive(Clone, Routable, PartialEq)]
      pub enum Route {
          #[at("/")]
          Home
          // ...
      }
      pub fn switch(routes: Route) -> Html {
          // log!(JsValue::from(routes));
          match routes {
              Route::Home => html! { to={MainRoute::Home}/>},
              // ...
          }
      }
      

      嵌套路由,之前在根组件中使用Switch处理路由,我们创建/routes/main.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! {

      {"首页"}

      }, MainRoute::User => html! {}, MainRoute::NotFound => html! { to={Route::NotFound} />}, } }

      同样的,在这个子路由中也存在NotFound,匹配不到时我们重定向到根路由Route::NotFound404 页面。在这里Home \ User就代表了左侧的两个菜单。

      在根路由中/routes/route.rs,定义了/main 和/main/*的匹配路由,它们都指向渲染main::App组件

      #[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页面中使用Switch分发路由

      // ...
      use crate::routes::main::{main_switch, MainRoute};
      #[function_component]
      pub fn App() -> Html {
          html! {
              
                  
                   
                      
                      
                           render={main_switch} />
                      
                  
              
          }
      }
      

      use_route 当前路由信息

      use_location 获取当前路由信息,比use_route信息多一点,同History::location。

      当路由发生变化时,当前组件会重新渲染。

      之前加了左侧菜单,需要处理下点击时添加对活动菜单的样式,增加类active

      #[function_component]
      pub fn App() -> Html {
          // ...
          html! {
              
                  
                       classes={is_active(MainRoute::Home)} to={MainRoute::Home}>{"首页"}>
                       classes={is_active(MainRoute::User)} to={MainRoute::User}>{"用户"}>
                  
              
          }
      }
      

      Link组件接受classes定义类,我们定义一个is_active方法去处理当前的链接是否处于活动状态。参数为当前渲染的路由枚举值。

      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更新值。

      // 当前路由
      let route: Option<_> = use_route::();
      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

      #[derive(Clone, Debug, PartialEq)]
      pub struct App {
          pub name: String,
      }
      

      在视图主文件中app.rs引入并初始化数据,使用ContextProvider传递。

      #[function_component]
      pub fn App() -> Html {
          let app = use_state(|| app::App {
              name: "yew-web".to_string(),
          });
          html! {
              
                  
                       render={switch} />
                  
              
          }
      }
      

      在所有的子孙组件通过use_context钩子函数获取消费使用。

      use yew::prelude::*;
      use crate::stores::app;
      #[function_component]
      pub fn App() -> Html {
          let context = use_context::().unwrap();
          html! {
              
                  

      {"个人中心"}

      {"消费来自根部组件的数据:"}{context.name}

      } }

      跨组件数据更新

      除了消费使用,可能还需要更新,我们在顶部栏加一个按钮处理更新。只有数据来源都在同一个地方,那么我们通过use_reducer来更新数据。数据变更后,根组件触发重新渲染,再向下传递。

      在/stores/app定义AppProvider,并初始化数据,这里通过使用use_reducer初始化了数据,向下传递的app是带有dispatch方法的,可以用来更新数据。

      // 自定义上下文数据类型
      pub type AppContext = UseReducerHandle;
      #[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! {
              
                  {props.children.clone()}
              
          }
      }
      

      我们向下传递的 context 是使用use_reducer创建的,对于ContextProvider需要的类型,通过自定义类型AppContext。在子孙组件使用,则需导入使用app::AppContext

      修改视图主文件,可以直接使用app::AppProvider包裹根组件:

      #[function_component]
      pub fn App() -> Html {
          // let app = use_reducer(|| app::App {
          //     name: "yew-web".to_string(),
          // });
          html! {
              
                  // 
                  //      render={switch} />
                  // 
                  
                       render={switch} />
                  
              
          }
      }
      

      网络请求

      网络请求必不可少,请求后端数据完成页面渲染。

      通过现有封装的依赖库完成数据请求,包括:

      • gloo-net http 请求库
      • serde 高效的数据序列化、反序列化框架
      • wasm-bindgen-futures 提供了在 rust 和 js 之间的异步交互能力

        安装后测试请求数据并渲染到页面

        定义了接口响应数据结构TopicResponse,主体数据结构Topic:

        // 数据主体
        #[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,
        }
        

        发起请求,使用use_effect_with接受依赖变更时触发,这里我们这调用一次:

        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的包装,以便我们更方便的调用。

        我们在这里调用没有传参数,我们可以定义接口需要的请求参数,首先定义请求数据结构:

        /**
         * 请求参数
         */
        #[derive(Serialize, Debug, Clone, PartialEq)]
        pub struct TopicRequest {
            pub page: i32,
            pub tab: String,
            pub limit: i32,
            pub mdrender: bool,
        }
        

        然后在视图文件中,创建请求数据实例,我们让use_effect_with依赖实例,当参数变化时,重新发出请求

        #[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()

        然后在页面上增加按钮,切换上一页、下一页。增加事件监听:

        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去渲染视图:

        html! {
            
                
          {data.iter().map(move |item|{ html! {
        • {format!("{}", item.title)}

        • } }).collect::()}
        }

        使用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

        在列表页面数据各项增加点击事件,事件处理并跳转至详情页面

        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! {
            
      • {format!("{}", &item.title)}

      • }

        在详情页面获取到传过来的参数id,然后发起请求获取当前文章的详情信息,

        先看一下 js 原生 api fetch 的请求示例

        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()方法。

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

        来定义这个回调闭包函数,通过 Closure::wrap()创建一个闭包,并转换为实现了FnMuttrait 的 trait 对象;使用了智能指针Box::new来创建Box类型的实例,因为响应数据不知道其大小、也不确定其生命周期。

        let cb = Closure::wrap(Box::new(move |value: JsValue| {
            let res = value.dyn_into::().unwrap();
            let json = res.json().unwrap();
            let _ = json.then(&get_data);
        }) as Box);
        

        根据 js 原生 fetch 调用,第一次的then方法返回的是一个Response类型,我们需要把JsValue转换为Response类型,通过JsCast::dyn_into()方法处理 js 与 rust 之间的数据转换。然后就可以调用json()方法了,解析响应并再次通过then()方法处理响应数据

        这里接收到的就是我们接口具体的数据响应了,定义数据结构:

        #[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用于互相转换

        let get_data = Closure::wrap(Box::new(move |value: JsValue| {
            let data = value.into_serde::().unwrap();
            if data.success {
                data_set.set(data.data);
            };
        }) as Box);
        

        将 JsValue 转换为 rust 数据结构后,就可以取响应数据进行页面渲染了。

        这样对比还是使用前一种二次封装的库更好,写法更优雅;更容易理解。毕竟已经习惯使用async..await了

转载请注明来自码农世界,本文标题:《基于trunk、yew构建web开发脚手架》

百度分享代码,如果开启HTTPS请参考李洋个人博客
每一天,每一秒,你所做的决定都会改变你的人生!

发表评论

快捷回复:

评论列表 (暂无评论,71人围观)参与讨论

还没有评论,来说两句吧...

Top