Vue 组件学习笔记

组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。在实际应用中,组件常常被组织成层层嵌套的树状结构:

组件树

1 定义和使用

当使用构建步骤时,我们一般会将 Vue 组件定义在一个单独的.vue文件中,这被叫做单文件组件 (简称 SFC):

1
2
3
4
5
6
7
8
9
10
<!-- ButtonCounter.vue -->
<script setup>
import { ref } from 'vue'

const count = ref(0)
</script>

<template>
<button @click="count++">You clicked me {{ count }} times.</button>
</template>

要使用一个子组件,我们需要在父组件中导入它。假设我们把计数器组件放在了一个叫做 ButtonCounter.vue 的文件中,这个组件将会以默认导出的形式被暴露给外部。

1
2
3
4
5
6
7
8
<script setup>
import ButtonCounter from './ButtonCounter.vue'
</script>

<template>
<h1>Here is a child component!</h1>
<ButtonCounter />
</template>

在单文件组件中,推荐为子组件使用 PascalCase 的标签名,以此来和原生的 HTML 元素作区分。

2 props

props(properties)是 父组件向子组件传递数据 的机制。

在 Vue3 中,props单向数据流,即子组件不能直接修改父组件传来的 props。

2.1 defineProps

<script setup> 中,用 defineProps() 定义,defineProps 是一个仅 <script setup> 中可用的编译宏命令,并不需要显式地导入。声明的 props 会自动暴露给模板。

defineProps 会返回一个对象,其中包含了可以传递给组件的所有 props。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- Child.vue -->
<template>
<div>
<h3>用户名:{{ name }}</h3>
<p>年龄:{{ age }}</p>
</div>
</template>

<script setup lang="ts">
// 定义 props
const props = defineProps({
name: String, // 字符串类型
age: Number, // 数字类型
greetingMessage: String // camelCase
})

// props 可以直接使用
console.log(props.name);

props.name = "foo" // ❌ 警告!prop 是只读的!
</script>
1
2
3
4
5
6
7
8
9
<!-- Parent.vue -->
<template>
<!-- kebab-case -->
<Child name="小明" :age="20" greeting-message="Hello World" />
</template>

<script setup lang="ts">
import Child from './Child.vue'
</script>

注意:父组件传递对象/数组时要用 v-bind

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Parent.vue -->
<template>
<!-- 使用v-bind -->
<Child :user="userInfo" />
</template>

<script setup lang="ts">
import Child from './Child.vue'

const userInfo = {
name: '张三',
age: 25
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Child.vue -->
<template>
<div>
<p>{{ user.name }}({{ user.age }}岁)</p>
</div>
</template>

<script setup lang="ts">
interface User {
name: string
age: number
}
const props = defineProps<{ user: User }>()
</script>

2.2 默认值与必填校验

可以在 props 定义中添加默认值和校验规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup lang="ts">
const props = defineProps({
name: {
type: String,
required: true // 必传
},
age: {
type: Number,
default: 18 // 默认值
},
hobbies: {
type: Array,
default: () => [] // 注意数组/对象必须用函数返回!
}
})
</script>

校验选项中的 type 可以是下列这些原生构造函数:StringNumberBooleanArrayObjectDateFunctionSymbolError

3 监听事件

Vue3中,监听事件用于 子组件 → 父组件通信

3.1 基本用法

组件可以显式地通过 defineEmits()宏来声明它要触发的事件,通过emit 派发事件,即子组件触发事件 → 父组件监听,实现了子组件向父组件传递值的效果。

3.1.1 defineEmits

defineEmits的参数有两种主要形式(数组形式对象形式 / 泛型形式),每种都适用于不同的场景。

  • 传入字符串数组

特点:数组中每个字符串表示一个事件名,没有类型校验,适合简单组件。

1
2
3
4
5
6
7
8
9
10
<script setup lang="ts">
const emit = defineEmits(['send', 'close'])

// 使用
function sendMessage() {
emit('send', '来自子组件的消息') // ✅ 允许
emit('close') // ✅ 允许
emit('other') // ❌ 报错(TS 检测不到)
}
</script>
  • 传入对象(带校验函数)

特点:这种写法允许对事件参数进行运行时校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<script setup>
const emit = defineEmits({
// 事件名: 校验函数
send: (msg) => {
if (typeof msg === 'string') return true
console.warn('send事件参数必须是字符串!')
return false
},
close: null // 不需要参数时可用 null
})

function trigger() {
emit('send', '你好') // ✅ 正确
emit('send', 123) // ⚠️ 控制台警告
}
</script>
  • TypeScript 泛型(推荐写法)

TypeScript 项目中最常用、最安全的方式:

1
2
3
4
5
6
7
8
9
10
11
<script setup lang="ts">
const emit = defineEmits<{
(e: 'send', msg: string): void // 泛型定义了事件签名(事件名 + 参数类型 + 返回值)
(e: 'close'): void
}>()

emit('send', 'Hello') // ✅ 正确
emit('close') // ✅ 正确
emit('send', 123) // ❌ 类型错误(TS 提示参数类型错误)
emit('other') // ❌ 类型错误(TS 提示事件名错误)
</script>

总结:

写法 参数类型 校验机制 优点 缺点
defineEmits(['send']) 字符串数组 简单 无类型提示
defineEmits({ send: (val)=>true }) 对象 运行时 可手动验证参数 无智能提示
defineEmits<{ (e:'send', msg:string):void }>() 泛型 编译时(TS) 类型安全、IDE 自动提示 仅适用于 TS

3.1.2 emit

emit子组件向父组件发送事件的机制,它的作用可以总结为一句话:让子组件主动通知父组件发生了某些事情,并可携带数据。

emit 的调用规则是:

1
emit(eventName: string, ...args: any[])

即第一个参数是事件名,其余参数为事件数据。

流程:

  1. 父组件通过 @事件名="回调函数" 来监听事件;
  2. 子组件内部通过 emit('事件名', 参数) 触发事件,其中emit 可以携带任意参数,父组件回调可以接收这些参数;

示例

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

<script setup lang="ts">
const emit = defineEmits<{
(e: 'send', msg: string, code: number): void
}>()

function onClick() {
emit('send', '数据', 200)
}
</script>

父组件监听:

1
2
3
4
5
6
7
8
9
<!-- 监听子组件的send事件,通过handleSend回调 -->
<Child @send="handleSend" />

<script setup lang="ts">
// 回调函数
function handleSend(msg: string, code: number) {
console.log(msg, code) // 输出:数据 200
}
</script>

3.2 与v-model关系

v-model 本质上就是 emit + props 的组合::

  • 传递一个 props(默认名:modelValue
  • 监听一个事件(默认名:update:modelValue

示例1

  • 子组件
1
2
3
4
5
6
7
8
9
10
11
12
<template>
<input :value="modelValue" @input="updateValue" />
</template>

<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: string): void }>()

function updateValue(e: Event) {
emit('update:modelValue', (e.target as HTMLInputElement).value)
}
</script>
  • 父组件
1
2
3
4
5
6
7
8
9
10
11
12
<template>
<ChildInput v-model="username" />
<!-- 本质就是<ChildInput @update:modelValue="username" /> -->
<p>输入的内容:{{ username }}</p>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import ChildInput from './ChildInput.vue'

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

当输入框内容变化时,emit('update:modelValue') 会自动更新 username,实现父子组件的双向绑定

示例2:自定义多个双向绑定

  • 子组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<template>
<input :value="title" @input="emit('update:title', $event.target.value)" />
<button @click="emit('update:count', count + 1)">count: {{ count }} />
</template>

<script setup lang="ts">
const props = defineProps<{
title: string
count: number
}>()

const emit = defineEmits<{
(e: 'update:title', v: string): void
(e: 'update:count', v: number): void
}>()
</script>
  • 父组件
1
2
3
4
5
6
7
8
9
10
11
<template>
<Child v-model:title="title" v-model:count="count" />
</template>

<script setup lang="ts">
import Child from './Child.vue'
import { ref } from 'vue'

const title = ref('Hello')
const count = ref(0)
</script>

3.3 defineModel

**defineModel**(Vue 3.3+ 新增 <script setup> 的宏)是 v-model 的新语法糖,用来在子组件中声明一个可双向绑定的 prop,比传统的 defineProps + defineEmits 更简洁。

特点:

  • 自动生成 props + emit,对应的事件名是 update:<name>
  • 适合做 v-model 双向绑定,并可指定类型和默认值

示例1

  • 子组件
1
2
3
4
5
6
7
8
9
10
11
<template>
<input type="text" v-model="text" />
</template>

<script setup lang="ts">
// 定义一个 v-model 绑定的属性 text
defineModel('text', {
type: String,
default: ''
})
</script>
  • 父组件
1
2
3
4
5
6
7
8
9
10
11
<template>
<Child v-model="username" />
<p>父组件接收到的值: {{ username }}</p>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Child from './Child.vue'

const username = ref('初始值')
</script>

本质触发事件:emit('update:value', newValue)

示例2:多个v-model

defineModel 支持 多个 v-model

1
2
3
// 子组件
defineModel('title', { type: String, default: '' })
defineModel('count', { type: Number, default: 0 })
1
2
// 父组件:
<Child v-model:title="pageTitle" v-model:count="pageCount" />

4 组件插槽

插槽(slot)是 组件内容分发机制,父组件可以把 模板内容传递给子组件渲染,而子组件通过 <slot> 渲染父组件传入的内容。

简单理解:子组件就像一个容器,父组件把内容放进去。

4.1 基本用法

假设有一个 <FancyButton> 组件,可以像这样使用:

1
2
3
<FancyButton>
Click me! <!-- 插槽内容 -->
</FancyButton>

<FancyButton> (子组件)的模板是这样的:

1
2
3
<button class="fancy-btn">
<slot></slot> <!-- 插槽出口,由父组件提供内容 -->
</button>

<slot> 元素是一个插槽出口 (slot outlet),标示了父元素提供的插槽内容 (slot content) 将在哪里被渲染。

插槽图示

最终渲染出的 DOM 是这样:

1
<button class="fancy-btn">Click me!</button>

4.2 默认插槽 Default Slot

1
2
3
4
5
6
7
8
9
10
11
<!-- Child.vue -->
<template>
<div class="child-box">
<h3>我是子组件</h3>
<!-- 渲染父组件传入的默认插槽 -->
<slot>默认内容:父组件没有传时显示</slot>
</div>
</template>

<script setup lang="ts">
</script>
1
2
3
4
5
6
<!-- Father.vue -->
<template>
<Child>
<p>父组件传入的内容</p>
</Child>
</template>

如果父组件没有传内容,则显示 <slot> 内的默认内容。

4.3 具名插槽 Named Slot

<slot> 元素可以有一个特殊的 attribute name,用来给各个插槽分配唯一的 ID,以确定每一处要渲染的内容,,这种指定name的插槽成为具名插槽。

具名插槽允许子组件定义 多个可插入位置

1
2
3
4
5
6
7
8
9
10
11
12
<!-- Child.vue -->
<div class="container">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- Father.vue -->
<BaseLayout>
<template v-slot:header>
<!-- header 插槽的内容放这里 -->
</template>
</BaseLayout>

<!-- 或者 -->
<BaseLayout>
<!-- v-slot的语法糖 -->
<template #header>
<h1>Here might be a page title</h1>
</template>
<!-- default是默认插槽的name -->
<template #default>
<p>A paragraph for the main content.</p>
<p>And another one.</p>
</template>

<template #footer>
<p>Here's some contact info</p>
</template>
</BaseLayout>

具名插槽图示

4.4 作用域插槽 Scoped Slot

问题:父组件模板中的表达式只能访问父组件的作用域;子组件模板中的表达式只能访问子组件的作用域。因此插槽中的变量(位于子组件的作用域),父组件是不能访问到的。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
<!-- Child.vue -->
<template>
<div>
<slot></slot>
</div>
</template>

<script setup lang="ts">
// 如何将这两者通过slot传递到父组件?
let greetingMessage = "Hello";
let count = 1;
</script>

作用域插槽作用:父组件可以访问到子组件提供的数据

方法:

  • 子组件通过<slot :props1="value1" :props2="value2" ...>暴露数据
  • 父组件通过v-slot="props"#插槽名接收数据

示例1:通过v-slot="props"接收

1
2
3
4
5
6
7
8
9
10
11
<!-- Child.vue -->
<template>
<div>
<slot :text="greetingMessage" :count="countNumber"></slot>
</div>
</template>

<script setup lang="ts">
let greetingMessage = "Hello";
let countNumber = 1;
</script>
1
2
3
4
5
6
7
8
9
<!-- Father.vue -->
<MyComponent v-slot="slotProps">
{{ slotProps.text }} {{ slotProps.count }}
</MyComponent>

<!-- 或者通过解构来接收 -->
<MyComponent v-slot="{text, count}">
{{ text }} {{ count }}
</MyComponent>

image-20251014110750850

示例2:通过#插槽名接收(针对具名插槽)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- Child.vue -->
<template>
<div class="child-box">
<slot :user="userInfo" name="userSlot">默认显示用户</slot>
</div>
</template>

<script setup lang="ts">
interface User {
name: string
age: number
}
const userInfo: User = { name: '小明', age: 18 }
</script>
1
2
3
4
5
6
7
8
9
<!-- Father.vue -->
<Child #userSlot="userProps">
<p>用户名: {{ userProps.user.name }}, 年龄: {{ userProps.user.age }}</p>
</Child>

<!-- 或者通过解构来接收 -->
<Child #userSlot="{user}">
<p>用户名: {{ user.name }}, 年龄: {{ user.age }}</p>
</Child>

应用:elementUI中的<el-table-column>提供了一个默认插槽,向外暴露了三个参数:

1
2
3
4
5
{ 
row: any, // 当前行对象
column: TableColumnCtx<T>, // 当前列对象
$index: number // 当前行索引
}

内部实现类似于:

1
2
3
4
<el-table-column>
<!-- 默认插槽:传递 row, column, $index -->
<slot :row="row" :column="column" :$index="index"></slot>
</el-table-column>

使用(非解构):

1
2
3
4
5
6
7
8
<el-table-column>
<template #default="scope">
<div class="column-scope">
<el-button size="small" @click="handleStyleEditFun(scope.row)">编辑样式</el-button>
</el-button>
</div>
</template>
</el-table-column>

解构:<template #default="{row, column, $index}">

4.5 动态插槽名

动态指令参数在 v-slot 上也是有效的,即可以定义下面这样的动态插槽名:

1
2
3
4
5
6
7
8
9
10
<base-layout>
<template v-slot:[dynamicSlotName]>
...
</template>

<!-- 缩写为 -->
<template #[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
<template>
<el-table :data="tableData">
<el-table-column
v-for="item in columns"
:key="item.prop"
:prop="item.prop"
:label="item.label"
>
<template #default="{ row, column, $index }">
<slot
:name="`cell-${item.prop}`"
:row="row"
:column="column"
:index="$index"
:prop="item.prop"
>
{{ row[item.prop] }}
</slot>
</template>
</el-table-column>
</el-table>
</template>

<script setup lang="ts">
defineProps<{
tableData: any[]
columns: { label: string; prop: string }[]
}>()
</script>

其中插槽名称由父组件传入的columnsprop决定。

整体作用,当渲染每一个单元格时:

  • 尝试使用父组件传入的具名插槽(例如 cell-xxx
  • 如果父组件提供了这个插槽,就用它来自定义渲染
  • 如果父组件没有提供,就默认显示 row[item.prop]

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<template
v-for="(item, index) in SYSTEM_USER_TABLE_HEADER"
:key="index"
#[`cell-${item.prop}`]="{ row }">
<span v-if="item.prop === 'status'">{{ row.status == "0" ? "正常" : "停用" }}</span>
<div v-else-if="item.prop === 'operation'" class="operationTableBox">
<div class="operationIconBox" @click.stop="setUserMenu(row)">
<img src="setUserMenu_default.png" alt="" style="cursor: pointer"/>
</div>
<div class="operationIconBox" @click.stop="resetPassword(row)">
<img src="resetPassword_default.png" alt="" style="cursor: pointer"/>
</div>
<div class="operationIconBox" @click.stop="addOrEditUser('edit', row)">
<img src="operationIcon_default.png" alt="" style="cursor: pointer"/>
</div>
<div class="operationIconBox" @click.stop="deleteUserFun(row)">
<img src="deleteUser_default.png" alt="" style="cursor: pointer"/>
</div>
</div>
</template>

效果:针对不同的列渲染不同的效果。

5 动态组件

动态组件主要依赖于 Vue 的内置组件:

1
<component :is="currentComponent"></component>
  • is 属性:指定当前要渲染的组件,currentComponent可以是:
    • 已注册组件的 名字字符串
    • 一个 组件对象(SFC 或 defineComponent 返回值)

示例

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>
<div>
<h2>动态组件示例</h2>

<!-- 动态组件核心语法 -->
<component :is="currentComponent" />

<div style="margin-top: 10px;">
<button @click="switchComponent">切换组件</button>
</div>
</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import HelloA from './components/HelloA.vue'
import HelloB from './components/HelloB.vue'

// 当前要渲染的组件
const currentComponent = ref<typeof HelloA | typeof HelloB>(HelloA)

// 切换函数
const switchComponent = () => {
currentComponent.value = currentComponent.value === HelloA ? HelloB : HelloA
}
</script>

6 内置组件

Vue3提供了一些内置组件,例如:

  • Transition
  • TransitionGroup
  • KeepAlive
  • Teleport
  • Suspense

用法略。

7 提供和注入

7.1 props透传问题

通常情况下,当我们需要从父组件向子组件传递数据时,会使用props。想象一下这样的结构:有一些多层级嵌套的组件,形成了一棵巨大的组件树,而某个深层的子组件需要一个较远的祖先组件中的部分数据。在这种情况下,如果仅使用 props 则必须将其沿着组件链逐级传递下去,这会非常麻烦:

image.png

注意,虽然这里的 <Footer> 组件可能根本不关心这些 props,但为了使 <DeepChild> 能访问到它们,仍然需要定义并向下传递。如果组件链路非常长,可能会影响到更多这条路上的组件。这一问题被称为“prop 逐级透传”,显然是我们希望尽量避免的情况。

provide 和 inject 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

image.png

7.2 Provide 提供

provide(key, value) :在祖先组件中调用,用于提供一个数据源。

  • key:提供名,字符串或Symbol
  • value:提供的值,可以是任意类型

示例

1
2
3
4
5
6
7
<script setup>
import { provide, ref } from 'vue'

const count = ref(0)
provide(/* 注入名 */ 'message', /* 值 */ 'hello!') // 注入字符串
provide('key', count) // 注入ref响应式变量
</script>

7.3 Inject 注入

inject(key, defaultValue?) :在任意后代组件中调用,用于注入(读取)这个数据。

  • key:提供名
  • defaultValue?:注入一个值时不要求必须有提供者,因此可以注入一个默认值防止抛出运行时异常

示例

1
2
3
4
5
6
7
8
9
<script setup>
import { inject } from 'vue'

const message = inject('message')

// 如果没有祖先组件提供 "message"
// `value` 会是 "这是默认值"
const value = inject('message', '这是默认值')
</script>

8 异步组件

8.1 基本用法

在大型项目中,我们可能需要拆分应用为更小的块,并仅在需要时再从服务器加载相关组件。Vue 提供了defineAsyncComponent方法来实现此功能,defineAsyncComponent接收一个返回 Promise 的加载函数,例如:

1
2
3
4
5
6
7
8
9
import { defineAsyncComponent } from 'vue'

const AsyncComp = defineAsyncComponent(() => {
return new Promise((resolve, reject) => {
// ...从服务器获取组件
resolve(/* 获取到的组件 */)
})
})
// ... 像使用其他一般组件一样使用 `AsyncComp`

示例

1
2
3
4
5
6
7
8
9
10
<template>
<AsyncComp />
</template>

<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// 定义异步组件
const AsyncComp = defineAsyncComponent(() => import('./MyComponent.vue'))
</script>

8.2 加载和错误状态

异步组件的第二种形式是传入一个配置对象:

1
2
3
4
5
6
7
defineAsyncComponent({   
loader: () => import('./MyComponent.vue'),
loadingComponent: LoadingComp, // 加载时显示的组件
errorComponent: ErrorComp, // 报错时显示的组件
delay: 200, // 延迟显示 loading(防止闪烁)
timeout: 3000 // 超时触发错误 })
)};