编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • Android提升进阶

  • iOS开发和进阶

  • Web开发和进阶

    • README
    • HTML工具手册

    • TypeScript入门

    • Vue高级进阶

      • 基础入门
      • 组件开发
        • 2.1 组件基础
          • 2.1.1 什么是组件
          • 2.1.2 定义组件
          • 2.1.3 使用组件
          • 2.1.4 组件命名规范
          • 2.1.5 综合案例与思考
        • 2.2 Props属性传递
          • 2.2.1 声明Props
          • 2.2.2 Props类型校验
          • 2.2.3 Props默认值
          • 2.2.4 单向数据流
          • 2.2.5 综合案例与思考
        • 2.3 组件事件Emit
          • 2.3.1 触发自定义事件
          • 2.3.2 事件参数
          • 2.3.3 事件校验
          • 2.3.4 v-model在组件上
          • 2.3.5 综合案例与思考
        • 2.4 插槽Slot
          • 2.4.1 默认插槽
          • 2.4.2 具名插槽
          • 2.4.3 作用域插槽
          • 2.4.4 综合案例与思考
        • 2.5 生命周期
          • 2.5.1 生命周期图示
          • 2.5.2 常用生命周期钩子
          • 2.5.3 各钩子使用场景
          • 2.5.4 综合案例与思考
        • 2.6 组件通信总结
          • 2.6.1 父子通信
          • 2.6.2 兄弟通信
          • 2.6.3 跨层级通信
          • 2.6.4 通信方式选择
          • 2.6.5 综合案例与思考
      • 响应式系统
      • 路由管理
      • 状态管理
      • 组合式API
      • 工程化实战
  • Linux应用开发

  • Apps
  • Web开发和进阶
  • Vue高级进阶
杨充
2026-04-23
目录

组件开发

# 02.组件开发

# 目录介绍

  • 2.1 组件基础
    • 2.1.1 什么是组件
    • 2.1.2 定义组件
    • 2.1.3 使用组件
    • 2.1.4 组件命名规范
    • 2.1.5 综合案例与思考
  • 2.2 Props属性传递
    • 2.2.1 声明Props
    • 2.2.2 Props类型校验
    • 2.2.3 Props默认值
    • 2.2.4 单向数据流
    • 2.2.5 综合案例与思考
  • 2.3 组件事件Emit
    • 2.3.1 触发自定义事件
    • 2.3.2 事件参数
    • 2.3.3 事件校验
    • 2.3.4 v-model在组件上
    • 2.3.5 综合案例与思考
  • 2.4 插槽Slot
    • 2.4.1 默认插槽
    • 2.4.2 具名插槽
    • 2.4.3 作用域插槽
    • 2.4.4 综合案例与思考
  • 2.5 生命周期
    • 2.5.1 生命周期图示
    • 2.5.2 常用生命周期钩子
    • 2.5.3 各钩子使用场景
    • 2.5.4 综合案例与思考
  • 2.6 组件通信总结
    • 2.6.1 父子通信
    • 2.6.2 兄弟通信
    • 2.6.3 跨层级通信
    • 2.6.4 通信方式选择
    • 2.6.5 综合案例与思考

# 2.1 组件基础

# 2.1.1 什么是组件

组件是 Vue 最核心的概念之一。它允许我们将页面拆分为独立、可复用的模块,每个组件包含自己的模板、逻辑和样式。

页面拆分为组件的示意:
┌──────────────────────────────┐
│ <Header />                   │  ← 头部组件
├──────────┬───────────────────┤
│          │                   │
│ <Sidebar │  <Content />      │  ← 侧边栏 + 内容组件
│  />      │                   │
│          │  ┌─────┐ ┌─────┐  │
│          │  │Card │ │Card │  │  ← 卡片组件(可复用)
│          │  └─────┘ └─────┘  │
├──────────┴───────────────────┤
│ <Footer />                   │  ← 底部组件
└──────────────────────────────┘
1
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>
1
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>
1
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
1
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>
1
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 让它们变成可配置的。

思考题:

  1. 为什么 <script setup> 中导入的组件不需要手动注册?Vue 是如何实现的?
  2. 组件应该按什么粒度拆分?拆得太细或太粗各有什么问题?
  3. 每个 <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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

案例知识融合:这个商品卡片组件展示了 Props 的完整用法:TypeScript 类型声明、必选和可选属性、withDefaults 默认值、基于 Props 的计算属性。父组件通过不同的 Props 配置,复用同一个组件展示不同的商品。

思考题:

  1. 为什么引用类型(数组、对象)的默认值必须用工厂函数 () => []?
  2. 如果子组件需要修改父组件传来的数据,应该怎么做?
  3. 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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

案例知识融合:这个星级评分组件综合了 Props(配置最大星数、标签、禁用状态)、Emit(update:modelValue 实现 v-model、change 事件通知)、计算属性(评分文字)和事件处理(点击、悬停),是组件通信的完整实战。

思考题:

  1. v-model 在组件上的本质是什么?它和 Props + Emit 是什么关系?
  2. defineProps 和 defineEmits 为什么叫"编译器宏"?它们和普通函数有什么区别?
  3. 如果需要在一个组件上绑定多个 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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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>
1
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。父组件可以完全自定义对话框的内容和按钮,体现了"插槽让组件更灵活"的设计理念。

思考题:

  1. 默认插槽和具名插槽各适用于什么场景?什么时候需要用作用域插槽?
  2. <Teleport to="body"> 的作用是什么?为什么对话框组件需要它?
  3. 这个Dialog的header/footer插槽都有默认内容,这种设计模式有什么好处?

# 2.5 生命周期

# 2.5.1 生命周期图示

Vue 3 组件的生命周期:

组件生命周期流程:

  创建阶段
  ┌─────────────┐
  │   setup()   │  ← 组合式API入口(最早执行)
  └──────┬──────┘
         ↓
  ┌─────────────┐
  │onBeforeMount│  ← DOM挂载前
  └──────┬──────┘
         ↓
  ┌─────────────┐
  │  onMounted  │  ← DOM已挂载(可以操作DOM、请求数据)
  └──────┬──────┘
         ↓
  更新阶段(数据变化时触发)
  ┌──────────────────┐
  │ onBeforeUpdate   │  ← DOM更新前
  └───────┬──────────┘
          ↓
  ┌──────────────────┐
  │   onUpdated      │  ← DOM已更新
  └───────┬──────────┘
          ↓
  销毁阶段
  ┌──────────────────┐
  │onBeforeUnmount   │  ← 组件卸载前(清理工作)
  └───────┬──────────┘
          ↓
  ┌──────────────────┐
  │  onUnmounted     │  ← 组件已卸载
  └──────────────────┘
1
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>
1
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>
1
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>
1
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 变量可以直观看到组件处于哪个生命周期阶段。这是生命周期钩子最典型的应用场景。

思考题:

  1. 为什么 API 请求推荐放在 onMounted 而不是 setup 中?有没有例外情况?
  2. 如果不在 onBeforeUnmount 清理定时器和事件监听,会产生什么问题?
  3. Vue 3 的 setup 替代了 Vue 2 的哪些生命周期?为什么要这样设计?

# 2.6 组件通信总结

# 2.6.1 父子通信

父子组件通信方式总览:

父 → 子(向下传递数据)
┌────────┐   Props    ┌────────┐
│ Parent │ ─────────→ │ Child  │
│        │   v-model  │        │
│        │ ←────────→ │        │
│        │   Emit     │        │
│        │ ←───────── │        │
└────────┘            └────────┘
             子 → 父(向上触发事件)
1
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" />
1
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 读写数据 -->
1
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>
1
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>
1
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>
1
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 的单向数据流原则。

思考题:

  1. 为什么推荐"数据在父组件管理,子组件通过Emit请求修改",而不是让子组件直接修改数据?
  2. 当通信层级超过3层时,Props逐层传递会变得麻烦,有什么解决方案?
  3. provide/inject 和 Pinia 都能实现跨层级通信,什么场景用 provide/inject,什么场景用 Pinia?
上次更新: 2026/06/10, 11:13:41
基础入门
响应式系统

← 基础入门 响应式系统→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式