本文是对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 trunk、cargo install --locked wasm-bindgen-cliwasm32-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 help 或 trunk help <子命令>。
恭喜
现在你已经配置好了Yew的开发环境,并且构建了第一个Yew Web应用, 恭喜你!
构建HTML
Yew使用Rust的过程宏来提供一种与JSX类似的语法来编写标签。
转换标准HTML
我们已经对网页展示内容有了一些想法,现在可以通过html!宏将草图转为具体的实现。如果你能编写简单的HTML页面,
那你将对在html!中编写语句毫无问题。但要记住,在某些地方,html!和标准的HTML有些区别:
- 表达式必须被大括号包裹(
"some string"也是一个表达式,需要将字符串放在引号中) - 只能有一个根节点,如果有多个元素,可以设置一个空的根节点(
<> elements </>) - 标签需要正确的关闭
我们需要开发的页面在标准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>
</>
}
刷新浏览器页面,页面将如下所示:
在标签语言中使用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中获取到的,而不是硬编码在代码中。下面就让我们从外部资源中获取数据。为了实现这个功能,我们需要添加下面这些包:
gloo-net用来发起请求serdewith derive features 用来反序列化JSON结果wasm-bindgen-futures用来执行Rust中的其他功能,比如 Promise
更新依赖配置文件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 样式学习如何增加样式。
其他的库
这个应用只使用了很少的外部依赖,还有很多库可以使用,详见外部依赖来查看更多细节。