拆分组件
组件化是 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>
<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>
<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>
<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>
<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>
<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>
<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 已就位,代码结构比第一章的一坨清晰多了。
