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 可以是下列这些原生构造函数:String,Number,Boolean,Array,Object,Date,Function,Symbol,Error。
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 项目中最常用、最安全的方式:
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[])
|
即第一个参数是事件名,其余参数为事件数据。
流程:
- 父组件通过
@事件名="回调函数" 来监听事件;
- 子组件内部通过
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>
|

示例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>
|
其中插槽名称由父组件传入的columns的prop决定。
整体作用,当渲染每一个单元格时:
- 尝试使用父组件传入的具名插槽(例如
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 则必须将其沿着组件链逐级传递下去,这会非常麻烦:

注意,虽然这里的 <Footer> 组件可能根本不关心这些 props,但为了使 <DeepChild> 能访问到它们,仍然需要定义并向下传递。如果组件链路非常长,可能会影响到更多这条路上的组件。这一问题被称为“prop 逐级透传”,显然是我们希望尽量避免的情况。
provide 和 inject 可以帮助我们解决这一问题。一个父组件相对于其所有的后代组件,会作为依赖提供者。任何后代的组件树,无论层级有多深,都可以注入由父组件提供给整条链路的依赖。

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() }) })
|
示例
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, timeout: 3000 )};
|