Preact 中的异步水合
原文链接:jovidecroock.com/blog/resume…
原文作者:Jovi De Croock
写于 2026 年 7 月 13 日
我以前在另一篇文章里写过水合(hydration),但我觉得早该再回头谈这个话题了。补看那篇或许值得,不过简要结论是:水合是一个同步过程——Preact 把服务端发来的 HTML 与已执行 JavaScript 所表示的虚拟 DOM 对照,并借此让页面可交互。每个 DOM 节点都会被访问,我们再按虚拟 DOM 描述挂上事件处理函数、refs 等细节。一旦发现不一致,就中止水合,并当作 mismatch(不匹配)处理。
为什么「同步」这件事重要?因为它是全有或全无:在树所需的每一段 JavaScript 与数据都抵达并执行完之前,页面上没有任何部分是可交互的;而在大型树上,水合过程本身就可能长时间占住主线程,长到一次点击都得不到响应。应用越大,用户盯着一张看起来就绪、实际却还不行的页面的时间就越长。
这种同步行为常被当成 Preact 的固有局限。其他技术(例如 React)有选择性水合(selective hydration),人们也因此低估 Preact——因为……嗯,我们不太擅长把这些话题讲清楚。若你此刻正责怪我以前没提过,那你完全有理由。
这篇文章要说的是:Preact 并不限于这种全有或全无模型。续接水合(resumed hydration)已经能让 Suspense 边界各自独立水合。Preact 11 里的 Hydration 2.0 改变了我们如何识别这些边界所拥有的 DOM,而流式渲染(streaming)则改变了这些 DOM 何时到达浏览器。它们是相关步骤,但不是同一特性。
进入续接水合
我从未真正定下 Preact 里这套做法的名字,但我常称之为续接水合(resumed hydration)。它的异步性质通过 Suspense 实现。在 Preact 中,我们经 compat 包、preact-suspense 包以及 preact-iso 路由提供支持。它们都可以选择启用续接水合。
这里要考虑两块:服务端与客户端。服务端历史上用 renderToString 把虚拟 DOM 渲染成 HTML 字符串,供客户端呈现给用户。进入续接水合世界后,我们改用 renderToStringAsync。差别不大,只是它会 await 任何挂起的虚拟 DOM 节点,并在每一块解析完成后继续拼出 HTML 字符串。
在客户端,我们开始对收到的 HTML 字符串做水合。当某个子节点挂起时,Preact 会暂停该子树的水合,记住在服务端渲染 DOM 中停在何处,并继续水合应用的其余部分。一旦缺失的数据或 JavaScript 解析完成,Preact 再回到该子树,使其可交互。
这种暂停再继续的行为就是续接水合,而且今天已经存在。Hydration 2.0 并不是引入它;它改的是 Preact 如何记录每个被暂停子树所对应的 DOM。子树暂停期间,其服务端渲染视图仍然可见,我们也不会用 Suspense 的 fallback(回退 UI)去替换它。
一次阻塞主线程的工作也会变少。当某个边界在等待代码或数据时,浏览器可以渲染、响应输入并做其他工作,而不必把时间花在水合一棵尚未就绪的子树上。每个边界恢复时水合仍占用主线程,但这是分成更小的片段进行,而不是一次漫长的全有或全无扫描。与此同时,已经水合完的部分已经可交互。
续接水合会保持服务端 HTML 可见,再在每个 Suspense 边界能够继续时补上可交互性。
那么这与 React 的选择性水合相比如何?实践中,它覆盖了人们提起选择性水合时通常指的大部分含义:边界各自独立水合,且服务端渲染内容在此期间保持可见。差别在于优先级:React 把交互当作信号,会优先水合你刚点到的那个边界;而 Preact 目前按各边界的代码或数据解析完成的顺序来恢复。
Hydration 2.0
这套做法也带来了陷阱,正如所有这类方案都会……最初的续接水合算法只记住且只记住 1 个可继续的 DOM 节点。这实际上假定:一个会挂起的 VNode 最终恰好产出一个 DOM 节点。
当 VNode 不产出任何 DOM(例如组件返回 null),或产出多个节点(例如带两个根元素的 Fragment)时,该假定就会破掉。Preact 可能从错误节点恢复,导致水合不匹配、重建 DOM、重复兄弟节点,或丢失状态。
Hydration 2.0 用服务端渲染 HTML 中的显式边界,取代了这种单节点假定。renderToStringAsync 会在挂起 VNode 所产出的 DOM 周围发出开闭标记。相邻标记表示没有 DOM;同一对标记之间可以放一个或多个节点。因此在客户端,Preact 可以在恢复之前识别出属于该挂起子树的完整范围。
最好把 Hydration 2.0 理解成:让我们既有的续接水合变得可靠,而不是第一次引入异步水合。它将作为 Preact 11 的一部分发布。
我们尚未解决的问题(目前)
Hydration 2.0 告诉我们哪些 DOM 属于被续接的子树。它本身并不能保证该子树在服务端与客户端生成相同的 id。这正是我们的 useId() hook 带来的另一问题。它发出的 id 由树中的位置推导而来,且必须在服务端与客户端之间一致——服务端 HTML 里的 aria-describedby 等属性会指向它们。当挂起的兄弟节点在客户端以不同于服务端的顺序解析时,客户端会推导出与 HTML 中已有 id 不同的值:引用会指向不再存在的 id,或两个元素最终共用一个 id。我们尝试过的一件事是 Fix useId stability across async Suspense (#5108),它让异步 Suspense 兄弟即便解析顺序不同也能保持相同 id。我们不得不在 #5135 中回滚:当仅客户端分支、或已完成水合的分支在水合过程中渲染时,会推移后续 Suspense 边界用于推导 id 的树位置,从而重新引入该问题。
useId 的问题不是产出唯一 id;而是在异步边界以不同顺序完成时,仍要产出相同的 id。
正是这次回滚,让我尚未对方案完全满意。我试过几个版本,但此刻我在考虑:要么让用户为 Suspense 边界指定名称,要么从树位置推导一个名称,再把它用作该挂起子树中 useId 调用的修饰符。
流式渲染
React 用略有不同的方式解决了这一点。它不是在服务端等所有异步都解析完再发送完整 HTML,而是流式发送 shell(壳层)、fallback 以及随可用而就绪的已解析 HTML 块,再在客户端对其水合。老实说,这种方式带来的古怪边界情况更少,也是很棒的特性。你永远不会听见我否认这一点。在 Preact 中,我也已开始推进在客户端正确水合这些流式块的工作,它同样应成为 Preact 11 的特性。进展见这个 issue。
流式渲染改变了“暂停键”:服务端可以先冲刷 shell 与 fallback,再在边界解析完成后发送其完成的 HTML。
关于流式渲染有一点很积极:它不只是在 HTML 完成时才流式发出。用户会更快在屏幕上看到内容,因为当 shell 渲染完成、我们到达一个挂起的子节点时,服务端就可以冲刷 shell 以及该 Suspense 边界的 fallback。
你当然也可以自己近似做到:快速服务端渲染一个骨架、冲刷出去,再开始 renderToStringAsync,然后冲刷结果。内置流式只是更优雅的版本:用户看到每一块依次出现,并几乎立刻可交互,而不是整页在一个偏晚的大块里一起到达。
回顾一下:续接水合已经能让 Preact 让 Suspense 边界各自独立变得可交互,并在边界等待时给主线程处理其他工作的机会。Hydration 2.0 让这些边界在包含零个、一个或多个 DOM 节点时都可靠。流式渲染还将让它们的 HTML 也能各自独立到达。跨异步边界的稳定 useId 生成仍是未解问题。
希望这些内容多少能说明我们在 Preact 水合方面一直在追求什么。我希望用 pracht 让这一切更容易用——这是我们正在构建的全栈框架:你可以按路由选择渲染模式(SSG、SSR、ISG、SPA),并在不必自己接线的情况下获得上述全部能力。若你觉得还有什么值得我们加上,请告诉我们。我们非常欢迎反馈!