现在位置: 首页 > Vue3 教程 > 正文

拆分组件

组件化是 Vue 最核心的设计思想。

本章你将学会如何把页面拆成可复用的小组件,并通过 props 和 emit 实现组件间通信。


为什么要拆组件?

随着功能增加,一个文件会变得越来越长,几百行代码塞在一个 .vue 文件里,难以维护。

拆组件的目的:每个组件只做一件事,组合起来完成复杂功能

什么粒度合适?

一个好的经验法则是:当一个代码块有自己的数据、自己的行为、可以在别处复用,它就应该成为一个独立组件。

页面区域是否该拆成组件理由
导航栏每个页面都需要,独立功能
文章卡片列表里每篇都是同一个结构
分类筛选按钮组有自己的状态和交互
简单包裹容器只是为了布局,没有独立行为

父传子:defineProps

父组件向子组件传递数据,通过 props 实现。

可以把它理解为「函数的参数」——父组件调用子组件时,把数据作为参数传入。

实例

<!-- 文件路径:src/components/BlogCard.vue -->
<script setup>
// 声明该组件接收的 props
// defineProps 是编译宏,无需导入即可使用
const props = defineProps({
  title: String,        // 文章标题,字符串类型
  summary: String,      // 文章摘要
  date: String,         // 发布日期
  category: String      // 分类标签
})

// 也可以写成数组形式(不校验类型):
// const props = defineProps(['title', 'summary', 'date', 'category'])
</script>

<template>
  <div class="card">
    <span class="tag">{{ category }}</span>
    <h3>{{ title }}</h3>
    <p>{{ summary }}</p>
    <span class="date">{{ date }}</span>
  </div>
</template>

<style scoped>
.card {
  background: #fff;
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 2px 12px rgba(0,0,0,0.08);
}
.tag {
  display: inline-block;
  padding: 2px 10px;
  background: #e8f5e9;
  color: #42b883;
  border-radius: 12px;
  font-size: 12px;
}
.card h3 { margin: 10px 0 8px; font-size: 18px; }
.card p { color: #666; font-size: 14px; line-height: 1.6; }
.date { font-size: 12px; color: #999; }
</style>

父组件使用 BlogCard 时,通过冒号绑定属性传入数据:

实例

<!-- 文件路径:src/views/HomeView.vue -->
<script setup>
import { ref } from 'vue'
import BlogCard from '../components/BlogCard.vue'

const articles = ref([
  { id: 1, title: 'Vue3 入门', summary: '从零学 Vue3', date: '2024-05-10', category: 'Vue' },
  { id: 2, title: 'Promise 详解', summary: '搞懂异步', date: '2024-05-08', category: 'JavaScript' },
])
</script>

<template>
  <div class="article-grid">
    <!-- 父组件通过 :属性名="值" 向子组件传递数据 -->
    <BlogCard
      v-for="article in articles"
      :key="article.id"
      :title="article.title"
      :summary="article.summary"
      :date="article.date"
      :category="article.category"
    />
  </div>
</template>

props 是单向数据流:只能父 → 子,子组件不能修改 props 的值。如果子组件需要修改,应该通过 emit 通知父组件去改。


子传父:defineEmits

子组件要通知父组件发生了什么事(比如「我被点击了」),通过 emit 发送事件。

可以把它理解为「回调函数」——子组件触发事件,父组件监听并响应。

实例

<!-- 文件路径:src/components/CategoryFilter.vue -->
<script setup>
const props = defineProps({
  categories: Array,          // 所有分类列表
  activeCategory: String      // 当前选中的分类
})

// 声明要发送的事件
const emit = defineEmits(['update-category'])

function selectCategory(cat) {
  // 触发事件,将选中的分类传给父组件
  emit('update-category', cat)
}
</script>

<template>
  <div class="filter-bar">
    <button
      v-for="cat in categories"
      :key="cat"
      :class="{ active: activeCategory === cat }"
      @click="selectCategory(cat)"
    >
      {{ cat }}
    </button>
  </div>
</template>

父组件通过 @事件名="处理函数" 来监听子组件事件:

实例

<!-- 文件路径:src/views/HomeView.vue -->
<script setup>
import { ref, computed } from 'vue'
import CategoryFilter from '../components/CategoryFilter.vue'

const activeCategory = ref('全部')

const categories = ['全部', 'Vue', 'JavaScript', 'CSS']

// 响应子组件发来的 update-category 事件
function handleCategoryChange(cat) {
  activeCategory.value = cat
}
</script>

<template>
  <!-- @update-category="handleCategoryChange" -->
  <CategoryFilter
    :categories="categories"
    :active-category="activeCategory"
    @update-category="handleCategoryChange"
  />
</template>

完整的数据流向

父组件通过 props 向下 传数据,子组件通过 emit 向上 发事件。

数据始终由父组件「拥有」和修改,子组件只负责展示和通知。


动手:拆出三个组件

博客项目需要三个组件,目录结构如下:

src/
├── components/
│   ├── NavBar.vue          # 顶部导航栏
│   ├── BlogCard.vue        # 单篇文章卡片
│   └── CategoryFilter.vue  # 分类筛选按钮组
├── views/
│   └── HomeView.vue        # 首页(父组件)
└── App.vue

NavBar.vue — 导航栏

实例

<!-- 文件路径:src/components/NavBar.vue -->
<script setup>
// 导航栏目前只需要展示,后续会加入搜索和暗黑模式切换
</script>

<template>
  <header class="navbar">
    <a href="/" class="logo">RUNOOB Blog</a>
    <nav>
      <a href="/">首页</a>
      <a href="#">关于</a>
    </nav>
  </header>
</template>

<style scoped>
.navbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 40px;
  background: #fff;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.logo {
  font-size: 22px;
  font-weight: bold;
  color: #42b883;
  text-decoration: none;
}

nav a {
  margin-left: 24px;
  text-decoration: none;
  color: #333;
  font-size: 15px;
}

nav a:hover {
  color: #42b883;
}
</style>

HomeView.vue — 整合所有组件

实例

<!-- 文件路径:src/views/HomeView.vue -->
<script setup>
import { ref, computed } from 'vue'
import BlogCard from '../components/BlogCard.vue'
import CategoryFilter from '../components/CategoryFilter.vue'

// 文章数据
const articles = ref([
  { id: 1, title: 'Vue3 入门完全指南', summary: '从零开始学习 Vue3 组合式 API', date: '2024-05-10', category: 'Vue' },
  { id: 2, title: 'JS 异步编程详解', summary: '搞懂 Promise 和 async/await', date: '2024-05-08', category: 'JavaScript' },
  { id: 3, title: 'CSS Grid 布局实战', summary: '用 Grid 实现响应式布局', date: '2024-05-05', category: 'CSS' },
  { id: 4, title: 'Vue3 响应式原理', summary: '深入理解 ref 和 reactive', date: '2024-05-03', category: 'Vue' },
  { id: 5, title: 'Flexbox 完全指南', summary: '一文学会弹性布局', date: '2024-05-01', category: 'CSS' },
])

// 提取所有分类(去重)
const categories = computed(() => {
  const cats = articles.value.map(a => a.category)
  return ['全部', ...new Set(cats)]
})

// 当前选中的分类
const activeCategory = ref('全部')

// 根据分类过滤文章
const filteredArticles = computed(() => {
  if (activeCategory.value === '全部') return articles.value
  return articles.value.filter(a => a.category === activeCategory.value)
})

// 处理子组件发来的分类切换事件
function handleCategoryChange(cat) {
  activeCategory.value = cat
}
</script>

<template>
  <div class="home">
    <h2 class="section-title">最新文章</h2>

    <!-- 分类筛选组件 -->
    <CategoryFilter
      :categories="categories"
      :active-category="activeCategory"
      @update-category="handleCategoryChange"
    />

    <!-- 空状态 -->
    <p v-if="filteredArticles.length === 0" class="empty-tip">
      该分类下暂无文章
    </p>

    <!-- 文章卡片列表 -->
    <div v-else class="article-grid">
      <BlogCard
        v-for="article in filteredArticles"
        :key="article.id"
        :title="article.title"
        :summary="article.summary"
        :date="article.date"
        :category="article.category"
      />
    </div>
  </div>
</template>

<style scoped>
.home { max-width: 960px; margin: 0 auto; padding: 20px; }
.section-title { font-size: 24px; margin-bottom: 20px; }
.article-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 24px;
  margin-top: 20px;
}
.empty-tip { text-align: center; color: #999; padding: 60px 0; }
</style>

本章小结

本章你掌握了组件化的三个核心知识点:为什么拆组件、父传子用 props(defineProps)、子传父用 emit(defineEmits)。

博客项目的三个组件 NavBar、BlogCard、CategoryFilter 已就位,代码结构比第一章的一坨清晰多了。