延迟水合(Deferred Hydration)
实验性
延迟水合是实验性功能。
在初次页面加载时,TanStack Start 会对你的页面做服务端渲染,让浏览器能快速展示有用的 HTML。水合(Hydration)是把这份初始 HTML 文档变成可交互应用的客户端工作:它加载并执行 JavaScript、运行组件、挂载事件处理器,并把已有的 DOM 重新连接到 React 上。
延迟水合(Deferred Hydration)作用于这份初始文档的水合工作。应用启动运行之后,后续的客户端导航是通过客户端应用渲染的,没有初始的服务端 HTML 需要 Start 保留。
默认情况下,TanStack Start 会水合整个文档。这通常是最简单、最安全的行为,但大型页面可能会把可观的启动时间花在加载 JavaScript 和水合用户可能并不需要的页面部分上。
延迟水合让你可以把页面中选定的部分标记为「暂不交互」。服务端 HTML 仍然保留在文档中,但 TanStack Start 会等到某个策略(strategy)说「时间到了」才水合那个边界。默认情况下,编译器还会把边界内的子内容移动到单独的 JavaScript chunk 中,让浏览器也能延迟加载那些代码。
当页面某部分应该立即可见、样式完整、可被索引,但不需要立即可交互时,就使用延迟水合。
添加延迟边界
在 @tanstack/react-start/hydration 中导入 Hydrate 和一个策略:
import { Hydrate } from '@tanstack/react-start'
import { visible } from '@tanstack/react-start/hydration'
export function ProductPage() {
return (
<Hydrate when={visible({ rootMargin: '400px' })}>
<Reviews />
</Hydrate>
)
}在初始服务端响应中,Reviews 仍然会被渲染成 HTML。在初始客户端水合阶段,这份 HTML 会被保留,但 Reviews 的 React 树还不会水合。当边界进入视口 400px 范围内时,TanStack Start 加载延迟的子 chunk 并水合该边界。
Hydrate 只保留初始文档中存在的服务端 HTML。如果同一个边界在更晚的时候才首次挂载,比如在客户端导航之后,就没有服务端 HTML 可以保留了,所以它会在客户端正常渲染。
译者注:延迟水合 vs 选择性 SSR
两者容易混淆。选择性 SSR 控制的是「这个路由要不要在服务器上渲染」;延迟水合控制的是「服务端已经渲染好的 HTML,什么时候在客户端变为可交互」。延迟水合是给首屏性能优化的:首屏 HTML 是全的(对 SEO 友好),只是把某些区域的水合工作推迟到用户需要时。
选择要延迟的内容
正确的边界取决于你的页面、你的产品优先级和真实用户行为。TanStack Start 无法知道页面哪些部分可以安全延迟。
好的候选通常是那些不需要立即交互的 SSR 内容:
- 首屏之下的评论、评价、产品详情、相关内容,或很长的营销区块。
- 地图、图表、轮播图、视频播放器、编辑器或嵌入组件等重型 widget。
- 由用户意图激活的面板,比如筛选器、预览面板或上下文工具。
- 只对某个匹配的媒体查询(media query)有意义的 UI。
- 不应在初始文档中水合的静态服务端渲染内容。
差的候选是用户可能立即需要的页面部分:
- 主导航、路由外壳(route chrome)、搜索框和账户控件。
- 首屏之上的表单、加入购物车按钮、结算操作或同意控件。
- LCP 或 hero 区域的可交互部分,如果用户可能立即点击。
- 无障碍关键控件,必须在页面出现时就支持键盘操作。
- 期望在应用启动后立即更新 props、context 或共享状态的组件。
为每个边界做衡量。一个好的边界应该在不让预期交互感觉滞后的前提下,减少启动时的 JavaScript 或水合工作量。
与 Astro Islands 对比
Astro 从静态出发,问「什么应该活过来?」每个答案都是一个隔离的框架根节点,被放进 HTML。Islands 是共享同一个 DOM 的独立运行时。
TanStack Start 从完全交互出发,问「什么可以等?」默认情况下整个文档作为一个 React 树水合;Hydrate 边界是这棵树内部的门(gate)。上下文、状态和事件正常流动,水合是父级优先的。
相同的触发词,不同的底层:Astro 组合多个运行时,Start 只调度一个运行时。这就是为什么 Start 有 interaction()、condition() 和意图冒泡(intent bubbling),而 Astro 有多框架能力。
与 React 选择性水合(Selective Hydration)对比
React 的选择性水合控制服务端渲染的边界以什么顺序水合。延迟水合控制每个边界是否水合、以及何时水合。
当 React 水合一个流式 SSR 页面时,每个服务端渲染的 <Suspense> 边界最终都会水合。选择性水合只是决定顺序:每个边界在代码到达时尽快水合,如果用户点击了某个边界内部,React 会把它跳到队列最前面。工作量由服务器渲染的内容决定;React 只是调度它,让体验更流畅。
延迟水合则改变了队列里一开始有什么。Hydrate 边界指定一个条件——visible()、idle()、interaction()、media()、condition() 或 never()——边界保持为静态服务端 HTML,直到该条件触发。默认情况下,子 JavaScript 也会被移入单独的 chunk,浏览器直到边界即将水合时才下载。如果条件从未触发,边界就永远不会水合,它的代码也永远不会被获取。
两者可以组合。Hydrate 边界决定 React 是否、以及何时开始水合一棵子树;一旦它打开,里面的任何东西(包括 <Suspense> 边界)都会回到 React 正常的水合调度器中。当水合必须发生、并且你想让 React 好好安排优先级时,用 <Suspense>。当水合可能根本不需要发生时,用 Hydrate。
三个决策
每个 Hydrate 边界都有三个性能决策:
| 决策 | 选项 | 控制什么 |
|---|---|---|
| 水合 | when | 保留的服务端 HTML 何时变为可交互。 |
| 代码分割 | split | 子内容是否移入生成的延迟子 chunk。 |
| 预取 | prefetch | 是否在 when 策略水合子内容之前就开始相关工作。 |
when:决定边界何时水合
when 是必需的。常见情况传一个策略对象:
<Hydrate when={visible()}>
<Reviews />
</Hydrate>当决策需要浏览器专属信息时,传一个函数:
import { Hydrate } from '@tanstack/react-start'
import { interaction, visible } from '@tanstack/react-start/hydration'
export function RecommendationsBoundary() {
return (
<Hydrate
when={() =>
navigator.connection?.saveData
? interaction({ events: 'click' })
: visible()
}
>
<Recommendations />
</Hydrate>
)
}函数形式只在客户端求值,并且必须同步返回一个策略。当你有意让初始服务端 HTML 保持静态时,使用 never()。
split:决定是否创建独立的子 chunk
默认情况下,Hydrate 会把子内容分割到生成的子 chunk 中:
<Hydrate when={visible()}>
<HeavyWidget />
</Hydrate>这会同时延迟水合工作和子 JavaScript 的加载。
当子代码很小或已经在别处需要时,设置 split={false},只延迟水合工作:
import { Hydrate } from '@tanstack/react-start'
import { idle } from '@tanstack/react-start/hydration'
export function SmallWidgetBoundary() {
return (
<Hydrate when={idle()} split={false}>
<SmallWidget />
</Hydrate>
)
}prefetch:决定是否在水合前开始加载
prefetch 在边界水合前开始加载。它有两种形式:
| 形式 | 示例 | 用途 |
|---|---|---|
| 预取策略 | prefetch={idle()} | 在水合前预加载生成的子 chunk。 |
| 过程式预取 | prefetch={async (ctx) => { ... }} | 预加载子 chunk 以及数据或其他异步资源。 |
两种形式都会提前开始工作,但都不会改变边界何时变为可交互。那仍然由 when 控制。
预取策略是简洁、声明式的形式:
import { idle, interaction, visible } from '@tanstack/react-start/hydration'
<Hydrate when={interaction()} prefetch={idle()}>
<ProductRecommendations />
</Hydrate>
<Hydrate
when={interaction()}
prefetch={visible({ rootMargin: '1200px' })}
>
<RelatedProducts />
</Hydrate>策略形式的 prefetch 会在边界水合前下载生成的子 chunk。这能让之后的水合触发感觉更快,因为当 when 解析时,浏览器可能已经拿到了 chunk。生成的子 chunk 只在启用 split 时存在,所以 TypeScript 会在 split={false} 时拒绝策略形式的 prefetch。
当你需要自定义工作时,使用过程式预取:
import { useQueryClient } from '@tanstack/react-query'
import { Hydrate } from '@tanstack/react-start'
import { visible } from '@tanstack/react-start/hydration'
function DeferredReviews() {
const queryClient = useQueryClient()
return (
<Hydrate
when={visible()}
prefetch={async ({ preload }) => {
await preload()
await queryClient.prefetchQuery(reviewsQueryOptions)
}}
>
<Reviews />
</Hydrate>
)
}过程式预取也可以与 split={false} 一起用。在这种情况下,preload() 会立即完成,相当于一个空操作(no-op),但函数仍然可以准备数据或其他资源。
常用配方
水合首屏之下的 SSR 内容
import { Hydrate } from '@tanstack/react-start'
import { visible } from '@tanstack/react-start/hydration'
export function ProductPage() {
return (
<>
<ProductHero />
<BuyBox />
<Hydrate when={visible({ rootMargin: '800px' })}>
<Reviews />
</Hydrate>
</>
)
}当边界应该在真正进入视口之前水合时,使用正的 rootMargin。
在需要之前下载子 chunk
import { Hydrate } from '@tanstack/react-start'
import { idle, visible } from '@tanstack/react-start/hydration'
export function ReviewsBoundary() {
return (
<Hydrate when={visible({ rootMargin: '200px' })} prefetch={idle()}>
<Reviews />
</Hydrate>
)
}这让边界在接近视口之前保持不可交互,但会在空闲时间开始加载子 chunk。
让 widget 保持「冷」直到用户意图
import { Hydrate } from '@tanstack/react-start'
import { interaction, visible } from '@tanstack/react-start/hydration'
export function RecommendationsBoundary() {
return (
<Hydrate
when={interaction({ events: ['focusin', 'click'] })}
prefetch={visible({ rootMargin: '1200px' })}
>
<RecommendationCarousel />
</Hydrate>
)
}这对那些可见或近在眼前、但只有在用户伸手去用时才重要的昂贵控件很有用。
不做代码分割,只延迟水合
import { Hydrate } from '@tanstack/react-start'
import { idle } from '@tanstack/react-start/hydration'
export function BadgeBoundary() {
return (
<Hydrate when={idle()} split={false}>
<SmallPersonalizedBadge />
</Hydrate>
)
}当 JavaScript 已经是启动打包产物的一部分,或单独的 child chunk 不值得时,使用这种方式。
保持初始 SSR HTML 静态
import { Hydrate } from '@tanstack/react-start'
import { never } from '@tanstack/react-start/hydration'
export function MarketingPage() {
return (
<Hydrate when={never()}>
<StaticTrustBadges />
</Hydrate>
)
}never() 保留现有的服务端 HTML,并且在初始文档水合期间不水合该边界。如果同一个边界在客户端导航期间更晚挂载,它会正常渲染,因为没有初始服务端 HTML 需要保留。never() 不能作为预取策略使用。
复用 Hydrate props
用 HydrateOptions 定义可复用对象,然后展开到 Hydrate 中:
import { Hydrate } from '@tanstack/react-start'
import type { HydrateOptions } from '@tanstack/react-start'
import { visible } from '@tanstack/react-start/hydration'
const belowFoldProps = {
when: () => visible({ rootMargin: '800px' }),
} satisfies HydrateOptions
export function Page() {
return (
<Hydrate
{...belowFoldProps}
prefetch={async ({ preload }) => {
await preload()
}}
>
<Widget />
</Hydrate>
)
}内联的 when 和 prefetch 函数是被支持的。你不需要把它们包在 useCallback 里;TanStack Start 会在内部保留最新的回调,不会仅仅因为函数身份改变就重新注册水合监听器。如果边界含义发生了变化,用常规的 React key 来创建新边界。
Hydrate Props 参考
Hydrate 接受这些 props:
| Prop | 类型 | 说明 |
|---|---|---|
when | HydrationStrategy | () => HydrationStrategy | 必需。控制边界何时水合。函数形式仅限客户端且是同步的。 |
prefetch | HydrationPrefetchStrategy | HydrationPrefetchFunction | 可选。策略形式预加载分割后的子 chunk。函数形式可以预加载 chunk、数据或其他资源,并且可以配合 split={false} 使用。 |
split | boolean | 默认 true。设为字面量 false 来禁用编译器的提取,只延迟水合工作。 |
fallback | ReactNode | 仅客户端的加载 UI,用于那些在应用已经水合之后才挂载、然后在子 chunk 或子 Suspense 上挂起的边界。 |
onHydrated | () => void | 边界在客户端水合后触发一次。 |
策略参考
从 @tanstack/react-start/hydration 导入策略。
| 策略 | 行为 |
|---|---|
load() | 应用水合后立即水合。 |
idle() | 在 requestIdleCallback 中水合,当空闲回调不可用时在 timeout 之后水合。 |
visible() | 当边界标记进入视口时水合。 |
media() | 当媒体查询匹配时水合。 |
interaction() | 在配置的交互意图事件上水合。 |
condition() | 条件为真时水合。 |
never() | 永不水合初始服务端渲染的边界。 |
策略选项:
| 策略 | 选项 |
|---|---|
idle | { timeout?: number },默认 2000。 |
visible | { rootMargin?: string; threshold?: number | Array<number> },默认边距 600px。 |
media | 查询字符串,例如 media('(min-width: 800px)')。 |
interaction | { events?: supported event or readonly array of supported events }。 |
condition | 布尔值或返回布尔值的函数。 |
支持的交互事件有 auxclick、click、contextmenu、dblclick、focusin、keydown、keyup、mousedown、mouseenter、mouseover、mouseup、pointerdown、pointerenter、pointerover 和 pointerup。
默认的 interaction() 事件列表是 pointerenter、focusin、pointerdown 和 click。当边界应该监听不同的事件或更小的事件集合时,使用 events:
import { Hydrate } from '@tanstack/react-start'
import { interaction } from '@tanstack/react-start/hydration'
<Hydrate when={interaction({ events: 'dblclick' })}>
<PreviewEditor />
</Hydrate>
<Hydrate when={interaction({ events: ['contextmenu', 'dblclick'] })}>
<ContextMenuEditor />
</Hydrate>在 condition() 边界水合之后,即使条件之后变为 false,它也会保持水合状态:
import { Hydrate } from '@tanstack/react-start'
import { condition } from '@tanstack/react-start/hydration'
export function CartRecommendationsBoundary() {
return (
<Hydrate when={condition(isCartOpen)}>
<CartRecommendations />
</Hydrate>
)
}预取参考
过程式预取接收一个上下文对象:
| 属性 | 含义 |
|---|---|
preload() | 加载编译器生成的子 chunk。当 split={false} 时立即解析。 |
waitFor(strategy) | 等待一个预取策略、水合触发器或中止。 |
signal | 用于可取消异步工作的 AbortSignal,比如 fetch。 |
element | 边界标记元素,用于自定义观察器或 DOM 测量。 |
waitFor(strategy) 会解析为:
| 结果 | 含义 |
|---|---|
'prefetch' | 提供的预取策略正常解析。 |
'hydrate' | 边界的水合触发器先触发。现在做必需的工作。 |
'abort' | 边界卸载或预取生命周期被放弃。 |
过程式预取返回的 Promise 会影响水合时机:如果 when 策略在预取函数完成之前解析,被 await 的工作会阻塞水合:
<Hydrate
when={visible()}
prefetch={async ({ preload }) => {
await preload()
}}
>
<Widget />
</Hydrate>即发即弃(fire-and-forget)的工作不会阻塞水合:
<Hydrate
when={visible()}
prefetch={({ preload }) => {
void preload()
}}
>
<Widget />
</Hydrate>请有意地使用这个区别。当资源是首次水合渲染所必需时,使用 await。当资源只是一个有用的先手优势时,使用即发即弃。
兜底(Fallback)
fallback 不是初始服务端渲染 HTML 的占位符。在初次页面加载时,TanStack Start 会保留现有的服务端 HTML,直到边界水合:
<Hydrate when={visible()} fallback={<ReviewsSkeleton />}>
<Reviews />
</Hydrate>在这个例子中,如果 Reviews 存在于初始 HTML 文档中,用户看到的是服务端渲染的评论。当边界等待 visible() 时,他们不会看到 ReviewsSkeleton。
fallback 用于边界在应用已经运行之后才首次出现、并且该边界没有现有服务端 HTML 的情况。常见例子包括客户端导航、有条件地显示面板,或打开一个内容不在初始文档中的标签页。在这些情况下,边界在客户端渲染,fallback 可以在生成的子 chunk 或子 Suspense 还在加载时显示。
使用 never() 时,初始服务端 HTML 保持静态,fallback 不会被使用。
编译器会从服务端打包产物中移除静态可见的 fallback props。尽量直接传 fallback、用内联对象展开,或通过单次使用的 const 对象展开,这样服务端构建才能剥离那些 UI。
正确性与更新
延迟水合是对 React 初始水合工作的性能提示。如果边界外部的 state、props、context 或 store 更新要求 React 在门(gate)打开之前在边界内部做 reconciliation,React 可能会比策略通常允许的更早水合延迟边界。这保证了正确性,并避免在周围应用发生变化后显示过时的服务端 HTML。
never() 是初始文档水合的例外。把它当作有意静态的 SSR HTML。不要依赖父级更新让 never() 边界变得可交互。如果同一个边界在客户端导航期间更晚挂载,它会正常渲染。
嵌套边界
嵌套边界是父级优先水合的。子边界只能在它的祖先边界水合之后水合。这意味着 visible、media、idle 或 condition 这类非交互的子策略,在父边界仍然未水合时无法运行。
例如,一个产品页可能把整个评论区块延迟到接近视口时才水合,同时让更重的评论工具保持「冷」状态,直到用户与它们交互:
import { Hydrate } from '@tanstack/react-start'
import { interaction, visible } from '@tanstack/react-start/hydration'
export function ProductPage() {
return (
<>
<ProductHero />
<BuyBox />
<Hydrate when={visible({ rootMargin: '600px' })}>
<section aria-labelledby="reviews-heading">
<h2 id="reviews-heading">Reviews</h2>
<ReviewsSummary />
<ReviewsList />
<Hydrate when={interaction({ events: ['focusin', 'click'] })}>
<ReviewFilters />
</Hydrate>
<Hydrate when={interaction({ events: 'click' })}>
<WriteReviewForm />
</Hydrate>
</section>
</Hydrate>
</>
)
}在这个例子中,滚动到评论附近会先水合父边界。只有在那之后,嵌套的交互边界才能因 focus 或 click 而水合。
当祖先本身也在等待交互时,交互意图可以逐层水合整条尚未水合的祖先链:
<Hydrate when={interaction({ events: ['focusin', 'click'] })}>
<section aria-label="Review tools">
<ReviewSortSummary />
<Hydrate when={interaction({ events: 'click' })}>
<WriteReviewForm />
</Hydrate>
</section>
</Hydrate>如果第一个有意义的意图是点击 WriteReviewForm 内部,TanStack Start 会水合未解析的父链,然后为目标边界重新派发一个同类型的事件。原生监听器的负载细节(如指针坐标)不保证被保留。一个 never() 祖先在初始水合期间仍然获胜,所以它下面的后代保持不可交互。
预加载与 CSS
转换后的 Hydrate JavaScript chunk 不会与路由一起被 modulepreload。没有 prefetch 时,子 chunk 在分割的边界准备好渲染时才加载。如果这个导入在客户端导航或其他仅客户端挂载期间挂起,会显示边界的 fallback。
被分割、延迟和 never() 边界使用的 CSS 会在匹配路由的 SSR HTML 中被链接。它不会与生成的子 JavaScript chunk 一起延迟,因为服务端渲染的 HTML 可能在任何 JavaScript 运行之前就需要这些样式。这是路由级的资源链接:如果一个路由模块包含一个导入 CSS 的延迟边界,即使该边界被条件渲染挡住、在特定响应中没有出现,这个样式表也可能为该路由被链接。
提取限制
编译器支持的 Hydrate 分割,是通过把边界的子内容移入一个生成的虚拟模块,并通过一个 lazy 组件渲染它们来实现的。这给了 TanStack Start 一个可以稍后加载的独立子 chunk,但也意味着编译器必须能安全地移动 JSX。
把你想分割的组件直接放在 Hydrate 内部。如果你把它藏在不可见的 children props 后面,编译器就无法在使用点静态地把那些子内容提取到生成的子 chunk 中。
分割的边界必须使用从 @tanstack/react-start 静态导入的 Hydrate 组件。重命名该导入是被支持的:
import { Hydrate as Deferred } from '@tanstack/react-start'
export function ProductPage() {
return (
<Deferred when={visible()}>
<Reviews />
</Deferred>
)
}把 Hydrate 赋给另一个组件变量则不会被分析分割:
import { Hydrate } from '@tanstack/react-start'
const Deferred = Hydrate
<Deferred when={visible()}>
<Reviews />
</Deferred>直接渲染导入的 Hydrate 标签、使用导入重命名,或者在你需要组件间接层时设置 split={false}。
用字面量 prop split={false} 来退出提取。动态值比如 split={shouldSplit} 不能在编译期用于退出。
这些模式不能被分割:
| 模式 | 为什么被拒绝 | 应该怎么做 |
|---|---|---|
| 函数作为 children | 编译器无法移动渲染函数并保留预期的调用模式。 | 用 split={false} 或把要渲染的 UI 移入子组件。 |
| 在提取的 JSX 中直接调用 hook | 移动那部分 JSX 会改变 hook 执行的位置。 | 把 hook 调用移入边界内的组件,然后渲染那个组件。 |
this 捕获 | 提取的函数组件无法安全地保留类实例上下文。 | 把 UI 包进函数组件或用 split={false}。 |
super 捕获 | 提取的函数组件无法保留父类访问。 | 把 UI 包进函数组件或用 split={false}。 |
下面这个会失败,因为 useThing() 会被移入生成的组件:
<Hydrate when={idle()}>
<p>{useThing()}</p>
</Hydrate>改为把 hook 移入一个组件:
function ThingText() {
const thing = useThing()
return <p>{thing}</p>
}
export function ProductPage() {
return (
<Hydrate when={idle()}>
<ThingText />
</Hydrate>
)
}从周围组件捕获的值可以被传入生成的子组件,但请保持边界简单。如果提取开始迫使复杂的数据流,最好使用一个命名子组件,把逻辑放到那里。
fallback 剥离是有意保持保守的。服务端构建可以剥离直接传入的 fallback UI、内联对象展开的 fallback UI,以及单次使用的 const 对象展开的 fallback UI。如果 fallback props 藏在动态展开或共享对象后面,编译器可能会保留它们。
你现在就可以提取可复用的 when 和 prefetch 辅助函数,但如果你需要子代码分割,就避免把分割边界藏在普通包装组件后面。包装器可以在运行时延迟水合,但编译器无法通过任意的组件间接层可靠地把调用点的子内容移入单独的 chunk。