理解所谓的 组件

这个组件,我们可能会第一时间的想到是前端中的那些,小的那种交互式的组件,是那种 Web Components

但是,Vue 中它不止这样,我们可以把 Vue 中的组件理解为 Java 中的类

  • 在Java中,你不会把所有代码都写在main方法里。你会创建User类、Order类、Product类。每个类都有自己的属性(字段)、行为(方法)和生命周期
  • 而在前端,写一个巨大的HTML文件是扫码的,要人命的。所以 Vue 建议你把页面拆分成一个个组件(比如NavBar、UserProfile、Footer)。
  • 一个Vue组件(通常是一个.vue文件)把HTML结构(模板)、JavaScript逻辑(数据与行为)、CSS样式(外观)封装在一起。就像Java类把字段和方法封装在一起一样。

所以,Vue 中,组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。组件化开发是 Vue 的核心思想,它允许你将大型应用拆分成小的、独立的、可复用的部分,就像搭积木一样构建应用。

Vue 组件是可复用的 Vue 实例,拥有自己的模板、数据、方法和生命周期钩子。

在实际应用中,组件常常被组织成一个层层嵌套的树状结构:

image-20251123200438204

组件机制的设计,可以让开发者把一个复杂的应用分割成一个个功能独立组件,降低开发的难度的同时,也提供了极好的复用性和可维护性。

  • 模块化: 是从代码逻辑的角度进行划分的;方便代码分层开发,保证每个功能模块的职能单一;
  • 组件化: 是从UI界面的角度进行划分的;前端的组件化,方便UI组件的重用。

但是其实,我们认为 Vue 和 Web Components 是互补的技术。

但是默认情况下,Vue 会将任何非原生的 HTML 标签优先当作 Vue 组件处理,而将“渲染一个自定义元素”作为后备选项。这会在开发时导致 Vue 抛出一个“解析组件失败”的警告。要让 Vue 知晓特定元素应该被视为自定义元素并跳过组件解析,我们可以指定 compilerOptions.isCustomElement 这个选项

如果在开发 Vue 应用时进行了构建配置,则应该在构建配置中传递该选项,因为它是一个编译时选项。

组件相关知识

attributes透传

Attributes 是属性的意思,是传递给组件的 HTML 特性(如 classstyleid 等),它们不属于组件声明的 props,但会被自动继承到组件的根元素上。

特性继承,当你向组件传递未声明为 props 的特性时,Vue 会自动将它们应用到组件的根元素上:

1
2
3
4
5
6
7
<!-- 父组件使用 -->
<WordCard class="custom-card" id="word-123" data-id="123"></WordCard>

<!-- 子组件根元素会自动继承这些特性 -->
<div class="word-card custom-card" id="word-123" data-id="123">
<!-- 组件内容 -->
</div>

如果不希望根元素继承特性,可以使用 inheritAttrs: false

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup>
// 在选项式API中
export default {
inheritAttrs: false
}
</script>

<!-- 或在组合式API中使用 defineOptions -->
<script setup>
defineOptions({
inheritAttrs: false
})
</script>

使用 useAttrs() 可以访问所有传递的特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
import { useAttrs } from 'vue'

const attrs = useAttrs()
console.log(attrs.class) // 获取 class 特性
console.log(attrs.id) // 获取 id 特性
</script>

<template>
<!-- 将特性应用到非根元素 -->
<div>
<div v-bind="attrs"> <!-- 绑定所有特性 -->
内容
</div>
</div>
</template>

Attributes 透传(Attribute Inheritance)指的是,当父组件传递给子组件的属性不是子组件显式声明的 props 时,这些属性会自动应用到子组件的根元素上。

  • 常见的透传属性包括 classstyleiddisabled 以及原生事件监听器(如 @click)。
  • 父组件向子组件传递未声明的属性。
  • 如果子组件只有一个根元素,这些属性会自动添加到该根元素上。
  • 如果子组件有多个根元素或使用了 <script setup>,需要显式使用 v-bind="$attrs" 来指定透传目标。

当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上。举例来说,父组件的模板如下

1
2
3
4
5
6
7
8
9
10
<!--父组件-->
<template>
<ChildComponent
id="child-id"
class="child-class"
title="This is a tooltip"
@click="handleClick"
:data-info="someInfo"
/>
</template>

单一根元素的子组件就是这样

1
2
3
4
5
6
7
8
9
10
11
<!--子组件 ChildComponent -->
<template>
<!-- 所有透传的属性会自动应用到这个 div 上 -->
<div class="internal-class">
我是子组件内容
</div>
</template>

<script setup>
// 没有声明任何 props
</script>

那么,html 的渲染结果就会是这样

1
2
3
4
5
6
7
8
<div 
id="child-id"
class="child-class internal-class" <!-- class 会合并 -->
title="This is a tooltip"
data-info="someInfoValue"
>
我是子组件内容
</div>

当存在多个根元素时,需要特殊处理

1
2
3
4
5
<template>
<!-- 需要显式指定透传目标 -->
<div>第一个根元素</div>
<div v-bind="$attrs">第二个根元素,接收透传属性</div>
</template>

可以使用 defineOptions 配置项禁用透传

1
2
3
4
5
6
<script setup>
// 使用 defineOptions 禁用透传
defineOptions({
inheritAttrs: false
});
</script>

props

什么是 Props

Props 是组件之间数据传递的接口,让父组件可以向子组件传递数据。

在实际应用中,父子组件之间的通信是非常常见的场景,可以用于共享状态、配置组件行为、实现交互逻辑等场景。

为了实现数据和内容的传递,Vue 提供了两种重要的机制:PropsSlots

Propsproperties的缩写,我们可以简单的把它们理解为,子组件声明的一组「属性」。

Props 是 Vue 组件间通信的一种主要方式,用于父组件向子组件传递数据。它是父子组件之间的桥梁,遵循单向数据流原则(父组件数据更新会流向子组件,但子组件不能直接修改父组件传递过来的 props)。

也就是说,Props 是只读的,子组件不应该修改接收到的 props。它具有单向性

而且父组件在通过 Props 传递数据之前,需要在子组件中明确声明这些属性

定义 Props

定义 Props,使用 TypeScript 的风格,继续我们之前的例子,我们在 WordList 中定义了这样的 Props 类型(接口声明)

1
2
3
4
5
6
7
8
9
10
11
12
// 定义 Props 接口 - 明确规定组件接收的属性结构
interface Props {
word: Word // Word 类型的对象
showExample?: boolean // 布尔值
cardStyle?: 'default' | 'compact' | 'detailed' // 限定值的字符串
}

// 使用 withDefaults 设置默认值
const props = withDefaults(defineProps<Props>(), {
showExample: true, // showExample 默认是 true
cardStyle: 'default' // cardStyle 默认是 'default'
})
  • interface Props:用 TS 接口严格定义了 props 的结构和类型
  • ? 表示可选属性:父组件可以不传这个属性
  • withDefaults:专门用于给 TS 风格的 props 设置默认值的工具函数

然后,通过 defineProps 接收并设置默认值,这行代码就是实际接收 props 的核心

image-20251124141207536
  • defineProps<Props>():告诉 Vue “我要接收符合 Props 接口定义的属性”
  • withDefaults(...):给可选的 filterBy 设置默认值(如果父组件没传,就用 'all'
  • 最终返回的 props 对象,就是父组件传递过来的属性集合

Props 的传递

那么 Props 如何传递,父组件 → 子组件

以我们的单词本为例子,在 WordList 组件中,向 WordCard 传递了 props:

1
2
3
4
5
6
7
<WordCard
v-for="word in filteredWords"
:key="word.id"
:word="word" <!-- 传递必填的 word 对象 -->
:show-example="true" <!-- 传递 showExample 属性 -->
@click="handleClick(word)"
>
  • 使用 :属性名="值" (v-bind 指令)的形式传递动态数据
  • 如果传递的是静态值(比如字符串),可以直接写 card-style="compact"

然后WordCard 子组件,在其组件的内部中使用父组件WordList中传递过来的 props

image-20251124140056897

其中,可以看到,WordCard 接收 wordshowExamplecardStyle props,使用了:进行绑定,用于渲染单个单词卡片,而 Word 的 Props 定义如下

image-20251124135445808
1
2
3
4
// script 中通过 props 对象访问
const difficultyClass = computed(() => {
return `difficulty-${props.word.difficulty}`
})

没有参数的 v-bind 也可以将一个对象的所有属性都当作 props 传入

image-20251127170303046

使用v-model绑定Props

如何使用v-model 在自定义组件中进行双向数据绑定呢?

  • 父组件通过 v-model 绑定数据
  • 子组件接收 modelValue props,并通过 update:modelValue 事件通知父组件更新

使用一个简单的例子帮助理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 子组件:CustomInput.vue -->
<template>
<!-- 绑定 value,监听 input 事件 -->
<input
:value="modelValue"
@input="handleInput"
placeholder="自定义输入框"
/>
</template>

<script setup>
// 接收父组件传递的 modelValue
const props = defineProps(['modelValue'])
// 声明触发的事件
const emit = defineEmits(['update:modelValue'])

// 输入时触发事件,传递新值
const handleInput = (e) => {
emit('update:modelValue', e.target.value)
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
<!-- 父组件使用 -->
<template>
<CustomInput v-model="inputValue" />
<p>父组件数据:{{ inputValue }}</p>
</template>

<script setup>
import { ref } from 'vue'
import CustomInput from './CustomInput.vue'

const inputValue = ref('')
</script>

如果需要多个双向绑定,可以给 v-model 指定参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- 父组件 -->
<CustomForm
v-model:name="username"
v-model:email="userEmail"
/>

<!-- 子组件:CustomForm.vue -->
<script setup>
const props = defineProps(['name', 'email'])
const emit = defineEmits(['update:name', 'update:email'])

// 更新 name
const updateName = (value) => {
emit('update:name', value)
}

// 更新 email
const updateEmail = (value) => {
emit('update:email', value)
}
</script>

更改Props

所有的 props 都遵循着单向绑定原则,props 因父组件的更新而变化,自然地将新的状态向下流往子组件,而不会逆向传递。这避免了子组件意外修改父组件的状态的情况,不然应用的数据流将很容易变得混乱而难以理解。

那么,props 的单向传递的,如果我们真的要去修改 props 的属性值的时候,应该如何处理

一般情况下,可以将 props 复制为本地数据,但这仅限制于不需要同步回父组件的情况

通过事件通知父组件更新是最常用的情况,而 v-model 也会导致父组件的 Props 被更改,这种情况就不说了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<!-- 子组件 Child.vue -->
<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update:count'])

// 通过事件通知父组件更新
const increment = () => {
emit('update:count', props.count + 1)
}

const decrement = () => {
emit('update:count', props.count - 1)
}
</script>

<template>
<div>
<p>计数:{{ count }}</p>
<button @click="increment">+</button>
<button @click="decrement">-</button>
</div>
</template>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- 父组件 Parent.vue -->
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const parentCount = ref(0)
</script>

<template>
<div>
<h3>父组件计数:{{ parentCount }}</h3>
<Child
:count="parentCount"
@update:count="parentCount = $event"
/>
</div>
</template>

总结

所以,使用 Props 是这样的

  • 模板<template>中可以直接用 props 名称(如 wordshowExample),但是在 script 中需要通过 props.xxx 访问

  • Props 的大小写命名规范

    • 声明 props 时,使用驼峰命名(camelCase):cardStyle
    • 模板传递时,使用连字符命名(kebab-case):card-style
    1
    2
    3
    4
    5
    6
    7
    <!-- 父组件中 -->
    <WordCard :card-style="compact" />

    <!-- 子组件中 -->
    <script setup>
    const props = defineProps(['cardStyle']) // 驼峰接收
    </script>
  • Props 可以和 v-model 结合实现双向绑定:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!-- 子组件:WordInput.vue -->
    <script setup>
    const props = defineProps(['modelValue'])
    const emit = defineEmits(['update:modelValue'])

    const handleInput = (e) => {
    emit('update:modelValue', e.target.value)
    }
    </script>

    <template>
    <input :value="modelValue" @input="handleInput" />
    </template>

    <!-- 父组件使用 -->
    <WordInput v-model="searchWord" />

data

上面说的 Props 是负责管理组件间传递数据的关键内容,而 data 负责管理组件内部状态

data 是 Vue 组件内部的状态,它存储了与组件相关的数据和信息。data 返回的是一个对象,这个对象中定义的属性会被 Vue 的响应式系统管理,意味着当 data 中的值发生变化时,Vue 会自动更新相关的视图。

  • 私有性:仅在当前组件内部生效,不直接对外暴露(区别于 props 接收的外部数据)。
  • 响应式data 中的数据被 Vue 监听,当数据变化时,依赖该数据的视图会自动更新(这是 Vue 响应式系统的核心)。
  • 函数形式:在组件中,data 必须以函数形式返回一个对象(避免多个组件实例共享同一份数据)。

在 Vue2 的时候,使用选项式API的时候,它的特征尤为明显,在选项式API中data 是一个函数,它返回一个对象,这个对象包含了组件的所有响应式数据。

而且这个对象的组织形式必须是 key:value形式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template>
<div>
<p>计数器:{{ counter }}</p>
<button @click="increment">增加计数</button>
</div>
</template>

<script>
export default {
data() {
return {
counter: 0
};
},
methods: {
increment() {
this.counter++;
}
}
};
</script>

在这个示例中,data 返回一个对象,其中的 counter 被 Vue 的响应式系统管理。每当 counter 的值改变时,页面会自动更新。

但是在使用 组件式API 时,data 的概念稍有不同。你需要通过 refreactive 来声明响应式的数据,使用 Vue 3 的 <script setup> 语法,这本质上是 data 的替代方案

例如,若要在组件中添加内部状态,在 <script setup> 中会这样写

1
2
3
4
5
6
7
8
9
10
// 在 WordCard.vue 中添加内部状态(示例)
import { ref } from 'vue'

// 声明一个响应式数据(等价于 data 中的状态)
const isExpanded = ref(false) // 控制卡片是否展开的内部状态

// 修改状态的方法
function toggleExpand() {
isExpanded.value = !isExpanded.value // ref 数据需通过 .value 访问/修改
}

在模板中使用

1
2
3
4
5
6
7
8
9
<template>
<div class="word-card">
<!-- 依赖内部状态的视图 -->
<p v-if="isExpanded">额外的详细信息...</p>
<button @click="toggleExpand">
{{ isExpanded ? '收起' : '展开' }}
</button>
</div>
</template>

点击按钮切换状态时,视图会自动更新

而且,在 Vue3 中,data() 必须是一个函数,而不是直接返回一个对象。这是因为 Vue 组件可能会被多次复用,如果 data() 是一个对象,那么所有实例将共享同一个数据对象,导致数据冲突。

通过将 data() 定义为函数,每次创建组件实例时,Vue 都会调用这个函数,返回一个新的数据对象,从而确保每个实例的数据是独立的

1
2
3
4
5
6
7
export default {
data() {
return {
message: 'Hello, Vue3!'
};
}
};

每次创建组件实例时,data() 函数都会被调用,返回一个全新的 message 数据。

data() 函数中,不要直接修改 Vue 实例的属性(如 this.$data),而是通过返回对象的方式来定义数据。

而且data 中的状态是组件私有的,外部组件(如父组件 WordList)不能直接修改,只能通过子组件暴露的方法或事件间接影响。

WordCard 有一个 “是否已掌握” 的内部状态,点击按钮切换状态时,视图会自动更新

1
2
3
4
5
6
7
8
// WordCard.vue 中添加响应式状态
import { ref } from 'vue'

const isMastered = ref(false) // 初始为“未掌握”

function toggleMastered() {
isMastered.value = !isMastered.value // 修改状态
}
1
2
3
4
5
6
7
8
9
<template>
<div class="word-card">
<!-- 视图会随 isMastered 变化自动更新 -->
<span v-if="isMastered" class="mastered-badge">已掌握</span>
<button @click="toggleMastered">
{{ isMastered ? '取消掌握' : '标记掌握' }}
</button>
</div>
</template>

那么,WordCardisMastered 状态只能在 WordCard 内部通过 toggleMastered 方法修改,WordList 无法直接操作,若需同步状态,需通过事件通知:

1
2
3
4
5
6
7
8
9
// WordCard.vue 中通过事件将状态变化通知父组件
const emit = defineEmits<{
(e: 'masteredChange', isMastered: boolean, wordId: number): void
}>()

function toggleMastered() {
isMastered.value = !isMastered.value
emit('masteredChange', isMastered.value, props.word.id) // 通知父组件
}

refreactive函数

在 Vue 3 中,refreactive 是实现响应式数据的核心 API,它们用于将普通数据转换为 “响应式数据”(即数据变化时,依赖该数据的视图会自动更新)

ref 是最常用的响应式 API,适用于基本类型(字符串、数字、布尔等)和对象 / 数组

  • 包裹基本类型:会将基本类型包装为一个 “响应式对象”(本质是一个包含 value 属性的对象)。
  • 自动解包:在模板中使用时,无需通过 .value 访问;但在脚本中必须通过 .value 访问 / 修改。
  • 兼容对象 / 数组:若传入对象或数组,ref 会自动调用 reactive 处理(内部转换为 reactive 对象)。

WordCard 中有这么一个 “是否已掌握” 的交互状态:

1
2
3
4
5
6
7
8
9
10
// WordCard.vue 中使用 ref
import { ref } from 'vue'

// 声明基本类型响应式数据(未掌握)
const isMastered = ref(false)

// 修改数据(脚本中需用 .value)
function toggleMastered() {
isMastered.value = !isMastered.value
}

模板中使用(自动解包,无需 .value):

1
2
3
4
5
6
7
8
9
<template>
<div class="word-card">
<!-- 依赖 isMastered 的视图 -->
<span v-if="isMastered" class="mastered-tag">已掌握</span>
<button @click="toggleMastered">
{{ isMastered ? '取消掌握' : '标记掌握' }}
</button>
</div>
</template>

reactive 专门用于将对象或数组转换为响应式数据,不适用于基本类型(如 reactive(123) 无效)。

  • 仅适用于对象 / 数组:传入基本类型会被忽略(非响应式)。
  • 直接访问属性:无需 .value,可直接修改对象的属性或数组的元素。
  • 深层响应式:对象的嵌套属性也会被转为响应式(如 obj.nested.prop 变化会触发更新)。

WordList 需要存储 “筛选条件” 的复杂状态(包含搜索关键词和难度):

1
2
3
4
5
6
7
8
9
10
11
12
13
// WordList.vue 中使用 reactive
import { reactive } from 'vue'

// 声明对象类型的响应式数据
const filterState = reactive({
searchKeyword: '', // 搜索关键词
difficulty: 'all' // 难度筛选
})

// 修改数据(直接操作属性)
function setDifficulty(difficulty: string) {
filterState.difficulty = difficulty
}

模板中使用:

1
2
3
4
5
6
7
8
9
10
<template>
<div class="word-list">
<input
type="text"
v-model="filterState.searchKeyword"
placeholder="搜索单词..."
>
<button @click="setDifficulty('easy')">简单</button>
</div>
</template>

在上述的模板中使用了双向绑定绑定了使用 reactive 包装的 filterState,它把 filterState 变成了一个响应式的数据,在下面中,通过filterState.searchKeyword访问其searchKeyword搜索关键词的属性,并且是双向更新,而且还搞了一个事件按钮去操作设置筛选的难度,使用 set 函数

表单数据使用 reactive 管理是非常合适的选择,例如,在 AddWordForm.vue 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 复杂表单对象适合用 reactive
const formData = reactive({
word: '',
translation: '',
phonetic: '',
example: '',
difficulty: 'easy' as 'easy' | 'medium' | 'hard'
})

// 验证错误信息也适合用 reactive 管理
const errors = reactive({
word: '',
translation: ''
})

这里的表单数据是一个包含多个字段的复杂对象,使用 reactive 可以:

  • 直接通过属性名访问(如 formData.word),无需 .value 前缀
  • 保持各字段间的关联性,统一管理整个表单状态
  • 配合 v-model 实现简洁的双向绑定(如 <input v-model="formData.word">

而上面说了ref 可以包裹数组和对象类型,只不过需要在脚本内访问的时候加.value,当 ref 包裹对象类型时,其内部会自动转换为 reactive 对象

1
2
3
4
5
// 等价于 const user = reactive({ name: '张三' })
const user = ref({ name: '张三' })

// 脚本中访问:user.value.name
// 模板中访问:user.name(自动解包)

这意味着 ref 可以看作是更通用的响应式包装器,而 reactive 是专门针对对象的优化实现。

reactive 对象在以下情况会丢失响应式,需特别注意:

  • 直接赋值整个对象(如 formData = { ... }
  • 解构赋值(如 const { word } = formData,解构出的 word 是非响应式的)

怎么办呢?

  • 对需要解构的属性使用 toRefs 转换:

    1
    2
    3
    4
    import { reactive, toRefs } from 'vue'

    const formData = reactive({ word: '', translation: '' })
    const { word, translation } = toRefs(formData) // 保持响应式
  • 对需要整体替换的对象使用 ref 包裹:

    1
    2
    const formData = ref({ word: '', translation: '' })
    formData.value = { word: 'test', translation: '测试' } // 保持响应式

Vue 3 的响应式系统通过 “Proxy 代理” 实现,本质上是追踪它的依赖:

  • ref 对基本类型包装为 { value: ... },通过代理 valuegetter/setter 追踪依赖。
  • reactive 通过 Proxy 直接代理对象,追踪对象属性的访问和修改。

当响应式数据被修改时,Vue 会自动触发依赖该数据的视图更新(如 WordListfilteredWords 依赖 props.filterBy,若用响应式数据替换 props.filterBy,筛选结果会自动更新)。

特性 ref reactive
适用类型 基本类型(string/number/boolean 等)、对象 / 数组 仅对象 / 数组
访问方式(脚本中) 需通过 .value 访问 / 修改 直接访问 / 修改属性
访问方式(模板中) 自动解包,无需 .value 直接访问属性
赋值行为 可直接替换整个值(ref.value = 新值 不能直接替换整个对象(会丢失响应式)

事件

上面说了,子组件不能直接修改 props 和 data ,如果需要修改,应该通过事件通知父组件

1
2
3
4
5
6
// ❌ 错误:直接修改 props
props.showExample = false

// ✅ 正确:通过事件让父组件修改
const emit = defineEmits(['update:showExample'])
emit('update:showExample', false)

Vue 的事件系统是组件间通信的核心机制之一,主要用于子组件向父组件传递数据或触发行为。

所以,什么是事件

Vue 事件是组件间通信的一种方式,允许子组件主动向父组件发送消息(如用户操作、数据变化等),并可携带数据。类似于浏览器的原生事件(如 click),但 Vue 提供了更灵活的自定义事件机制。

定义一个事件

子组件需要先声明自己可以触发哪些事件,类似 “接口约定”,便于类型检查和维护。

1
2
3
4
5
6
7
8
9
// 定义事件类型接口(TypeScript)
interface Emits {
deleteWord: [id: number] // 事件名:deleteWord,参数:id(number类型)
editWord: [word: Word] // 事件名:editWord,参数:word(Word类型)
wordClick: [word: Word] // 事件名:wordClick,参数:word(Word类型)
}

// 注册事件,得到emit函数
const emit = defineEmits<Emits>()
  • interface Emits:用 TypeScript 定义事件名称和参数类型,确保类型安全
  • defineEmits:Vue 提供的宏函数,用于注册组件可触发的事件,返回一个 emit 函数

然后定义事件对应触发后的逻辑即可

image-20251127194834973

<script setup> 中,组件可以显式地通过 defineEmits()宏来声明它要触发的事件

1
2
3
<script setup>
defineEmits(['inFocus', 'submit'])
</script>

因为在 <template> 中使用的 $emit 方法不能在组件的 <script setup> 部分中使用,但 defineEmits() 会返回一个相同作用的函数供我们使用

而且defineEmits()不能在子函数中使用。如上所示,它必须直接放置在 <script setup> 的顶级作用域下。

事件触发

子组件通过 emit 函数触发已定义的事件,并传递数据给父组件。

1
2
3
4
5
6
7
8
9
// 触发删除事件(传递id)
function handleDelete(id: number) {
emit('deleteWord', id) // 第一个参数:事件名,后续参数:传递的数据
}

// 触发编辑事件(传递完整word对象)
function handleEdit(word: Word) {
emit('editWord', word)
}
  • 调用 emit 时,第一个参数必须是 defineEmits 中声明的事件名
  • 后续参数为传递给父组件的数据(可多个,类型需与定义一致)

而且在组件的模板表达式中,可以直接使用 $emit 方法触发自定义事件

1
2
<!-- MyComponent -->
<button @click="$emit('handleDelete')">Click Me</button>

其中的@就是事件监听的指令缩写

事件监听

我们可以使用 v-on 指令来监听原生 DOM 事件。v-on 指令也可以缩写为 @

绑定事件时,可以直接将事件名称传入 v-on 指令,并在后面跟随事件处理函数。例如,监听 click 事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<template>
<button @click="handleClick">点击我</button>
</template>

<script setup>
import { ref } from 'vue';

const count = ref(0);

function handleClick() {
count.value++;
console.log("按钮点击次数:", count.value);
}
</script>
  • @click="handleClick" 表示绑定 click 事件,事件触发时会调用 handleClick 函数。

放到Vue的父子组件中,我们这样监听,父组件通过 @事件名 语法监听子组件触发的事件,并定义处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template>
<!-- 监听子组件事件 -->
<WordList
:words="wordList"
@deleteWord="handleDeleteWord" // 监听deleteWord事件
@editWord="handleEditWord" // 监听editWord事件
/>
</template>

<script setup>
function handleDeleteWord(id: number) {
// 处理删除逻辑(如从数据库删除)
console.log('删除ID为', id, '的单词')
}

function handleEditWord(word: Word) {
// 处理编辑逻辑(如打开编辑弹窗)
console.log('编辑单词', word)
}
</script>

有时候我们需要传递参数给事件处理函数。可以直接在事件处理函数调用时传入参数。例如

1
2
3
4
5
6
7
8
9
<template>
<button @click="handleClick('hello')">点击我</button>
</template>

<script setup>
function handleClick(message) {
console.log("传递的参数:", message);
}
</script>
  • @click="handleClick('hello')"表示点击按钮时调用 handleClick,并将 ‘hello’ 作为参数传入

子组件事件传递给父组件

上面说了,子组件无法直接修改父组件的数据(尤其是Props),但可以通过触发事件的方式向父组件传递信息(如用户操作、数据变化等),父组件通过监听事件接收信息并处理

  1. 子组件定义可触发的事件

    使用 defineEmits 声明组件能触发的事件(支持 TypeScript 类型校验),明确事件名称和传递的参数类型。

  2. 子组件触发事件

    通过 emit 方法触发已定义的事件,并传递需要给父组件的数据(参数)。

  3. 父组件监听事件

    在使用子组件时,通过 @事件名 监听子组件触发的事件,并定义处理函数接收参数。

例如,WordList.vue 作为子组件的时候,这样传递事件

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
<!-- 子组件 WordList.vue -->
<script setup lang="ts">
// 1. 定义事件类型(参数类型)
interface Emits {
deleteWord: [id: number] // 事件名:deleteWord,参数:number 类型的 id
editWord: [word: Word] // 事件名:editWord,参数:Word 类型的对象
wordClick: [word: Word] // 事件名:wordClick,参数:Word 类型的对象
}

// 2. 生成 emit 函数(用于触发事件)
const emit = defineEmits<Emits>()

// 3. 触发事件的方法(在子组件内部调用)
function handleDelete(id: number) {
emit('deleteWord', id) // 触发 deleteWord 事件,传递 id
}

function handleEdit(word: Word) {
emit('editWord', word) // 触发 editWord 事件,传递 word 对象
}
</script>

<template>
<!-- 点击按钮时调用触发事件的方法 -->
<button @click.stop="handleDelete(word.id)">删除</button>
<button @click.stop="handleEdit(word)">编辑</button>
</template>

父组件如何监听(父组件为 App.vue):

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
<!-- 父组件 App.vue -->
<template>
<!-- 3. 监听子组件触发的事件,通过处理函数接收参数 -->
<WordList
:words="wordList"
@deleteWord="handleDeleteWord" // 监听 deleteWord 事件
@editWord="handleEditWord" // 监听 editWord 事件
/>
</template>

<script setup lang="ts">
import WordList from './components/WordList.vue'
import { ref } from 'vue'
import type { Word } from './stores/wordbook'

const wordList = ref<Word[]>([])

// 处理子组件的 deleteWord 事件(接收 id 参数)
function handleDeleteWord(id: number) {
wordList.value = wordList.value.filter(word => word.id !== id)
}

// 处理子组件的 editWord 事件(接收 word 参数)
function handleEditWord(word: Word) {
console.log('需要编辑的单词:', word)
// 执行编辑逻辑...
}
</script>

这样,子组件仅传递事件和数据,由父组件决定如何处理(如修改数据),符合 Vue 单向数据流原则。

父组件传递事件给子组件

Vue 中没有 “父组件直接触发子组件事件” 的原生语法,但可以通过向子组件传递函数类型的 props,让子组件在特定时机调用该函数,间接实现 “父组件向子组件传递事件逻辑”。

  1. 父组件定义处理函数

    父组件中定义需要子组件执行的逻辑(函数)。

  2. 父组件通过 props 传递函数给子组件

    将函数作为 props 传递给子组件。

  3. 子组件接收函数 props 并调用

    子组件在需要的时机(如点击、数据变化)调用父组件传递的函数。

在父组件中,这样定义子组件需要执行的逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 父组件 App.vue -->
<template>
<!-- 2. 将函数作为 props 传递给子组件 -->
<WordList
:words="wordList"
:onWordHover="handleWordHover" // 传递函数 props
/>
</template>

<script setup lang="ts">
// 1. 父组件定义需要子组件执行的逻辑
function handleWordHover(word: Word) {
console.log('子组件中鼠标 hover 了单词:', word.spelling)
}
</script>

然后子组件这样接受事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 子组件 WordList.vue -->
<script setup lang="ts">
// 1. 子组件定义接收函数类型的 props
interface Props {
words: Word[]
onWordHover?: (word: Word) => void // 函数类型的 props
}

const props = defineProps<Props>()
</script>

<template>
<!-- 3. 子组件在特定时机调用父组件传递的函数 -->
<WordCard
v-for="word in filteredWords"
:key="word.id"
:word="word"
@mouseenter="props.onWordHover && props.onWordHover(word)" // 调用函数
/>
</template>

这其实本质上是函数的传递,父组件传递的是函数引用,子组件通过调用函数触发父组件逻辑。

访问事件对象

事件对象是浏览器原生事件机制的一部分,包含了事件相关的详细信息,当我们在 Vue 中绑定事件时,可以通过特定方式访问这个事件对象

Vue 中访问事件对象的方式主要有两种,取决于事件处理函数是否需要传递额外参数。

  • 无额外参数时:自动接收事件对象

    当事件处理函数不需要传递自定义参数时,Vue 会自动将原生事件对象($event)作为第一个参数传入处理函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <template>
    <!-- 点击按钮,处理函数自动接收原生事件对象 -->
    <button @click="handleClick">点击我</button>
    </template>

    <script setup>
    function handleClick(event) {
    // event 即为原生 Event 对象
    console.log('触发事件的元素:', event.target) // 输出 <button> 元素
    console.log('点击位置 X 坐标:', event.clientX)
    }
    </script>
  • 有额外参数时:使用 $event 手动传递

    当需要向事件处理函数传递自定义参数,同时又需要访问事件对象时,需通过 Vue 提供的特殊变量 $event 手动将事件对象传入。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <template>
    <!-- 传递 word.id 作为参数,同时通过 $event 传递事件对象 -->
    <button @click.stop="handleDelete($event, word.id)">删除</button>
    </template>

    <script setup>
    function handleDelete(event, id) {
    // event 是原生事件对象,id 是自定义参数
    console.log('删除的单词 ID:', id)
    console.log('触发事件的按钮:', event.target) // 输出当前按钮元素
    event.stopPropagation() // 等价于 @click.stop 修饰符的效果
    }
    </script>
    • 这里的 $event 是 Vue 内置的变量,专门用于指代原生事件对象
    • 顺序可以灵活调整(如 handleDelete(id, $event)),但需与函数参数对应

原生 Event 对象包含大量属性和方法,常用的有:

属性 / 方法 说明
target 触发事件的原始元素(最内层元素)
currentTarget 绑定事件的元素(当前元素)
type 事件类型(如 clickkeyup
clientX/clientY 鼠标点击位置相对于浏览器窗口的坐标
stopPropagation() 阻止事件冒泡(等价于 .stop 修饰符)
preventDefault() 阻止默认行为(等价于 .prevent 修饰符)
key 键盘事件中按下的键名(如 Enter

事件修饰符

Vue 提供了事件修饰符简化事件处理逻辑,例如

1
2
<!-- 按钮点击时阻止事件冒泡 -->
<button @click.stop="handleEdit(word)">编辑</button>
  • .stop:阻止事件冒泡(避免触发父元素的同名事件)。例如,WordCard 本身有 @click 事件,按钮的点击如果不阻止冒泡,会同时触发 WordCardhandleClick点击事件。

而事件修饰符(如 .stop.prevent)本质上是对事件对象方法的简化调用:

  • @click.stop 等价于在函数中调用 event.stopPropagation()

  • @click.prevent等价于event.preventDefault(),两者可以同时使用,例如:

    1
    2
    <!-- 既阻止冒泡,又在函数中访问事件对象 -->
    <button @click.stop="handleClick($event, word)">点击</button>

    真有人这么写吗???????

Vue 常用的事件修饰符如下

.stop - 阻止事件冒泡

  • 作用:阻止事件向上冒泡到父元素,避免触发父元素的同名事件。
  • 场景:子元素和父元素都绑定了同一事件(如 click)时,防止点击子元素时同时触发父元素的事件。

.prevent - 阻止默认行为

  • 作用:阻止事件的默认行为(如表单提交刷新页面、链接跳转等)。

  • 场景:需要自定义处理逻辑,而非浏览器默认行为时使用。

    1
    2
    3
    4
    5
    6
    7
    <!-- 阻止表单默认提交(避免页面刷新) -->
    <form @submit.prevent="handleSubmit">
    <button type="submit">提交</button>
    </form>

    <!-- 阻止链接默认跳转 -->
    <a href="https://example.com" @click.prevent="handleLinkClick">点击不跳转</a>

.capture - 事件捕获模式

  • 作用:改变事件触发顺序,让事件在捕获阶段(从父到子)触发,而非默认的冒泡阶段(从子到父)。

  • 场景:需要优先处理父元素事件时使用(较少见)。

    1
    2
    3
    4
    <div @click.capture="handleParent">
    <!-- 点击按钮时,先触发父元素的 handleParent,再触发子元素的 handleChild -->
    <button @click="handleChild">点击</button>
    </div>

.self - 仅自身触发

  • 作用:事件仅在触发事件的元素是自身时才执行,忽略冒泡或捕获阶段传递的事件。

  • 场景:避免父元素响应子元素冒泡上来的事件(与 .stop 区别:.self 不阻止冒泡,只是不响应冒泡事件)。

    1
    2
    3
    4
    <div @click.self="handleParent">
    <!-- 点击按钮时,子元素的事件触发后会冒泡到父元素,但父元素的 @click.self 不会执行 -->
    <button @click="handleChild">点击</button>
    </div>

.once - 仅触发一次

  • 作用:事件处理函数仅执行一次,之后自动解绑。

  • 场景:需要限制事件只能触发一次的场景(如 “同意协议” 按钮)。

    1
    2
    <!-- 按钮点击一次后,handleClick 不再执行 -->
    <button @click.once="handleClick">仅点击一次有效</button>

.passive - 提升滚动性能

  • 作用:告知浏览器事件处理函数不会调用 preventDefault(),用于优化触摸 / 滚动事件的性能(避免浏览器等待事件处理完成再滚动,减少卡顿)。

  • 场景:常用于 touchmovescroll 等频繁触发的事件。

    1
    2
    <!-- 滚动事件使用 .passive 提升性能 -->
    <div @scroll.passive="handleScroll">长列表内容</div>

还有一些常用的就是键鼠的触发事件修饰符了

插槽Slots

插槽(Slots)是 Vue 中实现组件内容分发的强大机制,它允许父组件向子组件中指定位置插入自定义内容,极大地提高了组件的灵活性和复用性。

本质上是子组件中预留的 “占位符”,父组件可以通过插槽向子组件注入内容。这种机制解决了父子组件间内容传递的问题,让组件结构更灵活。

简单理解就是组件内部留一个或多个的插槽位置,可供组件传对应的模板代码进去。插槽的出现,让组件变的更加灵活。

默认插槽

默认插槽是没有名字的插槽,使用 <slot> 标签定义,父组件中直接写在子组件标签之间的内容会被插入到默认插槽中。

子组件中定义

image-20251127182908131
1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<div class="word-container">
<!-- 其他内容... -->

<!-- 这里就是默认插槽的定义 -->
<footer class="container-footer">
<slot>
<!-- 这是默认内容:父组件没填内容时显示这个 -->
<p class="footer-text">这是默认的底部内容</p>
</slot>
</footer>
</div>
</template>

这里的 <slot> 标签就是一个占位符,它告诉 Vue:” 这里可以插入父组件的内容,如果父组件没给内容,就显示我里面的 <p> 标签 “

有时为一个插槽设置具体的后备 (也就是默认的) 内容是很有用的,它只会在没有提供内容的时候被渲染。其中 <p class="footer-text">这是默认的底部内容</p>就是插槽的后备内容

父组件中如何使用这个插槽呢(父组件往这个插槽内插东西)

如果父组件不提供内容,子组件中 <slot> 标签内的默认内容会生效。

1
2
3
4
5
6
<template>
<div>
<!-- 直接使用子组件,标签内不写任何内容 -->
<WordContainer />
</div>
</template>

此时子组件会显示默认内容:

<p class="footer-text">这是默认的底部内容</p>

如果父组件提供内容(替换默认内容)

1
2
3
4
5
6
7
8
9
10
11
12
<template>
<div>
<!-- 在子组件标签之间写内容,注意驼峰和隔断写法的转换 -->
<WordContainer>
<!-- 这些内容会被插入到子组件的默认插槽位置 -->
<div class="custom-footer">
<p>© 2025 我的单词本</p>
<p>联系邮箱:support@example.com</p>
</div>
</WordContainer>
</div>
</template>

此时子组件的 <slot> 标签会被父组件写的 <div class="custom-footer"> 替换,最终渲染:

<div class="custom-footer">...</div>

具名插槽

当子组件需要多个插槽时,可以给插槽命名,即 “命名插槽”。使用 name 属性指定插槽名称,父组件通过 #插槽名(v-slot 语法糖)来匹配对应插槽。

1
2
3
4
5
6
7
8
<header class="container-header">
<!-- 命名为 header 的插槽 -->
<slot name="header">
<!-- 插槽默认内容 -->
<h2>{{ title }}</h2>
<span class="count">共 {{ wordCount }} 个单词</span>
</slot>
</header>

在父组件中使用

其中,使用 v-slot指令把你需要插入的内容插入到插槽中,其缩写为#,和其他指令一样,该缩写只在其有参数的时候才可用。

具名插槽的内容必须使用模板< template ></ template >包裹

1
2
3
4
5
6
7
<WordContainer>
<!-- 使用 #header 语法指定填充到 header 插槽 -->
<template #header>
<h2>我的自定义标题</h2>
<p>这是自定义的头部内容</p>
</template>
</WordContainer>

那么,这些内容就被插入到这个插槽中了

其实,匿名插槽也具有隐藏的名字default

我的示例中,定义了三个命名插槽:headersearchcontent,分别对应组件的不同区域。

image-20251127192846469

动态插槽名

动态指令参数也可以用在 v-slot 上,来定义动态的插槽名

1
2
3
4
5
<base-layout>
<template v-slot:[dynamicSlotName]>
...
</template>
</base-layout>

父组件

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
// 现在 <template>元素中的所有内容都将会被传入相应的插槽。
// 任何没有被包裹在带有 v-slot 的 <template> 中的内容都会被视为默认插槽的内容。
<template>
<div class="home">
<footerComponent>
<template v-slot:header>
<h2>header</h2>
</template>
<template v-slot:[mybody]>
<h3>动态插槽名</h3>
</template>
<p>内容</p>
<template #footer>
<h2>footer</h2>
</template>
</footerComponent>
</div>
</template>

<script>
import footerComponent from '@/components/footerComponent.vue'
export default {
data(){
return{
mybody:'body',
}
},
components: {
footerComponent,
}
}
</script>

子组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<div class="footerComponent">
<h1>子组件</h1>
<slot name="header"></slot>
<slot name="body"></slot>
<slot><p>我是后补内容</p></slot> // 等价于 <slot name="default"></slot>
<slot name="footer"></slot>
</div>
</template>

<style scoped lang="stylus">
.footerComponent
width 100%
height 200px
background-color pink
</style>
image-20251127193046436

作用域插槽

子传父

作用域插槽允许子组件向插槽传递数据,父组件在填充插槽时可以访问这些数据。这解决了父组件需要使用子组件内部数据的问题。

1
2
3
4
5
6
7
8
9
<main class="container-content">
<slot
name="content"
:filteredWords="filteredWords" <!-- 向插槽传递数据 -->
:searchQuery="searchQuery"
>
<p>请提供内容插槽</p>
</slot>
</main>

这里通过 :filteredWords="filteredWords" 语法绑定了filteredWords的内容为filteredWords,向插槽传递了数据,父组件可以接收这些数据。

1
2
3
4
5
6
7
8
9
10
11
12
<WordContainer>
<!-- 通过解构获取子组件传递的数据 -->
<template #content="{ filteredWords, searchQuery }">
<ul>
<!-- 使用子组件传递过来的数据 -->
<li v-for="word in filteredWords" :key="word.id">
{{ word.word }} - {{ word.translation }}
</li>
</ul>
<p>当前搜索词:{{ searchQuery }}</p>
</template>
</WordContainer>

作用域插槽的关键是子组件通过 v-bind 向插槽传递数据,父组件通过解构赋值接收数据。

父传子

在插槽机制中,父组件向子组件传递数据主要通过两种方式:

  1. 直接通过 Props 传递(子组件声明 props,父组件绑定传递)

    • 这是最基础的父传子方式,子组件通过 defineProps 声明需要接收的数据,父组件在使用子组件时通过属性绑定传递。

    • 子组件(WordContainer.vue):声明 props

      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
      <script setup>
      import { defineProps } from 'vue'

      // 子组件声明需要接收的 props
      const props = defineProps({
      title: {
      type: String,
      default: '单词列表'
      },
      maxCount: {
      type: Number,
      default: 100
      }
      })
      </script>

      <template>
      <div class="word-container">
      <!-- 使用父组件传递的 props 数据 -->
      <header>
      <h2>{{ title }}</h2>
      <p>最多显示 {{ maxCount }} 个单词</p>
      </header>

      <!-- 插槽内容 -->
      <slot />
      </div>
      </template>
    • 父组件:传递 props 数据

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      <template>
      <div>
      <!-- 父组件通过属性绑定传递数据给子组件 -->
      <WordContainer
      title="我的英语单词本"
      :maxCount="50"
      >
      <!-- 插槽内容 -->
      <p>这里是插槽内容</p>
      </WordContainer>
      </div>
      </template>
    • 当数据需要被子组件内部逻辑使用(如子组件的计算属性、方法)时,必须通过 props 传递;若只是在插槽内容中显示,则直接使用父组件数据即可。

  2. 在插槽内容中直接使用父组件数据(插槽内容本身属于父组件作用域)

    • 插槽内容本身属于父组件的作用域,因此可以直接使用父组件的数据,这相当于间接向子组件的插槽区域传递了父组件数据。

    • 父组件:定义数据并在插槽中使用

      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
      <script setup>
      import { ref } from 'vue'
      import WordContainer from './WordContainer.vue'

      // 父组件的响应式数据
      const userInfo = ref({
      name: '张三',
      level: '高级学习者'
      })

      const favoriteColor = ref('blue')
      </script>

      <template>
      <WordContainer>
      <!-- 插槽内容中直接使用父组件的数据 -->
      <div class="user-info">
      <p>用户名:{{ userInfo.name }}</p>
      <p>等级:{{ userInfo.level }}</p>
      <div :style="{ color: favoriteColor }">
      自定义颜色文本
      </div>
      </div>
      </WordContainer>
      </template>
    • 子组件:渲染插槽内容(自动包含父组件数据)

      1
      2
      3
      4
      5
      6
      <template>
      <div class="word-container">
      <!-- 插槽会渲染父组件传递的内容,包含父组件数据 -->
      <slot />
      </div>
      </template>

事件与插槽的结合

通过插槽(#actions)向子组件 WordCard 传递按钮,按钮的点击事件最终由 WordList 处理并触发向上传递的事件:

1
2
3
4
5
6
7
<WordCard ...>
<!-- 插槽内容:操作按钮 -->
<template #actions>
<button @click.stop="handleEdit(word)">编辑</button>
<button @click.stop="handleDelete(word.id)">删除</button>
</template>
</WordCard>
  • 插槽中的按钮属于 WordList 组件的上下文,因此点击事件直接绑定到 WordList 的方法(handleEdit 等)
  • 这些方法再通过 emitWordList 的父组件传递事件,形成 “多层传递”