Vue 3项目架构设计:从2200行单文件到24个组件

0 阅读4分钟

🏗️ 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%
组件数量124⬆️ 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变量

总结

通过合理的架构设计和组件拆分,我们实现了:

  1. 更好的代码组织 - 职责清晰,易于理解
  2. 更高的可维护性 - 修改某个功能只需修改对应组件
  3. 更强的可复用性 - 组件可在多个页面中复用
  4. 更好的可测试性 - 独立组件更容易编写单元测试
  5. 更高的开发效率 - 团队成员可同时开发不同组件

标签:#Vue3 #组件化 #架构设计 #前端 #代码重构

点赞❤️ + 收藏⭐️ + 评论💬,你的支持是我创作的动力!