Skip to main content
Version: 0.22

教程

介绍​

在这个实践教程中,我们将学习如何使用 Yew 构建 Web 应用程序。 Yew 是一个现代的 Rust 框架,用于使用 WebAssembly 构建前端 Web 应用程序。 Yew 通过利用 Rust 强大的类型系统,鼓励可重用、可维护和良好结构化的架构。 一个庞大的社区创建的库生态系统,称为 Rust 中的 crates,为常用模式(如状态管理)提供了组件。 Rust 的包管理器 Cargo 允许我们利用 crates.io 上提供的大量 crate,例如 Yew。

我们将要构建的内容​

Rustconf 是 Rust 社区每年举办的星际聚会。 Rustconf 2020 有大量的演讲,提供了大量的信息。 在这个实践教程中,我们将构建一个 Web 应用程序,帮助其他 Rustaceans 了解这些演讲并从一个页面观看它们。

设置​

先决条件​

这个教程假设您已经熟悉 Rust。如果您是 Rust 的新手,免费的 Rust 书 为初学者提供了一个很好的起点,并且即使对于有经验的 Rust 开发人员来说,它仍然是一个很好的资源。

确保安装了最新版本的 Rust,方法是运行 rustup update 或者安装 Rust。

安装 Rust 后,您可以使用 Cargo 运行以下命令安装 trunk:

cargo install trunk

我们还需要添加 WASM 构建目标,运行以下命令:

rustup target add wasm32-unknown-unknown

设置项目​

首先,创建一个新的 cargo 项目:

cargo new yew-app
cd yew-app

为了验证 Rust 环境是否设置正确,使用 cargo 构建工具运行初始项目。 在关于构建过程的输出之后,您应该看到预期的 "Hello, world!" 消息。

cargo run

我们的第一个静态页面​

为了将这个简单的命令行应用程序转换为一个基本的 Yew web 应用程序,需要进行一些更改。

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

[dependencies]
yew = { version = "0.22", features = ["csr"] }
信息

如果你只是正在构建一个应用程序,你只需要 csr 特性。它将启用 Renderer 和所有与客户端渲染相关的代码。

如果你正在制作一个库,请不要启用此特性,因为它会将客户端渲染逻辑拉入服务器端渲染包中。

如果你需要 Renderer 进行测试或示例,你应该在 dev-dependencies 中启用它。

src/main.rs
use yew::prelude::*;

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

fn main() {
yew::Renderer::<App>::new().render();
}

现在,让我们在项目的根目录创建一个 index.html。

index.html
<!doctype html>
<html lang="en">
<head></head>
<body></body>
</html>

启动开发服务器​

运行以下命令构建并在本地提供应用程序。

trunk serve --open
信息

删除选项 '--open' 以在运行 trunk serve 后不打开默认浏览器。

Trunk 将在您修改任何源代码文件时实时重新构建您的应用程序。 默认情况下,服务器将在地址 '127.0.0.1' 的端口 '8080' 上监听 => http://localhost:8080。 要更改这部分配置,请创建以下文件并根据需要进行编辑:

Trunk.toml
[serve]
# 局域网上的监听地址
address = "127.0.0.1"
# 广域网上的监听地址
# address = "0.0.0.0"
# 监听的端口
port = 8000

如果您感兴趣,您可以运行 trunk help 和 trunk help <subcommand> 以获取更多关于正在进行的流程的详细信息。

恭喜​

您现在已经成功设置了 Yew 开发环境,并构建了您的第一个 Yew Web 应用程序。

构建 HTML​

Yew 利用了 Rust 的过程宏,并为我们提供了一种类似于 JSX(JavaScript 的扩展,允许您在 JavaScript 中编写类似 HTML 的代码)的语法来创建标记。

转换为经典 HTML​

由于我们已经对我们的网站长什么样有了一个很好的想法,我们可以简单地将我们的草稿转换为与 html! 兼容的表示。如果您习惯于编写简单的 HTML,那么您在 html! 中编写标记时应该没有问题。需要注意的是,这个宏与 HTML 有一些不同之处:

  1. 表达式必须用大括号({ })括起来
  2. 只能有一个根节点。如果您想要在不将它们包装在容器中的情况下拥有多个元素,可以使用空标签/片段(<> ... </>)
  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://placehold.co/640x360.png?text=Video+Player+Placeholder"
alt="video thumbnail"
/>
</div>

现在,让我们将这个 HTML 转换为 html!。将以下代码片段输入(或复制/粘贴)到 app 函数的主体中,以便函数返回 html! 的值

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://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
</div>
</>
}

刷新浏览器页面,您应该看到以下输出:

Running WASM application screenshot

在标记中使用 Rust 语言结构​

在 Rust 中编写标记的一个很大的优势是,我们在标记中获得了 Rust 的所有优点。 现在,我们不再在 HTML 中硬编码视频列表,而是将它们定义为 Vec 的 Video 结构体。 我们创建一个简单的 struct(在 main.rs 或我们选择的任何文件中)来保存我们的数据。

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

接下来,我们将在 app 函数中创建这个结构体的实例,并使用它们来替代硬编码的数据:

use website_test::tutorial::Video; // 换成你自己的路径

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(),
},
];

为了显示它们,我们需要将 Vec 转换为 Html。我们可以通过创建一个迭代器,将其映射到 html! 并将其收集为 Html 来实现:

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

在列表项上使用键有助于 Yew 跟踪列表中哪些项发生了变化,从而实现更快的重新渲染。始终建议在列表中使用键。

最后,我们需要用从数据创建的 Html 替换硬编码的视频列表:

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。它是一个“函数式组件”。

  1. 结构体组件
  2. 函数式组件

在本教程中,我们将使用函数式组件。

现在,让我们将 App 组件拆分为更小的组件。我们首先将视频列表提取到自己的组件中。

use yew::prelude::*;

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

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

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

注意我们的 VideosList 函数组件的参数。函数组件只接受一个参数,该参数定义了它的 "props"("properties" 的缩写)。Props 用于从父组件传递数据到子组件。在这种情况下,VideosListProps 是一个定义 props 的结构体。

important

用于 props 的结构体必须通过派生实现 Properties。

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

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

现在,我们可以更新我们的 App 组件以使用 VideosList 组件。

#[component(App)]
fn app() -> Html {
// ...
- let videos = videos.iter().map(|video| html! {
- <p key={video.id}>{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 组件需要在选择视频时“通知”其父组件,这是通过 Callback 完成的。这个概念称为“传递处理程序”。我们修改其 props 以接受一个 on_click 回调:

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

然后我们修改 VideosList 组件以将所选视频传递给回调。

#[component(VideosList)]
-fn videos_list(VideosListProps { videos }: &VideosListProps) -> Html {
+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 key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
+ <p key={video.id} onclick={on_video_select}>{format!("{}: {}", video.speaker, video.title)}</p>
}
}).collect()
}

接下来,我们需要修改 VideosList 的使用以传递该回调。但在这样做之前,我们应该创建一个新的组件 VideoDetails,当点击视频时才会显示。

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

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

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

现在,修改 App 组件以在选择视频时显示 VideoDetails 组件。

#[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://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
- </div>
</>
}
}

现在不用担心 use_state,我们稍后会回到这个问题。注意我们用 { for details } 提取列表数据的技巧。 Option<_> 实现了 Iterator,所以我们可以使用特殊的 { for ... } 语法来逐个显示 Iterator 返回的唯一元素,而这由 html! 宏支持。

处理状态​

还记得之前使用的 use_state 吗?那是一个特殊的函数,称为 "hook"。Hooks 用于 "hook" 到函数组件的生命周期中并执行操作。您可以在这里了解更多关于这个 hook 和其他 hook 的信息。

备注

结构体组件的行为不同。请查看文档了解有关这些的信息。

获取数据(使用外部 REST API)​

在真实世界的应用程序中,数据通常会来自 API,而不是直接硬编码在程序里。现在让我们从外部来源获取影片列表。为此,我们需要添加以下 crates:

  • gloo-net
    用于发送 fetch 请求。
  • serde 并启用 derive 功能
    用于将 JSON 响应反序列化。

让我们更新 Cargo.toml 文件中的依赖项:

Cargo.toml
[dependencies]
-yew = { version = "0.22", features = ["csr"] }
+yew = { version = "0.22", features = ["csr", "serde"] }
+gloo-net = "0.7"
+serde = { version = "1.0", features = ["derive"] }

Yew 的 serde 功能会启用与 serde crate 的集成。对我们来说最重要的一点是, 它会为 AttrValue 添加 serde::Deserialize 的实现。

备注

选择依赖项时,请务必确认它们与 wasm32 兼容!
否则你的应用程序将无法运行。

更新导入:

use yew::prelude::*;
+use serde::Deserialize;

更新 Video 结构体,让它派生 Deserialize trait:

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

接下来我们需要更新 App 组件,让它能够获取数据。现代 Yew 的做法是使用 use_future 和 <Suspense>。

另一种方式是使用 yew::platform::spawn_local, 如果 hooks 无法使用,例如在 struct component 或普通函数中时,可以采用这种方式。

use_future 会在异步操作完成前挂起该组件,而 <Suspense> 则会在这段期间显示 后备 UI(例如加载中的提示)。

更新导入:

use yew::prelude::*;
use serde::Deserialize;
+use gloo_net::http::Request;
+use yew::suspense::use_future;

我们将数据获取逻辑拆分到一个子组件 VideosFetcher 中,该组件返回 HtmlResult, 而父组件 App 则使用 <Suspense> 将它包裹起来:

use yew::suspense::use_future;
+#[derive(Properties, PartialEq)]
+struct VideosFetchProps {
+ on_click: Callback<Video>,
+ selected_video: Option<Video>,
+}

+#[component]
+fn VideosFetcher(
+ VideosFetchProps {
+ on_click,
+ selected_video,
+ }: &VideosFetchProps,
+) -> HtmlResult {
+ let videos = use_future(|| async {
+ Request::get("https://yew.rs/tutorial/data.json")
+ .send()
+ .await?
+ .json::<Vec<Video>>()
+ .await
+ })?;
+
+ match &*videos {
+ Ok(videos) => Ok(html! {
+ <>
+ <div>
+ <h3>{ "Videos to watch" }</h3>
+ <VideosList videos={videos.clone()} on_click={on_click.clone()} />
+ </div>
+ if let Some(video) = selected_video {
+ <VideoDetails video={video.clone()} />
+ }
+ </>
+ }),
+ Err(err) => Ok(html! {
+ <p>{format!("Error fetching videos: {err}")}</p>
+ }),
+ }
+}

现在我们会在 App 组件中使用这个新组件。

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

// ...

html! {
- <h1>{ "RustConf Explorer" }</h1>
- <div>
- <h3>{ "Videos to watch" }</h3>
- <VideosList {videos} on_click={on_video_select} />
- </div>
- if let Some(video) = &*selected_video {
- <VideoDetails video={video.clone()} />
- }
+ <h1>{ "RustConf Explorer" }</h1>
+ <Suspense fallback={html! {<p> {"Loading..."} </p>}} >
+ <VideosFetcher
+ on_click={on_video_select}
+ selected_video={(*selected_video).clone()}
+ />
+ </Suspense>
}
}
备注

我们在 use_future 调用上使用 ? 来传递 suspension。
使用 suspense hooks 时,组件返回的是 HtmlResult,而不是 Html。父组件则通过 <Suspense fallback=...> 在数据获取期间显示加载提示。

现在,回到浏览器查看,一切本来应该会如预期运行……如果不是因为 CORS 的话。
要解决这个问题,我们需要一个代理服务器。幸运的是,trunk 已经提供了这个功能。

更新以下这一行:

- Request::get("https://yew.rs/tutorial/data.json")
+ Request::get("/tutorial/data.json")

现在,使用以下命令重新启动服务器:

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

刷新页面后,一切就应该能如预期运行。

由于数据加载速度很快,你可能看不到加载中的文字。如果你想看到它,可以在 use_future hook 中使用 TimeoutFuture 添加一段人为延迟。首先,将 gloo-timers 添加到 Cargo.toml:

gloo-timers = { version = "0.3", features = ["futures"] }

然后在 hook 中加入延迟:

let videos = use_future(|| async {
+ gloo_timers::future::TimeoutFuture::new(3_000).await; // 延迟 3 秒
Request::get("/tutorial/data.json")
.send()
.await?
.json::<Vec<Video>>()
.await
})?;

总结​

恭喜!您已经创建了一个从外部 API 获取数据并显示视频列表的 Web 应用程序。

接下来​

这个应用程序离完美或有用还有很长的路要走。在完成本教程后,您可以将其作为探索更高级主题的起点。

样式​

我们的应用程序看起来非常丑陋。没有 CSS 或任何样式。不幸的是,Yew 没有提供内置的样式组件。请查看 Trunk 的 assets,了解如何添加样式表。

更多依赖库​

我们的应用程序只使用了很少的外部依赖。有很多 crate 可以使用。请查看外部库以获取更多详细信息。

了解更多关于 Yew​

阅读我们的官方文档。它更详细地解释了许多概念。要了解有关 Yew API 的更多信息,请查看我们的API 文档。