Yew Tutorial

1,877 阅读10分钟

本文是对Yew Tutorial的翻译。

概述

在本篇教程中,我们将主要关注如何使用Yew来构建Web应用。Yew是一个使用WebAssembly技术来开发前端应用的现代Rust框架。借助于Rust强有力的类型系统,Yew提供了一种可复用、易维护、代码结构清晰的编程方式。社区创建的大量生态包(Rust中的crate) 提供了很多常用的组件,比如状态管理组件。这些组件可以在crates.io 中获取到。

我们的目标

RustConf是Rust社区每年举办一次的大型聚会,RustConf 2020举行了大量的会谈,提出了很多很好的建议。在本教程中,我们将开发一个帮助Rustaceans(Rust开发者们)在一个页面上查询跟进每个议题概览的Web应用。

开始

先决条件

开始前,要先确保开发环境已经更新到最新,且已安装如下工具:

  • rust
  • trunk: 安装命令: cargo install --locked trunkcargo install --locked wasm-bindgen-cli
  • wasm32-unknown-unknown:安装命令:rustup target add wasm32-unknown-unknown

本教程默认你已经对Rust有了基本的了解,如果你是Rust新手,免费的 Rust Book 可以作为入门的指导书籍,并且也可以作为进阶的书籍。

初始化项目

首页要新建一个cargo项目: cargo new yew-app。这个项目和我们熟悉的Rust 二进制项目没有任何差别。

为了检查开发环境是否配置正确,可以通过cargo命令来启动这个初始化项目。cargo run,当项目执行完,并在控制台看到预期的Hello, world!时,表示一切正常。

第一个静态页面

为了将简单的控制台命令程序变为Yew的Web应用,首先要做如下的修改:

[package]
name = "yew-app"
version = "0.1.0"
edition = "2021"

[dependencies]
+ yew = "0.19"
use yew::prelude::*;

#[function_component(App)]
fn app() -> Html {
    html! {
        <h1>{ "Hello World" }</h1>
    }
}

fn main() {
    yew::start_app::<App>();
}

然后在项目的根路径下创建如下的index.html文件。

<!DOCTYPE html>
<html lang="en">
    <head> </head>
    <body></body>
</html>

启动开发环境服务器

运行如下命令构建应用并在本地启动:

trunk serve --open

Trunk将会在你的默认浏览器中打开我们开发的页面,并监视文件中的修改。当文件修改后会自动帮你重新编译构建服务,刷新页面。如果你对Trunk命令有任务疑问,可以使用如下命令来获取帮助:trunk helptrunk help <子命令>

恭喜

现在你已经配置好了Yew的开发环境,并且构建了第一个Yew Web应用, 恭喜你!

构建HTML

Yew使用Rust的过程宏来提供一种与JSX类似的语法来编写标签。

转换标准HTML

我们已经对网页展示内容有了一些想法,现在可以通过html!宏将草图转为具体的实现。如果你能编写简单的HTML页面, 那你将对在html!中编写语句毫无问题。但要记住,在某些地方,html!和标准的HTML有些区别:

  1. 表达式必须被大括号包裹("some string"也是一个表达式,需要将字符串放在引号中)
  2. 只能有一个根节点,如果有多个元素,可以设置一个空的根节点(<> elements </>)
  3. 标签需要正确的关闭

我们需要开发的页面在标准HTML中如下:

<h1>RustConf Explorer</h1>
<div>
    <h3>Videos to watch</h3>
    <p>John Doe: Building and breaking things</p>
    <p>Jane Smith: The development process</p>
    <p>Matt Miller: The Web 7.0</p>
    <p>Tom Jerry: Mouseless development</p>
</div>
<div>
    <h3>John Doe: Building and breaking things</h3>
    <img
        src="https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder"
        alt="video thumbnail"
    />
</div>

现在将HTML转为html!里的内容,如下所示,修改app函数的返回内容:

html! {
    <>
        <h1>{ "RustConf Explorer" }</h1>
        <div>
            <h3>{"Videos to watch"}</h3>
            <p>{ "John Doe: Building and breaking things" }</p>
            <p>{ "Jane Smith: The development process" }</p>
            <p>{ "Matt Miller: The Web 7.0" }</p>
            <p>{ "Tom Jerry: Mouseless development" }</p>
        </div>
        <div>
            <h3>{ "John Doe: Building and breaking things" }</h3>
            <img src="https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
        </div>
    </>
}

刷新浏览器页面,页面将如下所示:

Running WASM application screenshot

在标签语言中使用Rust提供的结构体

在Rust中开发Html的优势之一是可以使用Rust的所有很酷的功能。比如,可以使用Vec数组来代替HTML中的列表。 我们先创建一简单的结构体,这个结构体可以定义在main.rs文件中,也可以定义在其他文件中。

struct Video {
    id: usize,
    title: String,
    speaker: String,
    url: String,
}

然后就是在函数app中创建video列表:

use website_test::tutorial::Video; // replace with your own path

let videos = vec![
    Video {
        id: 1,
        title: "Building and breaking things".to_string(),
        speaker: "John Doe".to_string(),
        url: "https://youtu.be/PsaFVLr8t4E".to_string(),
    },
    Video {
        id: 2,
        title: "The development process".to_string(),
        speaker: "Jane Smith".to_string(),
        url: "https://youtu.be/PsaFVLr8t4E".to_string(),
    },
    Video {
        id: 3,
        title: "The Web 7.0".to_string(),
        speaker: "Matt Miller".to_string(),
        url: "https://youtu.be/PsaFVLr8t4E".to_string(),
    },
    Video {
        id: 4,
        title: "Mouseless development".to_string(),
        speaker: "Tom Jerry".to_string(),
        url: "https://youtu.be/PsaFVLr8t4E".to_string(),
    },
];

为了展示这些video,我们需要将Vec转为Html。首先创建一个迭代器,然后通过map将其变为html!,最后再组合成一个列表。

let videos = videos.iter().map(|video| html! {
    <p>{format!("{}: {}", video.speaker, video.title)}</p>
}).collect::<Html>();

最后将HTML中硬编码的列表转为在Rust中创建的数据:

html! {
    <>
        <h1>{ "RustConf Explorer" }</h1>
        <div>
            <h3>{ "Videos to watch" }</h3>
-           <p>{ "John Doe: Building and breaking things" }</p>
-           <p>{ "Jane Smith: The development process" }</p>
-           <p>{ "Matt Miller: The Web 7.0" }</p>
-           <p>{ "Tom Jerry: Mouseless development" }</p>
+           { videos }
        </div>
        // ...
    </>
}

组件

组件是Yew应用的组成部分,通过组装不同的组件(组件也可以由其他组件构成)来构建应用。组件需要很好的设计,以便具有可复用性和泛型性,这样就可以在不同的应用中重复使用这些组件,而不是复制组件的代码或逻辑。

事实上,我们之前创建的函数app就是一个组件,这个组件被命名为App,这是一个函数式组件。在Yew中有两类不同的组件:

  • 结构体式组件(又称类组件)
  • 函数式组件

现在,就让我们把App组件分解成更多更小的组件。我们从将videos列表转化为一个组件开始。

注意,一个组件,目前主要有两部分组成:组件属性定义和组件定义。组件属性可以从父组件向子组件传递参数。

use yew::prelude::*;

struct Video {
    id: usize,
    title: String,
    speaker: String,
    url: String,
}

#[derive(Properties, PartialEq)]
struct VideosListProps {
    videos: Vec<Video>,
}

#[function_component(VideosList)]
fn videos_list(VideosListProps { videos }: &VideosListProps) -> Html {
    videos.iter().map(|video| html! {
        <p>{format!("{}: {}", video.speaker, video.title)}</p>
    }).collect()
}

注意函数式组件VideoList的参数,一个函数式组件只能有一个参数,这个参数需要定义属性(Props:properties的简称)。属性是用来从父组件向子组件传递数据的。在示例中,我们用结构体VideosListProps来定义属性。

用来作为属性的结构体必须实现了Properties,可以通过deriving进行默认实现。

为了使上面的代码能编译,我们需要修改Video结构体:

(由于Properties实现了 PartialEq,所以Video也要实现此trate)

#[derive(Clone, PartialEq)]
struct Video {
    id: usize,
    title: String,
    speaker: String,
    url: String,
}

现在,就可以通过VideoList组件来更新App组件了。

#[function_component(App)]
fn app() -> Html {
    // ...
-    let videos = videos.iter().map(|video| html! {
-        <p>{format!("{}: {}", video.speaker, video.title)}</p>
-    }).collect::<Html>();
-
    html! {
        <>
            <h1>{ "RustConf Explorer" }</h1>
            <div>
                <h3>{"Videos to watch"}</h3>
-               { videos }
+               <VideosList videos={videos} />
            </div>
            // ...
        </>
    }
}

现在去观察浏览器,会发现列表已经正常展示了。在上面的示例中,我们将列表的渲染逻辑放到了单独的组件中,这样可以减少App组件的代码数量,更容易阅读和理解。

交互操作

还有一个目标,就是要展示已经选中的视频详情。为了实现这个目标,VideosList组件需要在选中了一条video时告知父组件哪个video被选中了。这被称作Callback。这就是所谓的传递handlers(处理器),这需要修改对属性参数来添加一个on_click回调。

#[derive(Clone, Properties, PartialEq)]
struct VideosListProps {
    videos: Vec<Video>,
+    on_click: Callback<Video>
}

修改VideoList组件,使得它可以传递选中的video到事件回调中。

#[function_component(VideosList)]
fn videos_list(VideosListProps { videos, on_click }: &VideosListProps) -> Html {
    let on_click = on_click.clone();
    videos.iter().map(|video| {
+        let on_video_select = {
+            let on_click = on_click.clone();
+            let video = video.clone();
+            Callback::from(move |_| {
+                on_click.emit(video.clone())
+            })
+        };

        html! {
-            <p>{format!("{}: {}", video.speaker, video.title)}</p>
+            <p onclick={on_video_select}>{format!("{}: {}", video.speaker, video.title)}</p>
        }
    }).collect()
}

下一步,修改VideosList组件,将回调传递进去。在此之前,还需要创建一个新的组件,VideoDetail,用来展示选中的video详情。

use website_test::tutorial::Video;
use yew::prelude::*;

#[derive(Clone, Properties, PartialEq)]
struct VideosDetailsProps {
    video: Video,
}

#[function_component(VideoDetails)]
fn video_details(VideosDetailsProps { video }: &VideosDetailsProps) -> Html {
    html! {
        <div>
            <h3>{ video.title.clone() }</h3>
            <img src="https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
        </div>
    }
}

现在,修改App组件,当选中一个video时,展示这条video的详情。

#[function_component(App)]
fn app() -> Html {
    // ...
+    let selected_video = use_state(|| None);

+    let on_video_select = {
+        let selected_video = selected_video.clone();
+        Callback::from(move |video: Video| {
+            selected_video.set(Some(video))
+        })
+    };

+    let details = selected_video.as_ref().map(|video| html! {
+        <VideoDetails video={video.clone()} />
+    });

    html! {
        <>
            <h1>{ "RustConf Explorer" }</h1>
            <div>
                <h3>{"Videos to watch"}</h3>
-               <VideosList videos={videos} />
+               <VideosList videos={videos} on_click={on_video_select.clone()} />
            </div>
+            { for details }
-            <div>
-                <h3>{ "John Doe: Building and breaking things" }</h3>
-                <img src="https://via.placeholder.com/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
-            </div>
-        </>
    }
}

目前先不用去理解use_state这个概念,后面还会专门来学习它。这里介绍一个小窍门,我们使用了for details,这是由于Option实现了Iterator,因此我们可以使用for ...语法来展示只有一个元素的Option

处理state

还记得之前的use_state吗?这是一个被成为hook的特殊函数。Hooks(钩子)被用来插入到函数式组件的生命周期中,并且对生命周期进行影响。可以通过如下链接了解更多钩子这个概念。点我了解更多

结构体组件是不一样的,通过结构体组件 来了解更多。

请求数据(使用外部Rest API)

在真是的项目开发中,数据往往是从其他API中获取到的,而不是硬编码在代码中。下面就让我们从外部资源中获取数据。为了实现这个功能,我们需要添加下面这些包:

更新依赖配置文件Cargo.toml

[dependencies]
gloo-net = "0.2"
serde = { version = "1.0", features = ["derive"] }
wasm-bindgen-futures = "0.4"

更新Video结构体,使其实现Deserializetrait。

+ use serde::Deserialize;

- #[derive(Clone, PartialEq)]
+ #[derive(Clone, PartialEq, Deserialize)]
struct Video {
    id: usize,
    title: String,
    speaker: String,
    url: String,
}

最后,我们需要更新App组件,替换原来的硬编码数据为从服务器上获取数据。

+ use gloo_net::http::Request;

#[function_component(App)]
fn app() -> Html {
-    let videos = vec![
-        // ...
-    ]
+    let videos = use_state(|| vec![]);
+    {
+        let videos = videos.clone();
+        use_effect_with_deps(move |_| {
+            let videos = videos.clone();
+            wasm_bindgen_futures::spawn_local(async move {
+                let fetched_videos: Vec<Video> = Request::get("https://yew.rs/tutorial/data.json")
+                    .send()
+                    .await
+                    .unwrap()
+                    .json()
+                    .await
+                    .unwrap();
+                videos.set(fetched_videos);
+            });
+            || ()
+        }, ());
+    }

    // ...

    html! {
        <>
            <h1>{ "RustConf Explorer" }</h1>
            <div>
                <h3>{"Videos to watch"}</h3>
-                <VideosList videos={videos} on_click={on_video_select.clone()} />
+                <VideosList videos={(*videos).clone()} on_click={on_video_select.clone()} />
            </div>
            { for details }
        </>
    }
}

由于这只是一个demo,为了简便,我们使用了unwrap,在真实的项目开发中,需要使用错误处理

现在再去查看一下浏览器,是否与预期一致。如果不一致,那有可能是由于CORS导致的。为了解决这个问题,我们需要一个代理服务。幸运的是,Trunk为我们提供了这个功能。

只需要更新下面一行代码:

// ...
-                let fetched_videos: Vec<Video> = Request::get("https://yew.rs/tutorial/data.json")
+                let fetched_videos: Vec<Video> = Request::get("/tutorial/data.json")
// ...

现在使用下面的命令重新启动服务:

trunk serve --proxy-backend=https://yew.rs/tutorial

刷新页面,一切将符合预期(如果没有出现数据,可以尝试禁用缓存试试)

总结

恭喜,你已经创建了一个web应用,这个应用可以从外部获取数据,并展示视频列表。

下一步

很明显,这个应用距离完美或者实用还有很远的距离。完成这个教程后,你可以把它当作一个起点,去学习了解更多高级的主题。

样式

这个应用看起来很丑,没有CSS,以及其他任何的样式。 很遗恨,yew并没有提供一个内置的样式组件。查看Trunk 样式学习如何增加样式。

其他的库

这个应用只使用了很少的外部依赖,还有很多库可以使用,详见外部依赖来查看更多细节。

学习yew的更多内容

阅读官方文档来了解更多细节,学习API文档来学习更多的yew API。