服務端渲染 (Server-Side Rendering)
預設情況下,Yew 元件在客戶端渲染。當使用者造訪一個網站時,伺服器會傳送一個骨架 HTML 文件,不包含任何實際內容,以及一個 WebAssembly 套件給瀏覽器。所有內容都由 WebAssembly 套件在客戶端渲染。這被稱為客戶端渲染。
這種方法對於大多數網站來說都是有效的,但有一些注意事項:
- 使用者在整個 WebAssembly 套件下載並完成初始渲染之前將看不到任何內容。這可能會導致在緩慢的網路上用戶體驗不佳。
- 有些搜尋引擎不支援動態渲染的網頁內容,而那些支援的搜尋引擎通常會將動態網站排名較低。
為了解決這些問題,我們可以在服務端渲染我們的網站。
工作原理
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_effect 或 use_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 輸出產生 之後 才執行。適合收集快取或彙總狀態。
兩者底層都使用 bincode 加 base64,並以 <script> 標籤的形式嵌入到 HTML 中。
yew-link:統一的資料取得
低階鉤子能處理 首次頁面載入(從 SSR 到水合),但水合之後的客戶端導覽需要一條獨立的資料取得路徑(例如 use_future_with 加上一個 HTTP 客戶端)。這意味著同一份資料邏輯要寫兩次。
yew-link crate 提供了一個更高層的抽象,將這三條路徑(SSR、水合與客戶端導覽)統一在同一個鉤子之下:
- 使用
#[linked_state]屬性巨集 定義 你的資料型別。 - 在伺服器端 註冊 一個 resolver。
- 用
<LinkProvider>包裹 你的應用程式。 - 在任意元件中 呼叫
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>>。外層用於 suspense(載入中)。該 handle 提供:
.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 })),
}
}
同樣的寫法適用於 axum、warp、rocket,或任何能夠將 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_router 與 actix_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 回饋。