Yew 前端与前后端共享代码¶
这一篇是第 4 章的下半场,兑现第 1 章就许下的愿:一门语言写前后端。 前半是 Rust 前端的完整技术栈——WebAssembly 目标、Trunk、Yew 组件的两种写法、 组件间通信、路由;后半是这件事最大的红利:同一份 DTO 和校验代码, 浏览器里跑一遍、服务器上再跑一遍。 主线例子延续上一篇:那张工单 #42,这次从「用户在浏览器里点开它」走到 「前后端用同一份规则校验它」。
1. 顶层全景¶
地基:rustup target add wasm32-unknown-unknown + cargo install trunk
↓
Yew 组件模型:函数组件(hook 管状态)↔ 结构体组件(生命周期 + 消息)
↓
通信三件套:props 下行 / callback 上行 / context 跨层
↓
服务层:gloo-net 发请求 → 消息回组件 → 视图重渲染;路由收尾
↓
共享层:shared/ 成员 = DTO + serde_valid 校验
↓
后端收口:ValidatedJson 提取器 + From<Model> 映射(密码=None)
图说:先是「Rust 也能写前端」,后是「写一遍,两端跑」——后者才是目的。
2. 地基:Wasm 目标与 Trunk¶
解决什么问题: 前端代码要编译成浏览器能跑的 Wasm 二进制,这是新的编译目标。
rustup target add wasm32-unknown-unknown # 装 Wasm 编译目标
cargo install --locked trunk # 装 Trunk:前端的部署与打包管家
trunk serve 起前端 dev server——像 cargo-watch 一样盯代码、变了就重编译
(书里吐槽它「勤快得过分」,改多了会慢,重启 dev server/语言服务器能复位)1。
前端不另开项目,就填进上一篇 workspaces 里建好的 frontend/ 成员2。
3. Yew:React 的亲缘框架¶
定位: 如果你写过任何 React 系库,Yew 会觉得熟悉——「把 Rust 和 React 的概念 打包成一个强力组合」就是它的设计意图3。前端渲染从后端挪到浏览器, 省请求、降延迟,界面更跟手4。
Yew 自己不裸奔,它站在两个「胶水」库上:wasm-bindgen(Rust 与 JavaScript 互操作)和 web-sys(浏览器 API 的 Rust 封装——书里的原话:它「不够 rusty」、 可能完全无视 Rust 惯例,但要摸元素、事件这些底层东西,别无选择)5。
3.1 前端的高层构件¶
书里给前端定的架构词汇(与后端的三件套呼应)6:
| 构件 | 是什么 |
|---|---|
| Components | 主构件;再分 Dialogs(弹窗)、Forms(表单组)、Wrappers(包装原生 HTML/CSS 元素) |
| Services | 与后端通信的简单接口 |
| Pages | 可被路由到的组件;一条路由对一个页面 |
Yew 不强制目录结构——React 的正统继承者都不强制——单职责是唯一硬规矩; 「发现自己在复制粘贴一段代码」就是该把它抽成组件的警报7。
还有个实用的泼冷水:Yew 的现成组件库大多不成熟(缺功能、API 不稳、没人维护), 当时最完整的是 Material Yew;多数时候你得自己写包装组件8。
3.2 函数组件与 hook:状态的借宿¶
最小组件长这样:
两个规则要记:组件必须返回 Html(用 html! 宏产生,它代表虚拟 DOM);
在标签里塞内容要加花括号——<h1>{ "Welcome User" }</h1>,直接写文本不行9。
输入靠属性(props):就是普通结构体,#[derive(Properties, PartialEq)],
按它分支渲染10。
问题来了:状态放哪? 函数组件讲「引用透明」——同样输入必得同样输出,
调几次都一样,所以函数返回后局部变量全没了。state(状态)需要借宿,
宿主就是 hook。let value = use_state_eq(|| checked); 让 Yew 替你记住值,
value.set(new_value) 更新它11。
3.3 组件间通信:下行 props,上行 callback¶
书里的通用规则,两行:
回调的写法分场合:函数组件里 Callback::from(闭包);结构组件里
ctx.link().callback(Msg::SomeMsg)(让回调以消息形式回流到 update)12。
属性还支持默认值:#[prop_or_default]、#[prop_or(true)] 这类宏,
用组件的人可以省略不重要的参数13。
一个性能提醒: 数据传来传去不是免费的,复杂页面里高频重渲染会拖慢应用——
所以 Yew 鼓励克隆便宜的不可变类型(引用计数类型:Rust 的 Rc/Arc,
Yew 自带的 IString 是字符串引用,还有向量、映射的对应物)14。
3.4 结构组件:生命周期与消息¶
解决什么问题: 函数组件装不下的复杂逻辑(内部状态、子组件事件驱动),
换结构组件——它有实例、有生命周期,OOP 背景的人更熟。要实现 Component trait,
关键成员:15
| 函数 | 什么时候被调 | 必填? |
|---|---|---|
| create | 创建时 | 是(可平凡) |
| update | 收到消息时;返回 bool 决定要不要重渲染 | 是 |
| changed | 外部属性变化时 | 否 |
| view | 返回 HTML | 是 |
| rendered / prepare_state / destroy | 渲染后 / 服务端渲染 / 卸载前 | 否(有默认实现) |
书里给的判据:如果你发现自己只写了薄薄的 create + view,回函数组件去; 结构组件是为「生命周期里要嵌入更多逻辑」准备的16。
3.5 消息与服务:页面怎么拿数据¶
模式定型(与 Elm、Actix 同宗):页面在 create 里发请求,然后忘掉它; 响应回来变成一条消息(update 处理),更新状态、返回 true、重渲染。 页面不等后端——先用默认值渲染,数据到了再补17:
enum Msg { FetchedTicket(TicketDto), }
fn create(ctx: &Context<Self>) -> Self {
TicketService::fetch(ctx.props().id,
ctx.link().callback(Msg::FetchedTicket)); // 回调 = 未来的消息
Self { ticket: TicketDto::default() } // 空票先渲染着
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
Msg::FetchedTicket(ticket) => { self.ticket = ticket; }
}
true // 要重渲染
}
调用还能链式接力:收到票后再去取它的负责人,拼成一个 ButtonLinkData
(label + 路由)喂给前面写的 ButtonLink 组件——API 返回 Option 时,
组件也吃 Option,空值渲染成占位按钮,「票还没指派项目」也是有效信息18。
服务层(gloo-net): TicketService::fetch(id, callback) 内部用
gloo-net(Rust & Wasm 工作组出品;除 HTTP 还支持 WebSocket 和 EventSource)
发请求,关键动作是 spawn_local:把异步任务spawn到当前线程,线程空闲时执行
——浏览器里没有真线程池,这是 Wasm 里的标准姿势19。
一个要当心的坑(书里手把手教的): .json() 反序列化会消费掉响应体,
失败就没得重试;而后端的规矩是「成功回资源、失败回 ApiError」——
前端事先不知道来的是哪种。解法:先取 text(),自己用
serde_json::from_str 反序列化;失败再试一次,这次按 ErrorResponse 解。
好处马上兑现:后端的校验错误也能直接反序列化成结构化对象,
前端拿去渲染成 UI 错误提示,不用自己拼字符串20。
3.6 context 与路由¶
解决什么问题: 数据只被「孙组件」需要,但按 props/callback 传, 中间的组件被迫替它拎包——这是 callback hell,违反单职责。 Yew 的方案叫 context:祖先提供,谁感兴趣谁自取,中间人零负担。 书里用它存「当前登录用户」的引用21。
路由满足「地址栏可书签」的直觉:<BrowserRouter> 包住应用,
里面放一个 <Switch<Route> render={switch} />;switch 是个普通函数,
对 Route 枚举做模式匹配,每条路由渲染对应页面;NotFound 路由兜底22。
4. 共享代码:这一章存在的理由¶
书里的开场白就是动机: 想做个表单,第一轮想在前端先校验一遍—— 不合格就不麻烦后端了。两端语言不同时,共享逻辑只能靠代码生成器复制, 容易脱节;Rust + Wasm 之后,再也不用复制了23。
但有一条硬限制(红字的注释): 不是所有 crate 都能进 Wasm——
依赖操作系统特性或系统库的(std::fs、chrono::Local、tokio 这些)
在浏览器沙箱里跑不了。共享代码必须挑「沙箱兼容」的依赖24。
4.1 DTO:共享的边界¶
实体( SeaORM 从 schema 生成)管数据库;DTO 管对外—— 「我们 chance 完全控制发给客户端什么」:加字段、删字段、按客户端需要裁剪; 页面先装满大框架、细节异步后补也完全可以25。
DTO 还是共享校验的落点:用 serde_valid crate 的宏给 DTO 挂上校验规则,
不够用时写自定义校验器——关注点干净分离26。看 UserDto:
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize, Validate)]
pub struct UserDto {
pub id: Option<u64>, // 新建时前端没有 id——可空
pub public_id: Option<Uuid>,
#[validate(min_length = 8)]
#[validate(max_length = 20)]
pub name: String,
pub username: Email,
#[validate(custom(UserValidation::password_validation))]
pub password: Option<String>, // 不该从后端传回前端——可空
#[validate(custom(UserValidation::role_validation))]
pub role: Option<UserRole>,
}
与实体的两处关键差异(书里点名):可空值(新建没 id、密码不回传)和 校验装饰(长度、自定义规则)27。
校验失败的响应长这样(后端把 serde_valid 的错误映射进 ApiError):
{
"code": "422 Unprocessable Entity",
"message": "Validation Error",
"origin": "validation_rejection",
"details": {
"errors": ["Passwords should match."],
"properties": {
"password": { "errors": ["Password should contain alphanumeric and special characters at a length range of 8-20."] },
"role": { "errors": ["Role should be one of the followings: Developer, Manager."] }
}
}
}
前端把同一份 DTO 拿去,校验错误也是同一格式——直接渲染成 UI 提示28。
4.2 后端收口:ValidatedJson 与 From 映射¶
后端要做的收尾两件事29:
- handler 签名换成 DTO + 校验提取器:
WithRejection<ValidatedJson<UserDto>, ApiError>——校验在进 handler 前就发生; - 实体 → DTO 的
From实现:
impl From<&Model> for UserDto {
fn from(value: &Model) -> Self {
Self {
id: Some(value.id),
name: value.name.to_owned(),
password: None, // 注意这一行:进了系统的密码,不再与任何人分享
role: UserRole::try_from(value.role.as_str()).ok(),
}
}
}
password: None 那行是书里的安全注脚:出于安全,密码其实应该存成不可读的
哈希格式,只能拿候选密码来比对——那是第 7 章的话30。
4.3 一句不能省的安全纪律¶
「不能因为有了和前端相同的校验,就在后端跳过校验。」 API 随时可能被前端以外的地方调用,后端必须自己有防线31。
共享校验省的是「规则漂移」(两端规则永远一致),省不掉「纵深防御」 (每一层都自己把关)。这两件事别混。
5. 主线例子走一遍:票 #42 的前端之旅¶
用户浏览器打开 /ticket/42
│ ① BrowserRouter 匹配到 Route::Ticket{id:42} → TicketPage
▼
TicketPage::create:先渲染空票占位;同时 TicketService::fetch(42, 回调)
│ ② spawn_local 异步发 GET /tickets/42(gloo-net)
▼
响应落地:先按 TicketDto 解;失败改按 ErrorResponse 解
│ ③ 成功 → 消息 FetchedTicket 进 update → 状态更新 → 重渲染
▼
视图渲染票详情;顺手再发一次请求取负责人 → ButtonLink(或空占位)
——用户点「改派」:表单里的 UserDto 先在前端过 serde_valid 校验,
不过就地在 UI 报错(同一份规则!);过了才 POST 到后端,
后端用同一个 DTO 再校验一遍(ValidatedJson),双层把关。
图说:同一份 DTO/校验代码,在浏览器沙箱和服务器各跑一遍——这是全章的落点。
(票号 42 与前篇一致,是为演示编的;消息名、路由名出自原文。)
6. 作者的判断与证据(分开标)¶
有出处的: wasm32 目标、Trunk、Yew 的组件/hook/context/路由 API 是原书实操代码; gloo-net 的消费响应体问题与 text() 双反序列化解法是原书原文1920。
作者自己的评估:
- 「函数组件起手,复杂再上结构组件」是 Yew 官方建议的转述 + 作者经验;
- 「组件库大多不成熟,多半自己写」是写书时的生态观察,今天需重查;
- 「不用另开前端项目、填 frontend 成员即可」是教学项目的简化,真实团队常有独立仓库。
7. 边界与局限¶
- 前端「值得单写一本书」: 本章只是导览——表单库、状态管理、SSR 都没展开; 参考实现在作者的 GitHub 仓库4。
- Wasm 沙箱限制是硬边界: 共享代码挑依赖时要逐 crate 确认沙箱兼容24; 时间、随机数、文件系统这类「系统味」的依赖都要找 Wasm 友好的替代。
- 乐观
unwrap()的坏毛病传染到了前端: 服务层示例里.unwrap()连发, 书里承认「乐观做法会失败,不过只会抛 JS 错误,应用不会崩」——教学可以,生产不行。 - 校验只是开头: 真正的密码哈希、会话管理,要等第 7 章。
8. 可带走的¶
主线例子一行走完: /ticket/42 路由命中 → 空票先渲染 → gloo-net 异步取数 →
消息回 update → 重渲染 → 链式取负责人 → 改派时同一份 DTO 校验前端先跑、
后端 ValidatedJson 再跑。
- Rust 前端三件套:
wasm32-unknown-unknown目标 + Trunk + Yew;trunk serve是前端的 cargo-watch。 - 组件通信只有两句话: 数据下行靠 props,事件上行靠 callback; 跨层共享用 context,别让中间组件拎包。
- 函数组件管展示,结构组件管生命周期; hook 是函数组件的状态宿主。
- Wasm 里发请求用
spawn_local+ gloo-net;.json()消费响应体, 要兼容「成功/错误两种体」就先取 text 再二次反序列化。 - DTO 是对外的合同: 可空设计(新建没 id、密码不回传)+ serde_valid 校验装饰。
- 同一份校验代码跑在两端, 错误格式天然一致,前端可直接渲染后端校验结果。
- 后端永不省略校验——共享规则 ≠ 共享防线。
- 进系统的密码不再出去(
password: None),以及它指向的下一章:哈希存储。
9. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| wasm32 目标与 Trunk | Tickets, Workflows, and Business Rules | text/36-fm-implementing-the-frontend.txt:13(搜「wasm32」)、:19(搜「trunk」)、:28(搜「trunk serve」) |
| Yew 定位与局限 | 同上 | text/36-fm-implementing-the-frontend.txt:37(搜「React-like」)、:43(搜「worth its own book」) |
| 前端高层构件 | 同上 | text/36-fm-implementing-the-frontend.txt:59(搜「Dialogs」)、:65(搜「Services」)、:67(搜「Pages」) |
| 复制粘贴警报 | 同上 | text/36-fm-implementing-the-frontend.txt:81(搜「alerting signal」) |
| 组件库不成熟 | 同上 | text/36-fm-implementing-the-frontend.txt:451(搜「Material Yew」) |
| wasm-bindgen 与 web-sys | 同上 | text/36-fm-implementing-the-frontend.txt:111(搜「Wasm-bindgen」)、:114(搜「Web-sys」) |
| 函数组件最小例 | 同上 | text/36-fm-implementing-the-frontend.txt:156(搜「function_component」)、:189(搜「virtual DOM」) |
| 花括号规则 | 同上 | text/36-fm-implementing-the-frontend.txt:205(搜「This is correct」) |
| props 结构体 | 同上 | text/36-fm-implementing-the-frontend.txt:220(搜「Properties」) |
| hook 与引用透明 | 同上 | text/36-fm-implementing-the-frontend.txt:289(搜「referential transparency」)、:367(搜「use_state_eq」) |
| 输入输出规则与两种 callback | 同上 | text/36-fm-implementing-the-frontend.txt:431(搜「receive input」)、:821(搜「Callback::from」) |
| 属性默认值宏 | 同上 | text/36-fm-implementing-the-frontend.txt:421(搜「sensible defaults」) |
| 不可变引用计数类型 | 同上 | text/36-fm-implementing-the-frontend.txt:818(搜「IString」) |
| 结构组件生命周期 | 同上 | text/36-fm-implementing-the-frontend.txt:462(搜「Component trait」)、:468(搜「asynchronous」) |
| create 发请求、消息回流 | 同上 | text/36-fm-implementing-the-frontend.txt:686(搜「beauty of asynchronous」)、:704(搜「fn create」)、:728(搜「fn update」) |
| ButtonLink 吃 Option | 同上 | text/36-fm-implementing-the-frontend.txt:680(搜「meaningful information」) |
| spawn_local 与 gloo-net | 同上 | text/36-fm-implementing-the-frontend.txt:839(搜「spawn_local」)、:887(搜「gloo-net」) |
| text() 二次反序列化 | 同上 | text/36-fm-implementing-the-frontend.txt:887(搜「consumes the response body」) |
| context 解决跨层 | 同上 | text/36-fm-implementing-the-frontend.txt:896(搜「higher-order components」)、:899(搜「logged-in user」) |
| BrowserRouter 与 Switch | 同上 | text/36-fm-implementing-the-frontend.txt:909(搜「BrowserRouter」)、:956(搜「NotFound」) |
| 「再也不用复制」 | 同上(Sharing code) | text/37-fm-sharing-code-between-backend-and-frontend.txt:4(搜「do not have to duplicate」) |
| Wasm 不兼容的 crate | 同上 | text/37-fm-sharing-code-between-backend-and-frontend.txt:7(搜「std::fs」) |
| DTO 的职责 | 同上 | text/37-fm-sharing-code-between-backend-and-frontend.txt:13(搜「full control」) |
| serde_valid 与 UserDto | 同上 | text/37-fm-sharing-code-between-backend-and-frontend.txt:16(搜「serde_valid」)、:22(搜「Validate」)、:68(搜「Optional values」) |
| 校验错误响应样例 | 同上 | text/37-fm-sharing-code-between-backend-and-frontend.txt:87(搜「422 Unprocessable Entity」)、:144(搜「validation_rejection」) |
| 后端不能跳过校验 | 同上 | text/37-fm-sharing-code-between-backend-and-frontend.txt:164(搜「cannot skip validation」) |
| ValidatedJson 与 From 映射 | 同上 | text/37-fm-sharing-code-between-backend-and-frontend.txt:197(搜「ValidatedJson」)、:212(搜「From<&Model>」)、:227(搜「password: None」) |
3.6 结构组件 ButtonLink 的逐行解剖(本章第一个完整组件)¶
书里用 ButtonLink(把链接渲染成按钮、空数据显示占位)演示了结构组件的 全部生命周期,值得逐行接住:59
pub struct ButtonLinkData<R: Routable> { label: IString, to: R } // 一个「值对象」
pub struct Props<R: Routable> { data: Option<ButtonLinkData<R>> } // 属性:可空!
pub struct ButtonLink<R: Routable> { data: Option<ButtonLinkData<R>> } // 组件自身状态
impl<R: Routable + 'static> Component for ButtonLink<R> {
type Message = (); // 本组件不发消息(纯展示)
type Properties = Props<R>;
fn create(ctx: &Context<Self>) -> Self {
Self { data: ctx.props().data.clone() } // 创建时把属性存进状态
}
fn changed(&mut self, ctx: &Context<Self>, _old: &Self::Properties) -> bool {
self.data = ctx.props().data.clone(); true // 外部属性变了:同步 + 重渲染
}
fn view(&self, _ctx: &Context<Self>) -> Html {
match self.data.clone() {
Some(ButtonLinkData { label, to }) => html! {
<Link<R> classes={classes!("button","is-small","is-info")} {to}>
{ html! {label} }
</Link<R>> },
None => html! { <span class={classes!("button","is-small","is-static")}>{ "None" }</span> },
}
}
}
三个教科书式的决定:属性打包成一个结构体(label 与 to 永不单独出现,
「把会一起用的字段捆成一个 prop,一次传入」);create 与 changed 都做
同一份拷贝(属性是外部世界,状态是自己的副本);Option 走视图分支
——API 返回的 Option 直接喂进来,「票还没指派项目」渲染成静态占位按钮,
「这是有效信息,不是错误」。泛型参数 <R: Routable> 则是因为要透明转发给
Yew 的 Link 组件——包装组件跟着被包装组件一起泛型化60。
3.7 消息机制与异步取数的完整时序(主线例子的前端半条)¶
把「页面怎么拿数据」按时序铺开(票 #42 的前端之旅,对应原书 4.22–4.27 各清单):61
TicketPage::create
├─ TicketService::fetch(42, ctx.link().callback(Msg::FetchedTicket))
│ └─ spawn_local: gloo-net 发 GET /tickets/42
│ 响应 text() → serde_json::from_str 按 TicketDto 解
│ 失败 → 按 ErrorResponse 再解一次(第 07 篇的二次反序列化)
│ 成功 → callback.emit(ticket)
│ └─ 即给组件发消息 Msg::FetchedTicket(ticket)
└─ Self { ticket: TicketDto::default() } // 空票先渲染,不等后端
update(Msg::FetchedTicket(ticket))
└─ self.ticket = ticket; true // 存状态,要求重渲染
view
└─ <p class="title">{ &ticket.title }</p> // 渲染到手
——链式接力:Msg::FetchedTicket 的处理里再发一次 fetch(负责人),
拿到后拼 ButtonLinkData{label, to: Route::User{id}} 喂给 <ButtonLink>.
图说:请求在 create 发起、在 update 落地、在 view 呈现——三个生命周期函数各管一段。
为什么状态默认值重要: TicketDto::default() 让页面立刻有东西可渲染
(空白详情页),数据到了再补——「异步的美妙:发完就忘,响应回来变成消息」。
这条「先占位、后灌水」的模式,正是第 5 章骨架章前端版的同款哲学62。
回调的两种写法再钉一次(书里给的经验法则):函数组件里
Callback::from(闭包);结构组件里 ctx.link().callback(Msg::X)——
「想让回调以消息形式回流组件时,用后一种」。混用的后果是回调发了但
update 永远收不到63。
3.8 路由深潜(Switch 函数与 NotFound)¶
Yew 路由的三件套再展开:BrowserRouter 包住应用(监听地址栏变化);
Switch<Route> render={switch} 把当前路由交给一个普通 Rust 函数;
该函数对 Route 枚举模式匹配,每个分支渲染对应页面;Route::NotFound
兜底「没有匹配项」——「所有重活 Yew 都包了」。深链接(bookmark)
之所以可行,是因为地址栏即状态;再叠上第 12 章 lighttpd 的
404→index.html,整条深链接链才闭合(服务端把未知路径交还前端路由)64。
4.4 共享层的完整目录(shared 成员里有什么)¶
把第 4 节的 DTO 收成一张「共享目录」——这是 workspaces 方案落到纸面的 样子,也是「一门语言两端」最真实的证据:32
| 文件 | 内容 | 两端怎么用 |
|---|---|---|
shared/src/dtos/user_dto.rs |
UserDto + serde_valid 校验装饰 | 前端表单校验、后端 ValidatedJson 复验 |
shared/src/dtos/login_dto.rs |
LoginDto(username/password/token/redirect) | 第 7 章登录流程的凭据载体 |
shared/src/validation/*.rs |
自定义校验器(密码强度、角色枚举) | serde_valid 的 custom 钩子 |
shared/src/error/*.rs |
ErrorResponse 结构 | 前端二次反序列化的目标类型 |
被 Wasm 兼容筛掉的依赖(第 7 篇的限制在这里变现):共享代码不许碰
std::fs、chrono::Local、tokio——「沙箱不允许浏览器访问这些」。
校验逻辑恰好是沙箱安全的(String 进 Result 出,纯计算),
这正是它成为共享首选的原因——选共享内容时,「沙箱兼容」是第一道筛子33。
4.5 校验错误的两端呈现(同一格式,两种皮肤)¶
书里图 4.4 的那张对照值得转述:同一个 422 校验响应,后端直接回;
前端把 details.properties.password.errors 数组逐条渲染成表单下方的
红字。「两种校验产生同一格式,可直接渲染为 UI 错误消息」——
翻译成本为零,因为根本不存在翻译:两端跑的是同一段校验代码,
连错误文案的字串都是同一个编译产物34。
边界情况的一句提醒: 用户可能绕过前端直接 curl API——「后端 API 随时可能被前端以外的地方调用,所以必须有健康的防御」。 共享校验解决「规则漂移」,不解决「纵深防御」;这两笔账别记混(第 4 节 已立,此处再敲)35。
5.1 前端性能观:引用计数类型为什么反复出现¶
IString/Rc/Arc 在本章已出现三次(属性、数据源、状态),值得把
道理说透:Yew 组件树重渲染时,属性要从父传到子;传大结构体 =
深拷贝 = 卡顿。引用计数类型的克隆只是「复制一个指针」,
「Yew 鼓励克隆便宜的不可变类型」;IString 是字符串的引用、IArray 是
数组的引用,向量、映射各有对应物。前端性能的很大一部分,就是「别在
渲染路径上做深拷贝」36。
6. 作者判断与边界(补记)¶
有出处的: ButtonLink 全清单、消息时序、二次反序列化、gloo-net 的 消费响应体问题、Wasm 不兼容清单,均出自原书代码清单与注释37。
作者自己的评估: 「组件库大多不成熟」是写书时的生态观察(2026 年
请重查);「前端实现细节进仓库不进书」是一以贯之的取舍;乐观 unwrap()
出现在服务层示例里,作者自己标注了「乐观做法会失败,但不破坏应用」——
教学可过,生产必改38。
3.9 本章前端概念的一张总表(七词表)¶
读完本章,这七个词应当能各用一句话向别人解释(依据原书各节):39
| 词 | 一句话 |
|---|---|
| 函数组件 | 一个返回 Html 的函数;状态要靠 hook 借宿 |
| 结构组件 | 实现 Component trait 的结构体;有生命周期、能收消息 |
| props | 父传子的输入,普通结构体 + #[derive(Properties)] |
| callback | 子报父的输出;函数组件 Callback::from,结构组件 link().callback(Msg::…) |
| context | 跨层共享的免拎包通道(登录用户就住这里) |
| hook(use_state_eq/use_reducer) | 函数组件的状态宿主;Reducible 让状态变更成「派发新状态」 |
| portal | 把内容注入远端 DOM 宿主、事件还能回流的通道(通知/弹窗) |
本章与下一章的接缝: 前端管「呈现与交互」,共享层管「合同(DTO 与 校验)」,后端管「真相」。第 5 章的 Page 概念(一个路由一个页面、 页面经 services 发请求)到这里就完整了;第 7 章(访问控制)会在这个 架构上加两件东西:全局 identity(AppState)和「没身份就重定向」的 路由守卫——组件通信三件套(props/callback/context)全数返岗40。
3.2 函数组件的完整语法账(html! 宏的使用纪律)¶
html! 是 Yew 的核心宏:产出 Html 类型(虚拟 DOM 的一棵子树)。
书里从 hello-world 到 ButtonLink,纪律一共四条:41
- 组件必须返回 Html——裸字符串不行,
html! { "Hello world" }才合法; - 标签内嵌内容要加花括号——
<h1 class="title is-1">{ "Welcome User" }</h1>; 直接写文本是编译错误(这是新手第一个坑); if/for/match都可以嵌进宏里,只要外面包花括号; 列表渲染就是「迭代器 map 出 Html +{ for iter }」;- 空标签
<></>是「什么都不渲染」的占位——None分支兜底用。
列表渲染的标准姿势(书里 projects 列表示例的骨架):42
let rows = self.list.iter().map(|dto| match dto.id {
Some(id) => html! { <tr><td><Link<Route> to={Route::Project{id}}>{dto.summary.clone()}</Link></Route></td></tr> },
None => html! { <></> },
});
html! { <tbody>{ for rows }</tbody> }
空数据的礼貌提示与列表同堂:「if self.list.is_empty() 时渲染
<em>{ "No projects yet" }</em>——好习惯,数据集再复杂也保持」。
这条小纪律在第 5 章(表格数据源的空态文案)还会以 empty_label 的
形式正式登场43。
3.3 属性(props)的完整机制(从结构体到默认值)¶
props 就是普通 Rust 结构体,三个要件:#[derive(Properties, PartialEq)]、
字段全 pub、组件函数签名收 &Props。两个进阶件:44
- 属性打包:「不会单独使用的字段捆成一个结构体」——ButtonLinkData {label, to} 作为 Props 的单个字段,传一次全到;
- 默认值:
#[prop_or_default](类型默认)、#[prop_or(true)]、#[prop_or(AttrValue::from(""))]——「给组件使用者弹性:想覆盖就传, 不想就省」。书里的 Checkbox 组件一口气用了四个默认值宏, 函数组件与结构组件通用。
属性与状态的分工(再敲一次): props 是「别人给我的」,状态是
「我自己记的」;结构组件要在 create 里把 props 拷进状态、在 changed 里
跟着刷新——「外部属性变了,组件要有反应」(changed 返回 true = 要
重渲染)45。
3.5 结构组件的七函数生命周期(完整表)¶
| 函数 | 必填 | 干什么 |
|---|---|---|
| create | 是 | 出生:把 props 存成状态 |
| view | 是 | 画自己:返回 Html |
| update | 是 | 收消息:返回 bool 决定重渲染 |
| changed | 否 | 外部 props 变了,同步 + 决定重渲染 |
| rendered | 否 | 渲染后、浏览器更新前(测 DOM 用) |
| prepare_state | 否 | 服务端渲染的状态制造 |
| destroy | 否 | 卸载前(清理资源用) |
「发现只有薄薄的 create + view 时,回函数组件去」——这句判定是 组件选型的一票否决;消息机制(update + Msg 枚举)只在「子组件事件 要驱动内部状态」时才值回它的复杂度46。
4.6 后端调整的三步全账(实体到 DTO 的完整迁移)¶
把第 4.2 节的两段代码扩成三步手术(原书 Code listing 4.33/4.34 的 完整上下文):47
手术前:handler 直接收 entity::users::Model
│ 问题:① 校验规则无处挂(实体是生成的)② 密码字段会原样流出
▼
第一步:handler 签名换成 DTO + 校验提取器
WithRejection(ValidatedJson(model), _): WithRejection<ValidatedJson<UserDto>, ApiError>
—— 校验在进 handler 之前发生,失败自动落统一 422
▼
第二步:给 UserDto 实现 From<&Model>
id: Some(value.id) // 实体有 id,DTO 也有
name: value.name.to_owned() // 字符串搬过去
password: None // ★ 进系统的密码,不再与任何人分享
role: UserRole::try_from(value.role.as_str()).ok() // 字符串 → 枚举,失败即 None
▼
第三步:handler 体内 ActiveModel 照旧(写入路径不变),
返回路径 Model → DTO(From 隐式调用)→ Json<UserDto>
图说:读路径过 DTO(裁剪 + 脱敏),写路径过 ValidatedJson(校验);
ActiveModel 只在体内当「施工队」。
这次迁移顺带解决的三个隐藏问题: 密码永不流出(第 43 行那行 None);
角色从裸字符串变成枚举(类型安全 + 校验自定义器的落点);实体的生成
代码从此可以整目录重新生成而不用担心覆盖手工改动——「生成的归生成,
手写的归 DTO」,两边互不踩踏48。
5.2 本章与 Wasm 限制的合流(为什么校验代码恰好能共享)¶
第 7 篇的 Wasm 限制清单(std::fs、chrono::Local、tokio 进不了沙箱)
在本章变现为「共享目录的准入审查」:serde_valid 的校验是纯计算
(String 进、Result 出、无系统调用),天然沙箱安全。反例也在书里:
想把「当前时间戳填进 DTO」这类逻辑共享,就得找 Wasm 兼容的替代
(js-sys 的时间或传参带入)——共享内容的选择,就是被沙箱边界
筛选的过程49。
3.10 gloo-net 发请求的完整账(从 URL 到回调)¶
TicketService::fetch(id, callback) 一行签名背后的五步(原书 Code
listing 4.28 的展开):50
- spawn_local 把异步块挂到当前线程的任务队列——「线程空闲时执行, 永远在反复循环的某次结尾」;Wasm 里没有真线程池,这是标准姿势;
- 拼 URL:
format!("{}{}/{}", get_api_url(), TICKETS_ENDPOINT, id)——api 基址来自配置,端点与 id 依次拼接; - Request::get(...).send().await 发出请求;
- 取 text() 再 serde_json::from_str(而非直接 .json())—— 「.json() 消费响应体,失败没得重试;先取文本,按 TicketDto 解, 失败再按 ErrorResponse 解」——这一个技巧支撑了后端统一错误格式的 前端消费(校验错误也能结构化拿到);
- callback.emit(ticket) 把结果递回组件(在结构组件里,
callback 就是
ctx.link().callback(Msg::…)造的)。
错误路径的同构性: 后端回什么,前端就能解什么——成功体是 DTO,
错误体是 {code, message, origin[, details]};两端共享的不只是
校验规则,还有错误协议的词汇表。这是「一门语言两端」最日常的红利:
跨语言的 API 消费要写两套反序列化 + 一套错误翻译,这里一套全包51。
6. 总结:这一篇在全书里的位置¶
06(后端)与 07(前端)合起来是第 4 章的完整拆解:06 把「业务怎么建模、 数据怎么流、错误怎么归一」定型;07 把「界面怎么组、通信怎么接、规则怎么 共享」定型。两篇的交汇点是 shared 成员——前后端的分界线不在语言 (都是 Rust),不在进程(同一仓库),而在职责(后端管真相,前端管呈现)。 这条分界线画得直不直,决定了第 5 章以后的每一次功能加法(分页、权限、 通知、实时)是「一层加一行」还是「全栈翻新」。本书用三章把它画直了, 这是「用一本书做一个项目」相对「用二十篇博客讲碎片」的最大优势。52
7.4 本章一页复习(前端半章的最终形态)¶
地基:rustup target add wasm32-unknown-unknown + cargo install trunk
组件:函数组件(hook 借宿)↔ 结构组件(生命周期 + 消息)
通信:props 下行 / callback 上行 / context 跨层
取数:services(gloo-net)→ 消息回流 → 视图(先占位后灌水)
路由:BrowserRouter + Switch 函数 + NotFound 兜底
呈现:portal 注入宿主,事件回流
共享:shared 成员的 DTO + serde_valid —— 前后端同一份合同
图说:左半是 Yew 世界的秩序,右半是「一门语言红利」的兑现处。
交接: 第 5 章(本组 08 篇)会在这个前端上加「大数据量」的九种兵器; 第 7 章(本组 10 篇)加全局 identity 与路由守卫;第 12 章(本组 16 篇) 把服务的「问一次」升级成「服务器推」。每一章的加法都落在这张图的 某个格子里,从不需要重画整张图。53
8. 本篇卷尾(一句话版)¶
前端半章的全部贡献,是把「界面怎么组、通信怎么接、规则怎么共享」 落到 Yew 与 shared 成员上;与后端半章(06)合卷后,一条职责清晰的分界线 贯穿全栈——这条线画得直,后面八章的加法才都是「一行式」。54
7.5 本篇卷尾补记(组件通信纪律的日常用法)¶
「props 下行、callback 上行、context 免拎包」三条,在加任何新组件前 都值得问一遍:这个数据是谁给谁的?穿过了几个不该被打扰的中间层? 问完再写,比写完再拆回调地狱省力一个数量级。55
7.6 本篇卷尾补记(共享合同的红线)¶
shared 成员立起来之后,往里加代码前问一句:它沙箱兼容吗(std::fs、 tokio、本地时钟都进不去)?它有敏感面吗(密码、内部实体)?两个「否」 再动手——共享的红利越大,准入的闸门越要窄。56
7.7 本篇卷尾补记(前后端边界测试)¶
「后端管真相、前端管呈现」这条线画得直不直,有个一分钟测试:随便挑 一个业务规则,问它在哪一端强制执行?答得出且只有一处,线就是直的; 答不出或两端都在查,就有重复或漏洞——共享 DTO 与共享校验的存在, 正是为了让答案永远是「后端一处,前端体验层」。57
7.8 本篇卷尾补记(共享合同的最小清单)¶
新建 DTO 时的最小清单:可空字段标清楚(id/password 这类)、校验装饰 挂上(serde_valid)、From<&Model> 补上(读路径)、密码与敏感字段 显式置 None(脱敏)。四步走完,DTO 才配进 shared 目录。58
-
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:13,搜「wasm32」;:19,搜「trunk」;:31,搜「zealously」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:25,搜「do not actually create a separate project」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:37,搜「React-like」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:40,搜「significantly speed up」)、:43(搜「worth its own book」)。 ↩↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:111,搜「Wasm-bindgen」)、:114(搜「less rusty」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:59,搜「Dialogs」;:65,搜「Services」;:67,搜「Pages」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:81,搜「alerting signal」)、:96(搜「delusory」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:451,搜「Material Yew」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:156,搜「function_component」)、:189(搜「virtual DOM」)、:201(搜「This is correct」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:220,搜「Properties」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:289,搜「referential transparency」)、:367(搜「use_state_eq」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:431,搜「receive input」)、:821(搜「Callback::from」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:421,搜「sensible defaults」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:818,搜「IString」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:462,搜「Component trait」)、:468(搜「Elm and Actix」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:488,搜「better off using function components」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:686,搜「beauty of asynchronous」)、:704(搜「TicketService::fetch」)、:728(搜「return true」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:680,搜「Option value」)、:779(搜「chain」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:839,搜「spawn_local」)、:887(搜「gloo-net」)。 ↩↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:887,搜「consumes the response body」)。 ↩↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:896,搜「providing context」)、:899(搜「logged-in user」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:909,搜「BrowserRouter」)、:956(搜「NotFound」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:4,搜「do not have to duplicate」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:7,搜「std::fs」)。 ↩↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:13,搜「full control」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:16,搜「serde_valid」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:22,搜「Validate」)、:68(搜「Optional values」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:87,搜「422 Unprocessable Entity」)、:172(搜「same format」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:197,搜「ValidatedJson」)、:188(搜「change the function signatures」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:227,搜「password: None」)、:245(搜「store it encrypted」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:164,搜「cannot skip validation」)。 ↩ -
目录结构依据原书 Code listing 4.31/4.33/4.34 的文件路径(shared/src/dtos/、backend/src/api/resources/)。 ↩
-
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:7,搜「std::fs」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:172,搜「same format」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:164,搜「cannot skip validation」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:818,搜「cheap to clone」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:671,搜「Code listing 4.21」)、:887(搜「consumes the response body」);「Sharing code」:7(搜「std::fs」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:887,搜「optimistic approach」)。 ↩ -
各词定义散见原书 Technical concepts offered by Yew、Using messages、Sharing state、Routing 诸节。 ↩
-
接缝判断为我们的归纳:AppState/identity/路由守卫见原书第 7 章 Proper frontend and backend security filtering 一节(本组文档 10-access-control.md)。 ↩
-
纪律见「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:189,搜「virtual DOM」)、:201(搜「This is correct」)。 ↩ -
出处:「Implementing the frontend」(
text/42-fm-introduction.txt:170,搜「iter()」)、:264(搜「for projects」)。 ↩ -
出处:「Implementing the frontend」(
text/42-fm-introduction.txt:240,搜「No projects yet」)、:292(搜「polite message」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:220,搜「Properties」)、:421(搜「sensible defaults」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:611,搜「changed」)。 ↩ -
七函数表见「Struct components」(
text/36-fm-implementing-the-frontend.txt:462,搜「Component trait」);判定见:488。 ↩ -
步骤见「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:197,搜「ValidatedJson」)、:212(搜「From<&Model>」)、:227(搜「password: None」)。 ↩ -
三个问题的解决为我们的归纳;机制出处同脚注 195 及
:245(搜「store it encrypted」)。 ↩ -
出处:「Sharing code between backend and frontend」(
text/37-fm-sharing-code-between-backend-and-frontend.txt:7,搜「std::fs」)。 ↩ -
五步见「Services and the gloo-net crate」(
text/36-fm-implementing-the-frontend.txt:839,搜「spawn_local」)、:887(搜「consumes the response body」)。 ↩ -
同构性为我们的归纳;错误协议见
text/35-fm-implementing-the-backend.txt:1117(搜「db_error」)。 ↩ -
位置总结为我们的归纳;shared 职责分界见
text/37-fm-sharing-code-between-backend-and-frontend.txt:4。 ↩ -
复习图与交接为我们的归纳;各加法见对应拆解篇。 ↩
-
卷尾为我们的归纳;分界线见本篇 §6。 ↩
-
补记为我们的用法建议;三条纪律见本篇 §3.3 与原书「Using messages」一节。 ↩
-
红线见本篇 §4.4 与原书
text/37-fm-sharing-code-between-backend-and-frontend.txt:7。 ↩ -
测试法为我们的产品化建议;职责分界见本篇 §6 与
06§5。 ↩ -
清单为我们的产品化整理;四要件见本篇 §4.2 与 §4.6。 ↩
-
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:671,搜「A component to create links」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:674,搜「single type to be used」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:707,搜「TicketService::fetch」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:686,搜「beauty of asynchronous」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:821,搜「Callback::from」)。 ↩ -
出处:「Implementing the frontend」(
text/36-fm-implementing-the-frontend.txt:909,搜「BrowserRouter」)。 ↩