React 18 五:Selective Hydration
SSR 的页面看得见点不动,卡在 hydration。React 18 把切口交给 Suspense 边界:shell 先发,边界里的内容后补,用户点到哪儿就先给哪儿注水。
SSR 的页面有一段很尴尬的时间:HTML 已经画在屏幕上,按钮和输入框看得清清楚楚,点下去却没有任何反应。要再等一会儿,页面才活过来。
这段时间就是 hydration。服务端发回来的只是一串 HTML,事件监听器一个都没有 —— 函数没办法序列化成字符串。客户端拿到 HTML 之后,得把整棵组件树在浏览器里重新跑一遍,让每个组件认领到自己那个已经存在的 DOM 节点,然后才挂事件、初始化 state。
React 18 之前,这件事有两个「全有全无」:整份 HTML 不到齐,客户端不能开始 hydrate;hydrate 一旦开始,就要一口气走完整棵树,中途不让位给别的事。Selective Hydration 拆掉的就是这两个「全」。
先把一个常见的误解挡掉:它不是一个开关,也没有哪个属性用来标记「这个组件先别注水」。 你能写的只有 <Suspense> —— 边界画在哪儿,React 就在哪儿切;切完之后先注水谁、谁能插队,是 React 自己调度的。
整页什么时候能点,由最慢的那个组件决定
renderToString 是同步的:一次调用,一趟走完整棵树,返回一个完整字符串。它没有中途暂停再接着写的能力,所以组件要等数据,就只能在调用之前把数据全部备齐。
React 18 里它其实被换到了新引擎上 —— renderToString 和 renderToStaticMarkup 现在也走 Fizz(ReactDOMLegacyServerImpl.js),只是走法不同:开工之后立刻 abort,把当时能拿到的东西一次性冲出去。所以碰到还没就绪的 <Suspense> 边界,它不等,直接把 fallback 写进 HTML,真正的内容留给客户端渲染。abort 时带的那句话就是让你换 renderToPipeableStream。
客户端这边,React 17 的 ReactDOM.hydrate 同样是一趟不可打断的同步遍历。树越大,主线程被占得越久。这段时间用户的点击不会丢 —— 浏览器照常派发事件 —— 只是没有任何监听器接住它。
两头一凑就是那个尴尬:一个查库慢 800ms 的评论区,能让整页 HTML 晚 800ms 发出去;评论区的组件树大,整页的 hydrate 就多花上这一份。而页面顶部那篇文章正文,可能早就该能读、能点了。
服务端换成 renderToPipeableStream
React 18 换了服务端的入口。Node 环境用 renderToPipeableStream,Deno、Cloudflare Workers 这类支持 Web Stream 的环境用 renderToReadableStream。renderToString 还在,但它拿不到下面这套东西。
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => { let didError = false;
const { pipe, abort } = renderToPipeableStream(<App />, { bootstrapScripts: ['/client.js'],
// shell 好了就发,不等 Suspense 边界里面的数据 onShellReady() { res.statusCode = didError ? 500 : 200; res.setHeader('Content-Type', 'text/html'); pipe(res); },
// 头一旦发出去状态码就改不了,这里是最后一次改的机会 onShellError(error) { res.statusCode = 500; res.setHeader('Content-Type', 'text/html'); res.send('<!doctype html><p>出错了</p>'); },
onError(error) { didError = true; console.error(error); }, });
// 流不能无限等下去。掐掉之后没发完的边界退回客户端自己渲染 setTimeout(abort, 10000);});关键是 onShellReady。shell 指的是所有 <Suspense> 边界之外的那部分内容,它渲染完就能发走,边界里面的慢慢来,顺着同一个连接往后追加。
onAllReady 是它的另一面:等所有边界都好了才回调。爬虫和静态生成走这条,因为它们要的是一份完整 HTML,不是尽快看到第一屏。
两个入口写的都是 from 'react-dom/server',但那是个条件导出:Node 下解析到 server.node.js,只有 renderToPipeableStream;browser、worker、deno 条件下解析到 server.browser.js,只有 renderToReadableStream。打包器的条件猜错了,报的是「XX 不是一个函数」,跟流式渲染看着毫无关系。renderToReadableStream 那边也没有 onShellReady 和 onAllReady —— 它返回的 Promise 兑现就等于 shell 好了,全部好了看 stream.allReady。
边界画在哪儿,切口就在哪儿
服务端的分段和客户端的注水单位是同一个东西 —— <Suspense>。
function App() { return ( <Layout> <Nav /> <Article /> {/* 包上这一层之后,Comments 不再挡着上面那些先发出去 */} <Suspense fallback={<CommentsSkeleton />}> <Comments /> </Suspense> </Layout> );}Nav 和 Article 属于 shell,第一时间就发。Comments 在等数据,服务端先把 CommentsSkeleton 写进 HTML,等数据回来再补发真正的内容。
这也解释了为什么没有「标记脱水」这回事:边界就是标记本身。你决定的是切口的位置,不是注水的顺序。
HTML 里的注释节点是边界的状态位
这一段值得自己看一眼,因为它把「选择性」变得很具体。用 curl 把流原样打出来(--no-buffer 别省,否则终端会替你攒完再显示):
curl --no-buffer -N http://localhost:3000/你会看到响应不是一次到齐的。先来的是 shell,末尾挂着占位内容:
<nav>…</nav><article>…</article><!--$?--><template id="B:0"></template><div class="skeleton">加载中…</div><!--/$--><!--$?--> 是 React 写进去的状态位,意思是「这个边界还没好」。数据回来之后,同一个连接上追加第二段:真正的内容先藏在页面末尾,再由一小段内联脚本搬到占位符的位置上,顺手把状态位改掉。
客户端要认边界,靠的就是这几个注释节点 —— DOM 里没有别的地方能存住「这块是哪个 Suspense 边界、好了没有」。想对着源码看的话,写出这几个标记的是 packages/react-dom/src/server/ReactDOMServerFormatConfig.js,读它们的常量在 packages/react-dom/src/client/ReactDOMHostConfig.js(SUSPENSE_START_DATA、SUSPENSE_PENDING_START_DATA 这一组,存的是去掉 <!-- --> 之后的 $ 和 $?)。
React 内部把这种还没等到内容的边界叫 dehydrated(脱水)边界。Suspense 的 state 上有个 dehydrated 字段,指的就是那个注释节点:
export type SuspenseState = {| dehydrated: null | SuspenseInstance, treeContext: null | TreeContext, retryLane: Lane,|};(本文提到的源码路径都按 v18.2.0 的树写。这几个文件后来挪过位置,换个 tag 搜不到是正常的。)
客户端只有 hydrateRoot 一行
import { hydrateRoot } from 'react-dom/client';import App from './App';
hydrateRoot(document.getElementById('root'), <App />);就这些。hydrateRoot 在 packages/react-dom/src/client/ReactDOMRoot.js,和 createRoot 的区别只在建根节点那一步:它调的是 createHydrationContainer 而不是 createContainer,意思是「DOM 已经在了,去认领,别重建」,返回的也是另一个类 ReactDOMHydrationRoot。
options 里能传的是 identifierPrefix、onRecoverableError 这些,没有一项是注水策略。文章开头那句话在这里能验证一遍:能调的东西只有 <Suspense> 边界的位置。
点到还没注水的组件,React 会插队
前面都还只是「分段」。让它变成 selective 的是这一条:注水顺序可以被用户改。
hydrateRoot 一上来就把所有事件都监听在根容器上了,不管里面注水没注水。所以用户点到一个还没注水的边界时,事件不会就地丢掉 —— React 在捕获阶段把它记下来,把这个边界拎出来先注水,注完再把刚才那次点击重放一遍。
这套逻辑在 packages/react-dom/src/events/ReactDOMEventReplaying.js,按事件类型分两条路:点击这种离散事件走 attemptDiscreteHydration,用的是 SyncLane,同步注水,插到所有事情前面;鼠标移动这种连续事件走 attemptContinuousHydration,用的是专门的 SelectiveHydrationLane,优先级抬高但不至于抢断。
于是行为变成这样:
- 边界之间没有固定顺序。文档里排在后面的评论区,可能比排在前面的侧边栏先注水,只要用户先点了它。
- 注水本身可以被打断。它跑在并发调度里,让位给更紧急的更新,不再是一趟锁死主线程的遍历。
- 点击不白点。等待时间还在,但那一次交互不会因为「当时还没注水」就消失。
这才是 Selective Hydration 真正卖的东西:不是让注水变快,是让注水的顺序对上用户的注意力。 总的 JS 执行量一点没少。
代价
<Suspense> 边界不是免费的,切之前先掂量:
- 每个边界都要占 HTML 体积。 一对注释节点、一个
<template>、外加补发时的一小段内联脚本。边界切得太碎,这些加起来不容小看,而且每次补发都可能带来一次布局抖动。 - 状态码只有一次机会。 shell 一发出去,响应头就定死了。边界内部再出错也改不了 500,只能靠 fallback 或者退回客户端渲染。
onShellError是最后一道能改的地方。 - 中间层可能把流吃掉。 一些 CDN、反向代理和 serverless 平台默认缓冲整个响应体。缓冲之后流式就退化成
renderToString,前面所有分段的收益归零,而且不会报任何错 —— 只能自己curl一次确认。 - fallback 会被真的看见。 传统 SSR 里 Suspense 的 fallback 在服务端基本不出现,流式之下它是首屏的一部分。骨架屏和最终内容尺寸差太多,用户看到的就是一次跳动。
反过来,什么时候不该切:内容很快就能拿到的组件不用包 —— 让它待在 shell 里,比多一个边界划算。首屏正上方的内容也别包,那块地方需要的是尽早稳定,不是尽早发出。
按数据延迟和用户注意力去画边界,别按组件树的形状画。评论区、推荐位、侧边栏这类「慢、而且用户不会第一眼看」的东西是天然的边界;导航和正文不是。
这篇是 React 18 的第 5 篇。前一篇是 React 18 四:Suspense 与异步渲染,后一篇是 React 18 完结:最佳实践与注意事项。