Vue 3 服务端渲染与静态站点生成:Nuxt 3 全栈开发实战
Orion K Lv6

随着现代 Web 应用对 SEO 和首屏性能要求的提高,服务端渲染(SSR)和静态站点生成(SSG)成为了前端开发的重要技术。本文将深入探讨 Vue 3 生态中的 SSR/SSG 解决方案,特别是 Nuxt 3 框架的实战应用。

SSR vs SSG vs SPA 对比分析

1. 渲染模式对比

渲染策略分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
// types/rendering.ts

/**
* 渲染模式枚举
*/
export enum RenderingMode {
SPA = 'spa', // 单页应用
SSR = 'ssr', // 服务端渲染
SSG = 'ssg', // 静态站点生成
ISR = 'isr', // 增量静态再生
HYBRID = 'hybrid' // 混合渲染
}

/**
* 渲染模式特性对比
*/
export const renderingModeComparison = {
[RenderingMode.SPA]: {
name: '单页应用',
description: '客户端渲染,动态加载内容',
advantages: [
'交互体验流畅',
'开发简单',
'服务器压力小',
'适合复杂交互'
],
disadvantages: [
'SEO 不友好',
'首屏加载慢',
'依赖 JavaScript',
'爬虫抓取困难'
],
useCases: [
'管理后台',
'复杂应用',
'用户交互密集'
],
performance: {
firstContentfulPaint: 'slow',
timeToInteractive: 'fast',
seoScore: 'poor'
}
},

[RenderingMode.SSR]: {
name: '服务端渲染',
description: '服务器生成 HTML,客户端激活',
advantages: [
'SEO 友好',
'首屏快速',
'爬虫友好',
'社交分享优化'
],
disadvantages: [
'服务器压力大',
'开发复杂',
'缓存策略复杂',
'TTFB 可能较高'
],
useCases: [
'电商网站',
'新闻媒体',
'企业官网',
'内容展示'
],
performance: {
firstContentfulPaint: 'fast',
timeToInteractive: 'medium',
seoScore: 'excellent'
}
},

[RenderingMode.SSG]: {
name: '静态站点生成',
description: '构建时预生成静态 HTML',
advantages: [
'SEO 完美',
'性能极佳',
'CDN 友好',
'安全性高'
],
disadvantages: [
'构建时间长',
'动态内容限制',
'更新需重新构建',
'不适合个性化'
],
useCases: [
'博客网站',
'文档站点',
'营销页面',
'产品展示'
],
performance: {
firstContentfulPaint: 'excellent',
timeToInteractive: 'fast',
seoScore: 'excellent'
}
},

[RenderingMode.ISR]: {
name: '增量静态再生',
description: 'SSG + 按需重新生成',
advantages: [
'结合 SSG 和 SSR 优势',
'内容可更新',
'性能优秀',
'扩展性好'
],
disadvantages: [
'实现复杂',
'缓存策略复杂',
'调试困难',
'框架支持有限'
],
useCases: [
'大型内容站',
'电商产品页',
'新闻网站',
'社区论坛'
],
performance: {
firstContentfulPaint: 'excellent',
timeToInteractive: 'fast',
seoScore: 'excellent'
}
}
}

/**
* 渲染模式选择决策树
*/
export class RenderingModeSelector {
/**
* 根据项目需求选择渲染模式
*/
static selectMode(requirements: {
seoRequired: boolean
contentFrequency: 'static' | 'dynamic' | 'mixed'
userInteraction: 'low' | 'medium' | 'high'
trafficVolume: 'low' | 'medium' | 'high'
developmentComplexity: 'simple' | 'medium' | 'complex'
}): RenderingMode {
const { seoRequired, contentFrequency, userInteraction, trafficVolume } = requirements

// SEO 不重要的应用
if (!seoRequired) {
return RenderingMode.SPA
}

// 静态内容为主
if (contentFrequency === 'static') {
return RenderingMode.SSG
}

// 高交互应用
if (userInteraction === 'high') {
return trafficVolume === 'high' ? RenderingMode.ISR : RenderingMode.SSR
}

// 混合内容
if (contentFrequency === 'mixed') {
return RenderingMode.HYBRID
}

// 默认 SSR
return RenderingMode.SSR
}

/**
* 获取推荐配置
*/
static getRecommendedConfig(mode: RenderingMode) {
const configs = {
[RenderingMode.SPA]: {
framework: 'Vue 3 + Vite',
deployment: 'CDN + SPA',
caching: 'Browser Cache',
monitoring: 'Client-side'
},
[RenderingMode.SSR]: {
framework: 'Nuxt 3',
deployment: 'Node.js Server',
caching: 'Server + CDN',
monitoring: 'Full-stack'
},
[RenderingMode.SSG]: {
framework: 'Nuxt 3 + generate',
deployment: 'Static Hosting',
caching: 'CDN',
monitoring: 'Client-side'
},
[RenderingMode.ISR]: {
framework: 'Nuxt 3 + ISR',
deployment: 'Edge Functions',
caching: 'Multi-layer',
monitoring: 'Full-stack'
},
[RenderingMode.HYBRID]: {
framework: 'Nuxt 3 + Hybrid',
deployment: 'Mixed',
caching: 'Intelligent',
monitoring: 'Full-stack'
}
}

return configs[mode]
}
}

2. 性能指标对比

性能监控工具

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
// utils/performanceMonitor.ts

/**
* 性能指标接口
*/
export interface PerformanceMetrics {
// Core Web Vitals
lcp: number // Largest Contentful Paint
fid: number // First Input Delay
cls: number // Cumulative Layout Shift

// 其他重要指标
fcp: number // First Contentful Paint
tti: number // Time to Interactive
tbt: number // Total Blocking Time
si: number // Speed Index

// 自定义指标
ttfb: number // Time to First Byte
domContentLoaded: number
loadComplete: number

// 资源指标
resourceCount: number
resourceSize: number
cacheHitRate: number
}

/**
* 性能监控器
*/
export class PerformanceMonitor {
private metrics: Partial<PerformanceMetrics> = {}
private observers: PerformanceObserver[] = []

/**
* 初始化性能监控
*/
init(): void {
this.observeWebVitals()
this.observeNavigation()
this.observeResources()
}

/**
* 监控 Core Web Vitals
*/
private observeWebVitals(): void {
// LCP 监控
const lcpObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
const lastEntry = entries[entries.length - 1] as PerformanceEntry
this.metrics.lcp = lastEntry.startTime
})
lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
this.observers.push(lcpObserver)

// FID 监控
const fidObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach((entry: any) => {
this.metrics.fid = entry.processingStart - entry.startTime
})
})
fidObserver.observe({ entryTypes: ['first-input'] })
this.observers.push(fidObserver)

// CLS 监控
let clsValue = 0
const clsObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach((entry: any) => {
if (!entry.hadRecentInput) {
clsValue += entry.value
}
})
this.metrics.cls = clsValue
})
clsObserver.observe({ entryTypes: ['layout-shift'] })
this.observers.push(clsObserver)
}

/**
* 监控导航性能
*/
private observeNavigation(): void {
const navigationObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()
entries.forEach((entry: any) => {
this.metrics.ttfb = entry.responseStart - entry.requestStart
this.metrics.domContentLoaded = entry.domContentLoadedEventEnd - entry.navigationStart
this.metrics.loadComplete = entry.loadEventEnd - entry.navigationStart
})
})
navigationObserver.observe({ entryTypes: ['navigation'] })
this.observers.push(navigationObserver)
}

/**
* 监控资源加载
*/
private observeResources(): void {
const resourceObserver = new PerformanceObserver((list) => {
const entries = list.getEntries()

let totalSize = 0
let cacheHits = 0

entries.forEach((entry: any) => {
totalSize += entry.transferSize || 0
if (entry.transferSize === 0 && entry.decodedBodySize > 0) {
cacheHits++
}
})

this.metrics.resourceCount = entries.length
this.metrics.resourceSize = totalSize
this.metrics.cacheHitRate = cacheHits / entries.length
})
resourceObserver.observe({ entryTypes: ['resource'] })
this.observers.push(resourceObserver)
}

/**
* 获取性能指标
*/
getMetrics(): Partial<PerformanceMetrics> {
return { ...this.metrics }
}

/**
* 发送性能数据
*/
async sendMetrics(endpoint: string): Promise<void> {
try {
await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
metrics: this.metrics,
timestamp: Date.now(),
userAgent: navigator.userAgent,
url: window.location.href
})
})
} catch (error) {
console.error('Failed to send performance metrics:', error)
}
}

/**
* 清理监控器
*/
destroy(): void {
this.observers.forEach(observer => observer.disconnect())
this.observers = []
}
}

// 导出单例
export const performanceMonitor = new PerformanceMonitor()

Nuxt 3 核心特性与配置

1. 项目初始化与配置

Nuxt 3 项目配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// nuxt.config.ts
export default defineNuxtConfig({
// 基础配置
app: {
head: {
title: 'Vue 3 SSR/SSG 实战项目',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ name: 'description', content: 'Vue 3 服务端渲染与静态站点生成实战项目' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
}
},

// 渲染模式配置
ssr: true,

// 路由配置
router: {
options: {
scrollBehaviorType: 'smooth'
}
},

// 构建配置
build: {
transpile: ['@headlessui/vue']
},

// Vite 配置
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData: '@use "~/assets/scss/variables.scss" as *;'
}
}
},
optimizeDeps: {
include: ['lodash-es', 'dayjs']
}
},

// CSS 框架
css: [
'~/assets/scss/main.scss'
],

// 模块配置
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@nuxtjs/color-mode',
'@vueuse/nuxt',
'@nuxt/content',
'@nuxt/image'
],

// Pinia 配置
pinia: {
autoImports: ['defineStore', 'storeToRefs']
},

// 颜色模式配置
colorMode: {
preference: 'system',
fallback: 'light',
hid: 'nuxt-color-mode-script',
globalName: '__NUXT_COLOR_MODE__',
componentName: 'ColorScheme',
classPrefix: '',
classSuffix: '',
storageKey: 'nuxt-color-mode'
},

// 内容模块配置
content: {
highlight: {
theme: {
default: 'github-light',
dark: 'github-dark'
},
preload: ['json', 'js', 'ts', 'html', 'css', 'vue', 'diff', 'shell', 'markdown', 'yaml', 'bash', 'ini']
},
markdown: {
toc: {
depth: 3,
searchDepth: 3
}
}
},

// 图片优化配置
image: {
format: ['webp', 'avif'],
quality: 80,
densities: [1, 2],
sizes: 'sm:100vw md:50vw lg:400px'
},

// 运行时配置
runtimeConfig: {
// 服务端环境变量
apiSecret: process.env.API_SECRET,

// 公共环境变量
public: {
apiBase: process.env.API_BASE || 'https://api.example.com',
gtmId: process.env.GTM_ID,
siteUrl: process.env.SITE_URL || 'https://example.com'
}
},

// 实验性功能
experimental: {
payloadExtraction: false,
inlineSSRStyles: false,
renderJsonPayloads: true
},

// 性能优化
optimization: {
keyedComposables: [
{
name: 'useState',
argumentLength: 2
}
]
},

// 开发工具
devtools: {
enabled: true
},

// TypeScript 配置
typescript: {
strict: true,
typeCheck: true
}
})

2. 页面和布局系统

布局组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!-- layouts/default.vue -->
<template>
<div class="min-h-screen bg-white dark:bg-gray-900 transition-colors">
<!-- 导航栏 -->
<AppHeader />

<!-- 主要内容 -->
<main class="container mx-auto px-4 py-8">
<slot />
</main>

<!-- 页脚 -->
<AppFooter />

<!-- 全局组件 -->
<AppNotification />
<AppLoading v-if="pending" />
</div>
</template>

<script setup lang="ts">
// 页面元数据
useHead({
htmlAttrs: {
lang: 'zh-CN'
},
bodyAttrs: {
class: 'font-sans antialiased'
}
})

// 全局状态
const { pending } = useLazyAsyncData('app-init', async () => {
// 初始化应用数据
await initializeApp()
})

/**
* 初始化应用
*/
async function initializeApp() {
// 加载用户信息
const userStore = useUserStore()
await userStore.fetchCurrentUser()

// 加载应用配置
const appStore = useAppStore()
await appStore.loadConfig()

// 初始化主题
const colorMode = useColorMode()
colorMode.preference = 'system'
}
</script>

页面组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
<!-- pages/blog/[slug].vue -->
<template>
<div class="max-w-4xl mx-auto">
<!-- 文章头部 -->
<article-header
:title="article.title"
:description="article.description"
:author="article.author"
:published-at="article.publishedAt"
:reading-time="article.readingTime"
:tags="article.tags"
/>

<!-- 文章内容 -->
<div class="prose prose-lg dark:prose-invert max-w-none">
<ContentRenderer :value="article" />
</div>

<!-- 文章底部 -->
<article-footer
:prev="prev"
:next="next"
:share-url="shareUrl"
/>

<!-- 评论系统 -->
<comment-system
v-if="article.enableComments"
:article-id="article.id"
/>
</div>
</template>

<script setup lang="ts">
interface Article {
id: string
title: string
description: string
content: string
author: {
name: string
avatar: string
}
publishedAt: string
readingTime: number
tags: string[]
enableComments: boolean
}

// 路由参数
const route = useRoute()
const slug = route.params.slug as string

// 获取文章数据
const { data: article } = await useAsyncData(`blog-${slug}`, () => {
return queryContent<Article>('/blog').where({ slug }).findOne()
})

// 获取相邻文章
const { data: surroundingArticles } = await useAsyncData(`blog-surrounding-${slug}`, () => {
return queryContent('/blog')
.only(['title', 'slug', 'publishedAt'])
.sort({ publishedAt: -1 })
.findSurround(slug)
})

const [prev, next] = surroundingArticles.value || []

// 分享链接
const shareUrl = computed(() => {
const config = useRuntimeConfig()
return `${config.public.siteUrl}${route.path}`
})

// SEO 优化
useHead({
title: article.value?.title,
meta: [
{
name: 'description',
content: article.value?.description
},
{
property: 'og:title',
content: article.value?.title
},
{
property: 'og:description',
content: article.value?.description
},
{
property: 'og:url',
content: shareUrl.value
},
{
property: 'og:type',
content: 'article'
},
{
name: 'twitter:card',
content: 'summary_large_image'
}
],
link: [
{
rel: 'canonical',
href: shareUrl.value
}
]
})

// 结构化数据
useJsonld({
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.value?.title,
description: article.value?.description,
author: {
'@type': 'Person',
name: article.value?.author.name
},
datePublished: article.value?.publishedAt,
url: shareUrl.value
})

// 404 处理
if (!article.value) {
throw createError({
statusCode: 404,
statusMessage: '文章不存在'
})
}
</script>

3. 数据获取策略

数据获取 Composables

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
// composables/useApi.ts

/**
* API 响应接口
*/
export interface ApiResponse<T = any> {
data: T
message: string
code: number
timestamp: number
}

/**
* 分页响应接口
*/
export interface PaginatedResponse<T = any> {
data: T[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}

/**
* API 请求选项
*/
export interface ApiOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
body?: any
headers?: Record<string, string>
query?: Record<string, any>
cache?: boolean
ssr?: boolean
}

/**
* API 请求 Hook
*/
export function useApi<T = any>(
url: string,
options: ApiOptions = {}
) {
const config = useRuntimeConfig()
const { $fetch } = useNuxtApp()

// 构建完整 URL
const fullUrl = computed(() => {
const baseUrl = config.public.apiBase
const path = url.startsWith('/') ? url : `/${url}`
return `${baseUrl}${path}`
})

// 请求选项
const fetchOptions = computed(() => ({
method: options.method || 'GET',
body: options.body,
headers: {
'Content-Type': 'application/json',
...options.headers
},
query: options.query,
server: options.ssr !== false
}))

// 缓存键
const cacheKey = computed(() => {
const key = `api:${url}`
if (options.query) {
const queryString = new URLSearchParams(options.query).toString()
return `${key}?${queryString}`
}
return key
})

/**
* 执行请求
*/
const execute = async () => {
try {
const response = await $fetch<ApiResponse<T>>(fullUrl.value, fetchOptions.value)

if (response.code !== 200) {
throw new Error(response.message || '请求失败')
}

return response.data
} catch (error) {
console.error('API request failed:', error)
throw error
}
}

// 使用 asyncData 进行数据获取
if (options.cache !== false) {
return useAsyncData(cacheKey.value, execute, {
server: options.ssr !== false,
default: () => null
})
}

return {
data: ref(null),
pending: ref(false),
error: ref(null),
refresh: execute
}
}

/**
* 分页数据获取 Hook
*/
export function usePaginatedApi<T = any>(
url: string,
options: ApiOptions & {
page?: number
limit?: number
} = {}
) {
const page = ref(options.page || 1)
const limit = ref(options.limit || 10)

const query = computed(() => ({
...options.query,
page: page.value,
limit: limit.value
}))

const { data, pending, error, refresh } = useApi<PaginatedResponse<T>>(url, {
...options,
query: query.value
})

const items = computed(() => data.value?.data || [])
const pagination = computed(() => data.value?.pagination)

/**
* 跳转到指定页
*/
const goToPage = async (newPage: number) => {
page.value = newPage
await refresh()
}

/**
* 下一页
*/
const nextPage = async () => {
if (pagination.value && page.value < pagination.value.totalPages) {
await goToPage(page.value + 1)
}
}

/**
* 上一页
*/
const prevPage = async () => {
if (page.value > 1) {
await goToPage(page.value - 1)
}
}

return {
items,
pagination,
pending,
error,
page: readonly(page),
limit: readonly(limit),
goToPage,
nextPage,
prevPage,
refresh
}
}

/**
* 无限滚动数据获取 Hook
*/
export function useInfiniteApi<T = any>(
url: string,
options: ApiOptions & {
limit?: number
} = {}
) {
const page = ref(1)
const limit = ref(options.limit || 10)
const allItems = ref<T[]>([])
const hasMore = ref(true)
const loading = ref(false)

/**
* 加载更多数据
*/
const loadMore = async () => {
if (loading.value || !hasMore.value) return

loading.value = true

try {
const query = {
...options.query,
page: page.value,
limit: limit.value
}

const { data } = await useApi<PaginatedResponse<T>>(url, {
...options,
query,
cache: false
})

if (data.value) {
allItems.value.push(...data.value.data)
hasMore.value = page.value < data.value.pagination.totalPages
page.value++
}
} catch (error) {
console.error('Load more failed:', error)
} finally {
loading.value = false
}
}

/**
* 重置数据
*/
const reset = () => {
page.value = 1
allItems.value = []
hasMore.value = true
}

// 初始加载
onMounted(() => {
loadMore()
})

return {
items: readonly(allItems),
loading: readonly(loading),
hasMore: readonly(hasMore),
loadMore,
reset
}
}

4. 状态管理集成

Pinia Store 定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
// stores/user.ts

/**
* 用户信息接口
*/
export interface User {
id: number
username: string
email: string
avatar: string
role: 'admin' | 'user' | 'guest'
preferences: {
theme: 'light' | 'dark' | 'auto'
language: 'zh-CN' | 'en-US'
notifications: boolean
}
createdAt: string
lastLoginAt: string
}

/**
* 用户 Store
*/
export const useUserStore = defineStore('user', () => {
// 状态
const user = ref<User | null>(null)
const isAuthenticated = computed(() => !!user.value)
const isAdmin = computed(() => user.value?.role === 'admin')

// 认证令牌
const token = useCookie('auth-token', {
default: () => null,
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 60 * 60 * 24 * 7 // 7 天
})

/**
* 获取当前用户信息
*/
const fetchCurrentUser = async (): Promise<void> => {
if (!token.value) return

try {
const { data } = await useApi<User>('/auth/me', {
headers: {
Authorization: `Bearer ${token.value}`
}
})

if (data.value) {
user.value = data.value
}
} catch (error) {
console.error('Failed to fetch current user:', error)
await logout()
}
}

/**
* 用户登录
*/
const login = async (credentials: {
email: string
password: string
}): Promise<void> => {
try {
const { data } = await useApi<{
user: User
token: string
}>('/auth/login', {
method: 'POST',
body: credentials
})

if (data.value) {
user.value = data.value.user
token.value = data.value.token

// 跳转到首页
await navigateTo('/')
}
} catch (error) {
throw new Error('登录失败,请检查邮箱和密码')
}
}

/**
* 用户注册
*/
const register = async (userData: {
username: string
email: string
password: string
}): Promise<void> => {
try {
const { data } = await useApi<{
user: User
token: string
}>('/auth/register', {
method: 'POST',
body: userData
})

if (data.value) {
user.value = data.value.user
token.value = data.value.token

// 跳转到首页
await navigateTo('/')
}
} catch (error) {
throw new Error('注册失败,请稍后重试')
}
}

/**
* 用户登出
*/
const logout = async (): Promise<void> => {
try {
if (token.value) {
await useApi('/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${token.value}`
}
})
}
} catch (error) {
console.error('Logout request failed:', error)
} finally {
user.value = null
token.value = null

// 跳转到登录页
await navigateTo('/login')
}
}

/**
* 更新用户信息
*/
const updateProfile = async (updates: Partial<User>): Promise<void> => {
if (!user.value) return

try {
const { data } = await useApi<User>('/user/profile', {
method: 'PUT',
body: updates,
headers: {
Authorization: `Bearer ${token.value}`
}
})

if (data.value) {
user.value = data.value
}
} catch (error) {
throw new Error('更新用户信息失败')
}
}

/**
* 更新用户偏好设置
*/
const updatePreferences = async (preferences: Partial<User['preferences']>): Promise<void> => {
if (!user.value) return

const updatedUser = {
...user.value,
preferences: {
...user.value.preferences,
...preferences
}
}

await updateProfile(updatedUser)
}

return {
// 状态
user: readonly(user),
isAuthenticated,
isAdmin,

// 方法
fetchCurrentUser,
login,
register,
logout,
updateProfile,
updatePreferences
}
})

// 服务端状态同步
if (process.server) {
const userStore = useUserStore()

// 在服务端初始化时获取用户信息
userStore.fetchCurrentUser()
}

静态站点生成(SSG)实战

1. 内容管理系统

Nuxt Content 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// content/blog/vue3-ssr-guide.md
---
title: Vue 3 SSR 完整指南
description: 深入了解 Vue 3 服务端渲染的实现原理和最佳实践
author:
name: 张三
avatar: /images/authors/zhangsan.jpg
publishedAt: 2023-08-15
tags: [Vue3, SSR, Nuxt3]
category: 前端开发
featuredImage: /images/blog/vue3-ssr-guide.jpg
readingTime: 15
enableComments: true
seo:
keywords: [Vue3, SSR, 服务端渲染, Nuxt3]
ogImage: /images/blog/vue3-ssr-guide-og.jpg
---

# Vue 3 SSR 完整指南

服务端渲染(SSR)是现代 Web 开发中的重要技术...

## 什么是 SSR

SSR 是指在服务器端执行 JavaScript 代码,生成完整的 HTML 页面后发送给客户端的技术。

### SSR 的优势

1. **SEO 友好**:搜索引擎可以直接抓取到完整的 HTML 内容
2. **首屏性能**:用户可以更快看到页面内容
3. **社交分享**:社交平台可以正确解析页面元信息

```vue
<!-- 示例组件 -->
<template>
<div class="ssr-example">
<h1>{{ title }}</h1>
<p>{{ content }}</p>
</div>
</template>

<script setup>
const title = 'SSR 示例'
const content = '这是在服务端渲染的内容'
</script>

实现原理

Vue 3 的 SSR 实现基于以下核心概念:

  • 同构应用:同一套代码在服务端和客户端运行
  • 水合(Hydration):客户端接管服务端渲染的静态 HTML
  • 状态同步:服务端状态传递给客户端

更多内容请参考官方文档…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177

**内容查询 API**

```typescript
// composables/useContent.ts

/**
* 文章接口
*/
export interface Article {
_path: string
title: string
description: string
author: {
name: string
avatar: string
}
publishedAt: string
tags: string[]
category: string
featuredImage?: string
readingTime: number
enableComments: boolean
seo: {
keywords: string[]
ogImage?: string
}
body: any
}

/**
* 内容查询 Hook
*/
export function useContentQuery() {
/**
* 获取所有文章
*/
const getAllArticles = async (options: {
limit?: number
skip?: number
category?: string
tag?: string
sortBy?: 'publishedAt' | 'title'
sortOrder?: 'asc' | 'desc'
} = {}) => {
const {
limit = 10,
skip = 0,
category,
tag,
sortBy = 'publishedAt',
sortOrder = 'desc'
} = options

let query = queryContent<Article>('/blog')

// 分类筛选
if (category) {
query = query.where({ category })
}

// 标签筛选
if (tag) {
query = query.where({ tags: { $contains: tag } })
}

// 排序
const sortOptions = { [sortBy]: sortOrder === 'desc' ? -1 : 1 }
query = query.sort(sortOptions)

// 分页
query = query.skip(skip).limit(limit)

return await query.find()
}

/**
* 获取文章详情
*/
const getArticleBySlug = async (slug: string) => {
return await queryContent<Article>('/blog')
.where({ slug })
.findOne()
}

/**
* 获取相关文章
*/
const getRelatedArticles = async (article: Article, limit = 3) => {
return await queryContent<Article>('/blog')
.where({
_path: { $ne: article._path },
$or: [
{ category: article.category },
{ tags: { $in: article.tags } }
]
})
.limit(limit)
.find()
}

/**
* 获取文章归档
*/
const getArticleArchive = async () => {
const articles = await queryContent<Article>('/blog')
.only(['title', '_path', 'publishedAt', 'category'])
.sort({ publishedAt: -1 })
.find()

// 按年月分组
const archive = articles.reduce((acc, article) => {
const date = new Date(article.publishedAt)
const year = date.getFullYear()
const month = date.getMonth() + 1
const key = `${year}-${month.toString().padStart(2, '0')}`

if (!acc[key]) {
acc[key] = {
year,
month,
articles: []
}
}

acc[key].articles.push(article)
return acc
}, {} as Record<string, any>)

return Object.values(archive)
}

/**
* 获取标签云
*/
const getTagCloud = async () => {
const articles = await queryContent<Article>('/blog')
.only(['tags'])
.find()

const tagCount = articles.reduce((acc, article) => {
article.tags.forEach(tag => {
acc[tag] = (acc[tag] || 0) + 1
})
return acc
}, {} as Record<string, number>)

return Object.entries(tagCount)
.map(([tag, count]) => ({ tag, count }))
.sort((a, b) => b.count - a.count)
}

/**
* 搜索文章
*/
const searchArticles = async (keyword: string, limit = 10) => {
return await queryContent<Article>('/blog')
.where({
$or: [
{ title: { $regex: keyword, $options: 'i' } },
{ description: { $regex: keyword, $options: 'i' } },
{ tags: { $contains: keyword } }
]
})
.limit(limit)
.find()
}

return {
getAllArticles,
getArticleBySlug,
getRelatedArticles,
getArticleArchive,
getTagCloud,
searchArticles
}
}

2. 静态生成配置

预渲染路由配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// nuxt.config.ts (SSG 配置)
export default defineNuxtConfig({
// 启用静态生成
nitro: {
prerender: {
routes: ['/sitemap.xml', '/robots.txt']
}
},

// 生成配置
generate: {
// 并发数
concurrency: 10,

// 生成间隔
interval: 100,

// 子目录
subFolders: true,

// 回退页面
fallback: '404.html'
},

// 路由规则
routeRules: {
// 首页预渲染
'/': { prerender: true },

// 博客列表页面预渲染
'/blog': { prerender: true },

// 博客详情页面按需生成
'/blog/**': { isr: true },

// API 路由
'/api/**': { cors: true },

// 管理后台 SPA 模式
'/admin/**': { ssr: false },

// 静态资源
'/images/**': { headers: { 'cache-control': 's-maxage=31536000' } }
}
})

动态路由生成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// plugins/generate-routes.ts

export default defineNuxtPlugin(async () => {
// 仅在生成时执行
if (process.env.NODE_ENV !== 'production' || !process.env.NUXT_GENERATE) {
return
}

const { getAllArticles, getTagCloud } = useContentQuery()

try {
// 获取所有文章
const articles = await getAllArticles({ limit: 1000 })

// 生成文章详情页路由
const articleRoutes = articles.map(article => {
const slug = article._path.split('/').pop()
return `/blog/${slug}`
})

// 获取所有标签
const tags = await getTagCloud()

// 生成标签页路由
const tagRoutes = tags.map(({ tag }) => `/blog/tag/${tag}`)

// 生成分页路由
const pageSize = 10
const totalPages = Math.ceil(articles.length / pageSize)
const paginationRoutes = Array.from({ length: totalPages }, (_, i) =>
i === 0 ? '/blog' : `/blog/page/${i + 1}`
)

// 注册所有路由
const allRoutes = [
...articleRoutes,
...tagRoutes,
...paginationRoutes
]

// 这里可以将路由信息传递给生成器
console.log(`Generated ${allRoutes.length} routes for static generation`)

} catch (error) {
console.error('Failed to generate routes:', error)
}
})

3. SEO 优化

SEO 组合式函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
// composables/useSEO.ts

/**
* SEO 配置接口
*/
export interface SEOConfig {
title?: string
description?: string
keywords?: string[]
author?: string
ogTitle?: string
ogDescription?: string
ogImage?: string
ogUrl?: string
twitterCard?: 'summary' | 'summary_large_image'
twitterSite?: string
twitterCreator?: string
canonical?: string
robots?: string
structuredData?: any
}

/**
* SEO Hook
*/
export function useSEO(config: SEOConfig = {}) {
const route = useRoute()
const runtimeConfig = useRuntimeConfig()
const siteUrl = runtimeConfig.public.siteUrl

// 默认配置
const defaultConfig = {
title: 'Vue 3 SSR/SSG 实战项目',
description: 'Vue 3 服务端渲染与静态站点生成实战项目',
author: 'Vue 3 Team',
ogImage: `${siteUrl}/images/og-default.jpg`,
twitterCard: 'summary_large_image' as const,
robots: 'index,follow'
}

// 合并配置
const seoConfig = { ...defaultConfig, ...config }

// 当前页面 URL
const currentUrl = `${siteUrl}${route.path}`

// 设置页面头部信息
useHead({
title: seoConfig.title,
meta: [
// 基础 meta
{
name: 'description',
content: seoConfig.description
},
{
name: 'keywords',
content: seoConfig.keywords?.join(', ')
},
{
name: 'author',
content: seoConfig.author
},
{
name: 'robots',
content: seoConfig.robots
},

// Open Graph
{
property: 'og:title',
content: seoConfig.ogTitle || seoConfig.title
},
{
property: 'og:description',
content: seoConfig.ogDescription || seoConfig.description
},
{
property: 'og:image',
content: seoConfig.ogImage
},
{
property: 'og:url',
content: seoConfig.ogUrl || currentUrl
},
{
property: 'og:type',
content: 'website'
},
{
property: 'og:site_name',
content: 'Vue 3 SSR/SSG 实战'
},

// Twitter Card
{
name: 'twitter:card',
content: seoConfig.twitterCard
},
{
name: 'twitter:site',
content: seoConfig.twitterSite
},
{
name: 'twitter:creator',
content: seoConfig.twitterCreator
},
{
name: 'twitter:title',
content: seoConfig.ogTitle || seoConfig.title
},
{
name: 'twitter:description',
content: seoConfig.ogDescription || seoConfig.description
},
{
name: 'twitter:image',
content: seoConfig.ogImage
}
].filter(meta => meta.content), // 过滤空值

link: [
{
rel: 'canonical',
href: seoConfig.canonical || currentUrl
}
]
})

// 结构化数据
if (seoConfig.structuredData) {
useJsonld(seoConfig.structuredData)
}

/**
* 生成面包屑结构化数据
*/
const generateBreadcrumbSchema = (breadcrumbs: Array<{ name: string; url: string }>) => {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: breadcrumbs.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: `${siteUrl}${item.url}`
}))
}
}

/**
* 生成文章结构化数据
*/
const generateArticleSchema = (article: {
title: string
description: string
author: string
publishedAt: string
modifiedAt?: string
image?: string
}) => {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.description,
author: {
'@type': 'Person',
name: article.author
},
datePublished: article.publishedAt,
dateModified: article.modifiedAt || article.publishedAt,
image: article.image ? `${siteUrl}${article.image}` : seoConfig.ogImage,
url: currentUrl,
publisher: {
'@type': 'Organization',
name: 'Vue 3 SSR/SSG 实战',
logo: {
'@type': 'ImageObject',
url: `${siteUrl}/images/logo.png`
}
}
}
}

/**
* 生成网站结构化数据
*/
const generateWebsiteSchema = () => {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'Vue 3 SSR/SSG 实战',
url: siteUrl,
description: 'Vue 3 服务端渲染与静态站点生成实战项目',
potentialAction: {
'@type': 'SearchAction',
target: `${siteUrl}/search?q={search_term_string}`,
'query-input': 'required name=search_term_string'
}
}
}

return {
generateBreadcrumbSchema,
generateArticleSchema,
generateWebsiteSchema
}
}

性能优化与部署

1. 缓存策略

多层缓存配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// server/api/cache.ts

/**
* 缓存配置
*/
export const cacheConfig = {
// 静态资源缓存
static: {
maxAge: 31536000, // 1年
immutable: true
},

// 页面缓存
page: {
maxAge: 3600, // 1小时
staleWhileRevalidate: 86400 // 24小时
},

// API 缓存
api: {
maxAge: 300, // 5分钟
staleWhileRevalidate: 3600 // 1小时
},

// 内容缓存
content: {
maxAge: 1800, // 30分钟
staleWhileRevalidate: 7200 // 2小时
}
}

/**
* 缓存中间件
*/
export default defineEventHandler(async (event) => {
const url = getRouterParam(event, 'url')
const method = getMethod(event)

// 只缓存 GET 请求
if (method !== 'GET') {
return
}

// 确定缓存策略
let cacheOptions = cacheConfig.page

if (url?.startsWith('/api/')) {
cacheOptions = cacheConfig.api
} else if (url?.startsWith('/content/')) {
cacheOptions = cacheConfig.content
} else if (url?.match(/\.(js|css|png|jpg|jpeg|gif|svg|woff|woff2)$/)) {
cacheOptions = cacheConfig.static
}

// 设置缓存头
setHeader(event, 'Cache-Control',
`public, max-age=${cacheOptions.maxAge}, stale-while-revalidate=${cacheOptions.staleWhileRevalidate}`
)

// 设置 ETag
const etag = await generateETag(url)
setHeader(event, 'ETag', etag)

// 检查 If-None-Match
const ifNoneMatch = getHeader(event, 'if-none-match')
if (ifNoneMatch === etag) {
setResponseStatus(event, 304)
return
}
})

/**
* 生成 ETag
*/
async function generateETag(url: string): Promise<string> {
// 这里可以基于内容或修改时间生成 ETag
const content = url + Date.now().toString()
const encoder = new TextEncoder()
const data = encoder.encode(content)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
}

2. 部署配置

Docker 部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app

# 复制依赖文件
COPY package*.json ./
RUN npm ci --only=production

# 复制源代码
COPY . .

# 构建应用
RUN npm run build

# 生产环境镜像
FROM node:18-alpine AS runner

WORKDIR /app

# 创建非 root 用户
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nuxtjs

# 复制构建产物
COPY --from=builder --chown=nuxtjs:nodejs /app/.output ./

# 切换用户
USER nuxtjs

# 暴露端口
EXPOSE 3000

# 启动应用
CMD ["node", "server/index.mjs"]

CI/CD 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# .github/workflows/deploy.yml
name: Deploy to Production

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm run test

- name: Run linting
run: npm run lint

- name: Type check
run: npm run type-check

build:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'

steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'

- name: Install dependencies
run: npm ci

- name: Build application
run: npm run build
env:
NUXT_PUBLIC_API_BASE: ${{ secrets.API_BASE }}
NUXT_PUBLIC_SITE_URL: ${{ secrets.SITE_URL }}

- name: Build Docker image
run: |
docker build -t vue3-ssr-app:${{ github.sha }} .
docker tag vue3-ssr-app:${{ github.sha }} vue3-ssr-app:latest

- name: Deploy to production
run: |
# 这里添加部署脚本
echo "Deploying to production..."

总结

Vue 3 的 SSR/SSG 生态为现代 Web 应用提供了完整的解决方案:

  1. 渲染模式选择:根据项目需求选择合适的渲染策略
  2. Nuxt 3 框架:提供了开箱即用的 SSR/SSG 解决方案
  3. 性能优化:通过缓存、预渲染等手段提升应用性能
  4. SEO 优化:完善的 SEO 支持和结构化数据
  5. 部署策略:灵活的部署选项和 CI/CD 集成

选择合适的技术方案需要综合考虑 SEO 需求、性能要求、开发复杂度等因素,在实践中不断优化和完善。

本站由 提供部署服务