🏗️ Vue 3项目架构设计:从2200行单文件到24个组件
分享我在Vue 3博客项目中的架构重构经验,代码可维护性大幅提升
前言
在项目初期,为了快速实现功能,我把大部分代码都写在了App.vue中,导致单文件达到了2200多行。随着功能增多,代码越来越难以维护。于是我开始进行架构重构,将代码拆分成24个独立组件,最终实现了更好的代码组织和可维护性。
重构前后对比
代码结构对比
重构前:
App.vue (2200+ 行)
├── 布局代码
├── 业务逻辑
├── 组件代码
└── 工具函数
重构后:
src/
├── components/
│ ├── layout/ (5个组件)
│ ├── features/ (4个组件)
│ ├── gamification/ (4个组件)
│ └── article/ (6个组件)
├── composables/ (5个组合函数)
├── utils/ (3个工具模块)
└── views/ (5个页面组件)
数据对比
| 指标 | 重构前 | 重构后 | 改善 |
|---|---|---|---|
| 单文件最大行数 | 2200+ | 400 | ⬇️ 82% |
| 组件数量 | 1 | 24 | ⬆️ 24倍 |
| 代码复用率 | 0% | 40%+ | ⬆️ 40% |
| 可维护性 | 低 | 高 | ⬆⬆⬆ |
架构设计原则
1. 单一职责原则
每个组件只负责一个功能模块。
<!-- ❌ 错误:一个组件包含多个职责 -->
<template>
<div>
<Header />
<ArticleList />
<Sidebar />
<MusicPlayer />
<Notification />
<Footer />
</div>
</template>
<!-- ✅ 正确:每个组件单一职责 -->
<template>
<div>
<AppBackground />
<TheHeader />
<TheMain>
<RouterView />
</TheMain>
<TheFooter />
<BackToTop />
<Notification />
</div>
</template>
2. 开闭原则
通过props和emits扩展组件功能,不修改组件内部代码。
<!-- ArticleCard.vue -->
<template>
<article :class="['article-card', variant]">
<ArticleMeta :article="article" />
<ArticleContent :article="article" />
<slot name="actions">
<ArticleActions :article="article" />
</slot>
</article>
</template>
<script setup lang="ts">
interface Props {
article: Article
variant?: 'default' | 'compact' | 'featured'
}
defineProps<Props>()
</script>
3. 依赖倒置原则
组件依赖于抽象的接口(props/emits),而非具体实现。
// composables/usePagination.ts
export function usePagination(options: PaginationOptions) {
const currentPage = ref(options.page || 1)
const pageSize = ref(options.pageSize || 10)
const nextPage = () => {
currentPage.value++
}
const prevPage = () => {
currentPage.value--
}
return {
currentPage,
pageSize,
nextPage,
prevPage
}
}
组件分类体系
1. 布局组件(5个)
AppBackground
<!-- components/layout/AppBackground.vue -->
<template>
<div class="app-background">
<div class="gradient-bg"></div>
<div class="particles"></div>
</div>
</template>
<script setup lang="ts">
// 背景动画逻辑
</script>
<style scoped>
.app-background {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
</style>
TheHeader
<!-- components/layout/TheHeader.vue -->
<template>
<header class="header">
<Logo />
<Navigation />
<SearchTrigger />
<SettingsTrigger />
<NotificationTrigger />
</header>
</template>
<script setup lang="ts">
import Logo from './Logo.vue'
import Navigation from './Navigation.vue'
import SearchTrigger from './SearchTrigger.vue'
</script>
TheFooter
<!-- components/layout/TheFooter.vue -->
<template>
<footer class="footer">
<Copyright />
<SocialLinks />
<Links />
</footer>
</template>
BackToTop
<!-- components/layout/BackToTop.vue -->
<template>
<transition name="fade">
<button
v-show="visible"
@click="scrollToTop"
class="back-to-top"
>
<el-icon><ArrowUp /></el-icon>
</button>
</transition>
</template>
<script setup lang="ts">
const visible = ref(false)
onMounted(() => {
window.addEventListener('scroll', handleScroll)
})
const handleScroll = () => {
visible.value = window.scrollY > 300
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
</script>
ReadingProgressBar
<!-- components/layout/ReadingProgressBar.vue -->
<template>
<div class="reading-progress">
<div
class="progress-bar"
:style="{ width: progress + '%' }"
></div>
</div>
</template>
<script setup lang="ts">
const progress = ref(0)
const updateProgress = () => {
const scrollTop = window.scrollY
const docHeight = document.documentElement.scrollHeight - window.innerHeight
progress.value = (scrollTop / docHeight) * 100
}
onMounted(() => {
window.addEventListener('scroll', updateProgress)
})
</script>
2. 功能组件(4个)
Notification
<!-- components/features/Notification.vue -->
<template>
<transition-group name="notification">
<div
v-for="notif in notifications"
:key="notif.id"
:class="['notification', notif.type]"
>
<el-icon><component :is="notif.icon" /></el-icon>
<span>{{ notif.message }}</span>
<el-button
icon="Close"
@click="remove(notif.id)"
/>
</div>
</transition-group>
</template>
<script setup lang="ts">
import { useNotification } from '@/composables/useNotification'
const { notifications, remove } = useNotification()
</script>
SearchPanel
<!-- components/features/SearchPanel.vue -->
<template>
<div class="search-panel">
<el-input
v-model="searchText"
placeholder="搜索文章..."
@input="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<div class="search-results">
<ArticleCard
v-for="article in results"
:key="article.id"
:article="article"
/>
</div>
</div>
</template>
<script setup lang="ts">
const searchText = ref('')
const results = ref<Article[]>([])
const handleSearch = debounce(async (text: string) => {
if (!text) {
results.value = []
return
}
results.value = await searchArticles(text)
}, 300)
</script>
SettingsPanel
<!-- components/features/SettingsPanel.vue -->
<template>
<div class="settings-panel">
<SettingSection title="主题">
<ThemeToggle />
</SettingSection>
<SettingSection title="字体">
<FontSizeSlider />
</SettingSection>
<SettingSection title="其他">
<el-checkbox v-model="settings.enableMusic">
启用背景音乐
</el-checkbox>
</SettingSection>
</div>
</template>
<script setup lang="ts">
const settings = useSettings()
</script>
KeyboardHints
<!-- components/features/KeyboardHints.vue -->
<template>
<div class="keyboard-hints">
<kbd v-for="hint in hints" :key="hint.key">
{{ hint.key }}
<span>{{ hint.action }}</span>
</kbd>
</div>
</template>
<script setup lang="ts">
const hints = [
{ key: 'K', action: '搜索' },
{ key: 'N', action: '下一篇' },
{ key: 'P', action: '上一篇' }
]
</script>
3. 游戏化组件(4个)
EnergyDisplay
<!-- components/gamification/EnergyDisplay.vue -->
<template>
<div class="energy-display">
<div class="energy-bar">
<div
class="energy-fill"
:style="{ width: energyPercentage + '%' }"
></div>
</div>
<div class="energy-value">{{ energy }}/100</div>
</div>
</template>
<script setup lang="ts">
const { energy } = useEnergy()
const energyPercentage = computed(() => energy.value)
</script>
SignDialog
<!-- components/gamification/SignDialog.vue -->
<template>
<el-dialog v-model="visible" title="每日签到">
<div class="sign-calendar">
<div
v-for="day in 7"
:key="day"
:class="['sign-day', signedDays.includes(day) ? 'signed' : '']"
>
{{ day }}
</div>
</div>
<el-button
type="primary"
:disabled="signedToday"
@click="handleSign"
>
{{ signedToday ? '已签到' : '签到' }}
</el-button>
</el-dialog>
</template>
<script setup lang="ts">
const { signedDays, signedToday, sign } = useSign()
const visible = ref(false)
const handleSign = () => {
sign()
}
</script>
MusicPlayer
<!-- components/gamification/MusicPlayer.vue -->
<template>
<div class="music-player">
<div class="player-info">
<img :src="currentTrack.cover" :alt="currentTrack.name" />
<div class="track-info">
<div class="track-name">{{ currentTrack.name }}</div>
<div class="track-artist">{{ currentTrack.artist }}</div>
</div>
</div>
<div class="player-controls">
<button @click="prevTrack">
<el-icon><DArrowLeft /></el-icon>
</button>
<button @click="togglePlay">
<el-icon><component :is="isPlaying ? VideoPause : VideoPlay" /></el-icon>
</button>
<button @click="nextTrack">
<el-icon><DArrowRight /></el-icon>
</button>
</div>
<div class="player-progress">
<div
class="progress-bar"
:style="{ width: progress + '%' }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
const {
currentTrack,
isPlaying,
progress,
togglePlay,
prevTrack,
nextTrack
} = useMusicPlayer()
</script>
4. 文章组件(6个)
ArticleCard
<!-- components/article/ArticleCard.vue -->
<template>
<article class="article-card">
<ArticleMeta :article="article" />
<ArticleContent :article="article" />
<ArticleActions :article="article" />
</article>
</template>
<script setup lang="ts">
import ArticleMeta from './ArticleMeta.vue'
import ArticleContent from './ArticleContent.vue'
import ArticleActions from './ArticleActions.vue'
defineProps<{ article: Article }>()
</script>
ArticleMeta
<!-- components/article/ArticleMeta.vue -->
<template>
<div class="article-meta">
<div class="meta-row">
<span class="author">{{ article.author }}</span>
<span class="date">{{ formatDate(article.date) }}</span>
</div>
<div class="tags">
<el-tag
v-for="tag in article.tags"
:key="tag"
size="small"
>
{{ tag }}
</el-tag>
</div>
</div>
</template>
<script setup lang="ts">
import { formatDate } from '@/utils/format'
defineProps<{ article: Article }>()
</script>
Composables设计
useArticle
// composables/useArticle.ts
export function useArticle() {
const articles = ref<Article[]>([])
const loading = ref(false)
const error = ref<Error | null>(null)
const fetchArticles = async () => {
loading.value = true
try {
articles.value = await getArticles()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
const getArticleById = (id: number) => {
return articles.value.find(a => a.id === id)
}
return {
articles,
loading,
error,
fetchArticles,
getArticleById
}
}
useTheme
// composables/useTheme.ts
export function useTheme() {
const isDark = ref(false)
const toggleTheme = () => {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark')
}
return {
isDark,
toggleTheme
}
}
useLocalStorage
// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
const stored = ref<T>(defaultValue)
// 初始化时读取
const init = () => {
const item = localStorage.getItem(key)
if (item) {
try {
stored.value = JSON.parse(item)
} catch (e) {
console.error('Failed to parse localStorage', e)
}
}
}
// 监听变化并保存
watch(stored, (value) => {
localStorage.setItem(key, JSON.stringify(value))
}, { deep: true })
init()
return stored
}
组件通信方式
1. Props Down
<!-- 父组件 -->
<ArticleCard :article="article" variant="featured" />
<!-- 子组件 -->
<script setup lang="ts">
interface Props {
article: Article
variant?: 'default' | 'compact' | 'featured'
}
defineProps<Props>()
</script>
2. Emits Up
<!-- 子组件 -->
<script setup lang="ts">
const emit = defineEmits<{
(e: 'like', articleId: number): void
(e: 'collect', articleId: number): void
}>()
const handleLike = () => {
emit('like', props.article.id)
}
</script>
<!-- 父组件 -->
<ArticleCard @like="handleLike" />
3. Provide/Inject
// 祖先组件
provide('theme', isDark)
// 后代组件
const theme = inject('theme')
4. Event Bus
// utils/eventBus.ts
import mitt from 'mitt'
export const eventBus = mitt<{
notification: NotificationEvent
refresh: void
}>()
// 发送事件
eventBus.emit('notification', { type: 'success', message: '操作成功' })
// 监听事件
eventBus.on('notification', (event) => {
// 处理通知
})
性能优化
1. 组件懒加载
const HeavyComponent = defineAsyncComponent({
loader: () => import('./HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorComponent,
delay: 200,
timeout: 3000
})
2. 虚拟滚动
<VirtualList
:data-sources="articles"
:data-key="'id'"
:keeps="30"
/>
3. 计算属性缓存
const hotArticles = computed(() => {
return articles.value
.filter(a => a.views > 1000)
.sort((a, b) => b.views - a.views)
})
最佳实践
1. 组件命名
- 使用PascalCase
- 组件名与文件名保持一致
- 使用语义化的名称
2. Props定义
- 明确定义类型
- 提供合理的默认值
- 使用TypeScript类型检查
3. 样式管理
- 使用scoped CSS
- 避免样式污染
- 使用CSS变量
总结
通过合理的架构设计和组件拆分,我们实现了:
- 更好的代码组织 - 职责清晰,易于理解
- 更高的可维护性 - 修改某个功能只需修改对应组件
- 更强的可复用性 - 组件可在多个页面中复用
- 更好的可测试性 - 独立组件更容易编写单元测试
- 更高的开发效率 - 团队成员可同时开发不同组件
标签:#Vue3 #组件化 #架构设计 #前端 #代码重构
点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!