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-dom 的 hydrate 函数中:
// 简化版伪代码
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 | 相近 | 相近 |
| 交互延迟 | 依赖全量水合完成 | 各岛独立就绪 |
五、实践建议
- 内容型网站优先 Islands:文档、博客、营销页用 Astro;混合型用 Nuxt + routeRules。
- 流式渲染优先:长页面用
renderToNodeStream配合<Suspense>。 - 避免 Hydration Mismatch:时间、随机数、用户态相关数据用
ClientOnly。 - 选择性水合:非首屏交互组件用
client:visible推迟水合。 - 监控真实水合耗时:用
performance.mark('hydrate-start')和performance.mark('hydrate-end')计算真实耗时。
结语
从 SSR 到 Islands,前端渲染架构的演进始终围绕一个核心矛盾:服务端渲染的首屏性能 vs 客户端框架的交互能力。Islands Architecture 通过"按需水合"给出了优雅的答案。理解这些原理,才能在实际项目中做出正确的架构选型。
Comments | 0条评论