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)
}

基于 VitePress 构建