Skip to main content
Version: Next

服务端渲染 (Server-Side Rendering)

默认情况下,Yew 组件在客户端渲染。当用户访问一个网站时,服务器会发送一个骨架 HTML 文件,不包含任何实际内容,以及一个 WebAssembly 包给浏览器。所有内容都由 WebAssembly 包在客户端渲染。这被称为客户端渲染。

这种方法对于大多数网站来说都是有效的,但有一些注意事项:

  1. 用户在整个 WebAssembly 包下载并完成初始渲染之前将看不到任何内容。这可能会导致在缓慢的网络上用户体验不佳。
  2. 一些搜索引擎不支持动态渲染的网页内容,而那些支持的搜索引擎通常会将动态网站排名较低。

为了解决这些问题,我们可以在服务端渲染我们的网站。

工作原理

Yew 提供了一个 ServerRenderer 来在服务端渲染页面。

要在服务端渲染 Yew 组件,您可以使用 ServerRenderer::<App>::new() 创建一个渲染器,并调用 renderer.render().await<App /> 渲染为一个 String

use yew::prelude::*;
use yew::ServerRenderer;

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

// 我们在使用 `flavor = "current_thread"` 以保证这个示例可以在 CI 中的 WASM 环境运行,
// 如果你希望使用多线程的话,可以使用默认的 `#[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);
}

组件生命周期

与客户端渲染不同,组件的生命周期在服务端渲染时会有所不同。

在组件成功第一次渲染为 Html 之前,除了 use_effect (和 use_effect_with) 之外的所有钩子都会正常工作。

浏览器接口不可用!

浏览器相关的接口,如 web_sys,在组件在服务端渲染时是不可用的。如果您尝试使用它们,您的应用程序将会崩溃。您应该将需要这部分逻辑隔离在 use_effectuse_effect_with 中,因为在服务端渲染时它们无法也不应当执行。

结构化组件

尽管可以在服务端渲染时使用结构化组件,但是在客户端安全逻辑(如函数组件的 use_effect 钩子)和生命周期事件之间没有明确的边界,并且生命周期事件的调用顺序与客户端不同。

此外,结构化组件将继续接受消息,直到所有子组件都被渲染并调用了 destroy 方法。开发人员需要确保不会将可能传递给组件的消息链接到调用浏览器接口的逻辑。

在设计支持服务端渲染的应用程序时,请尽量使用函数组件,除非您有充分的理由不这样做。

服务端渲染期间的数据获取

数据获取是服务端渲染和水合(hydration)期间的难点之一。

传统做法中,当一个组件渲染时,它会立即可用(输出一个虚拟 DOM 以进行渲染)。当组件不需要获取任何数据时,这种方式是有效的。但是如果组件在渲染时想要获取一些数据会发生什么呢?

过去,Yew 没有机制来检测组件是否仍在获取数据。数据获取客户端负责实现一个解决方案,以检测在初始渲染期间请求了什么,并在请求完成后触发第二次渲染。服务器会重复这个过程,直到在返回响应之前没有在渲染期间添加更多的挂起请求。

这不仅浪费了 CPU 资源,因为重复渲染组件,而且数据客户端还需要提供一种方法,在水合过程中使在服务端获取的数据可用,以确保初始渲染返回的虚拟 DOM 与服务端渲染的 DOM 树一致,这可能很难实现。

Yew 采用了一种不同的方法,通过 <Suspense /> 来解决这个问题。

<Suspense /> 是一个特殊的组件,当在客户端使用时,它提供了一种在组件获取数据(挂起)时显示一个回退 UI 的方法,并在数据获取完成后恢复到正常 UI。

当应用程序在服务端渲染时,Yew 会等待组件不再挂起,然后将其序列化到字符串缓冲区中。

在水合过程中,<Suspense /> 组件中的元素保持未水合状态,直到所有子组件不再挂起。

通过这种方法,开发人员可以轻松构建一个准备好进行服务端渲染的、与客户端无关的应用程序,并进行数据获取。

低层钩子

Yew 提供了两个低层钩子,用于将服务端计算出的状态传递到客户端:

  • use_prepared_state! 在 SSR 期间运行一个(可选 async 的)闭包,将结果序列化,并在水合期间传递到客户端。适合获取组件首次渲染所需的数据。
  • use_transitive_state! 类似,但闭包会在组件的 SSR 输出生成 之后 才运行。适合收集缓存或汇总状态。

两者底层都使用 bincodebase64,并以 <script> 标签的形式嵌入到 HTML 中。

yew-link:统一的数据获取

低层钩子能处理 首次页面加载(从 SSR 到水合),但水合之后的客户端导航需要一条独立的数据获取路径(例如 use_future_with 加上一个 HTTP 客户端)。这意味着同一份数据逻辑要写两遍。

yew-link crate 提供了一个更高层的抽象,将所有三条路径(SSR、水合和客户端导航)统一在同一个钩子背后:

  1. 使用 #[linked_state] 属性宏 定义 你的数据类型。
  2. 在服务端 注册 一个 resolver。
  3. <LinkProvider> 包裹 你的应用。
  4. 在任意组件中 调用 use_linked_state::<MyData>(input)
# use serde::{Serialize, Deserialize};
# pub struct DbPool;
# impl DbPool { async fn get_post(&self, _id: u32) -> Post { unreachable!() } }
use yew_link::{linked_state, LinkedState};

#[derive(Clone, Serialize, Deserialize)]
pub struct Post { pub title: String, pub body: String }

#[linked_state]
impl LinkedState for Post {
type Context = DbPool;
type Input = u32;

async fn resolve(ctx: &DbPool, id: &u32) -> Self {
ctx.get_post(*id).await
}
}

该宏会生成 LinkedState 以及(仅服务端的)LinkedStateResolve trait 实现。resolve 函数体会自动从 WASM 包中剥离。

类型化错误

如果 resolve 可能失败,可以声明 type Error

# use serde::{Serialize, Deserialize};
# use yew_link::{linked_state, LinkedState};
# #[derive(Clone, Debug, Serialize, Deserialize)]
# pub struct ApiError;
# impl std::fmt::Display for ApiError {
# fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "ApiError") }
# }
# pub struct DbPool;
# impl DbPool {
# async fn get_post(&self, _id: u32) -> Result<Post, ApiError> { unreachable!() }
# }
# #[derive(Clone, Serialize, Deserialize)]
# pub struct Post { pub title: String, pub body: String }
#[linked_state]
impl LinkedState for Post {
type Context = DbPool;
type Input = u32;
type Error = ApiError;

async fn resolve(ctx: &DbPool, id: &u32) -> Result<Self, ApiError> {
ctx.get_post(*id).await.map_err(ApiError::from)
}
}

省略 type Error 时,错误类型默认为不可实例化的 Never,并且 resolve 函数体会被自动包装在 Ok(…) 中。

use_linked_state 返回 SuspensionResult<LinkedStateHandle<T>>。外层用于挂起(加载中)。该句柄提供:

  • .data() 返回解析后的 Rc<T>,遇到错误时会 panic。
  • .as_result() 借用底层的 Result<Rc<T>, LinkError<T::Error>>,方便进行模式匹配。
  • .refresh() 触发后台重新拉取,同时仍然显示之前(旧)的值(stale-while-revalidate)。
  • .is_refreshing() 当后台刷新进行中时返回 true,便于在旧数据旁边显示加载指示器。

LinkError 区分了应用层错误(LinkError::Resolve)与基础设施错误(LinkError::Internal)。

如果多个组件并发请求相同的 (T, Input),它们会自动共用同一个进行中的请求。

服务端设置

# use std::sync::Arc;
# use serde::{Serialize, Deserialize};
# use yew_link::{linked_state, LinkedState};
# #[derive(Clone)]
# pub struct DbPool;
# impl DbPool {
# async fn get_post(&self, _id: u32) -> Post { unreachable!() }
# }
# #[derive(Clone, Serialize, Deserialize)]
# pub struct Post { pub title: String, pub body: String }
# #[linked_state]
# impl LinkedState for Post {
# type Context = DbPool;
# type Input = u32;
# async fn resolve(ctx: &DbPool, id: &u32) -> Self {
# ctx.get_post(*id).await
# }
# }
# fn main() {
# let db_pool = DbPool;
use yew_link::{Resolver, axum::linked_state_handler};

let resolver = Arc::new(
Resolver::new()
.register_linked::<Post>(db_pool.clone())
);

let app: axum::Router = axum::Router::new().route(
"/api/link",
axum::routing::post(linked_state_handler).with_state(resolver),
);
# let _ = app;
# }

上面的例子使用了 axum 特性。yew-link 也提供了一个 actix 特性,将相同的 handler 暴露在 yew_link::actix::linked_state_handler 之下:

# use serde::{Serialize, Deserialize};
# use yew_link::{linked_state, LinkedState};
# #[derive(Clone)]
# pub struct DbPool;
# impl DbPool {
# async fn get_post(&self, _id: u32) -> Post { unreachable!() }
# }
# #[derive(Clone, Serialize, Deserialize)]
# pub struct Post { pub title: String, pub body: String }
# #[linked_state]
# impl LinkedState for Post {
# type Context = DbPool;
# type Input = u32;
# async fn resolve(ctx: &DbPool, id: &u32) -> Self {
# ctx.get_post(*id).await
# }
# }
# async fn run() -> std::io::Result<()> {
# let db_pool = DbPool;
use actix_web::{App, HttpServer, web::{Data, post}};
use yew_link::{Resolver, actix::linked_state_handler};

let resolver = Data::new(
Resolver::new()
.register_linked::<Post>(db_pool.clone())
);

HttpServer::new(move || {
App::new()
.app_data(resolver.clone())
.route("/api/link", post().to(linked_state_handler))
})
.bind(("0.0.0.0", 8080))?
.run()
.await
# }
# fn main() {}
为什么 actix-web 被锁定在 4.12,以及如何为其他服务器框架实现 handler

actix 特性被锁定在 actix-web 4.12.x,因为 Yew 的 MSRV 为 1.85,而 actix-web 4.13 及更高版本要求 rustc 1.88。这一版本约束仅存在于 yew-link 内部,目的是让 workspace 的其他部分仍能在受支持的工具链上构建;它不会影响你在自己应用程序的其他地方使用哪个版本的 actix-web

如果你需要更新版本的 actix-web(或者任何其他 web 框架),并且不想启用内置的特性,这个 handler 足够小,完全可以自己内联实现。你需要用到的全部公开 API 只有 Resolver::resolve_request 以及表示传输格式的 LinkRequest 类型:

use actix_web::HttpResponse;
use actix_web::web::{Data, Json};
use serde_json::json;
use yew_link::{LinkRequest, Resolver};

pub async fn linked_state_handler(
resolver: Data<Resolver>,
Json(req): Json<LinkRequest>,
) -> HttpResponse {
match resolver.resolve_request(&req).await {
Ok(val) => HttpResponse::Ok().json(json!({ "ok": val })),
Err(err) => HttpResponse::UnprocessableEntity().json(json!({ "error": err })),
}
}

同样的写法适用于 axumwarprocket,或任何能够将 JSON 反序列化为 LinkRequest、调用一个 async 函数并将结果序列化为 JSON 响应的框架。使用 serde_json::json! 构造响应的传输格式,可以避免让 yew-link 内部的 LinkResponse 类型出现在你的依赖范围中。

组件中的使用

# use serde::{Serialize, Deserialize};
# use yew::prelude::*;
# use yew_link::{linked_state, LinkedState};
# pub struct DbPool;
# impl DbPool {
# async fn get_post(&self, _id: u32) -> Post { unreachable!() }
# }
# #[derive(Clone, Serialize, Deserialize)]
# pub struct Post { pub title: String, pub body: String }
# #[linked_state]
# impl LinkedState for Post {
# type Context = DbPool;
# type Input = u32;
# async fn resolve(ctx: &DbPool, id: &u32) -> Self {
# ctx.get_post(*id).await
# }
# }
# #[derive(Properties, PartialEq)]
# pub struct PostPageProps { pub id: u32 }
#[allow(unused_imports)]
use yew_link::{use_linked_state, LinkProvider};

#[component]
fn PostPage(props: &PostPageProps) -> HtmlResult {
let post = use_linked_state::<Post>(props.id)?.data();
Ok(html! { <h1>{ &post.title }</h1> })
}

在 SSR 期间,状态会通过 Resolver 在本地解析,并通过 use_prepared_state 嵌入到 HTML 中。水合时客户端会直接读取嵌入的状态,无需任何网络请求。在之后的客户端导航中,该钩子会自动从 LinkProvider 的 endpoint URL 拉取数据。

参见 axum_ssr_routeractix_ssr_router 示例查看完整可运行的演示。

渲染 <head> 标签

SSR 中的一个常见需求是渲染动态 <head> 内容(例如 <title><meta>),使爬虫和社交预览在首次加载时能看到正确的元数据。

ServerRenderer 只渲染组件树(通常对应文档的 body 部分),无法访问 <head>。因此,head 标签必须在服务端、Yew 之外生成,并在发送给客户端之前拼接到 HTML 模板中。

axum_ssr_router 示例 演示了这一模式:服务端从请求 URL 识别路由,生成适当的 <title><meta> 标签,并将它们注入到 Trunk 生成的 index.html</head> 之前。

信息

如需完全兼容 SSR 的第三方解决方案,请使用 Bounce 的 <Helmet/> 组件

SSR 水合(SSR Hydration)

水合是将 Yew 应用程序连接到服务端生成的 HTML 文件的过程。默认情况下,ServerRender 打印可水合的 HTML 字符串,其中包含额外的信息以便于水合。当调用 Renderer::hydrate 方法时,Yew 不会从头开始渲染,而是将应用程序生成的虚拟 DOM 与服务器渲染器生成的 HTML 字符串进行协调。

警告

要成功对由 ServerRenderer 创建的 HTML 标记进行水合,客户端必须生成一个虚拟 DOM 布局,它与用于 SSR 的布局完全匹配,包括不包含任何元素的组件。如果您有任何只在一个实现中有用的组件,您可能希望使用 PhantomComponent 来填充额外组件的位置。

注意

只有在浏览器初始渲染 SSR 输出(静态 HTML)后,真实 DOM 与预期 DOM 匹配时,水合才能成功。如果您的 HTML 不符合规范,水合可能会失败。浏览器可能会更改不正确的 HTML 的 DOM 结构,导致实际 DOM 与预期 DOM 不同。例如,如果您有一个没有 <tbody><table>,浏览器可能会向 DOM 添加一个 <tbody>

水合期间的组件生命周期

在水合期间,组件在创建后安排了 2 次连续的渲染。任何效果都是在第二次渲染完成后调用的。确保您的组件的渲染函数没有副作用是很重要的。它不应该改变任何状态或触发额外的渲染。如果您的组件当前改变状态或触发额外的渲染,请将它们移动到 use_effect 钩子中。

在水合过程中,可以使用结构化组件进行服务端渲染,视图函数将在渲染函数之前被调用多次。直到调用渲染函数之前,DOM 被认为是未连接的,您应该防止在调用 rendered() 方法之前访问渲染节点。

示例

use yew::prelude::*;
use yew::Renderer;

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

fn main() {
let renderer = Renderer::<App>::new();

// 对 body 元素下的所有内容进行水合,并移除尾部元素(如果有)。
renderer.hydrate();
}

示例: simple_ssr 示例: axum_ssr_router 示例: actix_ssr_router

单线程模式

Yew 支持以单线程进行服务端渲染,通过 yew::LocalServerRenderer。这种模式适用于像 WASI 这样的单线程环境。

// 使用 `wasm32-wasip1` 或 `wasm32-wasip2` 目标构建。

use yew::prelude::*;
use yew::LocalServerRenderer;

#[component]
fn App() -> Html {
use yew_router::prelude::*;

html! {
<h1>{"Yew WASI SSR demo"}</h1>
}
}

pub async fn render() -> String {
let renderer = LocalServerRenderer::<App>::new();
let html_raw = renderer.render().await;

let mut body = String::new();
body.push_str("<body>");
body.push_str("<div id='app'>");
body.push_str(&html_raw);
body.push_str("</div>");
body.push_str("</body>");

body
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("{}", render().await);
}

示例: wasi_ssr_module

备注

如果您使用 wasm32-unknown-unknown 目标构建 SSR 应用程序,您可以使用 not_browser_env 功能标志来禁用 Yew 内部对特定于浏览器的 API 的访问。这在像 Cloudflare Worker 这样的无服务器平台上非常有用。

警告

服务端渲染目前是实验性的。如果您发现了一个 bug,请在 GitHub 反馈