Vue 3 设计与实现

第 17 章 SSR 与同构渲染

作者 杨艺韬 · 11,991 字

第 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(激活/注水)。整个流程涉及两套渲染器的协调、状态的序列化与反序列化、生命周期的差异处理等一系列精密的工程实现。

从”要不要 SSR”到”什么场景用 SSR”

如果你关注过 Web 领域近十年的演进,会发现”渲染在哪里发生”这个看似简单的问题,其实是一场反反复复的钟摆运动。PHP / JSP / ASP 时代,渲染完全在服务端——HTML 现拼现发,浏览器只负责呈现;React / Vue / Angular 的 SPA 时代,渲染完全搬到了浏览器——服务端只返回一个空壳 <div id="app"></div>,所有的 UI 都由 JavaScript 在客户端构建;到了 2018 年前后,Next.js 和 Nuxt 的兴起让”两边都要”成为新常态——服务端负责首屏的速度和 SEO,客户端接管后续的交互。再到 React Server Components 和 Vue 3.6 的 Lazy Hydration,这场钟摆开始第三次摆回:“能不在客户端执行的代码就别执行”。这些演变的每一步都在回答同一个问题:什么东西归服务端,什么东西归浏览器,什么东西可以省掉?

本章要讲的不是 “SSR 好不好”——这个问题的答案在具体场景面前根本就不是非黑即白。我们要做的是把 Vue 3 SSR 的内部实现拆到你能做出知情决策的程度:它的渲染管线长什么样、Hydration 为什么会失败、Lazy Hydration 砍掉了哪些成本、状态污染为什么会泄露用户数据。读完你再面对”SSR 还是 CSR”、“Next 还是 Nuxt”、“ISR 还是 SWR”这类问题时,就能基于原理而不是传闻做选择。

本章和第 12 章(生命周期与调度器)、第 15 章(Pinia)有紧密联系——SSR 下的生命周期裁剪就靠第 12 章讲的那套钩子体系,状态序列化就用第 15 章讲的 Pinia 全局 state 设计。如果相关内容已经模糊了,推荐读本章时随手翻回那两章对照。

17.1 整体架构

Vue 3 的 SSR 由三个核心包协作完成——这套包结构是 Vue 3 “宿主无关渲染器”哲学的直接体现。@vue/runtime-core 里只有一份”不知道自己在哪跑”的核心逻辑,@vue/server-renderer@vue/runtime-dom 像两个插头,把核心逻辑插到不同的宿主上。这种分层让 Vue 3 未来可以很方便地扩展到新环境(比如原生平台、Web Worker、甚至终端 TUI)——只需要再写一个新的 runtime 包,核心逻辑不用动。这是”依赖倒置”在框架架构层面的经典应用——核心稳定,边缘可插拔。

graph TD
    A["@vue/server-renderer<br/>服务端渲染器"] --> B["renderToString()<br/>一次性渲染"]
    A --> C["renderToStream()<br/>流式渲染"]
    A --> D["renderToWebStream()<br/>Web Streams API"]

    E["@vue/runtime-core<br/>运行时核心"] --> F["createSSRApp()<br/>SSR 应用入口"]
    E --> G["ssrUtils<br/>SSR 工具函数"]

    H["@vue/runtime-dom<br/>客户端运行时"] --> I["hydrate()<br/>客户端激活"]

    F --> A
    I --> J["Hydration 算法<br/>DOM 复用 + 事件绑定"]

    style A fill:#42b883,color:#fff
    style E fill:#35495e,color:#fff
    style H fill:#35495e,color:#fff

createSSRApp vs createApp

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

// @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 节点

createAppcreateSSRApp 的名字看着很像”两个版本”,其实从实现角度讲它们是”同一个 app 不同的 mount 模式”。区别只在那个 isHydrate 参数——一个告诉渲染器”帮我造节点”、一个告诉渲染器”帮我认节点”。底下跑的是同一套 VNode patch 逻辑、同一套 setup 执行、同一套响应式建立。这种”上层语义差异,底层共享实现”的设计让 Vue 3 SSR 的内核极小——整个 hydration 相关代码不到 1000 行,而且其中大部分是和普通 patch 共享的。这种”用最小的代码差异支持最大的功能差异”是成熟框架的标志。

服务端渲染管线

// @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>

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 元素。

为什么 SSRBuffer 要做成”字符串 + Promise 混合数组”而不是纯字符串流?——这是一个非常典型的”问题倒推设计”案例。Vue SSR 必须支持异步组件和 Suspense,意味着渲染过程中会出现”暂时没渲染完,要等 Promise”的节点。如果是纯字符串流,遇到 Promise 就得阻塞整个流——要么全程等、要么全程快。把 buffer 设计成数组,每个元素既可以是字符串也可以是 Promise,同步部分立刻入队、异步部分挂在原位等待 resolve——这就是”同步和异步在同一个数据结构里优雅共存”的实现。这个思路和第 12 章讲 Vue 调度器三级队列(pre / main / post)异曲同工——都是用”数据结构的层次”把时序问题推到可控的位置。你在学 SSR 时感到的”这个怎么这么巧”,其实都是同一种工程思维在不同场景下的反复应用。

17.2 服务端组件渲染

服务端渲染看起来简单——“不就是把 VNode 变成 HTML 字符串吗”——但真正动手做会发现坑很多:异步组件怎么等、错误怎么处理、指令怎么降级、Teleport 怎么处理、Suspense 怎么对齐……每一个都是独立的小问题,加起来就构成了 Vue SSR 的工程复杂度。这一节从最简单的”组件渲染为字符串”开始,逐步把这些问题拆开来看。

组件渲染为字符串

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

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。

这种”同一个 API 在两个宿主下行为不同”的设计很有意思——它不是”bug”也不是”不一致”,而是顺应各自宿主的约束。客户端的异步 setup 如果没有 Suspense 包住,就意味着”组件 mount 会被 block”——这在客户端是糟糕的体验(页面 hang 住),所以 Vue 要求必须用 Suspense 让用户显式意识到这件事。服务端的 renderToString 本来就返回 Promise,整个调用方已经准备好等待,“等一下”是免费的——所以 Vue 选择在服务端放宽约束。好的 API 不是”处处一致”,而是”在正确的地方一致、在该区分的地方区分——一致性是手段不是目的,服务于开发者心智才是目的。

指令的服务端处理

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

// 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 的事件绑定则被完全忽略。

这种”指令按能力分级处理”的策略非常务实:v-show 能降级为样式(服务端就输出 style="display:none")、v-model 能降级为属性(服务端就输出 value=...)、v-on 无法降级(没有事件系统)就直接忽略。这告诉我们一个很重要的设计原则:同构不是两边做一样的事是两边做语义对应的事。服务端能做的就做,做不了的留给客户端 Hydration 阶段补全。这种”分层处理”让 SSR 输出的 HTML 既是合法的静态文档(SEO 爬虫能读)、又是 Vue 可以精确 Hydrate 的骨架(事件在 Hydration 时挂上去)。第 13 章讲指令时我们把焦点放在客户端实现,本章其实给了指令系统另一个维度的审视——好的指令设计从一开始就要考虑”服务端该怎么表现”,而不是等到实现 SSR 时再打补丁

17.3 流式渲染

为什么需要流式渲染

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

从用户体验的视角,TTFB(Time To First Byte)这个指标背后真正重要的是”用户以为页面在加载”的感觉。即使整体渲染没有变快,只要第一块内容能尽快到达浏览器——哪怕只是 <head><header>——浏览器就可以开始加载 CSS、开始解析、开始给用户视觉反馈。流式渲染的本质不是”加快渲染速度”,而是”把”时间窗口”切得足够小,用户感觉不到等待”。Google 在 2019 年的 Web Vitals 初版里把 LCP(Largest Contentful Paint)作为核心体验指标——它测的就是”页面主要内容何时呈现”。而流式渲染对 LCP 的贡献远大于总体 render 时间。所以下一次你看到团队讨论”我们的 SSR 渲染时间是 200ms”时,记得问一句”TTFB 是多少?“——对用户体验来说,前者是”盘子做好的时间”,后者才是”第一口能吃到的时间”。

sequenceDiagram
    participant B as 浏览器
    participant S as Node.js 服务器
    participant V as Vue SSR

    B->>S: GET /page
    S->>V: renderToStream(app)
    V-->>S: <html><head>...</head><body>
    S-->>B: chunk 1: 头部 HTML
    Note over B: 浏览器开始解析、加载 CSS
    V-->>S: <div id="app"><header>...</header>
    S-->>B: chunk 2: 页面头部
    Note over B: 用户已看到页面骨架
    V-->>S: <main>...大量内容...</main>
    S-->>B: chunk 3: 主体内容
    V-->>S: </div></body></html>
    S-->>B: chunk 4: 尾部
    Note over B: 页面完整,开始 Hydration

renderToStream 的实现

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 的分层结构实现真正的逐块推送:

// 更细粒度的流式渲染(概念实现)
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)。结合以下策略可以进一步优化:

// 策略 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>
  `
}

17.4 Hydration(激活)

Hydration 是 SSR 最精巧也最容易出问题的环节。它的核心任务是:复用服务端渲染的 DOM 节点,为它们附加事件监听和响应式更新能力

为什么叫”注水”?——这个中文译名其实比英文原词更形象。服务端发来的 HTML 是”干的”:它有骨架(标签)、有肉(文字),但没有血(事件)、没有神经(响应式)。Hydration 要做的就是把””注进去——水不是重新造一具身体,而是顺着已有的骨架把”活力”灌进去。这个比喻让你立刻理解两件事:第一,Hydration 不应该重建 DOM——如果重建就和从零创建没区别了,SSR 就没意义了;第二,“注水的顺序必须和骨架一致”——如果客户端渲染出的 VNode 树和服务端 HTML 不对齐,就会出现”水往不对的地方灌”,这就是 Mismatch。

从工程意义上看,Hydration 是 SPA 和 SSR 两个世界之间的””——桥修得稳,两边都舒服;桥修得晃,两边都遭殃。React 团队在 React 18 之前的 Hydration 是”全有全无”的(遇到任何 mismatch 就整棵子树重新渲染)——这个设计用了十年才被迭代掉。Vue 的 Hydration 一开始就支持”局部 fallback”(某个节点 mismatch 时只修复它和它的后代),这是 Vue 作者在 2019 年早期设计 Vue 3 SSR 时就做出的决策——这个决策的收益直到 2024 年 Lazy Hydration 出现时仍在延续。好的架构决策的价值会在很多年后被放大——你现在看得到的”流畅的 Hydration 体验”,是五六年前某个设计文档里的一个判断延续到现在的结果。

Hydration 的完整流程

graph TD
    A["客户端加载 JS"] --> B["createSSRApp()"]
    B --> C["app.mount('#app')"]
    C --> D["创建组件实例树"]
    D --> E["遍历 VNode 树"]

    E --> F{"VNode 类型?"}
    F -->|元素| G["hydrateElement()"]
    F -->|组件| H["hydrateComponent()"]
    F -->|文本| I["hydrateText()"]
    F -->|Fragment| J["hydrateFragment()"]

    G --> K["匹配 DOM 属性"]
    G --> L["附加事件监听"]
    G --> M["递归处理子节点"]

    H --> N["执行 setup()"]
    H --> O["建立响应式"]
    H --> P["递归 hydrate 子树"]

    K --> Q["检测 Mismatch"]
    Q -->|匹配| R["复用 DOM 节点 ✓"]
    Q -->|不匹配| S["警告 + 客户端重渲染"]

    style A fill:#42b883,color:#fff
    style Q fill:#e74c3c,color:#fff
    style R fill:#27ae60,color:#fff

hydrateNode 的核心实现

const hydrateNode = (
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeId: string | null,
  optimized: boolean
): Node | null => {
  const isFragmentStart = isComment(node) && node.data === '['
  const { type, ref, shapeFlag } = vnode

  const domType = node.nodeType
  vnode.el = node  // ← 关键:直接复用 DOM 节点

  switch (type) {
    case Text:
      if (domType !== DOMNodeTypes.TEXT) {
        // 文本节点类型不匹配
        return handleMismatch(node, vnode, parentComponent)
      }
      if ((node as Text).data !== vnode.children) {
        // 文本内容不匹配
        ;(node as Text).data = vnode.children as string
        // 开发模式下警告
        if (__DEV__) {
          warn('Hydration text mismatch')
        }
      }
      return node.nextSibling

    case Comment:
      return node.nextSibling

    case Static:
      return node.nextSibling

    default:
      if (shapeFlag & ShapeFlags.ELEMENT) {
        return hydrateElement(
          node as Element,
          vnode,
          parentComponent,
          parentSuspense,
          slotScopeId,
          optimized
        )
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        // 组件:先挂载组件实例,再 hydrate 子树
        const container = parentNode(node)!
        const hydrateComponent = () => {
          mountComponent(vnode, container, null, parentComponent, ...)
        }
        // Suspense 组件需要特殊处理
        if (isAsyncWrapper(vnode)) {
          (vnode.type as any).__asyncLoader!().then(hydrateComponent)
        } else {
          hydrateComponent()
        }
        return locateClosingAnchor(node)
      }
  }
}

vnode.el = node 这一行是整个 Hydration 的核心——直接将已有的 DOM 节点赋给 VNode,而不是像 createApp 那样通过 createElement 创建新节点。

Element Hydration 的细节

const hydrateElement = (
  el: Element,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null,
  parentSuspense: SuspenseBoundary | null,
  slotScopeId: string | null,
  optimized: boolean
) => {
  const { type, props, patchFlag, shapeFlag, dirs, transition } = vnode

  // 1. 开发模式:检查属性匹配
  if (__DEV__) {
    if (props) {
      // 检查 class 是否匹配
      if (props.class) {
        const clientClass = normalizeClass(props.class)
        const serverClass = el.getAttribute('class')
        if (clientClass !== serverClass) {
          warn(`Hydration class mismatch on ${el.tagName}`)
        }
      }
      // 检查 style 是否匹配
      if (props.style) {
        // ...类似检查
      }
    }
  }

  // 2. 绑定事件监听(服务端没有事件)
  if (props) {
    for (const key in props) {
      if (isOn(key) && !isReservedProp(key)) {
        patchProp(el, key, null, props[key], /* ... */)
      }
    }
  }

  // 3. 处理自定义指令
  if (dirs) {
    invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
    invokeDirectiveHook(vnode, null, parentComponent, 'mounted')
  }

  // 4. 递归 hydrate 子节点
  if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    let next = hydrateChildren(
      el.firstChild,
      vnode,
      el,
      parentComponent,
      parentSuspense,
      slotScopeId,
      optimized
    )
    // ...
  }

  return el.nextSibling
}

Hydration 的事件绑定是一个有趣的设计选择。服务端 HTML 不包含任何事件处理器,所有的 @click@input 等都在 Hydration 阶段添加。这意味着在 JavaScript 加载和 Hydration 完成之前,页面虽然可见但不可交互——这就是所谓的”不可交互窗口期”。

“可见但不可交互”是 SSR 时代最容易被忽视的 UX 坑。想象一个场景:用户打开一个电商详情页,SSR 把页面内容快速呈现了出来——商品图、价格、描述都在。用户看到”加入购物车”按钮,立刻就点。但 JS 还没加载完,按钮没绑定事件,点击没反应。用户再点一次、再点一次……几秒后 Hydration 终于完成了——三次点击全部触发,购物车里多了三件商品。这不是假想故事,是 Next.js、Nuxt、Remix 团队都在自己的文档里反复强调过的真实问题。

解决方案有两类。第一类是降低 Hydration 延迟——Lazy Hydration(本章 17.6)、Islands Architecture(Astro 的路线)、Server Components(React 的路线)都是这个方向。第二类是在不可交互期内把”点击先记下来”——Qwik 的 resumable 模式、Vue 未来可能支持的 event replay 都是这类思路。这两类方向目前都有探索,没有银弹。作为 SSR 应用的开发者,至少要知道这个窗口存在、知道它是用户体验的一个真实风险点,然后在设计关键交互时避免过度依赖”一加载就能点”的假设。

17.5 Hydration Mismatch

如果说 Hydration 是 SSR 的桥梁,Mismatch 就是这座桥梁最容易塌掉的地方。任何做过 SSR 项目的同学大概率都遇到过控制台突然弹出一行红字”Hydration node mismatch”,然后页面莫名其妙地被重新渲染一次——闪一下、Layout Shift,用户体验瞬间破坏。这一节把 Mismatch 的检测逻辑、常见触发原因、修复模式讲透,读完你会具备”看到一行警告就能定位到是哪个组件哪行代码引发的”能力。

检测机制

Hydration Mismatch 是 SSR 开发中最常见的问题。当服务端渲染的 HTML 与客户端期望的结构不一致时,Vue 会发出警告并尝试恢复:

function handleMismatch(
  node: Node,
  vnode: VNode,
  parentComponent: ComponentInternalInstance | null
): Node | null {
  if (__DEV__) {
    warn(
      `Hydration node mismatch:\n` +
      `- rendered on server: ${describeDOMNode(node)}\n` +
      `- expected on client: ${describeVNode(vnode)}`
    )
  }

  // 从不匹配的节点开始,执行完整的客户端渲染
  vnode.el = null

  // 如果在生产模式下,性能优先,可能直接跳过
  if (__FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__) {
    // 提供详细的 mismatch 信息
  }

  // 插入正确的节点
  const next = node.nextSibling
  const container = parentNode(node)!
  container.removeChild(node)
  patch(null, vnode, container, next, parentComponent, ...)

  return next
}

常见的 Mismatch 场景

// ❌ 场景 1:时间/日期相关
// 服务端和客户端运行在不同时区
const TimeDisplay = {
  setup() {
    return () => h('span', new Date().toLocaleString())
    // 服务端: "2026/4/1 13:00:00"
    // 客户端: "2026/4/1 21:00:00" → Mismatch!
  }
}

// ✅ 修复:使用 onMounted 处理客户端专属逻辑
const TimeDisplay = {
  setup() {
    const time = ref('')
    onMounted(() => {
      time.value = new Date().toLocaleString()
    })
    return () => h('span', time.value || '加载中...')
  }
}

// ❌ 场景 2:随机数/ID 生成
const RandomBanner = {
  setup() {
    const id = Math.random() // 服务端和客户端结果不同
    return () => h('div', { id: `banner-${id}` })
  }
}

// ✅ 修复:使用 useId()(Vue 3.5+)
const RandomBanner = {
  setup() {
    const id = useId() // SSR 安全的唯一 ID
    return () => h('div', { id })
  }
}

// ❌ 场景 3:浏览器 API 检测
const ResponsiveLayout = {
  setup() {
    // window 在服务端不存在!
    const isMobile = window.innerWidth < 768
    return () => h('div', isMobile ? '移动端' : '桌面端')
  }
}

// ✅ 修复:使用 <ClientOnly> 或 onMounted
const ResponsiveLayout = {
  setup() {
    const isMobile = ref(false) // 默认值须与服务端一致
    onMounted(() => {
      isMobile.value = window.innerWidth < 768
    })
    return () => h('div', isMobile.value ? '移动端' : '桌面端')
  }
}

生产环境的 Mismatch 处理

Vue 3.4+ 提供了 __FEATURE_PROD_HYDRATION_MISMATCH_DETAILS__ 编译标志。默认情况下,生产环境只会静默修复 Mismatch(移除旧节点、创建新节点),不会输出警告。开启此标志后,生产环境也能看到详细的不匹配信息,但会增加约 3KB 的包体积。

我强烈建议生产环境至少在灰度阶段开启这个标志。原因很简单:很多 Mismatch 问题只在真实生产流量下暴露——不同用户的 cookie、不同的地区(时区/语言)、不同的 A/B 实验分组、不同的 CDN 缓存状态都可能引入 dev 测不出的 Mismatch。如果生产静默修复,你永远看不到这些问题;开启详细日志上报到 Sentry / Datadog 之类的 APM,能让隐藏的 Mismatch 在大规模流量下被自然抓出来。3KB 的包体积换长期的质量保障——这个账在任何严肃项目里都划得来。

17.6 Lazy Hydration(懒激活)

问题:全量 Hydration 的性能瓶颈

Vue 3.6 是 2026 年初发布的大版本,Lazy Hydration 是它的招牌特性之一。理解这个特性首先要理解它解决的问题——“全量 Hydration”有多糟糕。

传统 SSR 的 Hydration 是”全量”的——页面上所有组件都需要执行 JavaScript 并建立响应式。对于内容密集的页面(比如电商首页),这意味着:

  • 下载大量 JavaScript(所有组件代码)
  • 执行所有组件的 setup()
  • 建立所有的响应式依赖
  • 绑定所有的事件监听

用户可能只看到了页面顶部,但底部的组件也被 Hydrate 了。

Vue 3.6 的 Lazy Hydration 策略

Vue 3.6 在 defineAsyncComponent 上加了一个新的 hydrate 选项,背后是一整套可组合的策略。每一种策略对应一种”什么时候值得激活”的判断——可见才激活、空闲才激活、媒体匹配才激活、用户交互才激活。这种”把触发时机作为策略参数”的 API 设计非常优雅——用户不需要学习四套 API,只需要替换一个策略函数,就能切换完全不同的激活行为。

import { defineAsyncComponent, hydrateOnVisible, hydrateOnIdle } from 'vue'

// 策略 1:进入视口时激活
const HeavyFooter = defineAsyncComponent({
  loader: () => import('./HeavyFooter.vue'),
  hydrate: hydrateOnVisible() // IntersectionObserver
})

// 策略 2:浏览器空闲时激活
const SidePanel = defineAsyncComponent({
  loader: () => import('./SidePanel.vue'),
  hydrate: hydrateOnIdle(/* timeout */ 3000)
  // requestIdleCallback,最长等 3 秒
})

// 策略 3:媒体查询匹配时激活
const MobileMenu = defineAsyncComponent({
  loader: () => import('./MobileMenu.vue'),
  hydrate: hydrateOnMediaQuery('(max-width: 768px)')
})

// 策略 4:自定义触发条件
const ChatWidget = defineAsyncComponent({
  loader: () => import('./ChatWidget.vue'),
  hydrate: hydrateOnInteraction(['click', 'focus'])
  // 用户点击或聚焦时才激活
})

Lazy Hydration 的内部机制

// hydrateOnVisible 的实现原理
export function hydrateOnVisible(
  opts?: IntersectionObserverInit
): HydrationStrategy {
  return (hydrate, forEach) => {
    const ob = new IntersectionObserver((entries) => {
      for (const e of entries) {
        if (!e.isIntersecting) continue
        ob.disconnect()
        hydrate()  // 触发真正的 Hydration
        break
      }
    }, opts)

    // 观察所有根 DOM 元素
    forEach((el) => ob.observe(el))

    // 返回清理函数
    return () => ob.disconnect()
  }
}

// hydrateOnIdle 的实现原理
export function hydrateOnIdle(timeout?: number): HydrationStrategy {
  return (hydrate) => {
    const id = requestIdleCallback(hydrate, { timeout })
    return () => cancelIdleCallback(id)
  }
}

// 在组件挂载时应用策略
function mountAsyncComponent(
  vnode: VNode,
  container: Element,
  parentComponent: ComponentInternalInstance | null,
  isHydrating: boolean
) {
  if (isHydrating && vnode.type.hydrate) {
    // 不立即 hydrate,而是注册策略
    vnode.type.hydrate(
      // hydrate 回调
      () => {
        // 加载组件代码
        vnode.type.__asyncLoader!().then((comp) => {
          // 执行真正的 Hydration
          hydrateSubTree(vnode, container, parentComponent)
        })
      },
      // forEach 回调:遍历未激活的 DOM 元素
      (cb) => {
        traverseStaticChildren(vnode, cb)
      }
    )
  }
}

这种设计让未激活的组件保持为静态 HTML——不执行 JavaScript、不建立响应式、不绑定事件。只有当策略条件满足时,才按需加载和激活。

为什么 Vue 3.6 才做 Lazy Hydration?——答案藏在技术依赖里。Lazy Hydration 要想实现,必须能做到三件事:第一,组件代码能按需加载——这依赖于完善的代码分割和异步组件机制,Vue 3 早期就具备了。第二,组件的”未激活静态 HTML”能精确对齐到”激活后的 VNode 子树”——这依赖于编译器能准确生成 SSR 输出和 hydration 对照,Vue 3 编译器的优化模式已经做到了。第三,策略接口足够通用,可以支持 visible / idle / media / interaction 等多种触发条件——这是纯 API 设计问题,成熟需要几个版本的迭代。三件事都齐了,Lazy Hydration 才成为可能。这再次印证本书反复出现的主题:好的新特性不是灵感突发,是一连串老积累到了临界点

Lazy Hydration 对业务的实际影响可以量化。一个中型电商首页通常有 30-40 个组件,其中只有顶部 5-10 个是首屏可见的。如果全部激活,需要执行的 JavaScript 大约 800KB-1.5MB、耗时 300-800ms(在中低端手机上)。如果用 Lazy Hydration 只激活首屏可见部分,首屏脚本执行时间可能降到 100ms 以内,FID(First Input Delay)从红字直接变绿。这种量级的收益是 Web Vitals 指标里极少见的”单点改造多个指标一起上升”——对 SEO 排名、广告投放、留存率都有实际影响。这也是为什么大厂对 Vue 3.6 Lazy Hydration 这个特性的热度超过往年——它直接兑现成了生意上的钱。

17.7 同构代码的约束

同构(isomorphic)“是 SSR 时代一个非常重要的词——它描述的是”同一份代码,在服务端和客户端都能跑”这种理想状态。理想很美好,但现实里总有一些地方两边必然不一致:window 在服务端不存在、localStorage 在服务端不存在、Date.now() 两边的时间可能不同、Math.random() 两边结果不同……同构代码的本质是”在两边语义冲突的地方,用正确的方式做出让步”。这一节讲三类最常见的约束,每一类都来自真实项目里反复出现的坑。

生命周期差异

服务端只执行部分生命周期钩子:

// 服务端执行的钩子
setup()           // ✅ 执行
beforeCreate()    // ✅ 执行(Options API)
created()         // ✅ 执行(Options API)
serverPrefetch()  // ✅ SSR 专属钩子

// 服务端不执行的钩子
beforeMount()     // ❌ 需要 DOM
mounted()         // ❌ 需要 DOM
beforeUpdate()    // ❌ 没有更新周期
updated()         // ❌ 没有更新周期
beforeUnmount()   // ❌ 不会卸载
unmounted()       // ❌ 不会卸载

serverPrefetch:SSR 专属的数据获取

export default {
  async serverPrefetch() {
    // 只在服务端执行,在渲染前获取数据
    await this.fetchArticle()
  },
  // 等同于 Composition API:
  async setup() {
    const article = ref(null)

    // onServerPrefetch 是 serverPrefetch 的 Composition API 版本
    onServerPrefetch(async () => {
      article.value = await fetchArticle()
    })

    // 客户端回退:如果服务端没获取到数据
    onMounted(async () => {
      if (!article.value) {
        article.value = await fetchArticle()
      }
    })

    return { article }
  }
}

状态污染问题

服务端的一个最隐蔽的陷阱是状态污染——多个请求共享同一份模块级状态:

// ❌ 危险:模块级状态被所有请求共享!
const globalState = reactive({
  user: null,
  cart: []
})

export function useGlobalState() {
  return globalState // 用户 A 的数据可能泄露给用户 B!
}

// ✅ 安全:每个请求创建新的状态
export function createGlobalState() {
  return reactive({
    user: null,
    cart: []
  })
}

// 在 app 工厂函数中使用
export function createApp() {
  const app = createSSRApp(App)
  const state = createGlobalState()
  app.provide('globalState', state)
  return { app, state }
}

这也是为什么 SSR 应用必须使用工厂函数模式——每个请求创建全新的 app 实例、router 实例和 store 实例。Pinia 的 SSR 支持就是基于这个原则设计的。

状态污染是 SSR 开发者必须懂的”职业病——它是那种”功能看似能跑,但在某个请求数达到一定量级时会突然爆炸”的问题。典型表现:开发环境一切正常,压测时发现用户 A 的订单列表出现在用户 B 的页面上、未登录用户看到了已登录用户的数据。原因都是同一个:模块级的 reactive() 对象被多个请求共享,而开发环境因为只有一个请求在跑所以看不出问题。

这类问题的痛苦在于它不会立刻暴露。单元测试不会暴露(测试时是串行跑的)、单机开发不会暴露(没有并发)、冒烟测试也不会暴露(只发一次请求)——只有上到生产、流量一大才炸。而且炸的方式通常是”偶发性数据错乱”——几千次请求里偶尔一次出错,查起来如同大海捞针。防范唯一的可靠方法是从架构层面把”每个请求独立的 app 实例”变成不可绕过的约束——这就是 Nuxt、Quasar 等成熟 SSR 框架都采用工厂函数模式的原因。自己搭 SSR 的同学,这一条写进代码规范里、写进 code review checklist 里,甚至写进静态检查规则里(检测是否在非工厂位置定义 reactive),把它变成”手不想写都不会写错”的默认。

平台 API 隔离

// 通用的平台安全访问模式
function useSafeWindow() {
  const isServer = typeof window === 'undefined'

  function getScrollPosition() {
    if (isServer) return { x: 0, y: 0 }
    return { x: window.scrollX, y: window.scrollY }
  }

  function getViewportSize() {
    if (isServer) return { width: 1024, height: 768 } // 合理的默认值
    return { width: window.innerWidth, height: window.innerHeight }
  }

  return { isServer, getScrollPosition, getViewportSize }
}

// 条件导入(Vite 支持)
// 在 vite.config.ts 中:
export default defineConfig({
  resolve: {
    alias: {
      // 服务端使用空实现
      './analytics': process.env.SSR
        ? './analytics.server.ts'  // 空操作
        : './analytics.client.ts'  // 真实实现
    }
  }
})

17.8 状态序列化与传输

状态序列化是 SSR 里”看起来简单、坑却很深”的一块——第 15 章讲 Pinia SSR 时已经埋下过伏笔(serialize-javascript / XSS 注入)。本节从更完整的角度把这件事讲清楚:为什么必须序列化、什么场景下 JSON.stringify 够用、什么场景下必须用 devalue、Teleport 这种跨位置渲染的组件怎么处理。

SSR 上下文与状态传输

服务端渲染的数据需要传递给客户端,避免重复请求:

// 服务端:渲染时收集状态
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)

const html = await renderToString(app, {
  // SSR 上下文对象
})

// 将 Pinia 状态序列化到 HTML
const state = JSON.stringify(pinia.state.value)
const finalHtml = html.replace(
  '</body>',
  `<script>window.__PINIA_STATE__=${devalue(state)}</script></body>`
)

// 客户端:恢复状态
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)

// Hydration 前恢复状态
if (window.__PINIA_STATE__) {
  pinia.state.value = window.__PINIA_STATE__
}

app.mount('#app') // Hydration 时已有正确的状态

注意使用 devalue 而不是 JSON.stringifydevalue 可以处理循环引用、DateRegExpMapSet 等 JSON 无法序列化的类型,还能防止 XSS 注入(自动转义 </script> 等危险字符串)。

这是一个非常值得展开的细节——JSON.stringify 的”数据丢失”问题在很多项目里是定时炸弹。你的 store 里一个 Date 字段,用 JSON 序列化后变成字符串 "2026-04-21T12:34:56.000Z";客户端恢复后访问 date.getTime() 就报错”is not a function”——因为它现在是 string 不是 Date。这种类型降级在开发环境很难暴露(开发时服务端返回 mock 数据直接从 JSON 来就是 string),只有生产环境从数据库读到真实 Date 对象时才爆炸。devalue 从设计之初就考虑了这类问题——它生成的是可执行的 JavaScript 表达式(比如 new Date("...") 而不是单纯的字符串),客户端 eval 后恢复的是原类型的对象。选序列化工具就是在选”还原忠实度”的等级——SSR 场景下的忠实度要求比纯 API 通信高得多,因为 API 通信至少能在调用方手动 new Date(str) 修复,SSR 恢复的状态是直接被 Hydration 使用的,没有手动修复的机会。

Teleport 在 SSR 中的处理

<Teleport> 在 SSR 中需要特殊处理,因为目标容器可能不在当前渲染范围内:

// 服务端渲染 Teleport
function ssrRenderTeleport(
  parentPush: PushFn,
  contentRenderFn: () => void,
  target: string,
  disabled: boolean,
  parentComponent: ComponentInternalInstance | null
) {
  parentPush('<!--teleport start-->')

  if (disabled) {
    // disabled 时内容渲染在原地
    contentRenderFn()
  } else {
    // 将内容存储到 SSR 上下文中
    const context = parentComponent!.appContext.provides[ssrContextKey]
    if (!context.__teleports) {
      context.__teleports = {}
    }
    if (!context.__teleports[target]) {
      context.__teleports[target] = ''
    }
    // 渲染到 teleport buffer
    const teleportBuffer = createBuffer()
    contentRenderFn = () => {
      renderTeleportContent(teleportBuffer.push, contentRenderFn)
    }
    contentRenderFn()
    context.__teleports[target] += teleportBuffer.getContent()
  }

  parentPush('<!--teleport end-->')
}

17.9 SSR 与 Suspense 的协作

Suspense 是 Vue 3 里和 SSR 最”相辅相成”的内置组件。它在客户端被用来”优雅地处理异步组件的 loading 状态”——这是它最广为人知的用途。但在服务端,它承担的角色完全不同:不是”展示 loading”,而是”让数据获取可以在渲染过程中发生”。这种”同一组件在两个宿主下语义翻转”的设计非常有意思——和第 17.2 节讨论过的”服务端异步 setup 不需要 Suspense 包”是同一种哲学的延续。

<Suspense> 在 SSR 中的行为与客户端有本质区别:

// 客户端:显示 fallback,等待异步组件
// 服务端:等待所有异步操作完成后输出最终 HTML

// SSR 中的 Suspense 渲染
function ssrRenderSuspense(
  push: PushFn,
  { default: defaultSlot, fallback }: Record<string, (() => void) | undefined>
) {
  if (defaultSlot) {
    // 服务端始终渲染 default 插槽(不是 fallback)
    // 因为我们会等待所有 Promise 解决
    defaultSlot()
  } else if (fallback) {
    fallback()
  }
}

这意味着在服务端,用户永远不会看到 Loading 状态——服务端会等待所有数据就绪后才发送完整的 HTML。这对用户体验是好事,但也意味着如果某个异步操作很慢,整个页面的响应都会被阻塞。

这个”全等数据就绪再发 HTML”的行为是一把双刃剑。好处:用户不会看到 skeleton 闪一下再变成真实数据;坏处:如果后端某个接口慢,整个 TTFB 就被拖慢。真实项目里权衡取决于你的场景——首屏必须呈现的核心数据(比如商品详情页的商品名字、价格)就应该走 Suspense 等它;可选的数据(比如商品评论、推荐列表)就应该做成流式 Suspense(先输出首屏骨架 + 评论 skeleton,评论就绪后再通过 stream 推送过去)。这种分层取舍的能力,是区分”会用 Suspense”和”把 Suspense 用得好”的关键分水岭。

结合流式渲染和 Suspense,可以实现更智能的策略:

// Nuxt 3 的 <NuxtLoadingIndicator> + Suspense 策略
// 1. 先发送页面骨架(不含异步数据的部分)
// 2. 异步数据就绪后,通过流式渲染推送剩余内容
// 3. 客户端接收到完整 HTML 后执行 Hydration

// 这要求渲染器支持"延迟块"(deferred chunks)
// Vue 3 的 renderToStream 天然支持这种模式

17.10 Nuxt 3 的 SSR 引擎

讲完 Vue 3 SSR 的内部机制,有必要把视角拉高看一下工程现实:真实项目里很少直接用 @vue/server-renderer 搭 SSR,大多数团队会直接用 Nuxt 或 Quasar SSR。原因不是”官方库不好用”——而是一个完整的 SSR 工程比我们本章讨论的要大十倍:路由级缓存策略、边缘部署、SEO 工具链、sitemap 生成、OG 卡片渲染、错误页面兜底、国际化、图片优化……每一项都是独立的工程问题,全自己做要耗半年。Nuxt 3 的价值在于”把这些工程问题的 90% 默认解决了”,你只需要写业务代码,其他靠 routeRules 声明式配置。本节用源码视角解析 Nuxt 3 几个最关键的设计——目标不是教你怎么用 Nuxt,而是让你在读 Nuxt 源码时能迅速定位到”这个设计是对应 Vue SSR 哪一层的扩展”。

Nuxt 3 在 Vue SSR 的基础上构建了完整的工程化解决方案:

graph TD
    A["Nitro Server Engine"] --> B["请求处理"]
    B --> C["路由匹配"]
    C --> D["数据获取<br/>useAsyncData / useFetch"]
    D --> E["Vue SSR 渲染"]
    E --> F["HTML 生成"]
    F --> G["状态序列化<br/>payload.state"]

    H["客户端"] --> I["下载 JS"]
    I --> J["恢复状态<br/>useNuxtApp().payload"]
    J --> K["Hydration"]
    K --> L["可交互应用"]

    style A fill:#00dc82,color:#fff
    style E fill:#42b883,color:#fff

useAsyncData 的同构实现

// Nuxt 3 的 useAsyncData 核心逻辑
export function useAsyncData<T>(
  key: string,
  handler: () => Promise<T>,
  options: AsyncDataOptions<T> = {}
) {
  const nuxt = useNuxtApp()

  // 1. 检查是否已有缓存数据(来自 SSR 或之前的请求)
  const cachedData = nuxt.payload.data[key]
  if (cachedData !== undefined && !options.force) {
    return {
      data: ref(cachedData),
      pending: ref(false),
      error: ref(null)
    }
  }

  const data = ref<T | null>(null)
  const pending = ref(true)
  const error = ref<Error | null>(null)

  const fetch = async () => {
    pending.value = true
    try {
      const result = await handler()
      data.value = result
      // 存入 payload,供客户端复用
      nuxt.payload.data[key] = result
    } catch (err) {
      error.value = err as Error
      nuxt.payload._errors[key] = true
    } finally {
      pending.value = false
    }
  }

  // 2. 服务端:在渲染前获取数据
  if (import.meta.server) {
    // 使用 callOnce 确保只执行一次
    nuxt.hook('app:created', async () => {
      await fetch()
    })
  }

  // 3. 客户端:检查 payload 或重新获取
  if (import.meta.client) {
    if (cachedData === undefined) {
      // 服务端没有获取到,客户端补充获取
      onMounted(fetch)
    }
  }

  return { data, pending, error, refresh: fetch }
}

渲染模式的选择

Nuxt 3 支持多种渲染模式,每种都有不同的权衡:

// nuxt.config.ts 中的路由级渲染规则
export default defineNuxtConfig({
  routeRules: {
    // SSR:每次请求都在服务端渲染
    '/': { ssr: true },

    // SSG:构建时预渲染为静态 HTML
    '/about': { prerender: true },

    // SWR:SSR + 缓存(Stale-While-Revalidate)
    '/api/**': { swr: 3600 }, // 缓存 1 小时

    // ISR:增量静态再生成
    '/blog/**': { isr: 600 }, // 10 分钟后重新生成

    // CSR:禁用 SSR,纯客户端渲染
    '/admin/**': { ssr: false },
  }
})

17.11 SSR 安全与性能考量

SSR 给应用带来的不只是性能收益——它同时带来了一套只有在服务端才会暴露的新攻击面。纯客户端 SPA 时代,服务器只负责返回静态壳,业务逻辑全在浏览器里跑——用户能攻击的主要是前端 DOM 和网络请求。到了 SSR 时代,服务端开始参与渲染:用户输入会被服务端处理、渲染出的 HTML 直接包含动态内容、session 等敏感数据可能在渲染过程中被访问到。这让前端同学第一次需要像后端一样思考”怎么防 XSS”、“怎么防内存泄漏”、“怎么防 DoS”。这一节把 SSR 环境下最关键的安全与性能陷阱讲清楚——每一个都是真实事故的教训结晶,不是理论讨论。

XSS 防护

SSR 最大的安全风险是 XSS——服务端渲染的 HTML 直接发送给浏览器,如果包含用户输入的恶意脚本,就会被执行:

Vue 团队对这件事做了非常扎实的默认防护:模板里的 {{ 变量 }} 插值会被自动转义,属性值也会被转义,v-text 也转义。你必须显式地使用 v-html 才能跳过转义——这个 API 的名字本身就是在警告你”这里有风险”。这种”默认安全、显式不安全”的设计模式,是所有成熟 Web 框架的共识——React 的 dangerouslySetInnerHTML、Angular 的 bypassSecurityTrustHtml、Svelte 的 {@html} 全都在 API 名字里写着”危险”二字。这种命名不是矫情——是在用 API 层面强制开发者意识到”我正在做一件不安全的事”。好 API 的一部分职责就是在开发者走偏的时候让他意识到自己走偏了——Vue SSR 做得很好。

// Vue 的 SSR 渲染器内置了转义机制
function ssrInterpolate(value: unknown): string {
  return escapeHtml(toDisplayString(value))
}

function escapeHtml(string: unknown): string {
  const str = '' + string
  const match = /["'&<>]/.exec(str)

  if (!match) return str

  let html = ''
  let escaped: string
  let index: number
  let lastIndex = 0

  for (index = match.index; index < str.length; index++) {
    switch (str.charCodeAt(index)) {
      case 34: escaped = '&quot;'; break  // "
      case 38: escaped = '&amp;'; break   // &
      case 39: escaped = '&#39;'; break   // '
      case 60: escaped = '&lt;'; break    // <
      case 62: escaped = '&gt;'; break    // >
      default: continue
    }

    if (lastIndex !== index) {
      html += str.substring(lastIndex, index)
    }
    lastIndex = index + 1
    html += escaped
  }

  return lastIndex !== index ? html + str.substring(lastIndex, index) : html
}

v-html 会绕过转义,这在 SSR 环境下特别危险:

// ❌ 极其危险:用户输入直接渲染为 HTML
const comment = ref(userInput) // 可能包含 <script>alert('xss')</script>
// <div v-html="comment"></div>

// ✅ 安全:使用 DOMPurify 等库清理
import DOMPurify from 'isomorphic-dompurify'
const safeComment = computed(() => DOMPurify.sanitize(comment.value))

内存管理

SSR 环境下的内存问题比客户端严重得多,因为每个请求都会创建新的组件树:

SSR 服务端是一个”多用户共享同一个 Node 进程”的环境——这和我们在客户端写 Vue 时的心智模型差异巨大。客户端下,每个标签页有自己的 JS 全局环境、组件实例、状态——不同用户天然隔离。服务端下,所有用户的请求都在同一个 Node 进程里跑——globalThis、模块级变量、共享的响应式对象如果不管好,就会变成”用户间泄露信道”。这和后端同学熟悉的”线程间共享”问题在 Node 单线程模型下变成了”请求间时序复用”——表现不一样,危害一样。

// 关键实践:避免内存泄漏
// 1. 不要在模块级创建响应式对象
// 2. 确保每个请求有独立的 app 实例
// 3. 设置合理的超时和并发限制

const server = createServer(async (req, res) => {
  const { app, router, pinia } = createApp()

  try {
    await router.push(req.url)
    await router.isReady()

    const html = await renderToString(app)
    res.end(html)
  } catch (err) {
    res.statusCode = 500
    res.end('Internal Server Error')
  }
  // app, router, pinia 在函数结束后自动被 GC
  // 关键:不要将它们存储到模块级变量中!
})

性能考量:SSR 服务端不是永动机

除了安全,SSR 的另一个常见翻车点是性能。纯客户端 SPA 时代,前端性能问题是”某个用户的浏览器卡”——影响一个人;SSR 下,性能问题是”服务端每个请求都慢”——影响所有人,还会堆积:渲染慢 → 连接积压 → CPU 打满 → 新请求排队超时 → 用户看到 502。这种级联崩溃在真实生产里比客户端性能问题难处理得多。

防范的关键有三条。第一,严格的渲染超时控制——任何 renderToString 调用都要套一层 timeout,超过 500ms / 1s 的请求直接降级返回 CSR 空壳 HTML,而不是让 Node 无限等。第二,并发限制——每台 Node 服务器设置同时处理的 SSR 请求上限(例如 100 个),超出就返回 503 让 LB 分流。第三,组件级缓存——对不依赖用户数据的组件用 renderToString + memo 的组合做结果缓存(Marko、Fastify 的 point-of-view 等都支持这个模式)。SSR 性能工程和后端性能工程本质上是同一件事——理解这一点,你才不会在压测阶段被现实狠狠上一课。

17.12 本章小结

SSR 与同构渲染是 Vue 3 工程化的重要能力。本章我们从底层实现的角度,深入探讨了整个 SSR 流程:

  1. 服务端渲染@vue/server-renderer 将组件树渲染为 HTML 字符串,通过 SSRBuffer 支持异步操作
  2. 流式渲染:通过 renderToStream 降低 TTFB,让用户更快看到页面内容
  3. Hydration:客户端通过 hydrateNode 遍历 DOM 树,复用现有节点并附加事件和响应式
  4. Mismatch 处理:检测服务端与客户端的渲染差异,开发模式下警告、生产模式下静默修复
  5. Lazy Hydration:Vue 3.6 的按需激活策略,显著减少首屏 JavaScript 执行量
  6. 同构约束:生命周期差异、状态污染、平台 API 隔离是同构开发的三大挑战
  7. 安全防护:SSR 环境下的 XSS 风险和内存管理需要特别关注

SSR 不是银弹。它增加了架构复杂度、服务器成本和开发约束。在选择渲染模式时,应当根据实际需求权衡:SEO 关键页面用 SSR/SSG,交互密集的管理后台用 CSR,高频更新的内容用 ISR/SWR。

一点更高维度的思考

本书前面的章节一直在讲”Vue 3 的内部是如何工作的”——本章把视角拉到了”Vue 3 如何在浏览器外工作”。你会发现:一套足够抽象的渲染器,可以同时服务浏览器和服务器两个完全不同的宿主。这种”宿主无关”的渲染器设计,是 Vue 3 相对 Vue 2 最重要的架构升级之一——它让 Vue 不再只是”浏览器 UI 框架”,而是一个”通用响应式渲染引擎”。未来如果有新的运行环境(比如 Web Worker、原生平台),Vue 3 几乎可以无缝扩展过去——这是 Vue 3 能持续保持生命力的底层原因。

延伸阅读

  • Vue 3 源码 packages/server-renderer/src/renderToString.tspackages/runtime-core/src/hydration.ts:本章所有讨论的原始材料,强烈建议和本章对照阅读。
  • Vue 官方文档 Server-Side Rendering (SSR) 部分:作者本人撰写,对 SSR 的架构决策有权威解释。
  • Evan You Rethinking Vue 3 Reactivity for SSR 演讲(2021 Vue Conf Toronto):讲解 Vue 3 的渲染器为什么要做成”宿主无关”的原始思路。
  • Qwik 官方文档 Resumability vs Hydration 部分:理解 Qwik 的 resumable 思路,对比 Vue / React 的 hydration 路线——看完你会更清楚每种 trade-off 的代价。
  • Nuxt 3 源码 packages/nuxt/src/app/nuxt.tspackages/nuxt/src/app/composables/asyncData.ts:工程化 SSR 的参考实现。
  • React 18 New Suspense Architecture 和 Selective Hydration 博客系列:对比两个主流框架在同一问题上的不同解法,能显著加深对 Hydration 设计空间的理解。

下一章我们回到客户端——讨论 Vue 3 的性能优化。SSR 是”把渲染的时间从客户端推回服务端”,性能优化则是”让客户端本身跑得更快”;两种思路互为补充,结合起来才能把 Web 应用的用户体验做到极致。


思考题

  1. 为什么 Hydration 使用 shallowRef 而不是 ref 来存储 currentRoute?如果使用 ref 会有什么影响?

  2. 假设你的应用有一个组件依赖 localStorage 来决定显示内容(比如”已读/未读”状态),在 SSR 环境下会发生什么?设计一个不产生 Hydration Mismatch 的方案。

  3. Lazy Hydration 中,如果用户在组件激活之前就点击了按钮,会发生什么?如何设计一个”点击时立即激活并重放点击事件”的策略?

  4. 在微服务架构中,多个服务分别渲染页面的不同区域(类似微前端 SSR),这对 Hydration 流程有什么影响?如何保证 Hydration 的正确性?

  5. 对比 React 18 的 Selective Hydration 和 Vue 3.6 的 Lazy Hydration,它们的设计哲学有什么异同?