Vue 服务端渲染
问题
什么是 Vue SSR(服务端渲染)?Vue 中如何实现同构应用?Nuxt.js 的核心架构是什么?
什么是 Vue SSR(服务端渲染)? SSR 是在 Node.js 服务端把 Vue 组件渲染成 HTML 字符串,浏览器拿到后直接展示,再由客户端 JS「激活」(Hydration)成可交互的应用。
- 解决两个核心问题:首屏速度(不用等 JS 下载执行就能看到内容)和 SEO(爬虫拿到的是完整 HTML)。
- 关键 API:服务端用
createSSRApp+renderToString(来自@vue/server-renderer),客户端用createSSRApp+app.mount。 - 与 CSR/SSG/ISR 的区别:CSR 是浏览器渲染;SSG 是构建时预渲染成静态 HTML;ISR 是 SSG + 按需重新生成。
Vue 中如何实现同构应用? 同构 = 同一套代码同时跑在 Node 和浏览器,几个关键点:
- 每个请求创建新实例:服务端是长进程,多个请求共享实例会串数据,所以必须
createSSRApp而不是单例。 - 路由/store 也要每请求新建:用工厂函数
createRouter/createPinia,避免跨请求污染。 - 数据预取:在路由匹配后、渲染前就把组件依赖的数据请求完,注入到 store/props 里;同时把 state 序列化到 HTML,客户端激活时复用,避免重复请求。
- 避免使用
window/document:服务端没有 DOM,要用import.meta.env.SSR或onMounted守住浏览器才执行的代码。 - 激活一致性:客户端首次渲染必须产出和服务端一模一样的 DOM,否则会报 hydration mismatch。
Nuxt.js 的核心架构是什么? Nuxt 是 Vue 官方的 SSR 框架,把上面的「脏活」全部封装好:
- 基于文件的路由:
pages/目录自动生成路由,支持动态路由、嵌套路由、布局。 - Nitro 引擎:底层 server,支持多种部署目标(Node、Vercel、Cloudflare Workers、Deno 等),同一份代码部署到不同平台。
- 多种渲染模式:通过
routeRules灵活配置每个页面是 SSR / SSG / ISR / SPA。 - 数据获取:内置
useFetch/useAsyncData,自动处理服务端预取 + 状态序列化。 - 自动导入:组件、composable、
ref/computed等都自动导入。 - 模块生态:
@nuxt/image、@nuxtjs/i18n、@pinia/nuxt等模块开箱即用。
答案
SSR(Server-Side Rendering)是指在服务端将 Vue 组件渲染为 HTML 字符串,发送给浏览器后再由客户端"激活"(Hydration)为可交互的应用。Vue 的 SSR 方案基于同构(Isomorphic / Universal)理念——同一套 Vue 代码同时运行在 Node.js 服务端和浏览器客户端。
- 首屏速度:用户直接看到渲染好的 HTML,无需等待 JS 下载和执行
- SEO 友好:搜索引擎爬虫可以直接抓取完整的 HTML 内容
- 社交分享:OG 标签中的动态内容可以被正确抓取
CSR vs SSR vs SSG vs ISR 对比
| 维度 | CSR | SSR | SSG | ISR |
|---|---|---|---|---|
| 渲染位置 | 浏览器 | 服务端(每次请求) | 构建时 | 构建时 + 按需重新生成 |
| 首屏速度 | 慢(白屏) | 快 | 最快(CDN 直出) | 快(CDN 缓存) |
| SEO | 差 | 好 | 好 | 好 |
| 服务器压力 | 无 | 高(每次请求渲染) | 无(纯静态) | 低(缓存 + 按需) |
| 数据实时性 | 实时 | 实时 | 构建时快照 | 可配过期时间 |
| Vue 实现 | createApp | createSSRApp | Nuxt generate | Nuxt routeRules |
更多渲染策略对比请参考 SSR 与 SSG。
Vue SSR 核心 API
1. 服务端:createSSRApp + renderToString
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
import App from './App.vue'
export async function render() {
// 每个请求创建新的应用实例,避免跨请求状态污染
const app = createSSRApp(App)
// 渲染为 HTML 字符串
const html = await renderToString(app)
return html
}
服务端代码运行在一个长期存活的 Node.js 进程中。如果多个请求共享同一个 Vue 实例,会导致跨请求状态泄漏——用户 A 的数据被用户 B 看到。
2. 客户端:Hydration
import { createSSRApp } from 'vue'
import App from './App.vue'
const app = createSSRApp(App)
// 客户端激活:复用服务端渲染的 DOM,绑定事件监听器
app.mount('#app')
Hydration 的本质是:客户端 Vue 不会重新创建 DOM,而是复用服务端生成的 HTML 节点,只附加事件监听器和响应式状态。
3. 流式渲染:renderToNodeStream
import { renderToNodeStream } from '@vue/server-renderer'
import { createSSRApp } from 'vue'
import App from './App.vue'
export function renderStream(res: NodeJS.WritableStream) {
const app = createSSRApp(App)
// 流式输出:边渲染边发送,减少 TTFB
const stream = renderToNodeStream(app)
stream.pipe(res)
}
renderToString 需要等整个组件树渲染完才发送 HTML;renderToNodeStream 则可以边渲染边发送,大幅减少首字节时间(TTFB),特别适合页面内容较多的场景。
SSR 渲染流程
同构注意事项
1. 服务端无 DOM 环境
服务端没有 window、document、navigator 等浏览器 API。在组件代码中直接访问这些 API 会导致运行时错误。
import { ref, onMounted } from 'vue'
export function useWindowSize() {
const width = ref(0)
const height = ref(0)
// onMounted 只在客户端执行,安全地访问 window
onMounted(() => {
width.value = window.innerWidth
height.value = window.innerHeight
})
return { width, height }
}
2. 生命周期差异
服务端只会执行有限的生命周期钩子:
| 钩子 | 服务端 | 客户端 |
|---|---|---|
setup() | 执行 | 执行 |
onServerPrefetch | 执行 | 不执行 |
onBeforeMount | 不执行 | 执行 |
onMounted | 不执行 | 执行 |
onBeforeUpdate | 不执行 | 执行 |
onUpdated | 不执行 | 执行 |
onBeforeUnmount | 不执行 | 执行 |
onUnmounted | 不执行 | 执行 |
详细生命周期说明参考 Vue 生命周期。
setInterval、addEventListener、操作 DOM 等副作用必须放在 onMounted 内。在 setup 顶层执行这些操作会在服务端报错,也可能导致内存泄漏。
3. 全局状态污染
// ❌ 错误:所有请求共享同一个 store,状态会泄漏
// const store = createPinia()
export function createApp() {
const app = createSSRApp(App)
// ✅ 正确:每个请求创建新的 Pinia 实例
const pinia = createPinia()
app.use(pinia)
return { app, pinia }
}
4. 第三方库兼容性
许多前端库(如 echarts、swiper)依赖浏览器 API,需要特殊处理:
<script setup lang="ts">
import { onMounted, shallowRef } from 'vue'
// 延迟导入,只在客户端加载 echarts
const chartInstance = shallowRef(null)
onMounted(async () => {
const echarts = await import('echarts')
chartInstance.value = echarts.init(document.getElementById('chart'))
})
</script>
数据预取(Data Fetching)
onServerPrefetch(Composition API)
onServerPrefetch 是 Vue 3 专为 SSR 设计的钩子,在服务端渲染前异步获取数据:
<script setup lang="ts">
import { ref, onServerPrefetch, onMounted } from 'vue'
const user = ref(null)
async function fetchUser() {
const res = await fetch('/api/user/1')
user.value = await res.json()
}
// 服务端:渲染前获取数据
onServerPrefetch(async () => {
await fetchUser()
})
// 客户端:如果服务端已获取数据则跳过
onMounted(() => {
if (!user.value) {
fetchUser()
}
})
</script>
状态序列化与恢复
服务端获取的数据需要序列化到 HTML,客户端 Hydration 时恢复,避免重复请求:
import { renderToString } from '@vue/server-renderer'
async function render(req) {
const { app, pinia } = createApp()
const html = await renderToString(app)
// 将 Pinia 状态序列化,注入到 HTML
const state = JSON.stringify(pinia.state.value)
return `
<div id="app">${html}</div>
<script>window.__INITIAL_STATE__ = ${state}</script>
`
}
const { app, pinia } = createApp()
// 客户端恢复服务端状态
if (window.__INITIAL_STATE__) {
pinia.state.value = window.__INITIAL_STATE__
}
app.mount('#app')
Nuxt.js SSR 实践
Nuxt 3 是 Vue 生态最主流的 SSR 框架,基于 Nitro 服务引擎,提供开箱即用的 SSR、SSG、ISR 支持。
Nuxt 3 架构
渲染模式对比
Nuxt 3 通过 nuxt.config.ts 配置不同的渲染模式:
| 模式 | 配置 | 说明 |
|---|---|---|
| Universal(SSR) | ssr: true(默认) | 服务端渲染 + 客户端 Hydration |
| SPA | ssr: false | 纯客户端渲染 |
| Hybrid | routeRules 按路由配置 | 不同路由使用不同渲染策略 |
| SSG | npx nuxi generate | 构建时预渲染所有页面 |
export default defineNuxtConfig({
// Hybrid 渲染:按路由配置不同策略
routeRules: {
'/': { prerender: true }, // SSG:构建时预渲染
'/blog/**': { isr: 3600 }, // ISR:缓存 1 小时后重新生成
'/admin/**': { ssr: false }, // SPA:纯客户端渲染
'/api/**': { cors: true }, // API 路由配置
},
})
useFetch vs useAsyncData vs $fetch
| API | SSR 数据预取 | 自动去重 | 响应式 | 适用场景 |
|---|---|---|---|---|
useFetch | 是 | 是 | 是 | 页面/组件数据获取(推荐) |
useAsyncData | 是 | 是 | 是 | 自定义数据源(非 HTTP) |
$fetch | 否 | 否 | 否 | 事件处理函数、客户端请求 |
<script setup lang="ts">
const route = useRoute()
// useFetch:SSR + 客户端导航均自动获取数据
const { data: post, pending, error } = await useFetch(
`/api/posts/${route.params.id}`
)
</script>
<template>
<div v-if="pending">加载中...</div>
<div v-else-if="error">加载失败</div>
<article v-else>
<h1>{{ post.title }}</h1>
<div v-html="post.content" />
</article>
</template>
// useAsyncData:自定义数据源
export function usePostWithTransform(id: string) {
return useAsyncData(`post-${id}`, async () => {
const raw = await $fetch(`/api/posts/${id}`)
// 可以做数据转换
return { ...raw, readTime: Math.ceil(raw.content.length / 500) }
})
}
Server API 路由
Nuxt 3 内置 server/api/ 目录,基于 Nitro 提供全栈能力:
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id')
// 直接访问数据库,此代码只在服务端运行
const post = await db.query('SELECT * FROM posts WHERE id = ?', [id])
if (!post) {
throw createError({ statusCode: 404, message: 'Post not found' })
}
return post
})
Nuxt 中间件与路由守卫
export default defineNuxtRouteMiddleware((to, from) => {
const { loggedIn } = useUserSession()
// 服务端和客户端都会执行
if (!loggedIn.value && to.path !== '/login') {
return navigateTo('/login')
}
})
SSR 性能优化
1. 组件级缓存
对于不依赖用户状态的组件(如导航栏、页脚),可以缓存渲染结果:
export default defineNuxtConfig({
// Nuxt 3 通过 routeRules 实现页面级缓存
routeRules: {
'/blog/**': { swr: 3600 }, // SWR:先返回缓存,后台重新验证
},
})
2. 流式渲染
Vue 3 + Nuxt 3 支持流式 SSR,将 HTML 分块发送:
3. 代码分割与异步组件
<script setup lang="ts">
// 异步组件:只在需要时加载,减少初始 bundle
const HeavyChart = defineAsyncComponent(() =>
import('~/components/HeavyChart.vue')
)
</script>
<template>
<Suspense>
<HeavyChart />
<template #fallback>
<div>图表加载中...</div>
</template>
</Suspense>
</template>
4. Edge Rendering
Nuxt 3 + Nitro 支持部署到边缘运行时,将 SSR 逻辑下沉到 CDN 节点:
| 平台 | 配置 | 优势 |
|---|---|---|
| Cloudflare Workers | nitro: { preset: 'cloudflare' } | 全球边缘节点,冷启动极快 |
| Vercel Edge | nitro: { preset: 'vercel-edge' } | 与 Vercel 平台深度集成 |
| Deno Deploy | nitro: { preset: 'deno-deploy' } | Deno 原生运行时 |
SSR 常见问题
Hydration Mismatch
当服务端渲染的 HTML 与客户端 Hydration 时生成的虚拟 DOM 不一致时,会触发 Hydration Mismatch 警告。
常见原因:
- 时间/随机数:服务端和客户端的
Date.now()或Math.random()结果不同 - 浏览器特有 API:在
setup中使用window.innerWidth导致服务端为undefined - HTML 规范差异:浏览器会自动修正不规范的 HTML(如
<p>嵌套<div>)
// 解决方案:使用 ref 延迟客户端特有内容
import { ref, onMounted } from 'vue'
export function useClientOnly() {
const isMounted = ref(false)
onMounted(() => {
isMounted.value = true
})
return { isMounted }
}
在 Nuxt 中可以直接使用 <ClientOnly> 组件:
<!-- 只在客户端渲染 -->
<ClientOnly>
<ThirdPartyWidget />
<template #fallback>
<div>加载中...</div>
</template>
</ClientOnly>
window is not defined
// Nuxt 中判断运行环境
if (import.meta.client) {
// 客户端代码
window.addEventListener('resize', handleResize)
}
// 或使用动态导入
const lib = import.meta.client ? await import('browser-only-lib') : null
内存泄漏
SSR 应用运行在长期存活的 Node.js 进程中,内存泄漏尤其需要注意:
- 模块级变量作为全局单例会在所有请求间共享
- 未清理的事件监听器不会被自动回收
- 大对象缓存没有 TTL 或 LRU 淘汰机制
Vue SSR vs React SSR 简要对比
| 维度 | Vue SSR | React SSR |
|---|---|---|
| 核心 API | createSSRApp + renderToString | renderToPipeableStream |
| 主流框架 | Nuxt 3 | Next.js |
| Hydration | 自动对比 + 修复 | React 18 Selective Hydration |
| 流式渲染 | renderToNodeStream | renderToPipeableStream(React 18) |
| 服务端组件 | 不支持(Vapor Mode 探索中) | React Server Components |
| 数据预取 | onServerPrefetch / useFetch | Server Components / fetch |
| 状态管理 | Pinia SSR 集成 | 无内置方案(RSC 自带) |
更多 React SSR 内容参考 Next.js 核心知识,Vue Composition API 与 Options API 对比参考 Composition API vs Options API。
常见面试问题
Q1: 什么是 SSR?为什么 Vue 需要 SSR?
答案:
SSR 是在服务端将 Vue 组件渲染为 HTML 字符串,发送给浏览器后再 Hydration 为可交互应用。主要解决两个问题:
- 首屏性能:CSR 需要等待 JS 下载和执行才能显示内容,SSR 直接返回 HTML,用户更快看到页面
- SEO:搜索引擎爬虫可能无法执行 JavaScript,SSR 返回完整 HTML 确保内容可被索引
Q2: Vue SSR 中为什么每个请求都要创建新的应用实例?
答案:
Node.js 服务端是一个长期运行的进程。如果所有请求共用同一个 Vue 实例和 Pinia store,用户 A 修改的状态会被用户 B 看到,造成跨请求状态污染。因此必须在每个请求中调用 createSSRApp() 和 createPinia() 创建全新实例。
Q3: 什么是 Hydration?Hydration Mismatch 怎么解决?
答案:
Hydration 是客户端 Vue 复用服务端渲染的 DOM 节点、附加事件监听器和响应式状态的过程。当服务端 HTML 与客户端虚拟 DOM 不一致时,会产生 Hydration Mismatch。
解决方法:
- 避免在
setup顶层使用Date.now()、Math.random()等不确定值 - 使用
<ClientOnly>包裹浏览器特有内容 - 确保 HTML 结构规范(不在
<p>内嵌套<div>)
Q4: Vue SSR 中哪些生命周期钩子会在服务端执行?
答案:
服务端只执行 setup()(同步部分)和 onServerPrefetch。onMounted、onUpdated、onBeforeUnmount 等钩子只在客户端执行。因此需要操作 DOM 或注册浏览器事件的逻辑必须放在 onMounted 中。详情参考 Vue 生命周期。
Q5: onServerPrefetch 的作用是什么?
答案:
onServerPrefetch 是 Vue 3 专为 SSR 设计的钩子,在服务端渲染前执行异步操作(如数据获取)。Vue 会等待其中的 Promise resolve 后才开始渲染组件。这确保服务端输出的 HTML 包含完整的数据内容。
Q6: Nuxt 3 中 useFetch 和 $fetch 有什么区别?
答案:
useFetch:在 SSR 时自动在服务端执行数据获取,结果序列化到 HTML,客户端不会重复请求。返回响应式的data、pending、error$fetch:基于 ofetch 的底层请求工具,不参与 SSR 数据预取流程,每次调用都会发起请求。适用于事件处理函数(如按钮点击后请求)
Q7: 如何处理只在浏览器端运行的第三方库?
答案:
三种方式:
<ClientOnly>组件:包裹使用该库的组件- 动态导入:在
onMounted中await import('library') - 环境判断:
if (import.meta.client) { ... }(Nuxt 3)
Q8: SSR 应用如何避免内存泄漏?
答案:
- 每个请求创建新实例:避免模块级单例
- 避免模块级可变状态:如
const cache = new Map()会在所有请求间共享且持续增长 - 使用带 TTL 的缓存:如
lru-cache,避免缓存无限增长 - 清理副作用:
setInterval等需要在组件卸载时清理
Q9: Nuxt 3 的渲染模式有哪些?如何选择?
答案:
| 场景 | 推荐模式 |
|---|---|
| 内容站(博客、文档) | SSG / ISR |
| 电商首页、SEO 页 | SSR / ISR |
| 管理后台 | SPA |
| 混合需求 | Hybrid(routeRules 按路由配置) |
Nuxt 3 支持通过 routeRules 为不同路由配置不同的渲染策略,实现 Hybrid 渲染。
Q10: 什么是流式渲染?有什么优势?
答案:
流式渲染(Streaming SSR)是指服务端不等整个页面渲染完成,而是边渲染边发送 HTML 片段。用户能更快看到首屏内容(降低 TTFB)。Vue 3 通过 renderToNodeStream / renderToWebStream 支持流式渲染,Nuxt 3 默认启用。
Q11: SSR 和 SSG 怎么选?
答案:
- SSG:内容不频繁变化的页面(文档、博客),构建时生成静态 HTML,性能最优
- SSR:内容实时变化或需要个性化的页面(用户主页、搜索结果),每次请求服务端渲染
- ISR:兼顾两者,构建时生成静态页面,设定过期时间后自动重新生成
Q12: Nuxt 3 的 Nitro 引擎有什么特点?
答案:
Nitro 是 Nuxt 3 的服务端引擎,核心特点:
- 跨平台部署:同一套代码可以部署到 Node.js、Cloudflare Workers、Vercel Edge、Deno 等
- 内置 API 路由:
server/api/目录自动注册为 API 端点 - 自动代码分割:服务端代码按路由分割,减少冷启动时间
- 文件系统路由:
server/api/、server/middleware/、server/plugins/自动扫描
Q13: Vue SSR 与 React SSR(Next.js)的主要区别是什么?
答案:
最大的区别在于 Server Components。React 18 引入了 RSC(React Server Components),组件可以声明只在服务端运行,不发送 JS 到客户端。Vue 目前不支持 Server Components(Vapor Mode 尚在探索中)。此外,Nuxt 使用 useFetch / useAsyncData 进行数据预取,而 Next.js App Router 可以直接在 Server Component 中 await fetch()。