mirror of
https://github.com/yewstack/yew.git
synced 2025-12-08 21:26:25 +00:00
585 lines
19 KiB
Plaintext
585 lines
19 KiB
Plaintext
---
|
||
title: '教程'
|
||
slug: /tutorial
|
||
---
|
||
|
||
## 介绍
|
||
|
||
在这个实践教程中,我们将学习如何使用 Yew 构建 Web 应用程序。
|
||
**Yew** 是一个现代的 [Rust](https://www.rust-lang.org/) 框架,用于使用 [WebAssembly](https://webassembly.org/) 构建前端 Web 应用程序。
|
||
Yew 通过利用 Rust 强大的类型系统,鼓励可重用、可维护和良好结构化的架构。
|
||
一个庞大的社区创建的库生态系统,称为 Rust 中的 [crates](https://doc.rust-lang.org/book/ch07-01-packages-and-crates.html),为常用模式(如状态管理)提供了组件。
|
||
Rust 的包管理器 [Cargo](https://doc.rust-lang.org/cargo/) 允许我们利用 [crates.io](https://crates.io) 上提供的大量 crate,例如 Yew。
|
||
|
||
### 我们将要构建的内容
|
||
|
||
Rustconf 是 Rust 社区每年举办的星际聚会。
|
||
Rustconf 2020 有大量的演讲,提供了大量的信息。
|
||
在这个实践教程中,我们将构建一个 Web 应用程序,帮助其他 Rustaceans 了解这些演讲并从一个页面观看它们。
|
||
|
||
## 设置
|
||
|
||
### 先决条件
|
||
|
||
这个教程假设您已经熟悉 Rust。如果您是 Rust 的新手,免费的 [Rust 书](https://doc.rust-lang.org/book/ch00-00-introduction.html) 为初学者提供了一个很好的起点,并且即使对于有经验的 Rust 开发人员来说,它仍然是一个很好的资源。
|
||
|
||
确保安装了最新版本的 Rust,方法是运行 `rustup update` 或者[安装 Rust](https://www.rust-lang.org/tools/install)。
|
||
|
||
安装 Rust 后,您可以使用 Cargo 运行以下命令安装 `trunk`:
|
||
|
||
```bash
|
||
cargo install trunk
|
||
```
|
||
|
||
我们还需要添加 WASM 构建目标,运行以下命令:
|
||
|
||
```bash
|
||
rustup target add wasm32-unknown-unknown
|
||
```
|
||
|
||
### 设置项目
|
||
|
||
首先,创建一个新的 cargo 项目:
|
||
|
||
```bash
|
||
cargo new yew-app
|
||
cd yew-app
|
||
```
|
||
|
||
为了验证 Rust 环境是否设置正确,使用 cargo 构建工具运行初始项目。
|
||
在关于构建过程的输出之后,您应该看到预期的 "Hello, world!" 消息。
|
||
|
||
```bash
|
||
cargo run
|
||
```
|
||
|
||
## 我们的第一个静态页面
|
||
|
||
为了将这个简单的命令行应用程序转换为一个基本的 Yew web 应用程序,需要进行一些更改。
|
||
|
||
```toml title="Cargo.toml" {7}
|
||
[package]
|
||
name = "yew-app"
|
||
version = "0.1.0"
|
||
edition = "2021"
|
||
|
||
[dependencies]
|
||
yew = { git = "https://github.com/yewstack/yew/", features = ["csr"] }
|
||
```
|
||
|
||
:::info
|
||
|
||
如果你只是正在构建一个应用程序,你只需要 `csr` 特性。它将启用 `Renderer` 和所有与客户端渲染相关的代码。
|
||
|
||
如果你正在制作一个库,请不要启用此特性,因为它会将客户端渲染逻辑拉入服务器端渲染包中。
|
||
|
||
如果你需要 Renderer 进行测试或示例,你应该在 `dev-dependencies` 中启用它。
|
||
|
||
:::
|
||
|
||
```rust ,no_run title="src/main.rs"
|
||
use yew::prelude::*;
|
||
|
||
#[function_component(App)]
|
||
fn app() -> Html {
|
||
html! {
|
||
<h1>{ "Hello World" }</h1>
|
||
}
|
||
}
|
||
|
||
fn main() {
|
||
yew::Renderer::<App>::new().render();
|
||
}
|
||
```
|
||
|
||
现在,让我们在项目的根目录创建一个 `index.html`。
|
||
|
||
```html title="index.html"
|
||
<!doctype html>
|
||
<html lang="en">
|
||
<head></head>
|
||
<body></body>
|
||
</html>
|
||
```
|
||
|
||
### 启动开发服务器
|
||
|
||
运行以下命令构建并在本地提供应用程序。
|
||
|
||
```bash
|
||
trunk serve --open
|
||
```
|
||
|
||
:::info
|
||
删除选项 '--open' 以在运行 `trunk serve` 后不打开默认浏览器。
|
||
:::
|
||
|
||
Trunk 将在您修改任何源代码文件时实时重新构建您的应用程序。
|
||
默认情况下,服务器将在地址 '127.0.0.1' 的端口 '8080' 上监听 => [http://localhost:8080](http://127.0.0.1:8080)。
|
||
要更改这部分配置,请创建以下文件并根据需要进行编辑:
|
||
|
||
```toml title="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 如下:
|
||
|
||
```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!` 的值
|
||
|
||
```rust ,ignore
|
||
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>
|
||
</>
|
||
}
|
||
```
|
||
|
||
刷新浏览器页面,您应该看到以下输出:
|
||
|
||

|
||
|
||
### 在标记中使用 Rust 语言结构
|
||
|
||
在 Rust 中编写标记的一个很大的优势是,我们在标记中获得了 Rust 的所有优点。
|
||
现在,我们不再在 HTML 中硬编码视频列表,而是将它们定义为 `Vec` 的 `Video` 结构体。
|
||
我们创建一个简单的 `struct`(在 `main.rs` 或我们选择的任何文件中)来保存我们的数据。
|
||
|
||
```rust
|
||
struct Video {
|
||
id: usize,
|
||
title: String,
|
||
speaker: String,
|
||
url: String,
|
||
}
|
||
```
|
||
|
||
接下来,我们将在 `app` 函数中创建这个结构体的实例,并使用它们来替代硬编码的数据:
|
||
|
||
```rust
|
||
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` 来实现:
|
||
|
||
```rust ,ignore
|
||
let videos = videos.iter().map(|video| html! {
|
||
<p key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
||
}).collect::<Html>();
|
||
```
|
||
|
||
:::tip
|
||
在列表项上使用键有助于 Yew 跟踪列表中哪些项发生了变化,从而实现更快的重新渲染。[始终建议在列表中使用键](/concepts/html/lists.mdx#keyed-lists)。
|
||
:::
|
||
|
||
最后,我们需要用从数据创建的 `Html` 替换硬编码的视频列表:
|
||
|
||
```rust ,ignore {6-10}
|
||
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` 组件拆分为更小的组件。我们首先将视频列表提取到自己的组件中。
|
||
|
||
```rust ,compile_fail
|
||
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 key={video.id}>{format!("{}: {}", video.speaker, video.title)}</p>
|
||
}).collect()
|
||
}
|
||
```
|
||
|
||
注意我们的 `VideosList` 函数组件的参数。函数组件只接受一个参数,该参数定义了它的 "props"("properties" 的缩写)。Props 用于从父组件传递数据到子组件。在这种情况下,`VideosListProps` 是一个定义 props 的结构体。
|
||
|
||
:::important
|
||
用于 props 的结构体必须通过派生实现 `Properties`。
|
||
:::
|
||
|
||
为了使上面的代码编译通过,我们需要修改 `Video` 结构体如下:
|
||
|
||
```rust {1}
|
||
#[derive(Clone, PartialEq)]
|
||
struct Video {
|
||
id: usize,
|
||
title: String,
|
||
speaker: String,
|
||
url: String,
|
||
}
|
||
```
|
||
|
||
现在,我们可以更新我们的 `App` 组件以使用 `VideosList` 组件。
|
||
|
||
```rust ,ignore {4-7,13-14}
|
||
#[function_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` 回调:
|
||
|
||
```rust ,ignore {4}
|
||
#[derive(Properties, PartialEq)]
|
||
struct VideosListProps {
|
||
videos: Vec<Video>,
|
||
+ on_click: Callback<Video>
|
||
}
|
||
```
|
||
|
||
然后我们修改 `VideosList` 组件以将所选视频传递给回调。
|
||
|
||
```rust ,ignore {2-4,6-12,15-16}
|
||
#[function_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`,当点击视频时才会显示。
|
||
|
||
```rust
|
||
use website_test::tutorial::Video;
|
||
use yew::prelude::*;
|
||
|
||
#[derive(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://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
|
||
</div>
|
||
}
|
||
}
|
||
```
|
||
|
||
现在,修改 `App` 组件以在选择视频时显示 `VideoDetails` 组件。
|
||
|
||
```rust ,ignore {4,6-11,13-15,22-23,25-29}
|
||
#[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://placehold.co/640x360.png?text=Video+Player+Placeholder" alt="video thumbnail" />
|
||
- </div>
|
||
</>
|
||
}
|
||
}
|
||
```
|
||
|
||
现在不用担心 `use_state`,我们稍后会回到这个问题。注意我们用 `{ for details }` 提取列表数据的技巧。
|
||
`Option<_>` 实现了 `Iterator`,所以我们可以使用特殊的 `{ for ... }` 语法来逐个显示 `Iterator` 返回的唯一元素,而这[由 `html!` 宏支持](concepts/html/lists)。
|
||
|
||
### 处理状态
|
||
|
||
还记得之前使用的 `use_state` 吗?那是一个特殊的函数,称为 "hook"。Hooks 用于 "hook" 到函数组件的生命周期中并执行操作。您可以在[这里](concepts/function-components/hooks/introduction.mdx#pre-defined-hooks)了解更多关于这个 hook 和其他 hook 的信息。
|
||
|
||
:::note
|
||
结构体组件的行为不同。请查看[文档](advanced-topics/struct-components/introduction.mdx)了解有关这些的信息。
|
||
:::
|
||
|
||
## 获取数据(使用外部 REST API)
|
||
|
||
在真实的应用程序中,数据通常来自 API 而不是硬编码。让我们从外部源获取我们的视频列表。为此,我们需要添加以下 crate:
|
||
|
||
- [`gloo-net`](https://crates.io/crates/gloo-net)
|
||
用于进行 fetch 调用。
|
||
- [`serde`](https://serde.rs) 和其派生特性
|
||
用于反序列化 JSON 响应
|
||
- [`wasm-bindgen-futures`](https://crates.io/crates/wasm-bindgen-futures)
|
||
用于将 Rust 的 Future 作为 Promise 执行
|
||
|
||
让我们更新 `Cargo.toml` 文件中的依赖项:
|
||
|
||
```toml title="Cargo.toml"
|
||
[dependencies]
|
||
gloo-net = "0.6"
|
||
serde = { version = "1.0", features = ["derive"] }
|
||
wasm-bindgen-futures = "0.4"
|
||
```
|
||
|
||
:::note
|
||
在选择依赖项时,请确保它们与 `wasm32` 兼容!否则,您将无法运行您的应用程序。
|
||
:::
|
||
|
||
更新 `Video` 结构体以派生 `Deserialize` 特性:
|
||
|
||
```rust ,ignore {1, 3-4}
|
||
+ use serde::Deserialize;
|
||
|
||
- #[derive(Clone, PartialEq)]
|
||
+ #[derive(Clone, PartialEq, Deserialize)]
|
||
struct Video {
|
||
id: usize,
|
||
title: String,
|
||
speaker: String,
|
||
url: String,
|
||
}
|
||
```
|
||
|
||
作为最后一步,我们需要更新我们的 `App` 组件,以便进行 fetch 请求,而不是使用硬编码的数据
|
||
|
||
```rust ,ignore {1,5-25,34-35}
|
||
+ 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((), 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 }
|
||
</>
|
||
}
|
||
}
|
||
```
|
||
|
||
:::note
|
||
我们在这里使用 `unwrap`,因为这是一个演示应用程序。在真实的应用程序中,您可能希望有[适当的错误处理](https://doc.rust-lang.org/book/ch09-02-recoverable-errors-with-result.html)。
|
||
:::
|
||
|
||
现在,查看浏览器,看看一切是否按预期工作……如果不是因为 CORS 的话。为了解决这个问题,我们需要一个代理服务器。幸运的是 trunk 提供了这个功能。
|
||
|
||
更新这些行:
|
||
|
||
```rust ,ignore {2-3}
|
||
// ...
|
||
- let fetched_videos: Vec<Video> = Request::get("https://yew.rs/tutorial/data.json")
|
||
+ let fetched_videos: Vec<Video> = Request::get("/tutorial/data.json")
|
||
// ...
|
||
```
|
||
|
||
现在,使用以下命令重新运行服务器:
|
||
|
||
```bash
|
||
trunk serve --proxy-backend=https://yew.rs/tutorial
|
||
```
|
||
|
||
刷新网页,一切应该按预期工作。
|
||
|
||
## 总结
|
||
|
||
恭喜!您已经创建了一个从外部 API 获取数据并显示视频列表的 Web 应用程序。
|
||
|
||
## 接下来
|
||
|
||
这个应用程序离完美或有用还有很长的路要走。在完成本教程后,您可以将其作为探索更高级主题的起点。
|
||
|
||
### 样式
|
||
|
||
我们的应用程序看起来非常丑陋。没有 CSS 或任何样式。不幸的是,Yew 没有提供内置的样式组件。请查看 [Trunk 的 assets](https://trunkrs.dev/assets/),了解如何添加样式表。
|
||
|
||
### 更多依赖库
|
||
|
||
我们的应用程序只使用了很少的外部依赖。有很多 crate 可以使用。请查看[外部库](/community/external-libs)以获取更多详细信息。
|
||
|
||
### 了解更多关于 Yew
|
||
|
||
阅读我们的[官方文档](../getting-started/introduction.mdx)。它更详细地解释了许多概念。要了解有关 Yew API 的更多信息,请查看我们的[API 文档](https://docs.rs/yew)。
|