实战指南:用 `Hydrate` 边界精准控制首屏交互时机)
TanStack Start 延迟水合Deferred Hydration实战指南用Hydrate边界精准控制首屏交互时机【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router延迟水合Deferred Hydration目前仍处于实验阶段API 可能在后续版本中调整。TanStack Start 默认在首屏加载时对整份 SSR 文档执行水合hydration让服务端产出的 HTML 迅速可交互。但大型页面的启动时间往往浪费在加载并水合那些用户暂时用不到的区块上。本文以 docs/start/framework/react/guide/deferred-hydration.md 为核心系统讲解如何用Hydrate边界把页面上的部分区域标记为暂不可交互让 SSR HTML 先展示、再按需水合并深入react-start、react-start-client、start-client-core的源码说明策略实现、代码分割与正确性保障的底层原理。读完本文你将掌握七种水合策略的选型、when/split/prefetch三项决策、常见场景配方以及编译器对可分割边界的提取限制。为什么需要延迟水合首次页面加载时TanStack Start 在服务端渲染出页面 HTML浏览器可以立刻显示有用的内容。水合hydration则是客户端把这份初始 HTML 文档变成可交互应用的工作加载并执行 JavaScript、运行组件、绑定事件处理器、把现有 DOM 重新接入 React。延迟水合只作用于这份初始文档的水合过程。应用运行之后后续的客户端导航完全由客户端应用渲染不存在需要保留的初始服务端 HTML因此不涉及延迟水合。默认情况下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它就会在客户端正常渲染。从源码看Hydrate组件的分发逻辑位于 packages/react-start-client/src/Hydrate.tsx当when传入的是函数时服务端渲染走ServerDynamicHydrate仅输出一个带data-ts-hydrate-id与data-ts-hydrate-when属性的标记 div 包裹的Suspense客户端则调用props.when()._h(props)交给对应策略的渲染器当when是策略对象时直接调用props.when._h(props)。也就是说每个策略都携带了自己的 React 渲染函数_h边界标记元素正是通过这些 data 属性与运行时关联的。选择要延迟的内容正确的边界取决于你的页面、产品优先级和真实用户行为——TanStack Start 无法替你判断页面哪些部分可以安全推迟。好的候选者通常是无需立即交互的 SSR 内容首屏之下的评论、留言、商品详情、相关内容或较长的营销区块富交互组件如地图、图表、轮播、视频播放器、编辑器或嵌入内容由用户意图触发的面板如筛选器、预览窗格或上下文工具只对特定媒体查询media query才有意义的 UI不应在初始文档水合的静态 SSR 内容。差的候选者是用户可能立即需要的部分主导航、路由外壳chrome、搜索框和账户控件首屏之上的表单、加购按钮、结账操作或同意控件LCP 或 hero 区域中用户可能立刻点击的交互部分必须随页面出现立即可键盘操作的无障碍关键控件其 props、context 或共享状态预期在应用启动后立即更新的组件。务必为每个边界做测量一个有用的边界应当减少启动 JavaScript 或水合工作量同时不会让预期的交互显得迟滞。与 Astro Islands 的对比Astro 以静态为起点问的是哪些内容应该活过来每个答案都是一个被丢进 HTML 的独立框架根节点。Islands 是共享同一个 DOM 的独立运行时。TanStack Start 则以完全可交互为起点问的是哪些内容可以等待整份文档默认作为一个 React 树水合Hydrate边界只是这棵树内部的闸门gate。Context、state 与事件都正常流动水合按父先子后的顺序进行。两者共享同一套触发词汇底层基底却不同Astro 组合运行时Start 调度单棵树。这正是 Start 能提供interaction()、condition()以及意图冒泡intent bubbling而 Astro 能提供多框架支持的原因。与 React 选择性水合的对比React 的选择性水合selective hydration控制的是服务端渲染边界水合的顺序延迟水合控制的是每个边界是否水合以及何时水合。当 React 水合一个流式 SSR 页面时每个服务端渲染的Suspense边界最终都会水合。选择性水合只是决定顺序每个边界在其代码到达后尽快水合如果用户点击了边界内部React 会把它跳到队列最前面。水合的总工作量由服务端渲染的内容决定React 只是调度它以保证响应性。延迟水合改变的则是队列里一开始放什么。一个Hydrate边界声明一个条件——visible()、idle()、interaction()、media()、condition()或never()——在条件触发前边界保持为静态服务端 HTML。默认情况下子 JavaScript 也会被移入独立的 chunk浏览器直到边界即将水合时才下载它。如果条件永不触发边界就永不水合其代码也永不被拉取。两者可以组合使用Hydrate边界决定 React 是否、以及何时开始水合某个子树一旦闸门打开其内部的一切包括Suspense边界都会回到 React 正常的水合调度器中。需要水合且希望 React 做好优先级时用Suspense水合可能根本不需要发生时用Hydrate。每个边界的三个性能决策每个Hydrate边界都有三个独立的性能决策决策选项控制内容水合时机Hydrationwhen保留的服务端 HTML 何时变得可交互。代码分割Code splitsplit子元素是否移入生成的延迟子 chunk。预准备Preparationprefetch是否在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()。从 packages/react-start-client/src/Hydrate.tsx 可以看到函数形式在服务端会被替换为ServerDynamicHydrate因此服务端始终输出静态 HTML客户端才真正求值函数并选择策略这正是它仅在客户端可用的原因。split决定是否创建独立子 chunk默认情况下Hydrate会把子元素拆进一个生成的子 chunkHydrate 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 ) }注意split必须是字面量false不能使用split{shouldSplit}这类动态值——编译器需要在编译期就确定能否做静态提取详见后文提取限制。prefetch决定是否在水合前就开始加载prefetch在边界水合之前就开始加载。它有两种形式形式示例适用场景策略形式Prefetch strategyprefetch{idle()}在水合前预加载生成的子 chunk。程序化形式Procedural prefetchprefetch{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——这一点在 packages/react-start-client/src/Hydrate.tsx 的HydrateOptions联合类型中得到了体现prefetch: HydrationPrefetchStrategy的分支强制split?: true。需要自定义工作时使用程序化 prefetchimport { 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 ) }程序化 prefetch 同样适用于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。在需要之前下载子 chunkimport { 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。让控件保持冷直到用户意图出现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 已经在启动 bundle 中、或独立子 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()不能用作 prefetch 策略。从 packages/react-start-client/src/hydration/never.tsx 的实现看NeverGate在客户端通过reactUse(neverPromise)抛出一个永不 resolve 的 PromiseneverPromise new Promisevoid(() {})使子树永远挂起同时用saveFallbackHtml(id, element)把边界内的服务端 HTML 保存下来、通过dangerouslySetInnerHTML原样还原从而保证静态 HTML 不被水合逻辑破坏。复用 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 内部始终保留最新回调不会因为函数标识identity变化而重新注册水合监听器。如果边界的语义发生变化请使用普通的 Reactkey来创建一个新边界。Hydrate Props 参考Hydrate接受以下 propsProp类型说明whenHydrationStrategy \| () HydrationStrategy必填。控制边界何时水合。函数形式仅限客户端且必须同步。prefetchHydrationPrefetchStrategy \| HydrationPrefetchFunction可选。策略形式预加载被分割的子 chunk。函数形式可以预加载 chunk、数据或其他资源并可用于split{false}。splitboolean默认为true。设置字面量false可禁用编译器提取仅推迟水合工作。fallbackReactNode仅客户端使用的加载 UI用于应用水合完成之后才挂载、并在子 chunk 或子Suspense上挂起的边界。onHydrated() void边界在客户端水合完成后触发一次。策略参考策略从tanstack/react-start/hydration导入该模块在 packages/react-start/src/hydration.ts 中从tanstack/react-start-client/hydration原样转发策略行为load()应用水合后立即水合。idle()在requestIdleCallback中水合空闲回调不可用时在timeout之后水合。visible()边界标记进入视口时水合。media()媒体查询匹配时水合。interaction()在配置的交互意图事件上水合。condition()条件变为真值后水合。never()永不在初始服务端渲染的边界上水合。策略选项策略选项idle{ timeout?: number }默认为2000。visible{ rootMargin?: string; threshold?: number \| Arraynumber }默认 margin 为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——这在 packages/start-client-core/src/hydration/interaction.ts 的defaultInteractionEvents中有直接体现。当边界应当监听不同事件或更小的事件集合时使用eventsimport { 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 / /Hydratecondition()边界水合后即使条件之后变回 false它也会保持水合状态import { Hydrate } from tanstack/react-start import { condition } from tanstack/react-start/hydration export function CartRecommendationsBoundary() { return ( Hydrate when{condition(isCartOpen)} CartRecommendations / /Hydrate ) }实现层面visible()在 packages/react-start-client/src/hydration/visible.tsx 中通过IntersectionObserver观察边界标记元素默认rootMargin: 600px、threshold: 0一旦isIntersecting就断开 observer 并放行idle()则依赖requestIdleCallback不可用时按timeout兜底。Prefetch 参考程序化 prefetch 接收一个上下文对象属性含义preload()加载编译器生成的子 chunk。split{false}时立即 resolve。waitFor(strategy)等待一个 prefetch 策略、水合触发或中止。signal用于可取消异步工作如fetch的AbortSignal。element边界标记元素可用于自定义观察器或 DOM 测量。waitFor(strategy)的 resolve 结果HydrationPrefetchWaitReason定义于 packages/start-client-core/src/hydration/types.ts结果含义prefetch提供的 prefetch 策略正常 resolve。hydrate边界的水合触发先发生。现在做必要的工作。abort边界已卸载或 prefetch 生命周期被放弃。程序化 prefetch 返回的 Promise 是有意义的。被 await 的工作会阻塞水合——如果when策略在 prefetch 函数完成之前 resolveHydrate when{visible()} prefetch{async ({ preload }) { await preload() }} Widget / /Hydratefire-and-forget 的工作不会阻塞水合Hydrate when{visible()} prefetch{({ preload }) { void preload() }} Widget / /Hydrate请刻意使用这种区分当资源是首次水合渲染所必需时用 await当资源只是有用的提前量时用 fire-and-forget。关于 fallback 的正确理解fallback不是初始服务端渲染 HTML 的占位符。在首次页面加载时TanStack Start 会在边界水合前一直保留现有服务端 HTMLHydrate when{visible()} fallback{ReviewsSkeleton /} Reviews / /Hydrate这个例子里如果Reviews存在于初始 HTML 文档中用户看到的是服务端渲染的评论在边界等待visible()期间不会看到ReviewsSkeleton。fallback用于边界在应用已经运行之后才首次出现、且该边界没有现成服务端 HTML 的情况。常见场景包括客户端导航、条件性显示面板、打开一个初始文档中没有内容标签页。此时边界在客户端渲染fallback可以在生成的子 chunk 或子Suspense仍在加载时显示。对于never()初始服务端 HTML 保持静态fallback不会被使用。编译器会从服务端 bundle 中移除静态可见的fallbackprops。请优先直接传递fallback、通过内联对象展开传递、或通过单次使用的const对象展开传递这样服务端构建才能剥离这部分 UI。正确性与更新延迟水合只是对 React 初始水合工作的性能提示。如果边界外部的 state、props、context 或 store 更新要求 React 在闸门打开之前就对其内部进行调和reconcileReact 可能早于策略允许的时机水合该延迟边界。这保证了正确性避免在外围应用已经变化后仍显示过期的服务端 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-labelledbyreviews-heading h2 idreviews-headingReviews/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-labelReview tools ReviewSortSummary / Hydrate when{interaction({ events: click })} WriteReviewForm / /Hydrate /section /Hydrate如果第一个有意义的意图是WriteReviewForm内部的 clickTanStack Start 会先水合未解析的父链然后为目标边界重新派发一个同类型事件。原生监听器 payload 的细节如指针坐标不保证被保留。never()祖先在初始水合期间仍然优先因此它下面的后代保持不可交互。预加载与 CSS 的关系转换后的HydrateJavaScript chunk不会随路由一起被 modulepreload。没有prefetch时子 chunk 在分割边界准备好渲染时才会加载。如果该 import 在客户端导航或其他仅客户端的挂载过程中挂起边界的fallback会显示。被分割、延迟以及never()边界使用的CSS 会在匹配路由的 SSR HTML 中链接。它不会随生成的子 JavaScript chunk 一起延迟因为服务端渲染的 HTML 在任何 JavaScript 运行之前就可能需要这些样式。这是路由级的资源链接如果路由模块包含一个导入了 CSS 的延迟边界即使该边界藏在条件渲染后面、没有出现在某次特定响应中该样式表也可以为该路由链接。编译器的提取限制编译器支持的Hydrate分割其原理是把边界的子元素移入一个生成的虚拟模块并通过 lazy 组件渲染它们。这让 TanStack Start 能获得一个稍后加载的独立子 chunk但也意味着编译器必须能安全地移动 JSX。把你要分割的组件直接放在Hydrate内部。如果把它藏在透明的childrenprops 后面编译器无法在使用点把这些 children 静态提取进生成的子 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}。使用字面量 propsplit{false}来退出提取。split{shouldSplit}这类动态值无法在编译期退出。以下模式无法被分割模式拒绝原因替代方案函数作为 childrenfunction-as-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辅助函数但如果需要子代码分割请避免把分割边界藏在普通包装组件后面。包装组件可以在运行时延迟水合但编译器无法可靠地通过任意组件间接层把调用点的 children 移入独立 chunk。小结延迟水合把整页水合拆解为按需水合服务端先输出完整可读、可索引的 HTML客户端用Hydrate边界声明七个策略load、idle、visible、media、interaction、condition、never之一控制每个区域何时、以及是否变为可交互。三个决策维度——when何时水合、split是否分割子 chunk、prefetch是否提前准备——让你能独立权衡启动 JS 体积、首屏交互延迟与代码加载时机配合嵌套边界的父先子后顺序、意图冒泡、fallback语义与编译器的提取限制即可在真实产品页面上安全落地。更多资料可参考 docs/start/framework/react/guide 下的其他指南以及源码 packages/react-start-client/src/Hydrate.tsx、packages/react-start-client/src/hydration 与 packages/start-client-core/src/hydration含各策略实现与运行时。【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考