代码组织与架构设计
项目结构最佳实践
vue-ssr-project/
├── src/
│ ├── components/ # 通用组件
│ │ ├── base/ # 基础组件
│ │ ├── business/ # 业务组件
│ │ └── layout/ # 布局组件
│ ├── views/ # 页面组件
│ │ ├── home/
│ │ ├── about/
│ │ └── posts/
│ ├── composables/ # 组合式函数
│ │ ├── useAuth.js
│ │ ├── useApi.js
│ │ └── useCache.js
│ ├── stores/ # 状态管理
│ │ ├── modules/
│ │ └── index.js
│ ├── router/ # 路由配置
│ │ ├── routes/
│ │ ├── guards.js
│ │ └── index.js
│ ├── utils/ # 工具函数
│ │ ├── api.js
│ │ ├── helpers.js
│ │ └── constants.js
│ ├── plugins/ # 插件
│ ├── directives/ # 自定义指令
│ ├── assets/ # 静态资源
│ ├── styles/ # 样式文件
│ ├── App.vue
│ ├── app.js
│ ├── entry-client.js
│ └── entry-server.js
├── server/ # 服务端代码
│ ├── api/ # API路由
│ ├── middleware/ # 中间件
│ ├── config/ # 配置
│ ├── utils/ # 服务端工具
│ ├── dev-server.js # 开发服务器
│ └── prod-server.js # 生产服务器
├── public/ # 公共静态文件
├── tests/ # 测试文件
├── docs/ # 文档
├── scripts/ # 构建脚本
└── config/ # 配置文件
组件设计原则
<!-- 好的组件设计示例 -->
<template>
<article class="post-card" :class="cardClasses">
<header class="post-card__header">
<h2 class="post-card__title">
<router-link :to="postUrl" class="post-card__link">
{{ post.title }}
</router-link>
</h2>
<PostMeta :post="post" :show-author="showAuthor" />
</header>
<div class="post-card__content">
<PostExcerpt
:content="post.excerpt"
:max-length="excerptLength"
/>
</div>
<footer class="post-card__footer">
<PostTags :tags="post.tags" :max-tags="maxTags" />
<PostActions
:post="post"
@like="handleLike"
@share="handleShare"
/>
</footer>
</article>
</template>
<script setup>
import { computed } from 'vue'
import PostMeta from './PostMeta.vue'
import PostExcerpt from './PostExcerpt.vue'
import PostTags from './PostTags.vue'
import PostActions from './PostActions.vue'
// Props定义
const props = defineProps({
post: {
type: Object,
required: true,
validator: (post) => {
return post && typeof post.id !== 'undefined' && post.title
}
},
variant: {
type: String,
default: 'default',
validator: (value) => ['default', 'featured', 'compact'].includes(value)
},
showAuthor: {
type: Boolean,
default: true
},
excerptLength: {
type: Number,
default: 150
},
maxTags: {
type: Number,
default: 3
}
})
// Emits定义
const emit = defineEmits([
'like',
'share',
'click'
])
// 计算属性
const cardClasses = computed(() => ({
[`post-card--${props.variant}`]: props.variant !== 'default',
'post-card--featured': props.post.featured
}))
const postUrl = computed(() => `/posts/${props.post.slug || props.post.id}`)
// 事件处理
const handleLike = () => {
emit('like', props.post)
}
const handleShare = (platform) => {
emit('share', { post: props.post, platform })
}
</script>
<style scoped>
.post-card {
@apply bg-white rounded-lg shadow-md overflow-hidden transition-shadow duration-200;
}
.post-card:hover {
@apply shadow-lg;
}
.post-card--featured {
@apply border-l-4 border-blue-500;
}
.post-card--compact {
@apply flex flex-row;
}
.post-card__header {
@apply p-6 pb-4;
}
.post-card__title {
@apply text-xl font-semibold text-gray-900 mb-2;
}
.post-card__link {
@apply text-inherit no-underline hover:text-blue-600 transition-colors;
}
.post-card__content {
@apply px-6 pb-4;
}
.post-card__footer {
@apply px-6 pb-6 flex justify-between items-center;
}
</style>
状态管理最佳实践
// stores/modules/posts.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { postsApi } from '@/utils/api'
import { useCache } from '@/composables/useCache'
export const usePostsStore = defineStore('posts', () => {
// State
const posts = ref([])
const currentPost = ref(null)
const loading = ref(false)
const error = ref(null)
const pagination = ref({
page: 1,
limit: 10,
total: 0,
totalPages: 0
})
// Cache
const { get: getCached, set: setCached, clear: clearCache } = useCache('posts')
// Getters
const featuredPosts = computed(() =>
posts.value.filter(post => post.featured)
)
const publishedPosts = computed(() =>
posts.value.filter(post => post.status === 'published')
)
const getPostById = computed(() => (id) =>
posts.value.find(post => post.id === id)
)
const hasNextPage = computed(() =>
pagination.value.page < pagination.value.totalPages
)
// Actions
async function fetchPosts(params = {}) {
const cacheKey = `posts-${JSON.stringify(params)}`
const cached = getCached(cacheKey)
if (cached && !params.force) {
posts.value = cached.data
pagination.value = cached.pagination
return cached
}
loading.value = true
error.value = null
try {
const response = await postsApi.getAll(params)
posts.value = response.data
pagination.value = {
page: response.page,
limit: response.limit,
total: response.total,
totalPages: response.totalPages
}
// 缓存结果
setCached(cacheKey, {
data: response.data,
pagination: pagination.value
}, 5 * 60 * 1000) // 5分钟缓存
return response
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function fetchPostById(id, options = {}) {
const cacheKey = `post-${id}`
const cached = getCached(cacheKey)
if (cached && !options.force) {
currentPost.value = cached
return cached
}
loading.value = true
error.value = null
try {
const post = await postsApi.getById(id)
currentPost.value = post
// 更新posts数组中的对应项
const index = posts.value.findIndex(p => p.id === id)
if (index !== -1) {
posts.value[index] = post
}
// 缓存结果
setCached(cacheKey, post, 10 * 60 * 1000) // 10分钟缓存
return post
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function createPost(postData) {
loading.value = true
error.value = null
try {
const newPost = await postsApi.create(postData)
posts.value.unshift(newPost)
// 清除相关缓存
clearCache()
return newPost
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function updatePost(id, updates) {
loading.value = true
error.value = null
try {
const updatedPost = await postsApi.update(id, updates)
// 更新posts数组
const index = posts.value.findIndex(p => p.id === id)
if (index !== -1) {
posts.value[index] = updatedPost
}
// 更新当前文章
if (currentPost.value?.id === id) {
currentPost.value = updatedPost
}
// 清除相关缓存
clearCache()
return updatedPost
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function deletePost(id) {
loading.value = true
error.value = null
try {
await postsApi.delete(id)
// 从数组中移除
posts.value = posts.value.filter(p => p.id !== id)
// 清除当前文章
if (currentPost.value?.id === id) {
currentPost.value = null
}
// 清除相关缓存
clearCache()
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
function resetState() {
posts.value = []
currentPost.value = null
loading.value = false
error.value = null
pagination.value = {
page: 1,
limit: 10,
total: 0,
totalPages: 0
}
}
return {
// State
posts,
currentPost,
loading,
error,
pagination,
// Getters
featuredPosts,
publishedPosts,
getPostById,
hasNextPage,
// Actions
fetchPosts,
fetchPostById,
createPost,
updatePost,
deletePost,
resetState
}
})
性能优化进阶
智能预加载策略
// composables/useIntelligentPreload.js
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
export function useIntelligentPreload() {
const router = useRouter()
const preloadedRoutes = ref(new Set())
const observer = ref(null)
// 预加载策略配置
const config = {
// 连接类型阈值
connectionThresholds: {
'4g': 0.8,
'3g': 0.5,
'2g': 0.2,
'slow-2g': 0.1
},
// 设备内存阈值
memoryThreshold: 4, // GB
// 电池电量阈值
batteryThreshold: 0.2,
// 数据节省模式
respectDataSaver: true
}
// 检查设备能力
function getDeviceCapabilities() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection
const memory = navigator.deviceMemory || 4
return {
connection: connection?.effectiveType || '4g',
memory,
saveData: connection?.saveData || false,
battery: null // 需要异步获取
}
}
// 获取电池信息
async function getBatteryInfo() {
if ('getBattery' in navigator) {
try {
const battery = await navigator.getBattery()
return {
level: battery.level,
charging: battery.charging
}
} catch (error) {
console.warn('无法获取电池信息:', error)
}
}
return { level: 1, charging: true }
}
// 计算预加载概率
async function calculatePreloadProbability() {
const capabilities = getDeviceCapabilities()
const battery = await getBatteryInfo()
let probability = 1.0
// 根据连接类型调整
const connectionMultiplier = config.connectionThresholds[capabilities.connection] || 0.8
probability *= connectionMultiplier
// 根据内存调整
if (capabilities.memory < config.memoryThreshold) {
probability *= 0.6
}
// 根据电池状态调整
if (!battery.charging && battery.level < config.batteryThreshold) {
probability *= 0.3
}
// 数据节省模式
if (config.respectDataSaver && capabilities.saveData) {
probability *= 0.1
}
return Math.max(0, Math.min(1, probability))
}
// 预加载路由
async function preloadRoute(to) {
const routeKey = typeof to === 'string' ? to : to.path
if (preloadedRoutes.value.has(routeKey)) {
return
}
const probability = await calculatePreloadProbability()
// 根据概率决定是否预加载
if (Math.random() > probability) {
return
}
try {
// 预加载路由组件
await router.resolve(to)
preloadedRoutes.value.add(routeKey)
console.log(`预加载路由: ${routeKey}, 概率: ${(probability * 100).toFixed(1)}%`)
} catch (error) {
console.warn('预加载路由失败:', routeKey, error)
}
}
// 设置链接悬停预加载
function setupHoverPreload() {
const links = document.querySelectorAll('a[href^="/"]')
links.forEach(link => {
let hoverTimer = null
link.addEventListener('mouseenter', () => {
hoverTimer = setTimeout(() => {
const href = link.getAttribute('href')
if (href) {
preloadRoute(href)
}
}, 100) // 100ms延迟,避免误触
})
link.addEventListener('mouseleave', () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
})
})
}
// 设置可见性预加载
function setupVisibilityPreload() {
observer.value = new IntersectionObserver(
async (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const link = entry.target
const href = link.getAttribute('href')
if (href && href.startsWith('/')) {
await preloadRoute(href)
}
}
}
},
{
rootMargin: '50px' // 提前50px开始预加载
}
)
// 观察所有内部链接
const links = document.querySelectorAll('a[href^="/"]')
links.forEach(link => observer.value.observe(link))
}
// 预加载关键路由
async function preloadCriticalRoutes() {
const criticalRoutes = [
'/',
'/about',
'/posts'
]
for (const route of criticalRoutes) {
await preloadRoute(route)
}
}
onMounted(async () => {
// 延迟初始化,避免阻塞首屏渲染
setTimeout(async () => {
await preloadCriticalRoutes()
setupHoverPreload()
setupVisibilityPreload()
}, 2000)
})
onUnmounted(() => {
if (observer.value) {
observer.value.disconnect()
}
})
return {
preloadRoute,
preloadedRoutes: preloadedRoutes.value
}
}
渐进式水合
// utils/progressive-hydration.js
import { nextTick } from 'vue'
class ProgressiveHydration {
constructor() {
this.hydrationQueue = []
this.isHydrating = false
this.observer = null
this.idleCallback = null
}
// 注册需要渐进式水合的组件
register(component, element, priority = 0) {
this.hydrationQueue.push({
component,
element,
priority,
hydrated: false
})
// 按优先级排序
this.hydrationQueue.sort((a, b) => b.priority - a.priority)
}
// 开始渐进式水合
async start() {
if (this.isHydrating) return
this.isHydrating = true
// 首先水合高优先级组件
await this.hydrateHighPriority()
// 然后根据可见性水合其他组件
this.setupVisibilityHydration()
// 最后在空闲时间水合剩余组件
this.scheduleIdleHydration()
}
// 水合高优先级组件
async hydrateHighPriority() {
const highPriorityItems = this.hydrationQueue.filter(
item => item.priority > 5 && !item.hydrated
)
for (const item of highPriorityItems) {
await this.hydrateComponent(item)
}
}
// 设置可见性水合
setupVisibilityHydration() {
this.observer = new IntersectionObserver(
async (entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const item = this.hydrationQueue.find(
item => item.element === entry.target && !item.hydrated
)
if (item) {
await this.hydrateComponent(item)
this.observer.unobserve(entry.target)
}
}
}
},
{
rootMargin: '50px'
}
)
// 观察未水合的组件
this.hydrationQueue
.filter(item => !item.hydrated && item.priority <= 5)
.forEach(item => {
if (item.element) {
this.observer.observe(item.element)
}
})
}
// 调度空闲时间水合
scheduleIdleHydration() {
const hydrateInIdle = () => {
const unhydratedItems = this.hydrationQueue.filter(
item => !item.hydrated
)
if (unhydratedItems.length === 0) {
return
}
this.idleCallback = requestIdleCallback(
async (deadline) => {
while (deadline.timeRemaining() > 0 && unhydratedItems.length > 0) {
const item = unhydratedItems.shift()
await this.hydrateComponent(item)
}
// 如果还有未水合的组件,继续调度
if (unhydratedItems.length > 0) {
hydrateInIdle()
}
},
{ timeout: 5000 }
)
}
hydrateInIdle()
}
// 水合单个组件
async hydrateComponent(item) {
if (item.hydrated) return
try {
const start = performance.now()
// 执行组件水合
await item.component.hydrate()
const duration = performance.now() - start
item.hydrated = true
console.log(`组件水合完成: ${item.component.name}, 耗时: ${duration.toFixed(2)}ms`)
// 触发水合完成事件
item.element?.dispatchEvent(new CustomEvent('hydrated', {
detail: { component: item.component, duration }
}))
} catch (error) {
console.error('组件水合失败:', error)
}
}
// 清理资源
cleanup() {
if (this.observer) {
this.observer.disconnect()
this.observer = null
}
if (this.idleCallback) {
cancelIdleCallback(this.idleCallback)
this.idleCallback = null
}
this.hydrationQueue = []
this.isHydrating = false
}
}
export const progressiveHydration = new ProgressiveHydration()
// Vue插件
export default {
install(app) {
app.config.globalProperties.$progressiveHydration = progressiveHydration
// 在客户端启动渐进式水合
if (typeof window !== 'undefined') {
nextTick(() => {
progressiveHydration.start()
})
}
}
}
团队协作与开发规范
代码规范配置
// .eslintrc.js
module.exports = {
root: true,
env: {
node: true,
browser: true,
es2022: true
},
extends: [
'eslint:recommended',
'@vue/eslint-config-typescript',
'@vue/eslint-config-prettier',
'plugin:vue/vue3-recommended'
],
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module'
},
rules: {
// Vue相关规则
'vue/multi-word-component-names': 'error',
'vue/component-definition-name-casing': ['error', 'PascalCase'],
'vue/component-name-in-template-casing': ['error', 'PascalCase'],
'vue/prop-name-casing': ['error', 'camelCase'],
'vue/attribute-hyphenation': ['error', 'always'],
'vue/v-on-event-hyphenation': ['error', 'always'],
// 组件组织
'vue/order-in-components': 'error',
'vue/component-tags-order': [
'error',
{
order: ['template', 'script', 'style']
}
],
// 性能相关
'vue/no-v-html': 'warn',
'vue/require-v-for-key': 'error',
'vue/no-use-v-if-with-v-for': 'error',
// 可访问性
'vue/require-explicit-emits': 'error',
'vue/require-prop-types': 'error',
'vue/require-default-prop': 'error',
// 通用规则
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-unused-vars': 'warn',
'prefer-const': 'error',
'no-var': 'error'
},
overrides: [
{
files: ['**/*.test.js', '**/*.spec.js'],
env: {
jest: true
}
}
]
}
Git工作流配置
# .github/workflows/ci.yml
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [16, 18, 20]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linting
run: npm run lint
- name: Run type checking
run: npm run type-check
- name: Run unit tests
run: npm run test:unit
- name: Run E2E tests
run: npm run test:e2e
- name: Build application
run: npm run build
- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
file: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
security-scan:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run security audit
run: npm audit --audit-level moderate
- name: Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
performance-test:
runs-on: ubuntu-latest
needs: lint-and-test
steps:
- name: Checkout code
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
- name: Start server
run: npm run serve &
- name: Wait for server
run: npx wait-on http://localhost:3000
- name: Run Lighthouse CI
run: npx lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
组件文档规范
<!-- components/BaseButton.vue -->
<template>
<button
:class="buttonClasses"
:disabled="disabled || loading"
:type="type"
@click="handleClick"
>
<BaseIcon v-if="loading" name="spinner" class="animate-spin" />
<BaseIcon v-else-if="icon" :name="icon" />
<slot />
</button>
</template>
<script setup>
/**
* 基础按钮组件
*
* @component BaseButton
* @example
* <BaseButton variant="primary" size="lg" @click="handleClick">
* 点击我
* </BaseButton>
*
* @example
* <BaseButton variant="secondary" icon="plus" loading>
* 加载中...
* </BaseButton>
*/
import { computed } from 'vue'
import BaseIcon from './BaseIcon.vue'
/**
* 组件属性
*/
const props = defineProps({
/**
* 按钮变体
* @type {'primary' | 'secondary' | 'danger' | 'ghost'}
* @default 'primary'
*/
variant: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger', 'ghost'].includes(value)
},
/**
* 按钮尺寸
* @type {'sm' | 'md' | 'lg'}
* @default 'md'
*/
size: {
type: String,
default: 'md',
validator: (value) => ['sm', 'md', 'lg'].includes(value)
},
/**
* 按钮类型
* @type {'button' | 'submit' | 'reset'}
* @default 'button'
*/
type: {
type: String,
default: 'button'
},
/**
* 是否禁用
* @type {boolean}
* @default false
*/
disabled: {
type: Boolean,
default: false
},
/**
* 是否显示加载状态
* @type {boolean}
* @default false
*/
loading: {
type: Boolean,
default: false
},
/**
* 图标名称
* @type {string}
*/
icon: {
type: String,
default: ''
}
})
/**
* 组件事件
*/
const emit = defineEmits({
/**
* 点击事件
* @param {MouseEvent} event - 鼠标事件对象
*/
click: (event) => event instanceof MouseEvent
})
/**
* 按钮样式类
*/
const buttonClasses = computed(() => {
const baseClasses = [
'inline-flex',
'items-center',
'justify-center',
'font-medium',
'rounded-md',
'transition-colors',
'focus:outline-none',
'focus:ring-2',
'focus:ring-offset-2'
]
const variantClasses = {
primary: [
'bg-blue-600',
'text-white',
'hover:bg-blue-700',
'focus:ring-blue-500',
'disabled:bg-blue-300'
],
secondary: [
'bg-gray-200',
'text-gray-900',
'hover:bg-gray-300',
'focus:ring-gray-500',
'disabled:bg-gray-100'
],
danger: [
'bg-red-600',
'text-white',
'hover:bg-red-700',
'focus:ring-red-500',
'disabled:bg-red-300'
],
ghost: [
'bg-transparent',
'text-gray-700',
'hover:bg-gray-100',
'focus:ring-gray-500',
'disabled:text-gray-400'
]
}
const sizeClasses = {
sm: ['px-3', 'py-1.5', 'text-sm'],
md: ['px-4', 'py-2', 'text-base'],
lg: ['px-6', 'py-3', 'text-lg']
}
return [
...baseClasses,
...variantClasses[props.variant],
...sizeClasses[props.size],
{
'opacity-50 cursor-not-allowed': props.disabled || props.loading
}
]
})
/**
* 处理点击事件
* @param {MouseEvent} event - 鼠标事件对象
*/
const handleClick = (event) => {
if (!props.disabled && !props.loading) {
emit('click', event)
}
}
</script>
<style scoped>
/* 组件特定样式 */
.animate-spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
总结
通过本教程的学习,你已经掌握了Vue SSR的核心概念和实践技巧:
核心知识点
- 基础概念:理解SSR的工作原理和优势
- 环境搭建:配置开发和生产环境
- 应用架构:设计同构应用结构
- 路由配置:实现客户端和服务端路由
- 状态管理:处理数据预取和状态同步
- 服务器配置:搭建Express服务器
- 性能优化:实现缓存和代码分割
- 部署配置:容器化和生产环境部署
- 故障排除:调试和监控技巧
- 最佳实践:代码组织和团队协作
进阶方向
- 微前端架构:将SSR应用拆分为微服务
- 边缘计算:利用CDN边缘节点进行SSR
- 流式渲染:实现更快的首屏渲染
- 静态生成:结合SSG提升性能
- 多端适配:支持移动端和桌面端
持续学习
- 关注Vue.js官方文档更新
- 参与开源项目贡献
- 学习相关技术栈(Nuxt.js、Vite等)
- 实践大型项目开发
- 分享经验和最佳实践
希望这个教程能够帮助你在Vue SSR的道路上走得更远!