Yew 服务端渲染(SSR)完整指南:ServerRenderer、Suspense 数据获取与 Hydration 实战
Yew 服务端渲染(SSR)完整指南:ServerRenderer、Suspense 数据获取与 Hydration 实战
导读
本文基于 Yew 官方 0.21 版本文档中的服务端渲染(Server-side Rendering, SSR)专题,系统讲解 Yew 应用如何在服务端预渲染出 HTML:从 ServerRenderer 的基础用法、函数组件与 Struct 组件在 SSR 下的生命周期差异,到利用 <Suspense /> 优雅解决服务端数据获取,再到通过 Renderer::hydrate 将客户端 Virtual DOM 与静态 HTML 拼接为可交互页面。读完本文,你将掌握一套完整、可落地的 "SSR + Hydration" 架构,并能在仓库源码与示例中找到每一处机制的实现依据。
为什么需要服务端渲染:客户端渲染的两大痛点
默认情况下,Yew 组件在客户端渲染(CSR)。当用户访问网站时,服务器只发送一个不含实际内容的 HTML 骨架文件和一个 WebAssembly 包到浏览器,页面完全由浏览器端加载的 WASM 包负责渲染。
这种方案对大多数网站都能正常工作,但存在两个明显问题:
- 首屏等待:在 WebAssembly 包完整下载并完成初次渲染之前,用户什么都看不到。对于慢速网络下的用户,这是非常糟糕的体验。
- SEO 不利:部分搜索引擎不支持动态渲染的网页内容;即使支持,也通常会给予动态网站较低的搜索排名。
服务端渲染正是为了解决这两类问题:让服务器直接输出包含内容的 HTML,用户与搜索引擎爬虫在收到响应时即可看到完整页面。
如何工作:ServerRenderer 基础用法
Yew 提供了 ServerRenderer 组件(结构体)用于在服务端渲染页面。最简用法是:用 ServerRenderer::<App>::new() 创建渲染器,然后 await 调用 renderer.render(),将 <App /> 渲染成一个 String:
use yew::prelude::*;
use yew::ServerRenderer;
#[function_component]
fn App() -> Html {
html! {<div>{"Hello, World!"}</div>}
}
// 我们使用 `flavor = "current_thread"` 以便该代码片段可以在 CI 中测试,
// CI 里测试运行在 WASM 环境中。实际项目中你更可能希望使用(默认的)
// `multi_thread` 形式,即:
// #[tokio::main]
#[tokio::main(flavor = "current_thread")]
async fn no_main() {
let renderer = ServerRenderer::<App>::new();
let rendered = renderer.render().await;
// 输出: <div>Hello, World!</div>
println!("{}", rendered);
}
从当前仓库源码 server_renderer.rs 可以看到,ServerRenderer 会将渲染任务派发到 Yew 的 Runtime 上执行(spawn_rendering_task),通过 oneshot 通道取回结果;而 LocalServerRenderer(同一文件 server_renderer.rs)则直接在当前线程内渲染,不自行启动运行时。ServerRenderer 还提供了三个值得了解的配套能力:
render_to_string(w: &mut String):把渲染结果追加到已存在的String中,便于拼接页面骨架。render_stream():以Stream<Item = String>形式流式输出 HTML 片段,适合在 Web 框架中逐块写回响应。with_props(create_props):为根组件传入自定义Properties(属性闭包只需Send,属性本身无需Send);另有with_runtime(rt)可指定渲染任务运行的Runtime。hydratable(val: bool):设置输出是否包含用于水合(hydration)的附加信息,默认值为true。
注意:ServerRenderer 与 LocalServerRenderer 均在 ssr 特性(feature)下编译(见 Cargo.toml),使用时需在依赖中开启:
yew = { path = "../../packages/yew", features = ["ssr"] }
组件生命周期与 SSR:函数组件为主,慎用 Struct 组件
推荐使用函数组件
在服务端渲染场景下,官方推荐使用函数组件。除 use_effect(以及 use_effect_with)之外的所有 Hooks 都会在组件首次成功渲染为 Html 之前正常工作。
⚠️ 警告:服务端没有 Web APIs
当组件在服务端渲染时,web_sys 等 Web API 不可用。一旦尝试使用,应用会直接 panic。由于 effects 在服务端渲染期间不会执行,你应该把依赖 Web API 的逻辑隔离到 use_effect 或 use_effect_with 中——这正是二者在服务端被"跳过"的意义所在,也是区分"纯渲染逻辑"与"浏览器副作用"的天然边界。
危险:Struct 组件
虽然 Struct 组件理论上也能用于 SSR,但存在两个隐患:
- 缺少函数组件
use_effect那样明确的"客户端安全逻辑"边界,且生命周期事件(如view、rendered)的调用顺序与客户端不同; - Struct 组件会持续接收消息,直到其所有子组件都渲染完成且
destroy方法被调用。开发者必须保证任何可能传给组件的消息都不会触达依赖 Web API 的逻辑。
因此,在设计支持 SSR 的应用时,除非有充分理由,否则一律优先使用函数组件。
SSR 中的数据获取:用 <Suspense /> 取代反复渲染
数据获取是 SSR 与 Hydration 中最棘手的环节之一。
组件渲染时本应立刻产出 Virtual DOM,这在组件不需要取数时没有问题。但组件若想渲染期间获取数据呢?过去 Yew 没有机制探测组件是否仍在取数,只能由数据客户端自行实现方案:记录初次渲染期间发起了哪些请求,等请求完成后再触发第二次渲染;服务器反复执行这一过程,直到某次渲染不再新增挂起请求,才返回响应。
这种方案有两个缺陷:一是反复渲染浪费 CPU;二是数据客户端还必须想办法让服务端取到的数据在 Hydration 阶段可用,以保证初次渲染产出的 Virtual DOM 与 SSR 产出的 DOM 树一致,实现难度很高。
Yew 采用了不同的思路——用 <Suspense /> 解决:
- 客户端:
<Suspense />是一个特殊组件,在组件取数期间(即"挂起"状态)显示 fallback 占位 UI,取数完成后恢复为正常 UI; - 服务端:Yew 会一直等待,直到组件不再挂起,才将其序列化进输出字符串;
- Hydration 阶段:
<Suspense />内的元素会保持"未水合"(dehydrated)状态,直到其所有子组件都不再挂起。
开发者因此几乎不需要额外工作,就能构建出"客户端无关"、开箱即用 SSR 的数据获取应用。
底层机制:Suspension
挂起机制的核心类型是 Suspension(见 suspension.rs)。它内部维护 id、监听器列表与 resumed 原子标志:Suspension::from_future 会把一个 Future 派发到本地任务(spawn_local),Future 完成后调用 SuspensionHandle::resume() 唤醒所有监听者;Suspension 本身实现了 Future,未恢复时返回 Poll::Pending。相关的 use_future / use_future_with Hook(见 hooks.rs)返回 SuspensionResult<UseFutureHandle<O>>,未就绪时以 Err(Suspension) 形式让组件挂起。
<Suspense /> 组件的 Props 只有两个:children(内容)与 fallback(占位 UI),见 component.rs。注意约束:"你不能在一个作为 fallback 渲染的组件里再次挂起"(见 component.rs)。
实战示例:use_prepared_state! 取数
仓库中的 simple_ssr 示例 完整演示了 "Suspense + use_prepared_state" 的数据获取模式:Content 组件在服务端用 use_prepared_state! 发起一个真实的 HTTP 请求(请求 https://httpbingo.org/uuid 获得随机 UUID),外层用 <Suspense> 包裹并提供 fallback:
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use yew::prelude::*;
#[derive(Serialize, Deserialize)]
struct UuidResponse {
uuid: Uuid,
}
#[cfg(feature = "ssr")]
async fn fetch_uuid() -> Uuid {
let client = reqwest::Client::builder()
.user_agent("yew-simple-ssr-example")
.build()
.unwrap();
let resp = client
.get("https://httpbingo.org/uuid")
.send()
.await
.unwrap()
.error_for_status()
.unwrap();
let uuid_resp = resp.json::<UuidResponse>().await.unwrap();
uuid_resp.uuid
}
#[function_component]
fn Content() -> HtmlResult {
let uuid = use_prepared_state!((), async move |_| -> Uuid { fetch_uuid().await })?.unwrap();
Ok(html! {
<div>{"Random UUID: "}{uuid}</div>
})
}
#[function_component]
pub fn App() -> Html {
let fallback = html! {<div>{"Loading..."}</div>};
html! {
<Suspense {fallback}>
<Content />
</Suspense>
}
}
use_prepared_state! 是过程宏(实现见 use_prepared_state.rs):宏要求闭包必须显式标注返回类型(该类型在客户端剥离闭包时用于生成占位代码);服务端构建时生成 use_prepared_state_with_suspension 调用并执行闭包,客户端构建时则通过 to_token_stream_without_closure 彻底剥离取数闭包,只读取服务端序列化好的数据。这样服务端取到的 UUID 会被嵌入 HTML 并随水合过程送达客户端,客户端首屏渲染不发起任何额外请求。
SSR Hydration:把静态 HTML 接上 Virtual DOM
Hydration 是把 Yew 应用"连接"到服务端生成的 HTML 文件的过程。默认情况下,ServerRenderer 输出的是可水合的 HTML 字符串——其中包含便于 Hydration 的附加信息。当客户端调用 Renderer::hydrate 时(见 renderer.rs),Yew 不再从零渲染,而是将应用产出的 Virtual DOM 与服务端渲染器生成的 HTML 字符串进行对账(reconcile)。
客户端示例:
use yew::prelude::*;
use yew::Renderer;
#[function_component]
fn App() -> Html {
html! {<div>{"Hello, World!"}</div>}
}
fn main() {
let renderer = Renderer::<App>::new();
// 水合 body 元素下的所有内容,移除多余的尾部元素(如果有)。
renderer.hydrate();
}
⚠️ 注意事项一:Virtual DOM 布局必须完全一致
要成功水合由 ServerRenderer 创建的 HTML 表示,客户端产出的 Virtual DOM 布局必须与 SSR 时完全一致,包括那些不包含任何元素的组件。如果某个组件只在其中一端有意义,可以用 PhantomComponent 来填补多出的组件位置。
⚠️ 注意事项二:HTML 必须规范
Hydration 只有在"浏览器对 SSR 静态 HTML 做初次渲染后的真实 DOM 与预期 DOM 一致"时才能成功。如果 HTML 不符合规范,Hydration 可能失败——浏览器会自行改写不正确的 HTML 结构,导致真实 DOM 与预期 DOM 不一致。典型例子是:<table> 缺少 <tbody> 时,浏览器会自动补上一个 <tbody>,使 DOM 结构发生变化。
Hydration 期间的组件生命周期
- Hydration 过程中,组件创建后会连续调度两次渲染;任何 effects 都要等第二次渲染完成后才被调用。因此务必保证组件渲染函数无副作用:不应变更任何状态或触发额外渲染;如果组件当前有这类行为,请把它们移入
use_effectHook。 - 若在 Hydration 中使用 Struct 组件,
view函数会在rendered函数被调用之前被多次调用;在rendered()被调用之前,DOM 被视为未连接,必须避免在此期间访问已渲染的节点。
端到端工程示例:simple_ssr 的项目结构与运行方式
仓库中的 simple_ssr 是一个完整可运行的 SSR + Hydration 示例。其 Cargo.toml 定义了与 SSR 相关的特性开关与二进制目标:
[[bin]]
name = "simple_ssr_hydrate"
required-features = ["hydration"]
[[bin]]
name = "simple_ssr_server"
required-features = ["ssr"]
[features]
hydration = ["yew/hydration"]
ssr = ["yew/ssr"]
按 README 的指引,运行分为两步:
# 1. 构建 Hydration 包
trunk build index.html
# 2. 运行服务器(warp 后端,为静态 HTML 与 SSR 页面提供服务)
cargo run --features=ssr --bin simple_ssr_server -- --dir dist
该示例还包含一个浏览器端 e2e 测试(见 tests/e2e.rs):测试启动 SSR 页面后,调用 yew::Renderer::<App>::with_root(output_element()).hydrate() 执行水合,并断言页面内容包含 "Random UUID:"——这直接验证了 "服务端预渲染 + 客户端水合" 全链路的数据一致性。
特性开关与当前版本演进
SSR 相关能力由 Yew 的 Cargo features 控制(见 packages/yew/Cargo.toml):
| feature | 说明 |
|---|---|
ssr |
启用服务端渲染(同时引入 html-escape、base64ct、bincode 依赖,用于 HTML 转义与数据序列化) |
csr |
启用客户端渲染(Renderer 等入口) |
hydration |
启用水合(依赖 csr 与 bincode) |
not_browser_env |
禁用 Yew 内部的浏览器特定 API 访问,适用于 Cloudflare Workers 等无浏览器环境 |
serde |
为 implicit-clone 启用 serde 支持 |
在 0.21 版本文档发布时,SSR 仍被标记为 experimental(实验性),官方建议发现 bug 时提交 issue。随着仓库演进到当前主线(0.23),SSR 能力已进一步扩展:新增了 yew::LocalServerRenderer 以支持 WASI 等单线程环境(可参考 wasi_ssr_module 示例)、not_browser_env 特性,以及 Axum / Actix 等 Web 框架的集成示例(axum_ssr_router、actix_ssr_router),这些均可作为深入学习 SSR 应用架构的参考,详见当前版本文档 server-side-rendering.mdx。
小结
Yew 的 SSR 方案围绕三个支柱展开:ServerRenderer 负责服务端产 HTML、<Suspense /> 负责统一服务端与客户端的异步数据获取、Renderer::hydrate 负责把静态 HTML 接续为可交互的 Yew 应用。实践中的关键纪律是:优先函数组件、把 Web API 相关逻辑隔离进 effects、保证渲染函数纯净且两端 Virtual DOM 布局一致。把握住这几点,即可构建出兼顾首屏速度、SEO 与交互体验的 Yew 全栈应用。