Iced.rs教程——如何建立一个简单的Rust前台网络应用程序

5,516 阅读15分钟

之前,我们研究了如何使用Yew网络框架在Rust中 创建一个基于Wasm 基本前端应用程序。在本教程中,我们将向你展示如何使用Iced.rs GUI库做类似的事情。

为了展示Iced.rs的操作,我们将使用Iced和Rust建立一个非常基本的前端应用程序,它使用JSONPlaceholder来获取数据。我们将获取帖子并将其显示在一个列表中,每个帖子都有一个详细的链接,引导用户进入带有评论的完整帖子。

Iced.rs vs. Yew

Iced.rs和Yew之间最大的区别是,Yew纯粹是为了建立网络应用,而Iced的重点实际上是跨平台的应用;网络只是你可以建立应用的几个平台之一。

就风格而言,对于了解React和JSX的人来说,Yew会感觉很熟悉,而Iced.rs在架构方面受到梦幻般的Elm的启发。

另外需要注意的是,Iced.rs在很大程度上处于早期和活跃的开发阶段。虽然完全可以用它来构建基本的应用程序,但这个生态系统还不是特别成熟。除了文档例子,在这个早期阶段,它的起步有点坎坷,特别是如果你想建立一些复杂的东西。

尽管如此,该项目似乎被管理得很好,并且在其路线图中进展迅速。

设置Iced.rs

要跟上这个教程,你只需要安装一个最新的Rust在撰写本文时,Rust 1.55是最新的版本)。

首先,创建一个新的Rust项目。

cargo new rust-frontend-example-iced
cd rust-frontend-example-iced

接下来,编辑Cargo.toml 文件并添加你需要的依赖项。

[dependencies]
iced_web = "0.4"
iced = { version = "0.3", features = ["tokio"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
wasm-bindgen = "0.2.69"
reqwest = { version = "0.11", features = ["json"] }

在本教程中,我们将使用Iced.rs作为我们的前端框架。由于Iced.rs是一个跨平台的GUI库,我们还需要添加iced_web ,它使我们能够从Iced.rs应用程序中创建一个基于Wasm的单页Web应用程序。

为了获取JSON格式的数据,我们还将添加reqwestserde 。我们将钉住wasm-bindgen 版本,这样我们在构建时就不会遇到任何兼容性问题。这很有用,因为Wasm生态系统仍然在不断变化,为你的项目使用特定的版本可以确保你不会在某天醒来时发现一个坏的项目。

index.html

我们使用Trunk来抽象出构建Wasm应用程序的细枝末节。Trunk希望在项目根部有一个index.html 文件,我们将提供这个文件。

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Tour - Iced</title>
  </head>
  <body>
    <script type="module">
      import init from "./iced/iced.js";

      init('./iced/iced_bg.wasm');
    </script>
  </body>
</html>

在这里,我们简单地创建了一个HTML骨架,并添加了包括我们编译的Iced.rs源代码的片段。

我们在这里没有添加任何CSS;在Iced.rs中,我们建立了自己的自定义widget,并在代码中对其进行样式设计。当然,也可以添加CSS,但在许多情况下,这些样式会被Iced.rs添加到输出HTML中的内联样式所覆盖。

在所有这些设置完成后,我们可以开始写一些Rust代码了。

数据访问

我们将从建立我们的数据访问层开始。为此,我们将在src 文件夹中的main.rs 旁边创建一个data.rs 文件,我们将使用mod data; 将这个data 模块添加到我们的main.rs

由于我们的计划是获取一个帖子的列表,然后获取一个帖子的细节及其评论,我们需要为PostComment 结构。

use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Post {
    pub id: i32,
    pub user_id: i32,
    pub title: String,
    pub body: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Comment {
    pub post_id: i32,
    pub id: i32,
    pub name: String,
    pub email: String,
    pub body: String,
}

接下来,我们将实现一些数据获取例程,从JSONPlaceholder获取实际的JSON数据。

impl Post {
    pub async fn fetch_all() -> Result<Vec<Post>, String> {
        let url = String::from("https://jsonplaceholder.typicode.com/posts/");
        reqwest::get(&url)
            .await
            .map_err(|_| String::new())?
            .json()
            .await
            .map_err(|_| String::new())
    }

    pub async fn fetch(id: i32) -> Result<Post, String> {
        let url = format!("https://jsonplaceholder.typicode.com/posts/{}", id);
        reqwest::get(&url)
            .await
            .map_err(|_| String::new())?
            .json()
            .await
            .map_err(|_| String::new())
    }
}

在这个简单的例子中,我们不会处理连接错误,所以我们在这里只是返回空字符串。然而,你可以通过返回错误信息或创建一个自定义的错误枚举来处理应用程序中的不同错误情况来轻松地扩展它。

我们有一个函数来获取所有的帖子。为了执行HTTP请求,我们使用ReqwestHTTP客户端,它也支持Wasm作为一个构建目标。

为了获取帖子的详细信息,我们创建了第二个函数,它以帖子的ID为参数,将其传递给JSONPlaceholder,并返回一个类型为Post 的结果。

现在我们需要对评论做同样的事情。

impl Comment {
    pub async fn fetch_for_post(id: i32) -> Result<Vec<Comment>, String> {
        let url = format!(
            "https://jsonplaceholder.typicode.com/posts/{}/comments/",
            id
        );
        reqwest::get(&url)
            .await
            .map_err(|_| String::new())?
            .json()
            .await
            .map_err(|_| String::new())
    }
}

这个数据访问函数也需要一个帖子ID,并获取给定帖子的所有评论,将JSON反序列化为一个Comment 结构的向量。

有了我们的data 模块,我们现在就可以获取帖子和评论了,并且有了包含数据的漂亮结构。

用Iced.rs构建用户界面

现在是时候开始构建用户界面了。

一个Iced.rs应用程序,类似于ELM,由四个中心概念组成。

  • 状态
  • 消息
  • 更新
  • 查看

State 是你的应用程序的状态。例如,在我们的案例中,这是我们从JSONPlaceholder获取和显示的数据。

Messages ,用于触发应用程序内部的流程。它们可以是用户互动、定时事件或任何其他事件,这些事件可能会改变应用程序中的某些东西。

Update 逻辑被用来对这些Messages 。例如,在我们的应用程序中,可能有一个Message ,用于导航到详细页面。在我们对这个消息的Update 逻辑中,我们将设置路由并获取数据,所以我们可以将应用程序的状态从List 更新到Detail

最后,View 逻辑描述了如何渲染应用程序的某个部分。它显示State ,并可能在用户交互时产生Messages

我们将首先为帖子和评论建立非常简单的部件,这将包括这些部件的渲染逻辑,然后用它们来连接所有的基本路由和我们的数据访问。

帖子和评论小工具

我们将从Comment widget开始,因为它是非常简约和简单的。

struct Comment {
    comment: data::Comment,
}

impl Comment {
    fn view(&self) -> Element<Message> {
        Column::new()
            .push(Text::new(format!("name: {}", self.comment.name)).size(12))
            .push(Text::new(format!("email: {}", self.comment.email)).size(12))
            .push(Text::new(self.comment.body.to_owned()).size(12))
            .into()
    }
}

基本上,我们的Comment widget只是有一个data::Comment 作为它的状态,所以一旦我们从我们的数据层得到评论数据,我们就可以开始创建这些widget。

view ,在这种情况下,描述了如何渲染一个Comment 。在这种情况下,我们创建一个Column ,在HTML中,它将只是一个div 。然而,还有一个Row 和其他预先存在的Container 小部件,我们可以用它们来以一种响应式的、连贯的方式构造我们的用户界面。

在这一列中,我们简单地添加一些iced::Text widgets,它们基本上可以编译成p (文本段落)。我们给它一个字符串,然后手动设置大小。

在最后,我们使用.into() ,因为view 返回一个Element<Message> ,其中Element 只是Iced的通用部件,Message 是我们的消息抽象,我们将在后面看。

现在让我们来看看Post widget的实现。

struct Post {
    detail_button: button::State,
    post: data::Post,
}

impl Post {
    fn view(&mut self) -> Element<Message> {
        Column::new()
            .push(Text::new(format!("id: {}", self.post.id)).size(12))
            .push(Text::new(format!("user_id: {}", self.post.user_id)).size(12))
            .push(Text::new(format!("title: {}", self.post.title)).size(12))
            .push(Text::new(self.post.body.to_owned()).size(12))
            .into()
    }

    fn view_in_list(&mut self) -> Element<Message> {
        let r = Row::new().padding(5).spacing(5);
        r.push(
            Column::new().spacing(5).push(
                Text::new(self.post.title.to_owned())
                    .size(12)
                    .vertical_alignment(VerticalAlignment::Center),
            ),
        )
        .push(
            Button::new(&mut self.detail_button, Text::new("Detail").size(12))
                .on_press(Message::GoToDetail(self.post.id)),
        )
        .into()
    }
}

根本的区别是,在我们的例子中,一个Post 也包含一个detail_button 。这使我们能够为我们的帖子列表创建细节按钮。

我们也有两个不同的帖子渲染函数:详细视图,这类似于我们在Comment 中的view 函数,以及一个view_in_list 函数,它使用一些paddingspacing (网络用语中的padding和margin)创建一个列表元素,使所有东西都对齐,重要的是,在行的末端添加Detail 视图按钮。

如果你查看了一些造型选项的文档,你会认识到许多网络上的选项,所以对你的组件进行造型是非常简单的。

为了创建一个按钮,我们需要一个button::State 。我们可以在点击它时添加一个动作,比如说如果按钮被按下,Message::GoToDetail(self.post.id)

有了这两个基本的小组件,让我们建立我们的App 小组件,并开始建立一个可运行的应用程序。

把它全部放在一起

在我们的main.rs ,我们可以从导入和我们的main 函数开始。

use iced::{
    button, executor, Align, Application, Button, Clipboard, Column, Command, Element, Row,
    Settings, Text, VerticalAlignment,
};

mod data;

pub fn main() -> iced::Result {
    App::run(Settings::default())
}

运行一个Iced.rs应用程序是很简单的。我们需要一些实现了Application 特质的东西--在我们的例子中是App --然后我们就可以用默认设置在上面调用run()

通过这些设置,我们可以设置一些基本的应用程序设置,如默认字体、窗口设置(对于本地应用程序),以及类似的东西。

接下来,让我们看一下我们的App 结构和其中的应用程序状态。

#[derive(Clone, Debug)]
enum Route {
    List,
    Detail(i32),
}

struct App {
    list_button: button::State,
    route: Route,
    posts: Option<Vec<Post>>,
    post: Option<Post>,
    comments: Option<Vec<Comment>>,
}

在我们的App ,我们有我们的list_button 的按钮状态,这是回主页的按钮,我们的根。

然后我们把route 的状态与我们的两个路由ListDetail 保持一致。我没能为iced_web 找到任何成熟的路由库,所以我们要建立我们自己的非常简单的路由,不需要改变浏览器中的URL、历史记录或返回处理。

如果你有兴趣建立一个更充实的路由,你可以把web-sys 加入到你的依赖关系中。

[dependencies.web-sys]
version = "0.3.32"
features = [
    "Document",
    "Window",
]

然后你就可以设置URL,例如使用。

let win = web_sys::window().unwrap_throw();
win.location()
  .set_hash(&format!("/detail/{}", id))
  .unwrap_throw();

但在本教程中,我们不会走到那个兔子洞。

此外,我们为postspostcomments 保留状态。这些只是与我们的widget相关的选项,分别为PostComment 以及用它们填充的向量。

接下来,让我们定义我们的Message 结构,它定义了我们应用程序中的数据流。

#[derive(Debug, Clone)]
enum Message {
    PostsFound(Result<Vec<data::Post>, String>),
    PostFound(Result<data::Post, String>),
    CommentsFound(Result<Vec<data::Comment>, String>),
    GoToList,
    GoToDetail(i32),
}

在我们的应用程序中,有五个信息。

最基本的是GoToListGoToDetail ,它们基本上是我们的路由信息。如果有人点击HomeDetail 链接,这些消息就会被触发。

然后,当数据从我们的数据访问层回来时,PostsFoundPostFoundCommentsFound 被触发。我们稍后会看一下这些消息的处理。

让我们开始为App 实现Application 特质。

impl Application for App {
    type Executor = executor::Default;
    type Flags = ();
    type Message = Message;

    fn new(_flags: ()) -> (App, Command<Message>) {
        (
            App {
                list_button: button::State::new(),
                route: Route::List,
                posts: None,
                post: None,
                comments: None,
            },
            Command::perform(data::Post::fetch_all(), Message::PostsFound),
        )
    }

    fn title(&self) -> String {
        String::from("App - Iced")
    }

Executor 实际上是一个异步执行器,它可以运行期货,如async-io或Tokio。我们只是使用默认的。我们也不使用任何标志,并将我们的Message 结构设置为用于消息。

new 函数中,我们只是为所有属性设置默认值,返回App ,重要的是,返回一个新的Command 。这种返回Command<Message> 的机制是Iced.rs中触发消息的方式。

在这种情况下,在创建App ,我们从我们的数据访问层执行Post::fetch_all future,并提供一个Message ,它将被调用,并带有future的结果 - 在这种情况下,Message::PostsFound

这意味着当应用程序被打开时,我们立即获取所有的帖子,以便我们能够显示它们。

使用title() ,我们还可以设置标题,但这并不特别有趣。

接下来让我们看看我们如何在update 中管理Messages

    fn update(&mut self, message: Message, _c: &mut Clipboard) -> Command<Message> {
        match message {
            Message::GoToList => {
                self.post = None;
                self.comments = None;
                self.route = Route::List;
                Command::perform(data::Post::fetch_all(), Message::PostsFound)
            }
            Message::GoToDetail(id) => {
                self.route = Route::Detail(id);
                self.posts = None;
                Command::batch(vec![
                    Command::perform(data::Post::fetch(id), Message::PostFound),
                    Command::perform(data::Comment::fetch_for_post(id), Message::CommentsFound),
                ])
            }
            Message::PostsFound(posts) => {
                match posts {
                    Err(_) => (),
                    Ok(data) => {
                        self.posts = Some(
                            data.into_iter()
                                .map(|post| Post {
                                    detail_button: button::State::new(),
                                    post,
                                })
                                .collect(),
                        );
                    }
                };
                Command::none()
            }
            Message::PostFound(post) => {
                match post {
                    Err(_) => (),
                    Ok(data) => {
                        self.post = Some(Post {
                            detail_button: button::State::new(),
                            post: data,
                        });
                    }
                }
                Command::none()
            }
            Message::CommentsFound(comments) => {
                match comments {
                    Err(_) => (),
                    Ok(data) => {
                        self.comments = Some(
                            data.into_iter()
                                .map(|comment| Comment { comment })
                                .collect(),
                        );
                    }
                };
                Command::none()
            }
        }
    }

这是相当多的代码,但是当涉及到我们的应用程序时,它也是逻辑方面的核心部分,所以让我们一步一步地看完它。

首先,我们处理GoToList 。当点击Home ,就会触发。如果发生这种情况,我们将重置postcomment 的保存数据,将route 设置为List ,最后,触发一个获取所有帖子的请求。

GoToDetail ,我们基本上做了同样的事情,但这次我们清除了posts ,并发出了一个batch 的命令--即取回给定的帖子ID的帖子和评论。

现在变得有趣了。处理Message::PostsFound(posts) 是在fetch_all 成功的任何时候发生的。因为我们忽略了错误,所以如果我们得到一个错误,我们不做任何事情,但是如果我们得到数据,我们实际上用返回的数据创建一个Post 小工具的向量,并将self.posts 设置为该小工具的列表。

这意味着,当数据回来时,我们实际上是在更新我们的应用程序状态。在这种情况下,我们返回Command::none() ,这意味着命令链在此结束。

对于PostFoundCommentsFound ,处理方法基本上是一样的:我们根据返回的数据,用小部件更新应用程序的状态。

最后,让我们看一下view 函数,看看我们是如何渲染我们的完整的应用程序。

    fn view(&mut self) -> Element<Message> {
        let col = Column::new()
            .max_width(600)
            .spacing(10)
            .padding(10)
            .align_items(Align::Center)
            .push(
                Button::new(&mut self.list_button, Text::new("Home")).on_press(Message::GoToList),
            );
        match self.route {
            Route::List => {
                let posts: Element<_> = match self.posts {
                    None => Column::new()
                        .push(Text::new("loading...".to_owned()).size(15))
                        .into(),
                    Some(ref mut p) => App::render_posts(p),
                };
                col.push(Text::new("Home".to_owned()).size(20))
                    .push(posts)
                    .into()
            }
            Route::Detail(id) => {
                let post: Element<_> = match self.post {
                    None => Column::new()
                        .push(Text::new("loading...".to_owned()).size(15))
                        .into(),
                    Some(ref mut p) => p.view(),
                };
                let comments: Element<_> = match self.comments {
                    None => Column::new()
                        .push(Text::new("loading...".to_owned()).size(15))
                        .into(),
                    Some(ref mut c) => App::render_comments(c),
                };

                col.push(Text::new(format!("Post: {}", id)).size(20))
                    .push(post)
                    .push(comments)
                    .into()
            }
        }
    }

首先,我们创建另一个Column ,这次有一个最大的宽度,把所有东西都放在中间。这是我们最外层的容器。

在这个容器中,我们添加了Home 按钮,点击后会触发GoToList 信息,使我们回到主页。

然后我们在self.route ,我们在应用程序中的路由状态。

如果我们在List 路由上,我们检查是否已经设置了self.posts ;记住,当这个设置时,一个获取帖子的请求已经被触发了。如果没有,我们会显示一个loading.. 的信息。

一旦有了数据,我们就调用App::render_posts ,这是一个用于实际呈现帖子列表的辅助程序。

impl App {
    fn render_posts(posts: &mut Vec<Post>) -> Element<Message> {
        let c = Column::new();
        let posts: Element<_> = posts
            .iter_mut()
            .fold(Column::new().spacing(10), |col, p| {
                col.push(p.view_in_list())
            })
            .into();
        c.push(posts).into()
    }

    fn render_comments(comments: &Vec<Comment>) -> Element<Message> {
        let c = Column::new();
        let comments: Element<_> = comments
            .iter()
            .fold(Column::new().spacing(10), |col, c| col.push(c.view()))
            .into();
        c.push(Text::new(String::from("Comments:")).size(15))
            .push(comments)
            .into()
    }
}

还有一个相应的帮助器用于呈现评论列表。在这两种情况下,我们简单地迭代数据,并创建一个列,其中包含所有单一的帖子和评论小工具。

最后,我们将单词Home 和返回的posts widget列表推送到一个列,将其添加到根列中。

对于Detail 路线,我们同样检查loading 的情况,一旦数据在那里,就把所有的东西组合在一起,在一个新的列中返回。

测试我们的Iced.rs应用程序

现在我们已经完成了我们简单的列表/细节应用程序,让我们测试一下,看看它是否真的能工作。

让我们运行trunk serve ,它将在http://localhost:8080 上构建和运行我们的应用程序

最初,我们看到Home 页面上有一个帖子列表和它们的Detail 链接。

Iced.rs Web App Example

点击其中一个链接,我们会被引导到帖子的详细页面,显示帖子的正文和所有的评论,这些评论都是从JSONPlaceholder中并行获取的。

Iced.rs Web App Example

这很有效!

你可以在GitHub上找到这个例子的完整代码。

总结

在过去玩过Elm,并且对Rust很熟悉的情况下,用Iced.rs构建这个小程序实际上是一个非常简单的体验。

当涉及到使用Iced.rs的网络时,有一点是可以立即注意到的,那就是缺乏一些基本的东西,比如一个成熟的路由库。看起来Iced的重点不是在网络上,而是在跨平台的GUI应用上,一般来说。

除此之外,例子和文档都很有帮助。已经有一个活跃的社区在研究和使用Iced.rs。对于以跨平台开发为重点的用例,我绝对可以看到Iced.rs在未来是一个强有力的竞争者。

帖子Iced.rs 教程:如何建立一个简单的Rust前端网络应用,首先出现在LogRocket博客上。