组件开发
# 02.组件开发
# 目录介绍
# 2.1 组件基础
# 2.1.1 什么是组件
组件是 Vue 最核心的概念之一。它允许我们将页面拆分为独立、可复用的模块,每个组件包含自己的模板、逻辑和样式。
页面拆分为组件的示意:
┌──────────────────────────────┐
│ <Header /> │ ← 头部组件
├──────────┬───────────────────┤
│ │ │
│ <Sidebar │ <Content /> │ ← 侧边栏 + 内容组件
│ /> │ │
│ │ ┌─────┐ ┌─────┐ │
│ │ │Card │ │Card │ │ ← 卡片组件(可复用)
│ │ └─────┘ └─────┘ │
├──────────┴───────────────────┤
│ <Footer /> │ ← 底部组件
└──────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
组件化的核心优势:
- 复用性:同一组件可在不同页面多次使用
- 可维护性:每个组件职责单一,修改互不影响
- 可测试性:组件可以独立测试
- 团队协作:不同成员负责不同组件
# 2.1.2 定义组件
在 Vue 3 中使用 <script setup> 定义组件:
<!-- src/components/MyButton.vue -->
<template>
<button class="my-btn" @click="handleClick">
{{ text }}
</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const text = ref('点击我')
const count = ref(0)
const handleClick = () => {
count.value++
text.value = `已点击 ${count.value} 次`
}
</script>
<style scoped>
.my-btn {
padding: 8px 20px;
background: #42b883;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
.my-btn:hover {
background: #369e6f;
}
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 2.1.3 使用组件
在父组件中导入并使用子组件:
<!-- src/App.vue -->
<template>
<div>
<h1>组件使用示例</h1>
<!-- 直接使用导入的组件(无需注册) -->
<MyButton />
<MyButton />
<MyButton />
</div>
</template>
<script setup lang="ts">
// 在 <script setup> 中导入即可直接在模板中使用
import MyButton from './components/MyButton.vue'
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
注意:在
<script setup>中,导入的组件会自动注册,无需像 Vue 2 那样手动在components选项中声明。
# 2.1.4 组件命名规范
推荐命名规范:
├── 文件名:PascalCase(大驼峰)
│ ✅ MyButton.vue
│ ✅ UserProfile.vue
│ ❌ my-button.vue(不推荐)
│
├── 模板中使用:PascalCase 或 kebab-case 都可以
│ ✅ <MyButton />
│ ✅ <my-button />
│
├── 多单词组件名(避免与HTML元素冲突)
│ ✅ TodoItem(多单词)
│ ❌ Todo(单单词,可能与未来HTML标签冲突)
│
└── 基础组件前缀
✅ BaseButton.vue / AppButton.vue
✅ BaseInput.vue / AppInput.vue
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 2.1.5 综合案例与思考
综合案例:计数器组件
<!-- src/components/Counter.vue -->
<template>
<div class="counter">
<span class="label">{{ label }}</span>
<div class="controls">
<button @click="decrement" :disabled="count <= min">-</button>
<span class="value">{{ count }}</span>
<button @click="increment" :disabled="count >= max">+</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const label = '计数器'
const min = 0
const max = 99
const count = ref(0)
const increment = () => {
if (count.value < max) count.value++
}
const decrement = () => {
if (count.value > min) count.value--
}
</script>
<style scoped>
.counter {
display: inline-flex; align-items: center; gap: 12px;
padding: 8px 16px; border: 1px solid #ddd; border-radius: 8px;
}
.controls { display: flex; align-items: center; gap: 8px; }
.controls button {
width: 32px; height: 32px; border-radius: 50%;
border: 1px solid #ddd; cursor: pointer; font-size: 18px;
}
.controls button:disabled { opacity: 0.3; cursor: not-allowed; }
.value { font-size: 20px; font-weight: bold; min-width: 40px; text-align: center; }
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
案例知识融合:这个计数器组件展示了组件的基本结构:模板、逻辑、样式三者合一。目前 label、min、max 都是写死的,下一节我们将学习如何通过 Props 让它们变成可配置的。
思考题:
- 为什么
<script setup>中导入的组件不需要手动注册?Vue 是如何实现的? - 组件应该按什么粒度拆分?拆得太细或太粗各有什么问题?
- 每个
<MyButton />实例的count是共享的还是独立的?为什么?
# 2.2 Props属性传递
# 2.2.1 声明Props
Props 是父组件向子组件传递数据的方式:
<!-- 子组件:UserCard.vue -->
<template>
<div class="user-card">
<h3>{{ name }}</h3>
<p>年龄:{{ age }}</p>
<p>邮箱:{{ email }}</p>
</div>
</template>
<script setup lang="ts">
// 使用 defineProps 声明(编译器宏,无需导入)
const props = defineProps<{
name: string
age: number
email: string
}>()
// 也可以在模板中直接使用 name / age / email
// 在 JS 中需要通过 props.name 访问
console.log(props.name)
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 父组件:使用 UserCard -->
<template>
<UserCard name="张三" :age="28" email="zhangsan@example.com" />
<UserCard name="李四" :age="32" email="lisi@example.com" />
</template>
<script setup lang="ts">
import UserCard from './components/UserCard.vue'
</script>
2
3
4
5
6
7
8
9
注意:静态字符串直接写
name="张三";数字、布尔值、对象等需要用:age="28"动态绑定。
# 2.2.2 Props类型校验
TypeScript 方式声明 Props 类型:
<script setup lang="ts">
// 方式一:纯类型声明(推荐)
defineProps<{
title: string
count: number
isActive: boolean
tags: string[]
user: { name: string; age: number }
callback?: (value: string) => void // 可选
}>()
// 方式二:运行时声明(可以设置 validator)
defineProps({
status: {
type: String as () => 'active' | 'inactive' | 'pending',
required: true,
validator: (value: string) => {
return ['active', 'inactive', 'pending'].includes(value)
}
},
score: {
type: Number,
required: true,
validator: (value: number) => value >= 0 && value <= 100
}
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 2.2.3 Props默认值
使用 withDefaults 设置默认值:
<script setup lang="ts">
interface Props {
title: string
count?: number
disabled?: boolean
tags?: string[]
theme?: 'light' | 'dark'
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
disabled: false,
tags: () => [], // 引用类型默认值必须使用工厂函数
theme: 'light'
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 2.2.4 单向数据流
Props 是单向向下的:父组件更新会传递到子组件,但子组件不应该修改 Props:
<script setup lang="ts">
const props = defineProps<{
initialCount: number
}>()
// ❌ 错误:不应直接修改 prop
// props.initialCount++
// ✅ 正确:用本地变量接收,再修改本地变量
import { ref } from 'vue'
const localCount = ref(props.initialCount)
// 修改 localCount.value 不影响父组件
// ✅ 正确:基于 prop 派生计算属性
import { computed } from 'vue'
const doubleCount = computed(() => props.initialCount * 2)
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 2.2.5 综合案例与思考
综合案例:可配置的商品卡片
<!-- ProductCard.vue -->
<template>
<div class="product-card" :class="{ 'on-sale': onSale }">
<img :src="image" :alt="name" class="product-img" />
<div class="product-info">
<h3>{{ name }}</h3>
<p class="desc">{{ description }}</p>
<div class="price-row">
<span class="price">¥{{ currentPrice.toFixed(2) }}</span>
<span v-if="onSale" class="original-price">¥{{ price.toFixed(2) }}</span>
<span v-if="onSale" class="discount">{{ discountText }}</span>
</div>
<div class="tags">
<span v-for="tag in tags" :key="tag" class="tag">{{ tag }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
name: string
price: number
image: string
description?: string
discount?: number // 0.0 - 1.0 折扣
tags?: string[]
}
const props = withDefaults(defineProps<Props>(), {
description: '暂无描述',
discount: 1,
tags: () => []
})
const onSale = computed(() => props.discount < 1)
const currentPrice = computed(() => props.price * props.discount)
const discountText = computed(() => `${(props.discount * 10).toFixed(1)}折`)
</script>
<style scoped>
.product-card {
width: 280px; border: 1px solid #eee; border-radius: 12px; overflow: hidden;
}
.product-card.on-sale { border-color: #ff6b6b; }
.product-img { width: 100%; height: 200px; object-fit: cover; }
.product-info { padding: 16px; }
.price { color: #ff4444; font-size: 20px; font-weight: bold; }
.original-price { text-decoration: line-through; color: #999; font-size: 14px; margin-left: 8px; }
.discount { background: #ff4444; color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; margin-left: 8px; }
.tags { display: flex; gap: 6px; margin-top: 8px; }
.tag { background: #f0f0f0; padding: 2px 8px; border-radius: 4px; font-size: 12px; }
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<!-- 父组件使用 -->
<template>
<div class="product-list">
<ProductCard
name="Vue.js 实战教程"
:price="99.00"
image="/vue-book.jpg"
description="从入门到精通的完整教程"
:discount="0.7"
:tags="['前端', 'Vue3', '热销']"
/>
<ProductCard
name="TypeScript 手册"
:price="59.00"
image="/ts-book.jpg"
:tags="['前端', 'TypeScript']"
/>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
案例知识融合:这个商品卡片组件展示了 Props 的完整用法:TypeScript 类型声明、必选和可选属性、withDefaults 默认值、基于 Props 的计算属性。父组件通过不同的 Props 配置,复用同一个组件展示不同的商品。
思考题:
- 为什么引用类型(数组、对象)的默认值必须用工厂函数
() => []? - 如果子组件需要修改父组件传来的数据,应该怎么做?
defineProps是一个函数调用,为什么不需要 import?它是什么机制?
# 2.3 组件事件Emit
# 2.3.1 触发自定义事件
子组件通过 emit 向父组件发送事件(子传父):
<!-- 子组件:MyButton.vue -->
<template>
<button @click="handleClick">{{ label }}</button>
</template>
<script setup lang="ts">
defineProps<{ label: string }>()
// 声明可以触发的事件
const emit = defineEmits<{
(e: 'click'): void
(e: 'doubleClick'): void
}>()
const handleClick = () => {
emit('click') // 触发 click 事件
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 父组件 -->
<template>
<MyButton label="点击" @click="onBtnClick" />
</template>
<script setup lang="ts">
import MyButton from './components/MyButton.vue'
const onBtnClick = () => {
console.log('按钮被点击了!')
}
</script>
2
3
4
5
6
7
8
9
10
11
# 2.3.2 事件参数
emit 可以携带数据传递给父组件:
<!-- 子组件:SearchBox.vue -->
<template>
<div class="search-box">
<input
v-model="keyword"
@keyup.enter="handleSearch"
placeholder="搜索..."
/>
<button @click="handleSearch">搜索</button>
<button @click="handleClear">清空</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const keyword = ref('')
const emit = defineEmits<{
(e: 'search', keyword: string): void
(e: 'clear'): void
}>()
const handleSearch = () => {
if (keyword.value.trim()) {
emit('search', keyword.value.trim())
}
}
const handleClear = () => {
keyword.value = ''
emit('clear')
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!-- 父组件 -->
<template>
<SearchBox @search="onSearch" @clear="onClear" />
<p>搜索结果:{{ result }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import SearchBox from './components/SearchBox.vue'
const result = ref('')
const onSearch = (keyword: string) => {
result.value = `正在搜索: "${keyword}"`
}
const onClear = () => {
result.value = ''
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 2.3.3 事件校验
可以对 emit 的参数进行校验:
<script setup lang="ts">
const emit = defineEmits({
// 返回 true 表示校验通过
submit: (payload: { name: string; email: string }) => {
if (!payload.name) {
console.warn('name 不能为空')
return false
}
if (!payload.email.includes('@')) {
console.warn('email 格式不正确')
return false
}
return true
}
})
// 校验失败时会在控制台输出警告,但事件仍然会触发
emit('submit', { name: '张三', email: 'zhangsan@example.com' })
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 2.3.4 v-model在组件上
v-model 可以用在自定义组件上,实现双向绑定:
<!-- 子组件:CustomInput.vue -->
<template>
<div class="custom-input">
<label v-if="label">{{ label }}</label>
<input
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
:placeholder="placeholder"
/>
</div>
</template>
<script setup lang="ts">
withDefaults(defineProps<{
modelValue: string // v-model 默认绑定到 modelValue
label?: string
placeholder?: string
}>(), {
label: '',
placeholder: '请输入'
})
defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!-- 父组件 -->
<template>
<!-- v-model 等价于 :modelValue="name" @update:modelValue="name = $event" -->
<CustomInput v-model="name" label="姓名" />
<CustomInput v-model="email" label="邮箱" placeholder="输入邮箱" />
<!-- 多个 v-model -->
<UserForm v-model:name="userName" v-model:age="userAge" />
</template>
2
3
4
5
6
7
8
9
多个 v-model 绑定:
<!-- UserForm.vue -->
<template>
<input :value="name" @input="$emit('update:name', ($event.target as HTMLInputElement).value)" />
<input :value="age" @input="$emit('update:age', Number(($event.target as HTMLInputElement).value))" type="number" />
</template>
<script setup lang="ts">
defineProps<{ name: string; age: number }>()
defineEmits<{
(e: 'update:name', value: string): void
(e: 'update:age', value: number): void
}>()
</script>
2
3
4
5
6
7
8
9
10
11
12
13
# 2.3.5 综合案例与思考
综合案例:评分组件
<!-- StarRating.vue -->
<template>
<div class="star-rating">
<span class="label" v-if="label">{{ label }}</span>
<span
v-for="star in maxStars"
:key="star"
class="star"
:class="{ filled: star <= modelValue, hover: star <= hoverValue }"
@click="selectStar(star)"
@mouseover="hoverValue = star"
@mouseleave="hoverValue = 0"
>
★
</span>
<span class="text">{{ ratingText }}</span>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
modelValue: number
maxStars?: number
label?: string
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
maxStars: 5,
label: '',
disabled: false
})
const emit = defineEmits<{
(e: 'update:modelValue', value: number): void
(e: 'change', value: number): void
}>()
const hoverValue = ref(0)
const ratingText = computed(() => {
const texts = ['未评分', '很差', '较差', '一般', '较好', '很好']
return texts[props.modelValue] || ''
})
const selectStar = (star: number) => {
if (props.disabled) return
emit('update:modelValue', star)
emit('change', star)
}
</script>
<style scoped>
.star-rating { display: flex; align-items: center; gap: 4px; }
.star { font-size: 24px; cursor: pointer; color: #ddd; transition: color 0.2s; }
.star.filled { color: #f5a623; }
.star.hover { color: #ffd700; }
.text { margin-left: 8px; font-size: 14px; color: #666; }
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
<!-- 父组件使用 -->
<template>
<StarRating v-model="rating" label="商品评分" @change="onRatingChange" />
<p>当前评分:{{ rating }}</p>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import StarRating from './components/StarRating.vue'
const rating = ref(0)
const onRatingChange = (value: number) => {
console.log('评分变更为:', value)
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
案例知识融合:这个星级评分组件综合了 Props(配置最大星数、标签、禁用状态)、Emit(update:modelValue 实现 v-model、change 事件通知)、计算属性(评分文字)和事件处理(点击、悬停),是组件通信的完整实战。
思考题:
v-model在组件上的本质是什么?它和 Props + Emit 是什么关系?defineProps和defineEmits为什么叫"编译器宏"?它们和普通函数有什么区别?- 如果需要在一个组件上绑定多个 v-model(如表单组件),应该怎么设计?
# 2.4 插槽Slot
# 2.4.1 默认插槽
插槽允许父组件向子组件传递模板内容:
<!-- 子组件:Card.vue -->
<template>
<div class="card">
<div class="card-body">
<!-- 插槽出口:父组件传入的内容会渲染在这里 -->
<slot>
<!-- 默认内容:父组件没传内容时显示 -->
<p>这是默认内容</p>
</slot>
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
}
.card-body { padding: 16px; }
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 父组件使用 -->
<template>
<!-- 传入自定义内容 -->
<Card>
<h3>自定义标题</h3>
<p>这是父组件传入的内容,会替换掉默认内容</p>
</Card>
<!-- 不传内容,显示默认 -->
<Card />
</template>
2
3
4
5
6
7
8
9
10
11
# 2.4.2 具名插槽
使用具名插槽在多个位置插入不同内容:
<!-- 子组件:PageLayout.vue -->
<template>
<div class="layout">
<header>
<slot name="header">默认头部</slot>
</header>
<main>
<slot>默认内容</slot> <!-- 没有 name 的就是默认插槽 -->
</main>
<footer>
<slot name="footer">默认底部</slot>
</footer>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 父组件使用 -->
<template>
<PageLayout>
<!-- 使用 #name 简写(v-slot:name) -->
<template #header>
<h1>页面标题</h1>
<nav>导航栏</nav>
</template>
<!-- 默认插槽内容 -->
<p>这是页面主体内容</p>
<p>可以包含多个元素</p>
<template #footer>
<p>© 2025 版权所有</p>
</template>
</PageLayout>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 2.4.3 作用域插槽
作用域插槽允许子组件向插槽传递数据,父组件决定如何渲染:
<!-- 子组件:DataList.vue -->
<template>
<ul class="data-list">
<li v-for="item in items" :key="item.id">
<!-- 将 item 数据传递给插槽 -->
<slot :item="item" :index="items.indexOf(item)">
<!-- 默认渲染 -->
{{ item.name }}
</slot>
</li>
</ul>
</template>
<script setup lang="ts">
defineProps<{
items: Array<{ id: number; name: string; [key: string]: any }>
}>()
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!-- 父组件:自定义渲染方式 -->
<template>
<DataList :items="users">
<!-- 解构插槽 props -->
<template #default="{ item, index }">
<span>{{ index + 1 }}. </span>
<strong>{{ item.name }}</strong>
<span> - {{ item.email }}</span>
</template>
</DataList>
</template>
<script setup lang="ts">
const users = [
{ id: 1, name: '张三', email: 'zhang@test.com' },
{ id: 2, name: '李四', email: 'li@test.com' }
]
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 2.4.4 综合案例与思考
综合案例:可定制的对话框组件
<!-- Dialog.vue -->
<template>
<Teleport to="body">
<div v-if="visible" class="dialog-overlay" @click.self="handleClose">
<div class="dialog" :style="{ width: width }">
<div class="dialog-header">
<slot name="header">
<h3>{{ title }}</h3>
</slot>
<button class="close-btn" @click="handleClose">×</button>
</div>
<div class="dialog-body">
<slot></slot>
</div>
<div class="dialog-footer">
<slot name="footer">
<button @click="handleClose">取消</button>
<button class="primary" @click="handleConfirm">确认</button>
</slot>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
interface Props {
visible: boolean
title?: string
width?: string
}
withDefaults(defineProps<Props>(), {
title: '提示',
width: '480px'
})
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void
(e: 'confirm'): void
(e: 'close'): void
}>()
const handleClose = () => {
emit('update:visible', false)
emit('close')
}
const handleConfirm = () => {
emit('confirm')
}
</script>
<style scoped>
.dialog-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: flex;
align-items: center; justify-content: center; z-index: 1000;
}
.dialog {
background: white; border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 30px rgba(0,0,0,0.15);
}
.dialog-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid #eee;
}
.dialog-body { padding: 20px; }
.dialog-footer {
display: flex; justify-content: flex-end; gap: 8px;
padding: 16px 20px; border-top: 1px solid #eee;
}
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; }
.primary { background: #42b883; color: white; border: none; padding: 8px 20px; border-radius: 6px; }
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<!-- 父组件使用 -->
<template>
<button @click="showDialog = true">打开对话框</button>
<Dialog v-model:visible="showDialog" title="用户信息" @confirm="onConfirm">
<p>这是对话框的主体内容</p>
<input v-model="inputValue" placeholder="输入内容" />
<template #footer>
<button @click="showDialog = false">自定义取消</button>
<button @click="onConfirm" style="background:#42b883;color:white">
自定义确认
</button>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Dialog from './components/Dialog.vue'
const showDialog = ref(false)
const inputValue = ref('')
const onConfirm = () => {
console.log('确认:', inputValue.value)
showDialog.value = false
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
案例知识融合:这个对话框组件综合了默认插槽(主体内容)、具名插槽(header、footer)、Props(title、width、visible)、Emit(close、confirm)和 v-model:visible。父组件可以完全自定义对话框的内容和按钮,体现了"插槽让组件更灵活"的设计理念。
思考题:
- 默认插槽和具名插槽各适用于什么场景?什么时候需要用作用域插槽?
<Teleport to="body">的作用是什么?为什么对话框组件需要它?- 这个Dialog的header/footer插槽都有默认内容,这种设计模式有什么好处?
# 2.5 生命周期
# 2.5.1 生命周期图示
Vue 3 组件的生命周期:
组件生命周期流程:
创建阶段
┌─────────────┐
│ setup() │ ← 组合式API入口(最早执行)
└──────┬──────┘
↓
┌─────────────┐
│onBeforeMount│ ← DOM挂载前
└──────┬──────┘
↓
┌─────────────┐
│ onMounted │ ← DOM已挂载(可以操作DOM、请求数据)
└──────┬──────┘
↓
更新阶段(数据变化时触发)
┌──────────────────┐
│ onBeforeUpdate │ ← DOM更新前
└───────┬──────────┘
↓
┌──────────────────┐
│ onUpdated │ ← DOM已更新
└───────┬──────────┘
↓
销毁阶段
┌──────────────────┐
│onBeforeUnmount │ ← 组件卸载前(清理工作)
└───────┬──────────┘
↓
┌──────────────────┐
│ onUnmounted │ ← 组件已卸载
└──────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 2.5.2 常用生命周期钩子
<script setup lang="ts">
import {
ref,
onBeforeMount,
onMounted,
onBeforeUpdate,
onUpdated,
onBeforeUnmount,
onUnmounted
} from 'vue'
const count = ref(0)
// setup 本身就是最早的生命周期(替代 created)
console.log('setup: 组件初始化')
onBeforeMount(() => {
console.log('onBeforeMount: DOM即将挂载')
// 此时模板已编译,但还没插入到页面
})
onMounted(() => {
console.log('onMounted: DOM已挂载')
// ✅ 可以操作 DOM
// ✅ 可以发起 API 请求
// ✅ 可以启动定时器、添加事件监听
})
onBeforeUpdate(() => {
console.log('onBeforeUpdate: 数据变化,DOM即将更新')
})
onUpdated(() => {
console.log('onUpdated: DOM已更新')
// 注意:避免在这里修改数据,可能导致无限循环
})
onBeforeUnmount(() => {
console.log('onBeforeUnmount: 组件即将卸载')
// ✅ 清理定时器、取消订阅、移除事件监听
})
onUnmounted(() => {
console.log('onUnmounted: 组件已卸载')
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 2.5.3 各钩子使用场景
| 钩子 | 典型使用场景 |
|---|---|
setup | 初始化响应式数据、定义方法 |
onMounted | DOM操作、API请求、第三方库初始化、启动定时器 |
onUpdated | DOM更新后的操作(谨慎使用) |
onBeforeUnmount | 清理定时器、取消请求、移除事件监听 |
onUnmounted | 最终清理工作 |
最常用的两个钩子:
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const data = ref<any>(null)
let timer: number
// 1. onMounted:请求数据 + 启动定时器
onMounted(async () => {
// 请求 API 数据
const response = await fetch('/api/data')
data.value = await response.json()
// 启动定时器
timer = setInterval(() => {
console.log('定时任务执行中...')
}, 5000)
// 添加全局事件监听
window.addEventListener('resize', handleResize)
})
// 2. onBeforeUnmount:清理资源
onBeforeUnmount(() => {
clearInterval(timer)
window.removeEventListener('resize', handleResize)
})
const handleResize = () => {
console.log('窗口大小变化:', window.innerWidth)
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 2.5.4 综合案例与思考
综合案例:实时数据监控面板
<!-- MonitorPanel.vue -->
<template>
<div class="monitor">
<h3>系统监控 <span class="status" :class="statusClass">{{ statusText }}</span></h3>
<div class="metrics">
<div class="metric">
<span class="label">CPU</span>
<div class="bar"><div class="fill" :style="{ width: cpu + '%' }"></div></div>
<span>{{ cpu }}%</span>
</div>
<div class="metric">
<span class="label">内存</span>
<div class="bar"><div class="fill memory" :style="{ width: memory + '%' }"></div></div>
<span>{{ memory }}%</span>
</div>
</div>
<p class="info">更新次数: {{ updateCount }} | 运行时长: {{ elapsed }}s</p>
<p class="info">组件状态: {{ lifecycle }}</p>
</div>
</template>
<script setup lang="ts">
import {
ref, computed, onMounted, onBeforeUnmount, onBeforeMount, onUpdated
} from 'vue'
const cpu = ref(0)
const memory = ref(0)
const updateCount = ref(0)
const elapsed = ref(0)
const lifecycle = ref('初始化中...')
let dataTimer: number
let clockTimer: number
const statusClass = computed(() => ({
online: cpu.value < 80,
warning: cpu.value >= 80
}))
const statusText = computed(() => cpu.value >= 80 ? '警告' : '正常')
// 模拟数据更新
const fetchData = () => {
cpu.value = Math.floor(Math.random() * 100)
memory.value = Math.floor(30 + Math.random() * 60)
updateCount.value++
}
onBeforeMount(() => {
lifecycle.value = 'DOM挂载前'
})
onMounted(() => {
lifecycle.value = '运行中'
// 每2秒更新数据
dataTimer = setInterval(fetchData, 2000)
// 每秒更新时钟
clockTimer = setInterval(() => elapsed.value++, 1000)
// 立即获取一次数据
fetchData()
})
onUpdated(() => {
// DOM更新后的操作(如滚动到底部等)
})
onBeforeUnmount(() => {
lifecycle.value = '清理中...'
clearInterval(dataTimer)
clearInterval(clockTimer)
})
</script>
<style scoped>
.monitor { padding: 20px; border: 1px solid #eee; border-radius: 12px; }
.status { font-size: 12px; padding: 2px 8px; border-radius: 10px; }
.status.online { background: #e8f5e9; color: #2e7d32; }
.status.warning { background: #fff3e0; color: #e65100; }
.metrics { margin: 16px 0; }
.metric { display: flex; align-items: center; gap: 12px; margin: 8px 0; }
.label { width: 40px; font-weight: bold; }
.bar { flex: 1; height: 20px; background: #f0f0f0; border-radius: 10px; overflow: hidden; }
.fill { height: 100%; background: #42b883; border-radius: 10px; transition: width 0.5s; }
.fill.memory { background: #3178c6; }
.info { font-size: 13px; color: #999; margin: 4px 0; }
</style>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
案例知识融合:这个监控面板在 onMounted 中启动两个定时器(模拟数据刷新和时钟),在 onBeforeUnmount 中清理。通过 lifecycle 变量可以直观看到组件处于哪个生命周期阶段。这是生命周期钩子最典型的应用场景。
思考题:
- 为什么 API 请求推荐放在
onMounted而不是setup中?有没有例外情况? - 如果不在
onBeforeUnmount清理定时器和事件监听,会产生什么问题? - Vue 3 的
setup替代了 Vue 2 的哪些生命周期?为什么要这样设计?
# 2.6 组件通信总结
# 2.6.1 父子通信
父子组件通信方式总览:
父 → 子(向下传递数据)
┌────────┐ Props ┌────────┐
│ Parent │ ─────────→ │ Child │
│ │ v-model │ │
│ │ ←────────→ │ │
│ │ Emit │ │
│ │ ←───────── │ │
└────────┘ └────────┘
子 → 父(向上触发事件)
2
3
4
5
6
7
8
9
10
11
<!-- 父 → 子:Props -->
<Child :message="msg" :count="num" />
<!-- 子 → 父:Emit -->
<Child @update="handleUpdate" @delete="handleDelete" />
<!-- 双向:v-model -->
<Child v-model="value" />
<!-- 父访问子:ref -->
<Child ref="childRef" />
2
3
4
5
6
7
8
9
10
11
# 2.6.2 兄弟通信
兄弟组件通过共同的父组件中转,或使用状态管理:
<!-- 方式一:父组件中转 -->
<template>
<BrotherA :data="sharedData" @update="sharedData = $event" />
<BrotherB :data="sharedData" />
</template>
<!-- 方式二:Pinia 状态管理(推荐) -->
<!-- 两个兄弟组件都从同一个 store 读写数据 -->
2
3
4
5
6
7
8
# 2.6.3 跨层级通信
provide / inject 实现祖先到后代的跨层级通信:
<!-- 祖先组件 -->
<script setup lang="ts">
import { provide, ref } from 'vue'
const theme = ref('light')
const toggleTheme = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light'
}
// 提供数据给所有后代组件
provide('theme', theme)
provide('toggleTheme', toggleTheme)
</script>
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 任意后代组件(无论嵌套多深) -->
<script setup lang="ts">
import { inject } from 'vue'
import type { Ref } from 'vue'
// 注入祖先提供的数据
const theme = inject<Ref<string>>('theme')
const toggleTheme = inject<() => void>('toggleTheme')
</script>
<template>
<div :class="theme">
<button @click="toggleTheme?.()">切换主题</button>
</div>
</template>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.6.4 通信方式选择
| 场景 | 推荐方式 |
|---|---|
| 父 → 子传数据 | Props |
| 子 → 父传事件 | Emit |
| 父子双向绑定 | v-model |
| 兄弟组件共享状态 | Pinia |
| 祖先 → 深层后代 | provide / inject |
| 全局共享状态 | Pinia |
| 父组件调用子方法 | ref + defineExpose |
# 2.6.5 综合案例与思考
综合案例:购物车组件通信
<!-- 父组件:ShoppingPage.vue -->
<template>
<div class="shop">
<h2>商品列表</h2>
<ProductItem
v-for="product in products"
:key="product.id"
:product="product"
@add-to-cart="addToCart"
/>
<h2>购物车 ({{ cart.length }})</h2>
<CartList
:items="cart"
@update-quantity="updateQuantity"
@remove="removeFromCart"
/>
<p class="total">总计: ¥{{ total.toFixed(2) }}</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ProductItem from './ProductItem.vue'
import CartList from './CartList.vue'
interface Product { id: number; name: string; price: number }
interface CartItem extends Product { quantity: number }
const products = ref<Product[]>([
{ id: 1, name: 'Vue实战教程', price: 99 },
{ id: 2, name: 'TypeScript手册', price: 59 },
{ id: 3, name: 'Vite构建指南', price: 79 }
])
const cart = ref<CartItem[]>([])
const total = computed(() =>
cart.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const addToCart = (product: Product) => {
const existing = cart.value.find(item => item.id === product.id)
if (existing) {
existing.quantity++
} else {
cart.value.push({ ...product, quantity: 1 })
}
}
const updateQuantity = (id: number, quantity: number) => {
const item = cart.value.find(i => i.id === id)
if (item) item.quantity = quantity
}
const removeFromCart = (id: number) => {
cart.value = cart.value.filter(item => item.id !== id)
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
案例知识融合:这个购物车案例展示了完整的父子组件通信链路:Props 传递商品数据和购物车列表,Emit 传递"加入购物车"、"修改数量"、"删除商品"等事件。数据统一在父组件管理,子组件只负责展示和触发事件,体现了 Vue 的单向数据流原则。
思考题:
- 为什么推荐"数据在父组件管理,子组件通过Emit请求修改",而不是让子组件直接修改数据?
- 当通信层级超过3层时,Props逐层传递会变得麻烦,有什么解决方案?
- provide/inject 和 Pinia 都能实现跨层级通信,什么场景用 provide/inject,什么场景用 Pinia?