Svelte 5 伺服器端渲染 (Runes) 是做什麼的?
此技能涵蓋的範圍
「SSR」意指每個請求都由伺服器(Node、Bun、無伺服器或邊緣環境)渲染成 HTML,然後瀏覽器才接收到該 HTML;之後同樣的元件會進行 hydration 變成一個互動式應用程式。與純靜態建置不同,在請求時是存在真實伺服器的,因此可以使用 +page.server.js、+layout.server.js、+server.js、表單動作(form actions)和 hooks.server.js——而這些正是資料和機密所屬的地方。
runes 的部分正是讓 SSR 變得棘手的原因:每個元件都會執行兩次,一次在伺服器上,一次在瀏覽器中。在純客戶端應用程式中沒問題的程式碼,在這裡就會出錯:
$effect在伺服器上永遠不會執行,因此它填充的任何狀態都會從伺服器 HTML 中遺失。.svelte.ts檔案中的模組層級$state只為每個伺服器程序建立一次,並在所有使用者的請求之間共享。- 在渲染期間從
Date.now()、Math.random()或localStorage計算出的值,在伺服器和用戶端之間會有所不同,導致 hydration 不匹配。
這三個失敗構成了 runes 程式碼中大部分的 SSR 錯誤,而每個錯誤在下方都有一個小的正確模式。
步驟 1 — 選擇專案架構
| 需求 | 使用方式 |
|---|---|
| 完整的應用程式:路由、伺服器資料載入、表單、認證、串流傳輸 | SvelteKit 搭配 adapter-auto(自動偵測 Vercel/Netlify/Cloudflare)、adapter-node(自託管)或平台轉接器。這是預設選項;除非有理由不使用,否則就使用它。 |
| 在現有後端(Express/Hono/Fastify/Bun)內的一個 Svelte 元件樹 | 純 Vite + Svelte 5:在伺服器上使用 svelte/server 的 render(),在瀏覽器中則使用 hydrate()。你需要自行處理路由、標頭和請求處理。 |
| 大部分靜態內容,但帶有一些動態路由 | 使用 export const prerender = true 的 SvelteKit(或使用 'auto' 來預渲染熱門頁面,並對長尾頁面進行伺服器渲染)。動態路由仍然遵循這裡的所有規則。 |
請勿設定 export const ssr = false 來「修復」SSR 錯誤——這會將路由變成一個僅客戶端渲染的 shell(一個 SPA),從而失去 SEO 和首次渲染效能。它僅適用於那些確實無法在沒有瀏覽器全域變數的情況下渲染的頁面。csr = false 則是相反的開關:產生無 hydration 的伺服器 HTML,適用於不需要互動性的頁面。
這兩種路徑的設定步驟,以及轉接器和頁面選項,都在 references/ssr-build.md 中。在產出 svelte.config.js / vite.config.js 之前請先閱讀它,因為選項名稱和轉接器行為在不同版本之間有所差異,且容易犯下難以察覺的錯誤。
步驟 2 — 了解哪些 runes 會在伺服器上執行
下表是此技能的核心:
| Rune | 在 SSR 期間 | 備註 |
|---|---|---|
$state(v) | 執行 | 初始值會被渲染。在渲染過程中改變它不會重新渲染已發出的 HTML——SSR 是單次傳遞。 |
$state.raw(v) | 執行 | 無代理(proxy)。適用於你會整體替換而非逐步修改的大量 data payload。 |
$derived(expr) | 執行 | 作為渲染的一部分被評估,因此其輸出會進入 HTML。保持其純淨,且不含僅瀏覽器適用的 API。 |
$effect / $effect.pre | 永不執行 | 僅限瀏覽器。它所設定的狀態會從 SSR HTML 中遺失(hydration 後才出現)。這不是 hydration 錯誤,但會造成空的 HTML 和明顯的版面位移(layout shift)。 |
$effect.root(fn) | 永不執行 | 同上。請勿在其中建構伺服器可見的狀態。 |
$props() / $bindable() | 執行 | 載入(load)資料會以 props 形式送達;初始值會被渲染。 |
$props.id() | 執行 | SSR 安全的唯一 ID,在伺服器和用戶端之間保持一致(Svelte ≥ 5.20)。用於 for / aria-labelledby 連結。 |
$inspect(v) | 僅開發環境 | 無 SSR 輸出。 |
$state.snapshot(v) | 執行 | 在將狀態交給非 Svelte API 或伺服器函式庫之前使用。 |
$effect.tracking() | false | 伺服器編譯器會將其替換為字面值 false;SSR 期間不存在追蹤上下文。 |
此表格所預防的經典 SSR 錯誤:
<!-- ❌ 在伺服器 HTML 中為空,然後在 hydration 後突然出現 -->
<script>
let user = $state();
$effect(() => { user = getUser(); });
</script>
<div>{user?.name}</div>
修復方法幾乎總是相同的:在 $derived 中計算 SSR 可見的值,或者在 load 中擷取它們並以 props 形式傳遞。將 $effect 保留給僅存在於瀏覽器中的事物(canvas、觀察器、分析、儲存同步)。
請閱讀 references/runes-ssr.md 以獲取完整的逐個 rune 伺服器行為、hydration 安全值的模式,以及實驗性的非同步 SSR 狀態。
步驟 3 — 保持狀態按請求隔離,絕不使用模組全域變數
// ❌ 一個共享物件用於整個伺服器程序——使用者 A 可以看到使用者 B 的資料
// src/lib/user.svelte.ts
export const user = $state({ name: '' });
標準的修復方法是從 load 返回按請求隔離的資料,並以 props 形式傳遞下去;當許多巢狀元件都需要它時,請搭配 getter 將其放入 context 中:
<!-- +layout.svelte -->
<script>
import { setContext } from 'svelte';
let { data } = $props();
setContext('user', () => data.user);
</script>
<!-- 任何子元件 -->
<script>
import { getContext } from 'svelte';
const user = getContext('user');
</script>
<p>Hi {user().name}</p>
Context 依附於元件樹,而每個 SSR 請求都有自己的元件樹,因此它不會在使用者之間洩漏。將一個函數(或帶有 getter 的物件)傳入 setContext,而不是值本身,否則會失去反應性。許多伺服器函數需要的按請求資料,可以存放在 event.locals 上,並在 hooks.server.ts 中填入。
對於不接觸任何使用者資料的純客戶端應用程式狀態(主題、功能標記),模組層級的狀態仍然可以安全使用,只要伺服器不要從中渲染出個人化的值即可。
步驟 4 — 將 Hydration 視為一份合約
Hydration 會重播伺服器的 HTML;用戶端的首次渲染必須與之匹配。任何不同的值都會導致 hydration_mismatch,這被故意設計為不可抑制的——修正程式碼,而不是忽略警告。
- 確定性地渲染:不要在渲染路徑中使用
Date.now()、new Date()格式化、Math.random()、crypto.randomUUID()、localStorage/window/navigator讀取。 - 使用
$props.id()產生 ID,而不是隨機性或模組計數器。 - 在伺服端
load中格式化日期,並傳遞字串,或者始終傳遞明確的timeZone——伺服器和用戶端的時區不同。 - 僅用戶端使用的值(儲存裝置、媒體查詢、地理位置):從伺服器安全的預設值開始,並在
$effect/onMount內部填入,或者使用帶有穩定伺服器備用值的mounted標記來控制標記的呈現。
<script>
import { browser } from '$app/environment';
let theme = $state('light');
$effect(() => {
if (!browser) return;
theme = localStorage.getItem('theme') ?? 'light'; // 在 mount 之後,因此 hydration 可以匹配
});
</script>
在純 Svelte SSR(無 SvelteKit)中,$app/environment 不可用——請在 effect 內部使用 typeof window !== 'undefined',永遠不要在渲染路徑中使用。
步驟 5 — 在伺服器上載入和串流傳輸資料
- 使用
+page.server.ts/+layout.server.ts來處理資料庫、檔案和私有環境變數;使用通用+page.ts來處理可在任一位置運行的公開 API。 - 伺服端的
load必須返回能夠經得起devalue序列化的資料:包括 JSON 加上Date、Map、Set、BigInt、RegExp以及循環引用。不包含函數或類別實例。 - 慢速、非必要的資料:返回 promise 並在頁面中使用
{#await}——巢狀的 promise 會在其解析時進行串流傳輸。在返回之前等待必要的資料,以便它們能出現在首次渲染中。 - 在
load內部使用event.fetch:它會繼承請求的 cookie,並將回應內聯以進行 hydration,而不是重新擷取。 - 導航後,
data會在不重建元件的情況下更新:從它推導出($derived)值,而不是將其複製到非反應性的區域變數中。
在 Svelte 5.57 / SvelteKit 2.7x(2026 年 9 月)之前,非同步 SSR(在元件中使用 await)和遠端函數仍然是實驗性的。除非我明確要求,否則不要在生產程式碼中啟用 compilerOptions.experimental.async;確切狀態和選擇加入方式在 references/runes-ssr.md 中。
步驟 6 — 應用 SSR 安全規則
- 機密保持私有。
$env/static/private/$env/dynamic/private僅限伺服器使用;只有PUBLIC_前綴的變數才能到達瀏覽器。從$lib/server/和*.server.js的匯入,如果元件拉入它們,則會拋出錯誤。 - 從
load返回的任何東西都會嵌入到 HTML payload 中——所有內容,而不僅僅是你渲染的欄位。絕對不要返回你不需要在客戶端使用的密碼哈希、令牌或內部欄位。 - 使用 nonce 的 CSP。 設定
kit.csp;SvelteKit 會將其 nonce 注入到自己的內聯腳本中,而%sveltekit.nonce%涵蓋了app.html中的手動標籤。真實的伺服器可以發送真實的 CSP 標頭——請使用這個,而不是較弱的<meta>標籤。 {@html}仍然是 XSS。 伺服器渲染不代表已清理。在渲染前立即使用 DOMPurify 清理不受信任的 HTML。- 認證屬於
hooks.server.ts(填入locals)以及路由層級的檢查。佈局(Layout)的load函數不會在每次客戶端導航時都執行。 - 表單提交預設開啟 CSRF 保護;請保持開啟,並在你的任何自訂
+server.js變動端點中自行檢查來源。 - Session Cookie: 應設定為
httpOnly、在生產環境中為secure,且sameSite: 'lax'為最低要求。 - 伺服器端對使用者提供的 URL 進行
fetch是 SSRF。 請對主機進行白名單列管;封鎖私有 IP 和雲端中繼資料端點。
完整檢查清單和程式碼:references/security.md。
審查現有應用程式
當任務是稽核或除錯而不是建置時,請依照 references/ssr-review.md 進行操作:渲染模式檢查、伺服器不可見的 runes、hydration 危險、按請求的作用域狀態、payload 暴露、安全性搜尋和故障演練。以「檔案:行號」以及每個問題會導致的生產環境失敗來報告發現。
參考檔案
references/runes-ssr.md— 逐個 rune 的伺服器行為、hydration 安全模式、按請求的作用域狀態、純svelte/serverrender +hydrate,以及實驗性的非同步 SSR / 遠端函數狀態。references/ssr-build.md— SvelteKit 設定和轉接器、頁面選項 (ssr/csr/prerender)、hooks、表單動作、串流、純 Vite SSR 以及部署注意事項。references/ssr-review.md— 現有 SSR 應用程式的稽核查檢清單:渲染模式、伺服器不可見的 runes、hydration 危險、狀態作用域、payload 暴露、安全性搜尋和故障演練。references/security.md— 機密和環境變數、CSP nonce、XSS、CSRF、認證/Session 處理、SSRF、錯誤洩漏以及上線前檢查清單。assets/— 可複製的入門檔案:svelte.config.js(帶有 CSP 的 SvelteKit)、hooks.server.ts(帶有安全標頭的認證),以及用於純 Vite SSR 的index.html/entry-server.js/entry-client.js(這個三重奏由tests/plain-ssr在 CI 中建置)。