WSの小屋

Vue 服务端渲染原理:从 SSR 到 Island Architecture

引言

现代前端框架对 SSR 的追求从未停止。从最早的字符串拼接模板,到 React/Vue 的同构渲染,再到 Nuxt 的混合渲染模式,以及 Astro 倡导的 Islands Architecture,每一次演进都在回答同一个问题:如何在保证首屏性能的同时,保留前端框架的交互能力?

本文将从底层原理出发,深入剖析 Vue SSR 的完整链路、Hydration 机制、Nuxt 的多种渲染模式,以及 Islands Architecture 的核心理念。

一、Vue SSR 渲染流程详解

1.1 服务端渲染的本质

Vue 的 SSR 并不是简单地在服务端运行组件。它的核心是 @vue/server-renderer 提供的 renderToString(或 renderToNodeStream)API,将组件树序列化为 HTML 字符串。

import { createSSRApp, h } from 'vue'
import { renderToString } from '@vue/server-renderer'

const app = createSSRApp({
  data: () => ({ message: 'Hello SSR' }),
  render() {
    return h('div', { class: 'container' }, this.message)
  },
})

const html = await renderToString(app)
// 输出: <div class="container" data-v-app>Hello SSR</div>

1.2 createSSRApp vs createApp

Vue 3 专门提供了 createSSRApp,它和 createApp 的区别在于挂载行为

特性 createApp createSSRApp
首次渲染 客户端创建 DOM 跳过,复用服务端 HTML
Hydration hydrate: true
事件绑定 创建时绑定 Hydration 阶段绑定

在同构应用中,服务端用 renderToString,客户端用 createSSRApp + app.mount('#app'),后者会以 hydration 模式挂载。

1.3 组件级 SSR 钩子

Vue SSR 生命周期和客户端有显著差异:

import { onServerPrefetch, defineComponent } from 'vue'

export default defineComponent({
  async setup() {
    const data = ref(null)

    // 服务端专属:等待异步数据完成后再序列化 HTML
    onServerPrefetch(async () => {
      data.value = await fetch('/api/user').then(r => r.json())
    })

    // 客户端:服务端未取到数据时补取
    if (!data.value) {
      onMounted(() => {
        fetch('/api/user').then(r => r.json()).then(d => data.value = d)
      })
    }

    return { data }
  },
})

onServerPrefetch 是 SSR 数据预取的关键钩子。renderToString 会等待所有 onServerPrefetch 的 Promise resolve 后再输出 HTML,确保首屏数据已填充。

1.4 renderToString vs renderToNodeStream

import { renderToNodeStream } from '@vue/server-renderer'
import express from 'express'

const app = express()

app.get('/', (req, res) => {
  const stream = renderToNodeStream(ssrApp)

  res.write('<html><body><div id="app">')
  stream.on('data', (chunk) => res.write(chunk))
  stream.on('end', () => {
    res.write('</div></body></html>')
    res.end()
  })
})

流式渲染能让浏览器尽早开始解析 HTML,无需等待整个组件树渲染完毕。对于数据量大的页面(如长列表、多区块),TTFB 显著降低。

二、Hydration 原理深入

2.1 什么是 Hydration

Hydration(水合)是 SSR 后客户端"激活"静态 HTML 的过程:将事件监听器绑定到已有 DOM 节点上,并建立响应式系统与 DOM 的关联,而不是重新创建 DOM。

Vue 3 的 Hydration 核心逻辑在 runtime-domhydrate 函数中:

// 简化版伪代码
function hydrate(node: Element, vnode: VNode, container: Element) {
  const el = node as Element

  // 1. 比对 vnode.props 和 DOM 属性
  // 2. 绑定事件监听器
  if (vnode.props) {
    for (const key in vnode.props) {
      if (isOn(key)) {
        patchEvent(el, key.slice(2).toLowerCase(), vnode.props[key])
      }
    }
  }

  // 3. 递归 hydrate 子节点
  hydrateChildren(el, vnode.children)
}

2.2 Hydration Mismatch

如果服务端渲染的 HTML 和客户端期望的 DOM 结构不一致,就会触发 Hydration Mismatch 警告。常见场景:

<template>
  <!-- ❌ 服务端和客户端时间不同 -->
  <div>当前时间:{{ new Date().toLocaleTimeString() }}</div>

  <!-- ✅ 使用 ClientOnly 包裹 -->
  <ClientOnly>
    <div>当前时间:{{ new Date().toLocaleTimeString() }}</div>
  </ClientOnly>
</template>

2.3 Selective Hydration 与 Partial Hydration

Vue 3.5+ 引入了改进的 Hydration 策略。在 Nuxt 中,可以通过组件配置实现部分水合:

// Nuxt 中标记组件为客户端渲染
definePageMeta({
  // 该页面不做 SSR
  ssr: false,
})

三、Nuxt 渲染模式全景

3.1 四种渲染模式对比

模式 全称 数据获取时机 适用场景
SSR Server-Side Rendering 每次请求服务端获取 动态内容、个性化页面
SSG Static Site Generation 构建时获取 博客、文档、营销页
ISR Incremental Static Regeneration 构建+定时重新生成 电商、新闻
Streaming Streaming SSR 服务端边渲染边推送 长页面、骨架屏

3.2 Nuxt Route Rules

Nuxt 3 通过 routeRules 实现混合渲染:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 首页静态生成
    '/': { prerender: true },
    // 博客列表 ISR,60秒重新生成
    '/blog/**': { isr: 60 },
    // 后台管理纯客户端渲染
    '/admin/**': { ssr: false },
    // 商品页带 SWR 缓存
    '/products/**': { swr: 300 },
    // API 路由 CORS
    '/api/**': { cors: true },
  },
})

3.3 Streaming SSR 与 <Suspense>

Nuxt 3 默认启用流式渲染,结合 Vue 的 <Suspense> 实现渐进式 HTML 输出:

<template>
  <Suspense>
    <template #default>
      <UserProfile />
    </template>
    <template #fallback>
      <SkeletonLoader />
    </template>
  </Suspense>
</template>

服务端会先输出 <SkeletonLoader /> 的 HTML,然后当 UserProfile 的异步数据就绪后,流式推送替换内容。

四、Islands Architecture 架构理念

4.1 问题:Hydration 成本

传统 SSR 应用的 Hydration 是全量的——整个页面所有组件都需要在客户端重新执行一遍 JS 并水合。对于内容型网站(博客、文档),大部分页面是静态的,只有少数交互区域需要 JS。

Islands Architecture 的核心思想:只对需要交互的区域(Islands)进行 Hydration,其余部分保持纯静态 HTML

4.2 Astro 的 Islands 实现

---
// 服务端获取数据
const posts = await getCollection('blog')
---

<html>
  <body>
    <!-- 静态 HTML,零 JS -->
    <h1>博客列表</h1>
    <ul>
      {posts.map(p => <li>{p.title}</li>)}
    </ul>

    <!-- Island:仅此组件水合 -->
    <SearchBar client:load />
    <NewsletterForm client:visible />
  </body>
</html>

Astro 的 client:* 指令控制每个 Island 的水合时机:

指令 时机 适用场景
client:load 页面加载立即水合 关键交互组件
client:idle 浏览器空闲时 次要交互
client:visible 进入视口时 评论、嵌入式组件
client:media 匹配媒体查询时 移动端专属组件
client:only 跳过 SSR 依赖 window 的组件

4.3 Vue 生态中的 Islands

Nuxt 3 的 nuxt-islands(实验性)和 nuxt-content 也在向 Islands 方向靠拢。通过 Server Components 实现:

<!-- components/ServerComment.vue -->
<script setup>
// Server Component:不在客户端水合
const comments = await $fetch('/api/comments')
</script>

4.4 性能对比

指标 传统 SSR Islands
JS 传输量 全部组件 仅交互组件
Hydration 时间 全页面 局部
TTI 较慢 显著提升
TTFB 相近 相近
交互延迟 依赖全量水合完成 各岛独立就绪

五、实践建议

  1. 内容型网站优先 Islands:文档、博客、营销页用 Astro;混合型用 Nuxt + routeRules。
  2. 流式渲染优先:长页面用 renderToNodeStream 配合 <Suspense>
  3. 避免 Hydration Mismatch:时间、随机数、用户态相关数据用 ClientOnly
  4. 选择性水合:非首屏交互组件用 client:visible 推迟水合。
  5. 监控真实水合耗时:用 performance.mark('hydrate-start')performance.mark('hydrate-end') 计算真实耗时。

结语

从 SSR 到 Islands,前端渲染架构的演进始终围绕一个核心矛盾:服务端渲染的首屏性能 vs 客户端框架的交互能力。Islands Architecture 通过"按需水合"给出了优雅的答案。理解这些原理,才能在实际项目中做出正确的架构选型。

Comments | 0条评论