SSR 与渲染模式
本教程共 38 篇 · 第 24 篇 · 更新于 2026-07-27 · 约 17 分钟阅读
24. SSR 与渲染模式
本节目标:搞懂 TanStack Start 的各种渲染策略。学会 SSR 基础与 hydration、selective-ssr 按路由控制、SPA 模式、静态预渲染、ISR 增量静态再生、deferred-hydration 延迟注水。学完你能为每个页面选最合适的渲染方式,平衡性能、SEO 和开发体验。
24.1 SSR 与 hydration:基础原理
什么是 SSR
SSR(Server-Side Rendering)是服务端渲染:用户请求页面时,服务器执行 React 组件,生成完整 HTML 发给浏览器。用户不用等 JavaScript 下载执行就能看到页面内容。
用户请求 → 服务器执行组件 → 生成 HTML → 浏览器显示
↓ (同时)
下载 JS → hydration → 可交互
什么是 hydration
浏览器拿到 HTML 后,页面只是”看得见但点不了”。JavaScript 下载执行后,React 把 HTML 变成可交互的应用,这个过程叫 hydration(注水)。
hydration 做的事:
- 读取服务端生成的 HTML
- 在内存中重建组件树
- 把事件处理器挂到 DOM 上
- 替换服务端 HTML 为客户端可控的 DOM
NoteTanStack Start 默认所有路由都做 SSR。
beforeLoad和loader在服务端执行,组件在服务端渲染,HTML 发给客户端后 hydration。
hydration 不匹配问题
如果服务端和客户端渲染的内容不一致,React 会报 hydration mismatch 警告。
// ❌ 服务端和客户端渲染不同内容
function CurrentTime() {
return <div>{new Date().toLocaleString()}</div>
// 服务端渲染时是时间 A,客户端 hydration 时是时间 B,不一致!
}
// ✅ 用 useEffect 确保 hydration 后才渲染动态内容
function CurrentTime() {
const [time, setTime] = useState<string>()
useEffect(() => {
setTime(new Date().toLocaleString())
}, [])
return <div>{time || '加载中...'}</div>
}
Warning服务端和客户端时间不同、随机数不同、
window/document在服务端不存在,都会导致 hydration mismatch。用useHydratedhook 或useEffect规避。
24.2 selective-ssr:按路由控制 SSR
不是所有路由都适合 SSR。有些路由的 loader 用了浏览器 API(localStorage),有些组件依赖 canvas。TanStack Start 的 Selective SSR 让你按路由控制。
三种 SSR 模式
| 模式 | beforeLoad/loader | 组件渲染 | 适用场景 |
|---|---|---|---|
ssr: true | 服务端执行 | 服务端渲染 | 默认,需要 SEO |
ssr: false | 客户端执行 | 客户端渲染 | 依赖浏览器 API |
ssr: 'data-only' | 服务端执行 | 客户端渲染 | 数据要 SEO,组件不要 |
ssr: true(默认)
export const Route = createFileRoute('/posts/$postId')({
ssr: true, // 默认值,不写也行
loader: () => fetchPost(),
component: PostDetail,
})
服务端执行 loader 获取数据,渲染组件生成 HTML。客户端拿到带数据的 HTML,直接显示。
ssr: false
export const Route = createFileRoute('/dashboard')({
ssr: false,
loader: () => {
// 只在客户端执行
const token = localStorage.getItem('token')
return fetchDashboardData(token)
},
component: Dashboard,
})
beforeLoad 和 loader 不在服务端执行,组件也不在服务端渲染。用户首次访问会看到 pendingComponent(或空白),客户端 JS 加载后才渲染。
ssr: ‘data-only’
export const Route = createFileRoute('/editor')({
ssr: 'data-only',
loader: () => fetchEditorData(),
component: Editor,
})
数据在服务端加载(SEO 友好),但组件不在服务端渲染。适合数据需要被搜索引擎索引,但组件依赖浏览器 API 的场景。
函数式配置
运行时动态决定:
export const Route = createFileRoute('/docs/$docType/$docId')({
ssr: ({ params, search }) => {
// 某些文档类型依赖浏览器 API,不做 SSR
if (params.docType === 'sheet') {
return false
}
// 带特定搜索参数时只 SSR 数据
if (search.preview === true) {
return 'data-only'
}
return true
},
})
继承规则
子路由继承父路由的 SSR 配置,但只能变得更严格:
true → 'data-only' → false(可以)
false → true(不行,不能变得更宽松)
root (ssr: true)
└─ posts (ssr: false)
└─ $postId (ssr: true) ← 实际还是 false,继承自父级
Tip全局关闭 SSR 用
defaultSsr: false,但<html>外壳仍然在服务端渲染(用shellComponent配置)。
24.3 SPA 模式:纯客户端
有些应用不需要 SSR:内部工具、后台管理、不需要 SEO 的应用。Start 的 SPA 模式 完全关闭 SSR。
开启 SPA 模式
// vite.config.ts
import { defineConfig } from 'vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
export default defineConfig({
plugins: [
tanstackStart({
spa: {
enabled: true,
},
}),
viteReact(),
],
})
SPA 模式怎么工作
构建时会额外执行预渲染步骤:
- 只预渲染根路由(生成 HTML 外壳)
- 路由匹配的位置渲染
pendingComponent - 生成
/_shell.html文件 - 配置所有 404 请求重写到
/_shell.html
用户访问任何 URL 都会拿到同一个 HTML 外壳,客户端 JS 加载后由路由器接管。
SPA 模式的优缺点
优点:
- 部署简单:CDN 能托管静态文件就行
- 成本低:不需要服务器运行
- 少出错:没有 hydration 问题
缺点:
- 首屏慢:所有 JS 下载执行后才能看到内容
- SEO 差:搜索引擎可能抓不到内容
NoteSPA 模式不等于不能用 Server Functions 和 Server Routes。只是首屏 HTML 不含渲染内容,但可以配合服务端功能使用。
部署重定向
SPA 部署到 CDN 需要配置重定向,确保所有路径都返回 /_shell.html:
# Netlify _redirects
/_serverFn/* /_serverFn/:splat 200
/api/* /api/:splat 200
/* /_shell.html 200
24.4 静态预渲染:构建时生成 HTML
静态预渲染(Static Prerendering) 在构建时把页面渲染成静态 HTML 文件。用户请求时直接返回 HTML,不用服务端实时渲染。
配置预渲染
// vite.config.ts
export default defineConfig({
plugins: [
tanstackStart({
prerender: {
enabled: true,
// 自动发现静态路由
autoStaticPathsDiscovery: true,
// 从预渲染的页面中提取链接,继续预渲染
crawlLinks: true,
// 并发数
concurrency: 14,
// 重试次数
retryCount: 2,
// 过滤不需要预渲染的页面
filter: ({ path }) => !path.startsWith('/admin'),
},
}),
viteReact(),
],
})
自动路由发现
Start 自动发现可以预渲染的路由:
- 静态路由(如
/about):自动预渲染 - 动态路由(如
/posts/$postId):需要参数值,不会自动发现 - 布局路由:不渲染独立页面,跳过
链接爬取
crawlLinks: true 时,预渲染 / 后会提取页面里的链接,继续预渲染链接指向的页面。从首页开始能预渲染大部分页面。
指定特定页面
tanstackStart({
prerender: {
enabled: true,
crawlLinks: true,
},
pages: [
{
path: '/landing/special-campaign',
prerender: { enabled: true, outputPath: '/landing/special-campaign/index.html' },
},
],
})
Tip预渲染适合内容不常变的页面:博客文章、营销页、文档。动态内容(用户数据、实时数据)不适合。
24.5 ISR:增量静态再生
ISR(Incremental Static Regeneration) 是预渲染 + 定期更新。页面在构建时预渲染,CDN 缓存,过期后后台重新生成。
Start 的 ISR 不用框架特有的机制,而是用标准 HTTP 缓存头配合 CDN。
基本用法
在路由上设置 headers:
// src/routes/blog/posts/$postId.tsx
export const Route = createFileRoute('/blog/posts/$postId')({
loader: async ({ params }) => {
const post = await fetchPost(params.postId)
return { post }
},
headers: () => ({
// CDN 缓存 1 小时,过期后 24 小时内返回旧内容同时后台刷新
'Cache-Control':
'public, max-age=3600, s-maxage=3600, stale-while-revalidate=86400',
}),
})
Cache-Control 指令
| 指令 | 含义 |
|---|---|
public | 任何缓存都能存(CDN、浏览器) |
max-age=3600 | 1 小时内视为新鲜 |
s-maxage=3600 | 覆盖 CDN 的 max-age |
stale-while-revalidate=86400 | 过期后 24 小时内返回旧内容,同时后台刷新 |
private | 只浏览器能缓存(认证数据) |
immutable | 内容永不变(哈希命名的资源) |
多层缓存策略
CDN 缓存 + 客户端缓存配合:
export const Route = createFileRoute('/posts/$postId')({
loader: async ({ params }) => fetchPost(params.postId),
// CDN 缓存(通过 headers)
headers: () => ({
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
}),
// 客户端缓存(通过 Router)
staleTime: 60_000, // 客户端 60 秒内视为新鲜
gcTime: 5 * 60_000, // 客户端内存保留 5 分钟
})
三层缓存:
- CDN 边缘:1 小时缓存,过期后 24 小时 stale-while-revalidate
- 客户端 Router:60 秒新鲜,5 分钟内存
- 服务器:CDN 缓存未命中时回源
按需重新验证
内容更新后需要立即刷新缓存,调 CDN 的 purge API:
export const Route = createFileRoute('/api/revalidate')({
server: {
handlers: {
POST: async ({ request }) => {
const { path, secret } = await request.json()
// 验证密钥
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Invalid token' }, { status: 401 })
}
// 调 CDN API 清除缓存
await fetch(
`https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/purge_cache`,
{
method: 'POST',
headers: { Authorization: `Bearer ${CF_API_TOKEN}` },
body: JSON.stringify({ files: [`https://yoursite.com${path}`] }),
},
)
return Response.json({ revalidated: true })
},
},
},
})
常见 ISR 场景
// 博客文章:缓存 1 小时,7 天 stale
headers: () => ({
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=604800',
})
// 电商产品:库存变化快,缓存 5 分钟
headers: () => ({
'Cache-Control': 'public, max-age=300, stale-while-revalidate=3600',
})
// 营销页面:内容稳定,缓存 1 天
headers: () => ({
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800',
})
// 用户仪表盘:私有数据,不 CDN 缓存
headers: () => ({
'Cache-Control': 'private, max-age=60',
})
Warning用户相关数据必须用
private,不能用public。否则 CDN 会把一个用户的数据缓存了给另一个用户。
24.6 deferred-hydration:延迟注水
NoteDeferred hydration 目前是实验性功能,API 可能变化。
为什么要延迟注水
SSR 让用户快速看到 HTML,但 hydration 要下载执行所有 JavaScript。页面下方的评论区、推荐栏,用户暂时不需要交互,却要为它们付出 hydration 成本。
Deferred Hydration(延迟注水)让你标记部分内容”先不交互”,等需要时再注水。
基本用法
import { Hydrate } from '@tanstack/react-start'
import { visible } from '@tanstack/react-start/hydration'
export function ProductPage() {
return (
<>
<ProductHero />
<BuyBox />
{/* 评论区:滚动到可视区域时才注水 */}
<Hydrate when={visible({ rootMargin: '400px' })}>
<Reviews />
</Hydrate>
</>
)
}
服务端仍然渲染 <Reviews /> 的 HTML(用户能看到内容),但客户端不会立即注水。当评论区进入可视区域 400px 范围内时,才加载 JavaScript 并注水。
注水策略
| 策略 | 行为 |
|---|---|
load() | 应用启动时就注水 |
idle() | 浏览器空闲时注水 |
visible() | 进入可视区域时注水 |
media() | 媒体查询匹配时注水 |
interaction() | 用户交互时注水(点击、聚焦等) |
condition() | 条件为真时注水 |
never() | 永不注水(保持静态 HTML) |
常见用法
可视区域触发(首屏下方的评论区):
<Hydrate when={visible({ rootMargin: '800px' })}>
<Reviews />
</Hydrate>
用户交互触发(昂贵控件,需要时才激活):
<Hydrate when={interaction({ events: ['focusin', 'click'] })}>
<ReviewFilters />
</Hydrate>
空闲时注水(小组件,不急但也不推迟太久):
<Hydrate when={idle()} split={false}>
<SmallBadge />
</Hydrate>
永不注水(纯展示内容,不需要交互):
<Hydrate when={never()}>
<StaticBadges />
</Hydrate>
代码分割
Hydrate 默认把子组件代码拆到单独的 chunk,注水时才加载:
// 默认:split=true,代码拆分
<Hydrate when={visible()}>
<HeavyWidget />
</Hydrate>
// 关闭拆分:只延迟注水,不拆代码
<Hydrate when={idle()} split={false}>
<SmallWidget />
</Hydrate>
预加载
在注水前提前加载代码,让注水触发时更快:
import { idle, visible } from '@tanstack/react-start/hydration'
// 可视区域触发注水,但空闲时就预加载代码
<Hydrate when={visible({ rootMargin: '200px' })} prefetch={idle()}>
<Reviews />
</Hydrate>
嵌套边界
export function ProductPage() {
return (
<>
<ProductHero />
<BuyBox />
<Hydrate when={visible({ rootMargin: '600px' })}>
<section>
<h2>评论</h2>
<ReviewsList />
<Hydrate when={interaction({ events: ['focusin', 'click'] })}>
<ReviewFilters />
</Hydrate>
<Hydrate when={interaction({ events: 'click' })}>
<WriteReviewForm />
</Hydrate>
</section>
</Hydrate>
</>
)
}
父级先注水,子级才能注水。滚动到附近先激活评论区,用户点击筛选器再激活筛选功能。
Tip好的延迟注水候选:首屏下方的评论、推荐、地图、图表、轮播。差的候选:主导航、搜索框、加购按钮、首屏表单—这些用户可能立刻就要用。
24.7 渲染模式选择指南
| 模式 | 首屏速度 | SEO | 实时性 | 部署成本 | 适用场景 |
|---|---|---|---|---|---|
| SSR(默认) | 快 | 好 | 高 | 高(需服务器) | 通用,动态内容 |
| selective-ssr | 快 | 部分 | 高 | 高 | 混合场景 |
| SPA 模式 | 慢 | 差 | 高 | 低(CDN) | 后台管理 |
| 静态预渲染 | 最快 | 好 | 低 | 低(CDN) | 博客、文档 |
| ISR | 快 | 好 | 中 | 中 | 电商、新闻 |
| deferred-hydration | 快 | 好 | 高 | 高 | 复杂页面优化 |
实际项目中可以组合使用:
- 首页用预渲染(最快首屏)
- 博客用 ISR(定期更新)
- 用户仪表盘用
ssr: 'data-only'(数据 SEO,组件客户端渲染) - 评论区用 deferred-hydration(延迟注水)
24.8 小结
这一章覆盖了 Start 的所有渲染模式:
- SSR 基础:服务端渲染 HTML,客户端 hydration 变可交互
- hydration mismatch:服务端客户端不一致导致,用
useEffect或useHydrated规避 - selective-ssr:按路由控制
ssr: true/false/'data-only',子路由只能更严格 - SPA 模式:完全关闭 SSR,生成
/_shell.html外壳,部署到 CDN - 静态预渲染:构建时生成 HTML,自动发现路由,爬取链接
- ISR:用标准 HTTP 缓存头配合 CDN,
stale-while-revalidate后台刷新 - deferred-hydration:标记部分内容延迟注水,
visible/interaction/idle/never策略控制
下一章讲部署与生产环境:怎么把 Start 应用部署到各种平台。