第 2 章 · Vue 3 组件化与单文件组件
本章目标:将 ch01 的内联模板拆分为可复用单文件组件(SFC);掌握 props / emits / slots、父子与跨层通信、scoped 样式 与 CSS 变量;产出 CourseCard.vue 与 CourseList.vue 完整实现。
学时建议:3~4 小时
前置:完成本模块 ch01;理解 ES 模块 import/export。
2.1 为什么组件化
| 问题(ch01 单文件) | 组件化收益 |
|---|---|
App.vue 超过 200 行难维护 | 按 UI 边界拆分,职责单一 |
| 卡片 markup 重复 | CourseCard 一处修改全局生效 |
| 样式类名冲突 | scoped 隔离组件 CSS |
| 难以单测 | 纯 props 输入、emit 输出易测 |
行业案例 · 某在线教育 SaaS:课程卡片在首页、搜索结果、推荐侧栏三处复用。组件化后设计令牌(颜色、圆角)通过 CSS 变量统一下发,品牌换肤从改 40 个文件变为改 tokens.css 一个文件,QA 回归范围缩小 60%。
2.2 单文件组件(SFC)结构
<!-- CourseCard.vue -->
<script setup>
// 逻辑
</script>
<template>
<!-- 结构 -->
</template>
<style scoped>
/* 样式,仅作用于本组件 */
</style>
| 块 | 编译结果 |
|---|---|
<script setup> | 组件 setup() 函数 |
<template> | 渲染函数 |
<style scoped> | 带 data-v-xxxxx 属性选择器 |
2.3 Props:父 → 子
父组件通过 props 向子组件传递数据;子组件只读,不可直接修改 props。
<!-- CourseCard.vue -->
<script setup>
const props = defineProps({
course: {
type: Object,
required: true,
},
favorited: {
type: Boolean,
default: false,
},
})
// 基于 props 的派生 — 用 computed,勿改 props
import { computed } from 'vue'
const levelLabel = computed(() => {
const map = { beginner: '入门', intermediate: '进阶', advanced: '高级' }
return map[props.course.level] ?? props.course.level
})
</script>
<template>
<article class="course-card">
<img
v-if="course.cover"
:src="course.cover"
:alt="course.title"
class="cover"
loading="lazy"
/>
<h3 class="title">{{ course.title }}</h3>
<p class="desc">{{ course.desc }}</p>
<div class="tags">
<span v-for="tag in course.tags" :key="tag" class="tag">{{ tag }}</span>
</div>
<footer class="footer">
<span>{{ course.hours }} 学时</span>
<span>{{ levelLabel }}</span>
<button
type="button"
class="fav-btn"
:aria-pressed="favorited"
@click="$emit('toggle-favorite', course.id)"
>
{{ favorited ? '已收藏' : '收藏' }}
</button>
</footer>
</article>
</template>
Props 命名:脚本中用 camelCase(favorited),模板中可用 kebab-case(favorited 或 :is-favorited)。
2.4 Emits:子 → 父
子组件通过 emit 通知父组件处理业务(改状态、调 API)。
<script setup>
const emit = defineEmits({
'toggle-favorite': (id) => typeof id === 'string',
'open-detail': (id) => typeof id === 'string',
})
</script>
父组件监听:
<CourseCard
:course="c"
:favorited="favoriteIds.has(c.id)"
@toggle-favorite="onToggleFavorite"
@open-detail="goDetail"
/>
| 模式 | 说明 |
|---|---|
| Props down | 数据、配置自父传子 |
| Events up | 用户操作由子 emit,父改状态 |
| 禁止子改 props | 需改时父更新源数据 |
2.5 Slots:内容分发
当子组件需要「外壳固定、内部可定制」时使用插槽。
<!-- SearchBar.vue -->
<template>
<div class="search-bar">
<slot name="prefix" />
<input
:value="modelValue"
type="search"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
<slot name="suffix">
<span class="hint">Enter 搜索</span>
</slot>
</div>
</template>
<script setup>
defineProps({
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '搜索…' },
})
defineEmits(['update:modelValue'])
</script>
父组件:
<SearchBar v-model="keyword">
<template #prefix>
<span aria-hidden="true">🔍</span>
</template>
<template #suffix>
<button type="button" @click="keyword = ''">清空</button>
</template>
</SearchBar>
| 插槽类型 | 语法 | 场景 |
|---|---|---|
| 默认插槽 | <slot /> | 单块自定义内容 |
| 具名插槽 | <slot name="footer" /> | 多区域布局 |
| 作用域插槽 | <slot :item="row" /> | 父定制子列表每一项 |