客户端水合

什么是水合(Hydration)

服务端渲染输出了完整的 HTML,但这个 HTML 是"静态"的——按钮点击没有反应,输入框无法交互。水合就是让客户端 JavaScript 接管这些已有的 DOM 节点,绑定事件监听器和响应式状态,使页面"活"过来。

水合的核心思路是:不重新创建 DOM,而是复用服务端已经渲染好的 DOM 节点,只补充事件绑定和响应式关联。这样可以避免页面闪烁,也能提升性能。

hydrate 函数

hydrate 是 Vitarx 提供的水合函数,将服务端渲染的 HTML 与客户端应用关联起来。

tsx
import { createSSRApp, hydrate } from 'vitarx'
import App from './App'

const app = createSSRApp(App)

// 水合到服务端渲染的 HTML
await hydrate(app, '#app', window.__INITIAL_STATE__)

函数签名

typescript
declare function hydrate(
  root: SSRApp | View,
  container: HostContainer | string,
  context?: SSRContext
): Promise<void>
参数类型说明
rootSSRApp | ViewSSR 应用实例或虚拟节点
containerHostContainer | string挂载容器,可以是 DOM 元素或选择器字符串
contextSSRContextSSR 上下文对象,用于恢复服务端状态(可选)

返回值Promise<void> — 水合完成后 resolve

hydrate vs SSRApp.mount

SSRApp.mount 内部就是调用 hydrate,两者效果相同。区别在于:

  • app.mount('#app', context) — 同步返回,不等待水合完成
  • await hydrate(app, '#app', context) — 异步等待水合完成后才继续执行

如果你需要在确保水合完成后再执行某些操作(比如启动路由),使用 hydrate 更合适。

tsx
// 方式一:mount(不等待水合完成)
const app1 = createSSRApp(App)
app1.mount('#app', window.__INITIAL_STATE__)

// 方式二:hydrate(等待水合完成)
const app2 = createSSRApp(App)
await hydrate(app2, '#app', window.__INITIAL_STATE__)
// 水合已完成,可以安全地执行后续操作

自动检测水合模式

hydrate 会自动检测容器中是否包含服务端渲染的内容:

  • 容器有子节点:执行水合,逐节点匹配并复用 DOM
  • 容器为空:跳过水合,直接执行正常的客户端渲染

这意味着即使服务端渲染失败或未启用 SSR,客户端代码也能正常工作。

tsx
// 无论服务端是否渲染了内容,客户端代码都一样
const app = createSSRApp(App)
await hydrate(app, '#app', window.__INITIAL_STATE__)

水合失败时的降级策略

水合过程中可能遇到服务端和客户端渲染结果不一致的情况,例如:

  • 找不到对应的 DOM 节点
  • 节点类型不匹配(期望 <div> 但找到了 <span>

遇到这些情况时,Vitarx 会采取以下策略:

  1. 节点缺失:创建新的 DOM 节点并插入到容器中,输出警告日志
  2. 节点不匹配:用新创建的节点替换不匹配的节点,输出警告日志
  3. 整体失败:如果水合过程抛出异常,会清空容器并回退到完整的客户端渲染
tsx
// hydrate 内部的降级逻辑(简化版)
try {
  // 尝试逐节点水合
  await hydrateNode(rootView, containerEl)
  activate(rootView, containerEl)
} catch (err) {
  // 水合失败,清空容器,回退到客户端渲染
  containerEl.innerHTML = ''
  rootView.mount(containerEl)
}

这种降级策略保证了即使水合出现问题,页面仍然可以正常工作,只是失去了 SSR 的性能优势。

客户端激活(activate)流程

水合分为两个阶段:

  1. 水合阶段(hydrateNode):遍历虚拟节点树,逐个与服务端渲染的 DOM 节点匹配,将 DOM 引用关联到虚拟节点上
  2. 激活阶段(activate):调用视图的 mount 方法,绑定事件监听器、建立响应式关联,使页面具备交互能力

activate 是一个内部函数,由 hydrate 在水合匹配完成后自动调用,通常不需要直接使用。

激活阶段的一个关键优化是:防止重复插入 DOM。激活时会临时重写渲染器的 append 方法,如果发现节点已经在正确的父容器中,就跳过插入操作。

tsx
// activate 的核心逻辑(简化版)
function activate(view, container) {
  const renderer = getRenderer()
  const originalAppend = renderer.append.bind(renderer)

  // 重写 append,跳过已在正确位置的节点
  renderer.append = (child, parent) => {
    if (getParentNode(child) === parent) return
    originalAppend(child, parent)
  }

  // 执行挂载(绑定事件、建立响应式关联)
  view.mount(container)

  // 恢复原始 append
  renderer.append = originalAppend
}

完整示例

下面是一个包含异步数据获取的水合示例,展示服务端和客户端如何协作。

根组件 App.tsx

tsx
import { ref, onMounted, useSSRContext, isSSR } from 'vitarx'

export default function App() {
  const message = ref('加载中...')

  onMounted(async () => {
    // 仅在客户端且非水合阶段时获取数据
    // 服务端渲染的数据已通过上下文恢复
    if (!isSSR()) {
      const res = await fetch('/api/message')
      message.value = await res.text()
    }
  })

  return (
    <div>
      <h1>SSR 水合示例</h1>
      <p>{message.value}</p>
    </div>
  )
}

服务端入口 server.ts

tsx
import express from 'express'
import { createSSRApp, renderToString } from 'vitarx'
import App from './App'

const server = express()

server.get('/', async (req, res) => {
  const context = {}

  // 服务端预取数据
  const message = await fetch('http://localhost:3000/api/message').then((r) => r.text())
  context.message = message

  const app = createSSRApp(App)
  const html = await renderToString(app, context)

  res.send(`
    <!DOCTYPE html>
    <html>
      <head><meta charset="utf-8" /><title>SSR 水合示例</title></head>
      <body>
        <div id="app">#123;html}</div>
        <script>window.__INITIAL_STATE__ = #123;JSON.stringify(context)}</script>
        <script src="/client.js"></script>
      </body>
    </html>
  `)
})

server.get('/api/message', (req, res) => {
  res.send('来自服务端的数据')
})

server.listen(3000)

客户端入口 client.ts

tsx
import { createSSRApp, hydrate } from 'vitarx'
import App from './App'

const app = createSSRApp(App)

// 水合,传入服务端上下文
await hydrate(app, '#app', window.__INITIAL_STATE__)

下一步

  • SSR 上下文 — 了解如何在服务端和客户端之间传递和恢复状态
  • 流式渲染 — 了解如何使用流式渲染提升首屏速度