SSR、水合与服务端组件:HTML 从哪里来,交互在哪里接上
客户端应用先取得 HTML 空壳,再下载脚本、运行组件、获取数据,最后才有内容。即使把请求写成正确的 Effect,第一次 HTML 响应仍可能只有“加载中”。服务端渲染(SSR)把首次组件求值提前到服务器,浏览器先得到可显示的 HTML;水合再让客户端 React 接管这份已有 DOM。服务端组件(RSC)进一步决定哪些组件代码只在服务端执行、哪些需要发到客户端。三者处理的阶段不同。
两次执行,中间传递的不是事件函数
一个计数按钮的服务器首次渲染会读到初始值 2,产生带文字的 button。onClick 是函数,HTML 不能把那个 JavaScript 闭包传给浏览器。浏览器虽然能显示“计数 2”,此时还没有 React 点击行为。
客户端加载相同组件和初始数据,再调用 hydrateRoot。React 对已有结构进行水合,使事件与后续状态更新开始工作。它仍会执行组件来建立客户端的状态与事件逻辑;SSR 并不是“组件以后永远不用在浏览器运行”。
最小实验:先生成页面,再接上交互
沿用本书已有 React 19 + Vite 项目。在项目根目录新建 hydration-lab 文件夹,放入下面三个文件;它有自己的入口,不替换原来的 src/App.jsx。先运行 node hydration-lab/render.mjs,再启动原来的 npm run dev,访问开发服务的 /hydration-lab/。
这里用 createElement(type, props, ...children) 写共享组件,功能相当于对应 JSX。这样 Node 可以直接读取 .mjs,无需为一次服务器实验增加 JSX 编译器。React、react-dom 和 Vite 使用已有依赖。
// hydration-lab/App.mjs
import { createElement as h, useEffect, useState } from 'react'
export default function App({ initialCount }) {
const [count, setCount] = useState(initialCount)
useEffect(() => { console.log('客户端 Effect 已运行') }, [])
return h('section', null,
h('h1', null, '同一份初始内容'),
h('button', { onClick: () => setCount(n => n + 1) }, `计数 ${count}`),
)
}// hydration-lab/render.mjs
import { writeFile } from 'node:fs/promises'
import { createElement } from 'react'
import { renderToString } from 'react-dom/server'
import App from './App.mjs'
const initialCount = 2
const markup = renderToString(createElement(App, { initialCount }))
const html = `<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><title>水合实验</title>
<body>
<div id="root" data-initial-count="${initialCount}">${markup}</div>
<button id="hydrate" type="button">连接 React 交互</button>
<pre id="trace">尚未水合</pre>
<script type="module" src="./client.mjs"></script>
</body></html>`
await writeFile(new URL('./index.html', import.meta.url), html)
console.log('已生成 hydration-lab/index.html')// hydration-lab/client.mjs
import { createElement } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App.mjs'
const container = document.querySelector('#root')
const trace = document.querySelector('#trace')
const trigger = document.querySelector('#hydrate')
const before = container.querySelector('button')
trigger.addEventListener('click', () => {
trigger.disabled = true
const initialCount = Number(container.dataset.initialCount)
hydrateRoot(container, createElement(App, { initialCount }), {
onRecoverableError(error) {
trace.textContent += `\n恢复错误:${error.message}`
},
})
trace.textContent = '已请求水合,稍后点击计数按钮'
setTimeout(() => {
trace.textContent += `\n仍是原按钮:${before === container.querySelector('button')}`
}, 100)
})人工按钮把通常自动进行的水合暂停下来,便于分别观察:
- 页面打开已经显示计数 2;点计数按钮,数字不变。查看生成的 HTML 文件,里面确实已有 button,不是浏览器脚本刚刚创建的。
- 点击“连接 React 交互”,等客户端 Effect 日志,再点计数按钮,数字变为 3。正常匹配时,实验显示“仍是原按钮:true”。
- 刷新页面又回到 2。此次没有保存数据,服务器生成的初始文件也没变。
100ms 只用于延后打印 DOM 身份,不是水合完成协议;慢设备以实际 Effect 与交互观察为准。服务器调用 renderToString 不运行 Effect,也不执行事件处理器。客户端执行组件和 Effect,才能获得后续交互。若开发入口额外包 StrictMode,开发模式的重复检查也不代表服务器发了两次请求。
故意让两边不同,观察 React 为什么要恢复
只把 client.mjs 的 initialCount 改为 9,保留服务器文件中的 2,刷新后再连接交互。服务器已有“计数 2”,客户端首次却要求“计数 9”。文本不一致可能导致 React 报告可恢复错误并重新生成受影响树,原按钮身份也可能改变。不要依赖错误消息的完整文字或一定修补每个属性;应把水合不一致当作需要修复的问题。
恢复从 data-initial-count 读取,就恢复了共同输入。这里仅传受程序控制的数字。真实页面传输复杂数据时,要采用框架提供的安全序列化,不能把未经处理的 JSON 任意拼入 script 标签:数据中的结束标签可能突破 HTML 上下文。
同类故障也来自首次渲染直接读 Date.now、Math.random、localStorage、窗口宽度,或服务器与客户端使用不同的地区/时区。typeof window !== 'undefined' 虽然避免服务端访问报错,却可能让两边第一份 JSX 不同,仍然破坏一致性。需要客户端特有内容时,先用共同的初始占位,再在 Effect 中更新;代价是多一次渲染和可能的内容变化。
useId 用于建立跨服务端与客户端一致的关联 id,但要求组件树与前缀等条件一致;不能用随机 id 替代,也不能拿 useId 充当数据列表 key。具体表单关联与 Portal 例子见Portal 与可访问标识。
renderToString 的边界:不是完整服务器框架
本例在构建时生成一次 HTML,用来固定观察两阶段;按请求生成时可以把相同渲染调用放进 HTTP 请求处理器,但还需路由、状态码、数据、错误与资源管理。客户端显示 404 并不替代服务器返回 404 状态。
renderToString 同步返回字符串,不会等待暂停的数据再逐块补全;Suspense 内容暂停时会输出回退,并交给客户端后续处理。需要流式 SSR 的 Node 服务使用 renderToPipeableStream;Web Streams 环境可用 renderToReadableStream。流式输出让可用的外壳先发送,不能自动让依赖彼此的请求并行,也不是 RSC 的同义词。
不要在水合尚未完成时立即 root.render 重画根;那会让 React 提前切换到客户端渲染。没有服务器 HTML 的空容器仍使用 createRoot,已有服务器结果则使用 hydrateRoot。
RSC 改变的是哪些组件代码需要送到客户端
前面 App.mjs 同时给 Node 和浏览器加载,因此它只是普通组件的 SSR,不是 Server Component。RSC 需要能区分模块边界、生成专用传输结果并装配客户端引用的框架或构建配置。下面是支持 RSC 的框架中的模块边界示意,不是普通 Vite 的可运行替换文件:
// Counter.jsx:客户端边界
'use client'
import { useState } from 'react'
export default function Counter({ initialCount, children }) {
const [count, setCount] = useState(initialCount)
return <section>{children}<button onClick={() => setCount(n => n + 1)}>
{count}
</button></section>
}// Page.jsx:由支持 RSC 的框架作为服务端入口执行
import Counter from './Counter.jsx'
export default async function Page() {
const note = await Promise.resolve({ title: '服务端取得的标题', count: 2 })
return <Counter initialCount={note.count}><h1>{note.title}</h1></Counter>
}按执行与传输追踪这两个文件:
- 框架在构建时或请求时运行 Page,等待数据,得到标题节点以及对 Counter 客户端模块的引用。
- RSC 结果描述需要组装的树、客户端引用和可传输 props;不是把 Page 函数源码发给浏览器,也不等于一段普通 HTML。
- 首次访问时框架还可以将这份结果 SSR 为 HTML;Counter 虽被标为客户端组件,仍可能参与服务器的 HTML 预渲染,不能假定其 render 可任意访问 window。
- 浏览器取得 Counter 的客户端代码和需要的数据,水合交互。Page 的数据读取与服务端专用依赖不需要进入客户端 bundle。
'use client' 影响模块依赖图:Counter 导入的交互依赖也进入客户端边界。Page 传给 Counter 的 children 已由服务端产生,并不因为 JSX 视觉上嵌在 Counter 内,就把 Page 模块变成客户端模块。反过来,在 Counter 内直接导入需要数据库的服务端模块,并不能靠文件名保证它留在服务器。
服务端组件不能拥有浏览器事件 state/Effect;交互放进客户端组件。跨边界 props 要符合 React 支持的序列化规则,普通事件闭包不能直接跨越;Server Function 引用属于框架支持的特殊通道。React 的可传输类型比 JSON 更多,不应笼统说“只能 JSON”,也不能传任意类实例。
'use server' 标记 Server Function,不是标记 Server Component。服务端运行也不意味着输出天然保密:写入 JSX 或传给客户端 props 的值会到达用户。数据库权限、请求身份与可见字段必须在服务端确定。
练习:一致的首屏与客户端变化
在 hydration-lab 的 App.mjs 增加一行“连接状态”:服务器与客户端首次均为“等待客户端”;Effect 运行后变为“客户端已连接”。保留计数交互。刷新验证初始 HTML 包含等待文本,连接后文本变化且无 recoverable error。不要在 render 中用 typeof window 决定首次文字。
再分析 RSC 示例的三个变体:把 onClick 函数在 Page 中创建并作为普通 prop 传给 Counter;在 Counter 导入数据库模块;由 Page 把已构建的标题 JSX 作为 children 传入。分别指出是否越过约定边界,说明为什么。
下一章
进入测试、可访问性与交付,把这些行为变成可重复验证。