用Yew构建一个Rust + WebAssembly的前端Web应用

4,037 阅读10分钟

虽然Rust以其后端Web开发能力而闻名,但WebAssembly(Wasm)的出现使在Rust中构建丰富的前端应用程序成为可能。

对于那些渴望探索Rust开发的前端的人来说,我们将学习如何使用Yew网络框架建立一个非常基本的前端网络应用。

如果你熟悉React或其他JavaScript前端框架,你会对Yew有宾至如归的感觉;它使用的语法和应用结构与JSX相似。

为了证明Rust和Yew的互操作性,我们的前端应用将包含一个简单的待办事项列表(我知道,很有创意!),使用JSONPlaceholder作为获取数据的后端。该列表将提供一个列表视图,每个待办事项选项的详细视图,以及一个刷新数据的选项。

然而,需要注意的是,Wasm生态系统和Yew仍处于早期开发阶段,因此,虽然本教程今天是准确的,但Wasm和Yew的一些功能在未来可能会改变。这可能会稍微影响到设置和库的生态系统,但我们仍然可以使用这个堆栈建立真正的Rust应用程序。

现在,让我们直接进入!

设置网络应用程序

确保Rust1.50或更高版本和Trunk都已安装。Trunk是基于Rust的Wasm应用程序的构建和管道工具,它提供了一个本地开发服务器,自动文件观察,并简化了向Wasm发送Rust代码。

要了解如何使用Yew框架来开发应用程序,请查阅Yew文档

创建一个Rust项目

让我们开始创建一个新的Rust项目,内容如下。

cargo new --lib rust-frontend-example-yew
cd rust-frontend-example-yew

添加需要的依赖性,用下面的代码编辑Cargo.toml 文件。

[dependencies]
yew = "0.18"
wasm-bindgen = "0.2.67"
serde = "1"
serde_derive = "1"
serde_json = "1"
anyhow = "1"
yew-router = "0.15.0"

通过添加YewYew-Router ,我们可以开始在Yew框架内工作。我们还添加了用于基本错误处理的anyhow ,用于处理JSON的serde ,和 [wasm-bindgen]来使用Rust的JavaScript。

设置完毕后,让我们开始构建。

使用Trunk的HTML设置

由于我们要建立一个前端的Web应用程序,我们需要一个HTML基础。使用Trunk ,我们可以通过以下方式在我们的项目根中创建一个最小的index.html

<html>
  <head>
    <title>Rust Frontend Example with Yew</title>
    <style>
        body {
            font-size: 14px;
            font-family: sans-serif;
        }
        a {
            text-decoration: none;
            color: #339;
        }
        a:hover {
            text-decoration: none;
            color: #33f;
        }
        .todo {
            background-color: #efefef;
            margin: 100px 25% 25% 25%;
            width: 50%;
            padding: 10px;
        }
        .todo .nav {
            text-align: center;
            font-size: 16px;
            font-weight: bold;
        }
        .todo .refresh {
            text-align: center;
            margin: 10px 0 10px 0;
        }
        .todo .list .list-item {
            margin: 2px;
            padding: 5px;
            background-color: #cfc;
        }
        .todo .list .completed {
            text-decoration: line-through;
            background-color: #dedede;
        }
        .detail {
            font-size: 16px;
        }
        .detail h1 {
            font-size: 24px;
        }
        .detail .id {
            color: #999;
        }
        .detail .completed {
            color: #3f3;
        }
        .detail .not-completed {
            color: #f33;
        }
    </style>
  </head>
</html>

有了一个最小的HTML骨架和一些非常基本的CSS,Trunk创建了dist/index.html ,并注入了一个body ,持有我们Wasm应用程序的入口点。

打开src/lib.rs 文件,我们现在可以为我们的Yew网络应用程序创建基本的东西。

用基本路由设置TodoApp

通过实现基本路由,我们可以从高层次的路由定义到实际的路由实现。

首先,让我们为TodoApp 创建一个类型。

struct TodoApp {
    link: ComponentLink<Self>,
    todos: Option<Vec<Todo>>,
    fetch_task: Option<FetchTask>,
}

#[derive(Deserialize, Clone, PartialEq, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Todo {
    pub user_id: u64,
    pub id: u64,
    pub title: String,
    pub completed: bool,
}

这个结构包括link ,在这个组件中注册回调。我们还将定义一个可选的待办事项列表,其中包括Option<Vec<Todo>> 和一个用于获取数据的fetch_task

要创建一个根组件作为入口点,我们必须实现Component 特质。

enum Msg {
    MakeReq,
    Resp(Result<Vec<Todo>, anyhow::Error>),
}

impl Component for TodoApp {
    type Message = Msg;
    type Properties = ();
    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
        Self {
            link,
            todos: None,
            fetch_task: None,
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, _props: Self::Properties) -> ShouldRender {
        false
    }

    fn view(&self) -> Html {
        html! {
            <div class=classes!("todo")>
                ...
            </div>
        }
    }
}

通过定义Msg 结构,即组件的Message 的类型,我们可以在组件内部协调消息传递。在我们的案例中,我们将定义MakeReq 消息和Resp 消息来进行HTTP请求和接收响应。

稍后,我们将使用这些状态来构建一个状态机,告诉我们的应用程序在触发请求和响应到达时如何反应。

Component 特质定义了六个生命周期函数。

  • create 是一个构造函数,接收道具和ComponentLink
  • view 渲染该组件
  • update ,当一个Message 被发送到该组件时被调用,实现消息传递的逻辑
  • change 重新渲染变化,优化渲染速度
  • renderedview 之后但在浏览器更新之前被调用一次,以区分第一次渲染和连续渲染。
  • destroy ,当一个组件被卸载并需要进行清理操作时被调用。

由于我们的根组件没有任何道具,我们可以让change ,返回false。

我们还不会在update 中实现任何东西,所以我们将定义该组件必须在任何时候重新渲染Message

view ,我们将使用html! 宏来建立一个基本的外部div ,并使用classes! 宏来为其创建HTML类,我们将在后面实现。

为了渲染这个组件,我们需要下面的代码片断。

#[wasm_bindgen(start)]
pub fn run_app() {
    App::<TodoApp>::new().mount_to_body();
}

这个代码片断使用wasm-bindgen ,并定义这个函数作为我们的入口点,将TodoApp 组件挂载到主体内的根部。

获取数据

很好,现在基本的东西都到位了,让我们看看如何获取一些数据。

我们将首先改变create 生命周期方法,在组件被创建时发送一个MakeReq 消息,以便立即获取数据。

    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
        link.send_message(Msg::MakeReq);
        Self {
            link,
            todos: None,
            fetch_task: None,
        }
    }

然后,我们实现update

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::MakeReq => {
                self.todos = None;
                let req = Request::get("https://jsonplaceholder.typicode.com/todos")
                    .body(Nothing)
                    .expect("can make req to jsonplaceholder");

                let cb = self.link.callback(
                    |response: Response<Json<Result<Vec<Todo>, anyhow::Error>>>| {
                        let Json(data) = response.into_body();
                        Msg::Resp(data)
                    },
                );

                let task = FetchService::fetch(req, cb).expect("can create task");
                self.fetch_task = Some(task);
                ()
            }
            Msg::Resp(resp) => {
                if let Ok(data) = resp {
                    self.todos = Some(data);
                }
            }
        }
        true
    }

这是相当多的代码,让我们一起逐步了解它。

Yew提供的服务是预先建立的抽象,用于记录或使用HTTPfetch() (即JavaScriptfetch() )等。

在我们的代码中,我们可以将self.todos 设置为None ,当我们在获取数据时,数据总是会被重置。通过添加FetchService ,我们创建了一个HTTPGET 请求到JSONPlaceholder

定义一个回调,解析对JSONPlaceholder的响应,并发送一个带有返回数据的Msg::Resp 消息。

当我们用请求和回调启动准备好的fetch() 调用时,我们也将组件的fetch_task 设置为返回的FetchService::fetch 任务以保持fetch-task

处理响应很简单:如果一个Msg::Resp ,我们可以检查是否有数据。如果有数据,我们就可以把self.todos 设置为该数据。

这也是我们可以做一些错误处理的地方,如果请求失败或数据无效,可以设置一个错误信息来显示。

最后,我们必须使用view 方法显示我们新获取的数据。

    fn view(&self) -> Html {
        let todos = self.todos.clone();
        let cb = self.link.callback(|_| Msg::MakeReq);
        ConsoleService::info(&format!("render TodoApp: {:?}", todos));
        html! {
            <div class=classes!("todo")>
              <div>
                  <div class=classes!("refresh")>
                      <button onclick=cb.clone()>
                          { "refresh" }
                      </button>
                  </div>
                  <todo::list::List todos=todos.clone()/>
              </div>
            </div>
        }
    }

通过获取to-dos并使用ConsoleService ,我们可以在每次渲染这个组件时记录它们,这对调试很有用。

创建一个带有onclick 处理程序的简单的refresh 按钮,让我们调用我们的数据获取管道,允许我们从html! 标记中调用动作。

将待办事项传递给todo::list::List 组件,我们就可以在网络应用中显示待办事项。

添加List 组件

pub mod list 为了开始构建我们的List 组件,我们必须创建一个todo 文件夹,其中包含一个mod.rs 和一个list.rs 文件。

list.rs ,我们必须以实现TodoApp 的方式实现我们的List 组件。

#[derive(Properties, Clone, PartialEq)]
pub struct Props {
    pub todos: Option<Vec<Todo>>,
}

pub struct List {
    props: Props,
}

pub enum Msg {}

impl Component for List {
    type Properties = Props;
    type Message = Msg;

    fn create(props: Self::Properties, _link: ComponentLink<Self>) -> Self {
        Self { props }
    }

    fn view(&self) -> Html {
        html! {
            <div>
                { self.render_list(&self.props.todos)}
            </div>
        }
    }

    fn update(&mut self, _msg: Self::Message) -> ShouldRender {
        true
    }

    fn change(&mut self, props: Self::Properties) -> ShouldRender {
        self.props = props;
        true
    }
}

通过为list 组件的道具定义List 结构,我们可以包括待办事项的列表。然后,我们实现Component 特质。

每当道具出现变化时,我们必须设置道具并重新渲染。由于我们没有任何消息传递,我们可以忽略Msg 结构和update 函数。

view ,让我们创建一个div 来调用self.render_list

我们可以在List 本身实现这种渲染。

impl List {
    fn render_list(&self, todos: &Option<Vec<Todo>>) -> Html {
        if let Some(t) = todos {
            html! {
                <div class=classes!("list")>
                    { t.iter().map(|todo| self.view_todo(todo)).collect::<Html>() }
                </div>
            }
        } else {
            html! {
                <div class=classes!("loading")>{"loading..."}</div>
            }
        }
    }

    fn view_todo(&self, todo: &Todo) -> Html {
        let completed = if todo.completed {
            Some("completed")
        } else {
            None
        };
        html! {
            <div class=classes!("list-item", completed)>
                { &todo.title }
            </div>
        }
    }
}

如果我们在render_list 中没有任何待办事项,我们可以在浏览器中显示正在加载...的渲染,以表明数据正在被获取。

如果数据已经存在,我们可以在html! 中使用Yew的表达式语法来迭代待办事项的列表。为每个人调用view_todo ,并将其收集到Html ,在html! 内呈现。

我们还为我们的应用程序添加了条件样式,当待办事项在浏览器中被标记为完成时,在view_todo 中设置为completed ;如果它们没有被标记为完成,则不应用CSS样式。

为了给每个待办事项创建标题,我们只需在标记中为每个待办事项创建一个div ,包含相应的标题。

下一步是使这个标题成为一个链接,以便我们可以从列表视图切换到详细视图。但为此,我们必须首先在我们的应用程序中设置导航,也称为路由。

使用Yew的基本应用程序路由

为了给我们的应用程序创建基本路由,我们将使用Yew-router

Switch 特质和pub 枚举中,我们可以在AppRoute 中定义我们的路由。

#[derive(Switch, Clone, Debug)]
pub enum AppRoute {
    #[to = "/todo/{id}"]
    Detail(i32),
    #[to = "/"]
    Home,
}

在这个枚举中定义Detail 路由,需要一个待办事项的ID,在/todo/$idHome 路由,也就是我们的列表视图。

现在,我们必须调整我们的view 方法,以包括以下路由机制。

    fn view(&self) -> Html {
        let todos = self.todos.clone();
        let cb = self.link.callback(|_| Msg::MakeReq);
        ConsoleService::info(&format!("render TodoApp: {:?}", todos));
        html! {
            <div class=classes!("todo")>
                <div class=classes!("nav")>
                    <Anchor route=AppRoute::Home>{"Home"}</Anchor>
                </div>
                <div class=classes!("content")>
                    <Router<AppRoute, ()>
                        render = Router::render(move |switch: AppRoute| {
                            match switch {
                                AppRoute::Detail(todo_id) => {
                                    html! {
                                        <div>
                                            <todo::detail::Detail todo_id=todo_id/>
                                        </div>}
                                }
                                AppRoute::Home => {
                                    html! {
                                        <div>
                                            <div class=classes!("refresh")>
                                                <button onclick=cb.clone()>
                                                    { "refresh" }
                                                </button>
                                            </div>
                                            <todo::list::List todos=todos.clone()/>
                                        </div>
                                    }
                                }
                            }
                        })
                    />
                </div>
            </div>
        }
    }

在我们的列表上方,我们可以创建一个导航div ,其中包括一个回到Home 的链接,这样我们就可以在任何时候导航回去。

在这下面,我们可以定义一个内容div ,其中包括一个Router<AppRoute,()> 。在这个路由器中,我们可以定义一个render 函数,告诉路由器要根据当前的路由渲染什么。

render 方法里面,我们可以打开给定的AppRoute ,在HomeDetail 上显示待办事项列表,在Home 上显示一个refresh 按钮。

最后,我们必须调整list.rs 中的view_todo 函数,以包括一个指向待办事项详细页面的链接。

    fn view_todo(&self, todo: &Todo) -> Html {
        let completed = if todo.completed {
            Some("completed")
        } else {
            None
        };
        html! {
            <div class=classes!("list-item", completed)>
                <Anchor route=AppRoute::Detail(todo.id as i32)>
                    { &todo.title }
                </Anchor>
            </div>
        }
    }

为此,我们将使用Yew-router'sAnchor 组件。这种方便的机制让我们可以使用我们的AppRoute 枚举在应用程序内部进行路由,消除了类型错误的可能性。这意味着我们的路由有编译器级别的类型检查。非常酷!

为了完成我们的应用程序,让我们来实现一个单一待办事项的详细视图。

实现详细视图

为了开始实现我们应用程序的待办事项详细视图,打开todo 文件夹,将pub mod detail; 添加到mod.rs ,并添加一个detail.rs 文件。

而现在我们可以实现另一个组件。然而,为了使它更有趣,我们将(在这种情况下不必要地),实现一些数据的获取。由于我们只把待办事项的ID传递给详细视图,我们将不得不在详细视图中重新获取待办事项的数据。

虽然在我们的例子中使用这个功能并没有提供太多的价值,但具有多个数据源、大型产品列表和丰富的详细页面的网络商店可以从数据获取的效率中受益。

同样,从最基本的开始,我们将添加以下内容。

#[derive(Properties, Clone, PartialEq)]
pub struct Props {
    pub todo_id: i32,
}

pub struct Detail {
    props: Props,
    link: ComponentLink<Self>,
    todo: Option<Todo>,
    fetch_task: Option<FetchTask>,
}

pub enum Msg {
    MakeReq(i32),
    Resp(Result<Todo, anyhow::Error>),
}

我们组件的Detail 结构包括用于获取数据的linkfetch_task 以及保存待办事项ID的props。

Component 特质的实现与我们的TodoApp 组件中的类似。

impl Component for Detail {
    type Properties = Props;
    type Message = Msg;

    fn create(props: Self::Properties, link: ComponentLink<Self>) -> Self {
        link.send_message(Msg::MakeReq(props.todo_id));
        Self {
            props,
            link,
            todo: None,
            fetch_task: None,
        }
    }

    fn view(&self) -> Html {
        html! {
            <div>
                { self.render_detail(&self.todo)}
            </div>
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::MakeReq(id) => {
                let req = Request::get(&format!(
                    "https://jsonplaceholder.typicode.com/todos/{}",
                    id
                ))
                .body(Nothing)
                .expect("can make req to jsonplaceholder");

                let cb =
                    self.link
                        .callback(|response: Response<Json<Result<Todo, anyhow::Error>>>| {
                            let Json(data) = response.into_body();
                            Msg::Resp(data)
                        });

                let task = FetchService::fetch(req, cb).expect("can create task");
                self.fetch_task = Some(task);
                ()
            }
            Msg::Resp(resp) => {
                if let Ok(data) = resp {
                    self.todo = Some(data);
                }
            }
        }
        true
    }

    fn change(&mut self, props: Self::Properties) -> ShouldRender {
        self.props = props;
        true
    }
}

同样,使用FetchService/todos/$todo_id 获取数据,我们可以在我们的组件中设置返回的数据。

在这种情况下,让我们也在Detail 上直接实现render_detail 方法。

impl Detail {
    fn render_detail(&self, todo: &Option<Todo>) -> Html {
        match todo {
            Some(t) => {
                let completed = if t.completed {
                    Some("completed")
                } else {
                    Some("not-completed")
                };
                html! {
                    <div class=classes!("detail")>
                        <h1>{&t.title}{" ("}<span class=classes!("id")>{t.id}</span>{")"}</h1>
                        <div>{"by user "}{t.user_id}</div>
                        <div class=classes!(completed)>{if t.completed { "done" } else { "not done" }}</div>
                    </div>
                }
            }
            None => {
                html! {
                    <div class=classes!("loading")>{"loading..."}</div>
                }
            }
        }
    }
}

同样,如果我们还没有数据,我们会显示一个简单的loading...消息。根据我们正在处理的待办事项中的completed 状态,我们可以设置一个不同的类,在完成时将文本染成绿色,在未完成时染成红色,就像在浏览器中看到的那样。

Detailed View Completed To-Do Item

已完成的待办事项的详细视图

Detailed View Open To-Do Item

打开的待办事项的详细视图

运行最终的Rust项目

当我们在本地使用trunk serve 来运行我们的项目时,一个服务器会在http://localhost:8080 上启动;我们现在可以看到一个漂亮的、基于Rust的前端Web应用。

List View

列表视图

点击一个待办事项会将我们引向它的详细信息页面,而点击主页会将我们带回列表视图。

待办事项应用完成后,你可以在GitHub上找到这个例子的完整代码。

总结

WebAssembly的出现使得用Rust构建前端Web应用成为可能,就像我们刚刚构建的那个,为开发者扩大了开发机会。

虽然这篇文章中的所有库、框架和技术都还处于开发的早期阶段,但可用的特性和功能已经成熟和稳定,为未来更大的项目提供了可能。

The postBuild a Rust + WebAssembly frontend web app with Yewappeared first onLogRocket Blog.