Skip to content

第 17 章 SSR 与同构渲染

本章要点

  • SSR 的本质:在服务端将组件树渲染为 HTML 字符串,在客户端"激活"为可交互应用
  • Vue 3 SSR 架构:@vue/server-renderer 的流式渲染管线
  • renderToString vs renderToStream:一次性输出与流式输出的权衡
  • Hydration(激活)的完整流程:从静态 HTML 到响应式组件树的 12 个步骤
  • Hydration Mismatch 的检测机制与常见陷阱
  • Vue 3.6 的 Lazy Hydration:按需激活与性能优化
  • 同构代码的约束:生命周期、平台 API、状态污染的三重挑战
  • SSR 与 Suspense 的协作:异步数据获取的服务端解决方案
  • Nuxt 3 的 SSR 引擎:Nitro + Vue SSR 的工程化实践

"首屏白屏 3 秒"——这大概是 SPA 最让人头疼的问题。用户点开链接,看到的是一片空白,浏览器还在忙着下载 JavaScript、解析执行、请求数据、渲染 DOM。而 SSR 的核心思想非常直白:既然浏览器渲染慢,那就让服务器先把 HTML 拼好,直接发给浏览器

但事情远没有这么简单。服务端渲染完 HTML 后,客户端还需要"接管"这些 HTML,让它变成可交互的 Vue 应用——这个过程叫 Hydration(激活/注水)。整个流程涉及两套渲染器的协调、状态的序列化与反序列化、生命周期的差异处理等一系列精密的工程实现。

17.1 整体架构

Vue 3 的 SSR 由三个核心包协作完成:

createSSRApp vs createApp

SSR 应用的入口是 createSSRApp 而不是 createApp。它们的核心差异在于渲染器的选择:

typescript
// @vue/runtime-dom 中的实现
export const createSSRApp = ((...args) => {
  const app = ensureHydrationRenderer().createApp(...args)

  // 注入 SSR 上下文
  const { mount } = app
  app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
    const container = normalizeContainer(containerOrSelector)
    if (container) {
      return mount(container, true, resolveRootNamespace(container))
      //                        ^^^^ isHydrate = true
    }
  }

  return app
}) as CreateAppFunction<Element>

关键在第二个参数 isHydrate = true。这告诉渲染器:不要从零创建 DOM,而是复用已有的 HTML 节点

服务端渲染管线

typescript
// @vue/server-renderer 的核心流程
export async function renderToString(
  input: App | VNode,
  context: SSRContext = {}
): Promise<string> {
  // 1. 创建渲染缓冲区
  const buffer: SSRBuffer = createBuffer()

  // 2. 安装 SSR 上下文
  if (isVNode(input)) {
    // 直接渲染 VNode
    renderVNode(buffer.push, input, context)
  } else {
    // App 实例:安装后渲染根组件
    const vnode = createVNode(input._component, input._props)
    vnode.appContext = input._context
    renderComponentVNode(buffer.push, vnode, context)
  }

  // 3. 等待所有异步操作完成
  const result = await unrollBuffer(buffer)
  await context.__watcherEffects?.forEach(e => e())

  return result
}

SSRBuffer 是一个特殊的数据结构,它可以包含字符串和 Promise。这使得渲染过程可以处理异步组件和 <Suspense>

typescript
type SSRBufferItem = string | SSRBuffer | Promise<SSRBuffer>
type SSRBuffer = SSRBufferItem[] & { hasAsync?: boolean }

function createBuffer(): SSRBuffer {
  let appendable = false
  const buffer: SSRBuffer = [] as any

  return {
    getBuffer: () => buffer,
    push(item: SSRBufferItem) {
      const isStringItem = isString(item)
      // 优化:连续字符串合并,减少数组元素数量
      if (appendable && isStringItem) {
        buffer[buffer.length - 1] += item as string
      } else {
        buffer.push(item)
      }
      appendable = isStringItem
      if (!isStringItem && (item as any).hasAsync) {
        buffer.hasAsync = true
      }
    }
  }
}

注意字符串合并优化:连续的字符串被拼接为一个,减少了最终 unrollBuffer 时的遍历次数。这个小优化在渲染大型页面时效果显著——一个包含 1000 个静态节点的页面,合并后可能只有几十个 buffer 元素。

17.2 服务端组件渲染

组件渲染为字符串

在服务端,Vue 不需要创建真实 DOM 节点,只需要输出 HTML 字符串。这意味着整个渲染流程可以大幅简化:

typescript
function renderComponentVNode(
  push: PushFn,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null = null,
  slotScopeId?: string
): void {
  const instance = createComponentInstance(vnode, parentComponent, null)

  // 1. 设置组件(与客户端相同)
  const setupResult = setup
    ? callWithErrorHandling(setup, instance, [instance.props, setupContext])
    : undefined

  // 2. 处理异步 setup(服务端特有)
  if (isPromise(setupResult)) {
    const p = setupResult.then(/* ... */)
    push(p as any)
    return
  }

  // 3. 渲染子树
  const subTree = (instance.subTree = renderComponentRoot(instance))
  renderVNode(push, subTree, instance)
}

注意一个关键差异:服务端的 setup 可以是异步的。在客户端,异步 setup 必须配合 <Suspense> 使用,但在服务端,所有异步操作都会被自然地等待。这是因为 renderToString 本身就返回 Promise,而 SSRBuffer 天然支持嵌套 Promise。

指令的服务端处理

大多数指令在服务端没有意义(比如 v-on 绑定事件),但有些需要特殊处理:

typescript
// v-show 的 SSR 处理
function ssrRenderStyle(raw: unknown): string {
  // v-show="false" → style="display:none"
  if (!raw) return ''
  if (isString(raw)) return raw
  // 对象语法
  let styles = ''
  for (const key in raw as Record<string, string>) {
    const value = (raw as Record<string, string>)[key]
    if (value != null && value !== '') {
      styles += `${hyphenate(key)}:${value};`
    }
  }
  return styles
}

// v-model 的 SSR 处理
function ssrRenderAttrs(attrs: Record<string, unknown>): string {
  let result = ''
  for (const key in attrs) {
    if (key === 'innerHTML' || key === 'textContent') continue
    const value = attrs[key]
    if (key === 'class') {
      result += ` class="${ssrRenderClass(value)}"`
    } else if (key === 'style') {
      result += ` style="${ssrRenderStyle(value)}"`
    } else {
      result += ssrRenderDynamicAttr(key, value)
    }
  }
  return result
}

v-model 在服务端被渲染为对应的属性值。比如 <input v-model="name"> 在服务端会渲染为 <input value="John">,而 v-on 的事件绑定则被完全忽略。

17.3 流式渲染

为什么需要流式渲染

renderToString 需要等整个页面渲染完成后才能发送响应。对于大型页面,这意味着用户要等待几百毫秒甚至几秒才能看到第一个字节。流式渲染(renderToStream)解决了这个问题:

renderToStream 的实现

typescript
export function renderToStream(
  input: App | VNode,
  context: SSRContext = {}
): Readable {
  const stream = new Readable({
    read() {}  // 由 push 驱动
  })

  // 异步渲染,渐进式推送
  Promise.resolve(renderToString(input, context))
    .then(html => {
      stream.push(html)
      stream.push(null) // 结束信号
    })
    .catch(err => {
      stream.destroy(err)
    })

  return stream
}

// Vue 3.3+ 的 Web Streams API 支持
export function renderToWebStream(
  input: App | VNode,
  context: SSRContext = {}
): ReadableStream {
  return new ReadableStream({
    start(controller) {
      renderToString(input, context).then(html => {
        controller.enqueue(new TextEncoder().encode(html))
        controller.close()
      })
    }
  })
}

在实际生产环境中,更高效的方式是利用 SSRBuffer 的分层结构实现真正的逐块推送:

typescript
// 更细粒度的流式渲染(概念实现)
async function* renderToIterator(
  input: App | VNode,
  context: SSRContext = {}
): AsyncGenerator<string> {
  const buffer = createBuffer()

  renderVNode(buffer.push, createVNode(input), context)

  // 遍历 buffer,遇到 Promise 则等待
  for (const item of buffer.getBuffer()) {
    if (isString(item)) {
      yield item
    } else if (isPromise(item)) {
      const resolved = await item
      yield* yieldBuffer(resolved)
    }
  }
}

TTFB 优化策略

流式渲染的核心价值在于降低 TTFB(Time To First Byte)。结合以下策略可以进一步优化:

typescript
// 策略 1:提前发送 <head>,不等组件渲染
app.use((req, res) => {
  // 立刻发送 HTML 头部(包含 CSS link)
  res.write(`<!DOCTYPE html>
    <html>
    <head>
      <link rel="stylesheet" href="/style.css">
      <link rel="preload" href="/app.js" as="script">
    </head>
    <body>
  `)

  // 然后流式渲染 Vue 应用
  const stream = renderToStream(app)
  stream.pipe(res, { end: false })
  stream.on('end', () => {
    res.end('</body></html>')
  })
})

// 策略 2:利用 Suspense 实现分块推送
const App = {
  template: `
    <header>立刻渲染的导航</header>
    <Suspense>
      <template #default>
        <AsyncMainContent />
      </template>
      <template #fallback>
        <div class="skeleton">加载中...</div>
      </template>
    </Suspense>
  `
}

基于 VitePress 构建