组件允许我们将 UI 划分为独立的、可重用的部分,并且可以对每个部分进行单独的思考。在实际应用中,组件常常被组织成一个层层嵌套的树状结构:
一、注册
Vue 组件本质上是一个可以复用的 自定义 HTML 元素,为了在其他组件中使用一个组件,你必须先注册它。注册的过程就是:把组件告诉 Vue,这样 Vue 才能在模板中识别并正确渲染它。
注册方式分类(Vue 3)
注册方式 | 说明 | 特点 |
---|---|---|
全局注册 | 应用级注册,所有组件都能使用 | 简便、但增加全局污染 |
局部注册 | 在组件内部通过 components 注册 | 推荐,更清晰、可维护 |
<script setup> | Composition API 的简化写法 | Vue 3 推荐,自动引入更方便 |
1、全局注册
// main.js 或 main.ts
import { createApp } from 'vue'
import App from './App.vue'
import MyComponent from './components/MyComponent.vue'
const app = createApp(App)
// 注册全局组件
app.component('MyComponent', MyComponent)
app.mount('#app')
特点:
-
一次注册,整个项目中的模板都能使用
<MyComponent />
-
缺点是全局污染命名空间,不利于维护,不推荐在大型项目中滥用
2、局部注册
<script setup>
import MyComponent from './MyComponent.vue'
</script>
<template>
<MyComponent />
</template>
特点:
-
只在当前组件作用域内生效
-
更加清晰、模块化,推荐使用
3、自动注册(文件自动引入)
在使用 Vite 或 Webpack 的 Vue 项目中,可以借助 import.meta.glob
或 require.context()
实现自动注册所有组件:
示例(自动全局注册 components 文件夹下所有组件):
// main.js
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
const modules = import.meta.glob('./components/*.vue', { eager: true })
for (const path in modules) {
const component = modules[path].default
const name = component.name || path.split('/').pop().replace('.vue', '')
app.component(name, component)
}
app.mount('#app')
4、动态组件注册
使用 <component :is="componentName">
可以动态切换渲染的组件:
<template>
<component :is="currentComponent" />
</template>
<script setup>
import ComponentA from './ComponentA.vue'
import ComponentB from './ComponentB.vue'
const currentComponent = ref('ComponentA')
</script>
需要配合预先注册这些组件或使用异步加载。
5、 Vue 3 <script setup>
的注册简化优势
从 Vue 3.2 起,推荐使用 <script setup>
,其优势是:
-
无需写
components
选项; -
直接引入即可自动生效;
-
模板中可直接使用变量名作为标签。
<script setup>
import MyComponent from './MyComponent.vue'
</script>
<template>
<MyComponent />
</template>
这种方式既是局部注册,又是最现代、推荐的写法。
二、组件通信(prop与emit)
Vue 的组件是树状结构,存在父子关系、兄弟关系、跨层级关系:
-
父 → 子:传递数据(
props
) -
子 → 父:触发事件(
emit
) -
兄弟之间或跨层通信:需要状态共享或事件中心
掌握组件通信的方式,是 Vue 构建中大型项目的基础。
Vue 组件通信方式总览(Vue 3)
类型 | 通信方式 | 说明 |
---|---|---|
父 → 子 | props | 父组件向子组件传递数据 |
子 → 父 | emit | 子组件向父组件发送事件 |
双向绑定 | v-model | 基于 modelValue 和 update:modelValue |
任意组件间 | 状态管理(如 Pinia) | 共享状态,兄弟 / 跨层通信 |
任意组件间 | provide/inject | 祖先 → 后代,非响应式(默认) |
任意组件间 | 事件总线(不推荐) | Vue 3 中已不推荐,Pinia 更优 |
父/子操作子/父组件实例 | ref 绑定组件 | 访问子组件方法或属性 |
1、父传子通信(props
)
1、什么是 props
?
props
是指 父组件通过 HTML 属性的形式,向子组件传递的数据。
你可以把 props
理解为组件的“参数”:
父组件在使用子组件时,像调用函数一样传入值,而子组件通过 props
来接收值并使用。
2、基本使用
父组件传值:
<!-- Parent.vue -->
<MyCard title="Vue 面经" content="面试知识点" />
子组件接收值:
<!-- MyCard.vue -->
<script setup>
const props = defineProps(['title', 'content'])
</script>
<template>
<div>
<h3>{{ props.title }}</h3>
<p>{{ props.content }}</p>
</div>
</template>
在 <script setup>
中你也可以这样写更简洁:
const { title, content } = defineProps(['title', 'content'])
除了使用字符串数组来声明 props 外,还可以使用对象的形式:
// 使用 <script setup>
defineProps({
title: String,
content: String})
对于以对象形式声明的每个属性,key 是 prop 的名称,而值则是该 prop 预期类型的构造函数。比如,如果要求一个 prop 的值是 number
类型,则可使用 Number
构造函数作为其声明的值。
对象形式的 props 声明不仅可以一定程度上作为组件的文档,而且如果其他开发者在使用你的组件时传递了错误的类型,也会在浏览器控制台中抛出警告。
Vue 中 props
是只读的:
-
子组件可以读取
props
的值; -
但不可以修改它,因为这可能会破坏父组件的状态管理。
如果你确实需要修改,可以复制到一个 ref
:const localTitle = ref(props.title)
更改对象 / 数组类型的 props
当对象或数组作为 props 被传入时,虽然子组件无法更改 props 绑定,但仍然可以更改对象或数组内部的值。这是因为 JavaScript 的对象和数组是按引用传递,对 Vue 来说,阻止这种更改需要付出的代价异常昂贵。
这种更改的主要缺陷是它允许了子组件以某种不明显的方式影响父组件的状态,可能会使数据流在将来变得更难以理解。在最佳实践中,你应该尽可能避免这样的更改,除非父子组件在设计上本来就需要紧密耦合。在大多数场景下,子组件应该抛出一个事件(emit)来通知父组件做出改变。
3、响应式 Props 解构
Vue 的响应式依赖追踪机制是这样的:
-
当你在一个 计算属性 或
watchEffect
/watch
函数中访问某个响应式数据的属性,Vue 会自动收集这个属性作为依赖。 -
当这个属性变化时,对应的副作用函数就会被重新执行。
3.4 及以下版本的问题:props 解构会“失去响应性”
const { foo } = defineProps(['foo'])
watchEffect(() => {
console.log(foo)
})
在 Vue 3.4 及以前版本:
这里 foo 是普通值(不是 ref 或 reactive 的值),不会追踪变更。
所以 watchEffect() 只执行一次,不会在 foo 更新时再次触发。
Vue 3.5 的改进:解构的 props 自动补充响应式追踪
在 Vue 3.5 中,Vue 编译器做了优化:
const { foo } = defineProps(['foo'])
等价于:
const props = defineProps(['foo'])
watchEffect(() => {
console.log(props.foo) // Vue 编译器自动转换为 props.foo
})
这就意味着:
-
解构的变量
foo
仍然是响应式的**(由编译器自动转换)**; -
在
watchEffect
、computed
等副作用函数中访问foo
时可以正常追踪变化; -
无需再使用
toRefs()
手动保持响应性,前提是你是在<script setup>
的作用域内直接用。
注意:在传递到函数时不会保留响应性!
const { foo } = defineProps(['foo'])
watch(foo, () => {
// ❌ 不会追踪 foo 的变化,因为 foo 是值,不是响应式引用
})
这是一个很容易踩的坑!
在 watch(foo) 这种写法中,foo 被当作一个“值”传入,而不是 getter;
所以watch 只监听了一次值,不会追踪变化,Vue 也会在控制台发出警告。
正确做法:将解构后的 prop 包装成 getter
watch(() => foo, () => {
// ✅ 正确,foo 的变化会被追踪
})
为什么这样可以?
因为 () => foo 是一个 getter 函数;
Vue 会自动追踪 getter 内部访问的响应式属性;
所以 foo 的变化会被正确响应。
在 Vue 3.5 中,解构的 props 在 watchEffect/computed 中是响应式的,但在函数调用/事件中不是,此时要用 () => foo
包装成 getter。
4、props 类型声明和校验
Vue 3 推荐的方式:
const props = defineProps<{
title: string
content?: string
}>()
使用对象形式提供更多配置:
const props = defineProps({
title: {
type: String,
required: true
},
content: {
type: String,
default: '默认内容'
}
})
选项 | 作用 |
---|---|
type | 声明类型,如 String、Number 等 |
required | 是否必传 |
default | 默认值 |
Vue 组件可以更细致地声明对传入的 props 的校验要求。如果传入的值不满足类型要求,Vue 会在浏览器控制台中抛出警告来提醒使用者。这在开发给其他开发者使用的组件时非常有用。
要声明对 props 的校验,你可以向 defineProps()
宏提供一个带有 props 校验选项的对象,例如:
defineProps({
// 基础类型检查
// (给出 `null` 和 `undefined` 值则会跳过任何类型检查)
propA: Number,
// 多种可能的类型
propB: [String, Number],
// 必传,且为 String 类型
propC: {
type: String,
required: true
},
// 必传但可为 null 的字符串
propD: {
type: [String, null],
required: true
},
// Number 类型的默认值
propE: {
type: Number,
default: 100
},
// 对象类型的默认值
propF: {
type: Object,
// 对象或数组的默认值
// 必须从一个工厂函数返回。
// 该函数接收组件所接收到的原始 prop 作为参数。
default(rawProps) {
return { message: 'hello' }
}
},
// 自定义类型校验函数
// 在 3.4+ 中完整的 props 作为第二个参数传入
propG: {
validator(value, props) {
// The value must match one of these strings
return ['success', 'warning', 'danger'].includes(value)
}
},
// 函数类型的默认值
propH: {
type: Function,
// 不像对象或数组的默认,这不是一个
// 工厂函数。这会是一个用来作为默认值的函数
default() {
return 'Default function'
}
}
})
4、在模板中使用 props:
你可以像使用普通变量一样直接使用 props
中的数据。 <p>{{ content }}</p>
5、动态绑定 props:使用 v-bind
使用 v-bind
或缩写 :
来进行动态绑定的 props:
<!-- 根据一个变量的值动态传入 -->
<MyCard :title="post.title" />
<!-- 根据一个更复杂表达式的值动态传入 -->
<MyCard :title="post.title + ' by ' + post.author.name" />
或者使用 v-bind 对象方式:
<MyButton v-bind="{ label: buttonText, disabled: true }" />
使用一个对象绑定多个 prop
如果你想要将一个对象的所有属性都当作 props 传入,你可以使用
没有参数的v-bind
,即只使用 v-bind
而非 :prop-name
。例如,这里有一个 post
对象:
const post = { ititle: 'My Journey with Vue' ,content:'面试知识点'}
以及下面的模板:<MyCard v-bind="post" />
而这实际上等价于:<MyCard :title="post.title" :content="post.content" />
6、布尔类型的特殊写法
Vue 中的布尔型 props
可以使用属性存在即为 true的简写方式:
<!-- 等价于 :isLoading="true" -->
<LoadingSpinner isLoading />
7、CamelCase 与 kebab-case 的映射
Vue 支持驼峰式声明 props:const props = defineProps(['isLoading'])
但是在模板中使用组件时应使用 kebab-case
:<MyComponent is-loading />
2、子 → 父:使用 emit
事件
在 Vue 中,emit
是子组件向父组件发送事件的核心机制,它是实现子 → 父通信的标准方式。理解和正确使用 emit
对于掌握组件通信和构建响应式组件非常重要,尤其在面试和组件封装时经常考查。
1、什么是 emit
?
emit
是 Vue 组件中用来在子组件中触发自定义事件,通知父组件做出响应的方法。
-
父组件可以监听这些事件,就像监听原生 DOM 事件一样。
-
子组件不会直接操作父组件的数据,而是通过 emit 发出事件,由父组件处理。
2、最基础的使用方式
子组件:触发事件
<!-- MyButton.vue -->
<script setup>
const emit = defineEmits(['click'])
</script>
<template>
<button @click="emit('click')">点击</button>
</template>
🌱 父组件:监听事件
<!-- Parent.vue -->
<MyButton @click="handleClick" />
<script setup>
function handleClick() {
console.log('子组件点击事件触发了')
}
</script>
3、emit 的参数传递
你可以在 emit
时传递参数,供父组件使用:
子组件:emit('select', itemId)
父组件:<MyItem @select="onItemSelect" />
function onItemSelect(id) {
console.log('你选择了 ID:', id)
}
4、defineEmits()
的类型定义
1、什么是 defineEmits()
?
defineEmits()
是 <script setup>
中用来 声明和使用自定义事件 的语法糖(宏函数):
-
它的作用等价于传统组件的
emits
选项; -
并返回一个名为
emit
的函数,供你在组件内触发事件; -
只能在
<script setup>
的顶层作用域中使用。
2、基本用法:声明 + 触发事件
<script setup>
const emit = defineEmits(['submit', 'inFocus'])
function onClick() {
emit('submit') // 触发“submit”事件
}
</script>
解释:
-
defineEmits(['submit'])
表示这个组件会触发名为'submit'
的事件; -
defineEmits接收一个字符串数组,每个字符串代表一个事件名。
-
emit('submit')
就是在组件内部触发这个事件; -
父组件就可以通过
<MyForm @submit="handleSubmit" />
来监听。
为什么要显式声明事件?
虽然 emit()
可以直接使用事件名,但声明事件有很多好处:
好处 | 说明 |
---|---|
🧠 文档化 | 让组件的可用事件一目了然 |
✅ 类型校验 | 在 TypeScript 下自动提示事件名和参数 |
💡 IDE 补全 | 支持事件名的自动补全 |
⚠️ 区分 DOM 属性 | 帮助 Vue 更好地区分自定义事件和 HTML 属性(比如 focus 和 inFocus ) |
在非 <script setup>
中如何使用?
export default {
emits: ['submit', 'inFocus'], // 显式声明
setup(props, ctx) {
ctx.emit('submit') // 触发事件
}
}
或者解构方式:
setup(props, { emit }) {
emit('submit')
}
在选项式组件中,事件由 emits
选项声明,通过 ctx.emit()
或解构 emit()
使用。
对象语法 + 参数校验(Vue 支持)
const emit = defineEmits({
submit(payload: { email: string; password: string }) {
return payload.email.includes('@') && payload.password.length > 6
}
})
说明:
-
这是对象语法,事件名作为 key;
-
value 是一个验证函数,用于校验参数合法性;
-
如果返回
false
,Vue 会在 dev 环境报出 warning,提醒你传参错误。
TypeScript 的推荐写法(强类型声明)
const emit = defineEmits<{
(e: 'change', id: number): void
(e: 'update', value: string): void
}>()
3、重要规则和限制
限制 | 说明 |
---|---|
顶层使用 | defineEmits() 只能写在 <script setup> 顶部作用域 |
不可嵌套 | 不能在函数体或条件判断里调用 |
返回值是 emit() | 用它触发事件(而不是 $emit() ) |
TypeScript 推荐 | 使用泛型定义事件名和参数 |
5、事件校验
在 Vue 中我们熟悉了对 props
做类型校验,比如:
const props = defineProps({
title: String
})
而 defineEmits()
也支持类似的“事件参数校验”,尤其是在你希望防止父组件传错数据、提高组件代码自解释性时很有用。
示例:
<script setup>
const emit = defineEmits({
// 没有校验
click: null,
// 校验 submit 事件
submit: ({ email, password }) => {
if (email && password) {
return true
} else {
console.warn('Invalid submit event payload!')
return false
}
}
})
function submitForm(email, password) {
emit('submit', { email, password })
}
</script>
你声明了两个事件:
事件名 | 校验方式 | 说明 |
---|---|---|
click | null | 没有参数校验,随便触发 |
submit | 函数 | 有参数校验逻辑,必须传入合法对象 |
submit
校验函数解释:
-
接收的是你
emit('submit', { ... })
传入的参数; -
返回
true
表示校验通过; -
返回
false
则该事件不会被触发(Vue 会阻止它),并在控制台输出警告。
什么时候使用事件校验?
适用场景 | 示例 |
---|---|
表单提交事件需要 email + password | 防止少传字段或格式错误 |
对自定义事件要求固定格式 | 如 { id: number, status: string } |
组件复杂、文档难覆盖时 | 用事件校验机制辅助防错 |
团队协作开发 | 强约束组件输入,提高可维护性 |
事件校验 vs 类型提示
特性 | 类型提示(TS 泛型) | 参数校验(对象语法) |
---|---|---|
校验时间 | 编译时 | 运行时 |
表现方式 | IDE 提示、自动补全 | 控制台 warning / 阻止触发 |
安全性 | 提前发现问题 | 运行时保护 |
推荐搭配 | 强烈建议一起使用 |
推荐组合用法(TS + 校验):
const emit = defineEmits<{
(e: 'submit', payload: { email: string, password: string }): void
}>({
submit(payload) {
return !!payload.email && !!payload.password
}
})
说明:
-
让你同时拥有类型安全 + 运行时验证;
-
避免父组件漏传或传错参数;
-
自动提示 + 编译保护 + 控制台预警 = 组件更安全!
三、组件的v-model
v-model
是 Vue 提供的一个语法糖,用于在 父组件和子组件之间同步数据。
它相当于下面两件事的组合:
<!-- 相当于 -->
<MyInput
:modelValue="username"
@update:modelValue="username = $event"
/>
换句话说,v-model="username" 背后做了两件事:
父组件将 username 作为 modelValue 传给子组件;
子组件通过 emit('update:modelValue', newValue) 通知父组件更新。
1、在子组件中实现 v-model
子组件 MyInput.vue
:
<script setup>
const props = defineProps(['modelValue']) // 接收值
const emit = defineEmits(['update:modelValue']) // 声明事件
function onInput(event) {
emit('update:modelValue', event.target.value) // 通知父组件更新
}
</script>
<template>
<input :value="modelValue" @input="onInput" />
</template>
父组件使用:<MyInput v-model="username" />
现在 username
就在父子组件之间保持同步了 ✅
2、为什么叫 modelValue
?
在 Vue 3 中:
-
默认
v-model
会绑定到modelValue
; -
触发事件名为
update:modelValue
; -
这是一个约定俗成的机制,是 Vue 内部解析
v-model
的基础。
支持多个 v-model:自定义参数名
Vue 3 新特性:可以给 v-model
自定义参数名,实现多个绑定!
<MyComponent
v-model:title="postTitle"
v-model:content="postContent"
/>
对应子组件写法:
const props = defineProps(['title', 'content'])
const emit = defineEmits(['update:title', 'update:content'])
function updateTitle(val) {
emit('update:title', val)
}
function updateContent(val) {
emit('update:content', val)
}
模板中:
<input :value="title" @input="updateTitle($event.target.value)" />
<textarea :value="content" @input="updateContent($event.target.value)" />
这样你就可以用多个 v-model
在一个组件上双向绑定多个值!
3、常见错误和调试点
问题 | 原因 |
---|---|
父组件数据不更新 | 子组件没有 emit update:modelValue |
输入后无效 | 子组件没绑定 :value |
事件名拼错 | 必须是 update:modelValue |
未声明 props | modelValue 没在 defineProps 中注册 |
Vue 3.4+ 中的新能力 —— 自定义组件支持 v-model
修饰符,也就是:
如何让一个组件能够识别、响应并处理像
.trim
、.capitalize
这样的 v-model 修饰符,并实现自定义行为。
这在实际开发中非常有用,比如封装输入组件时自动格式化大小写、过滤空格、处理数字等。
1、 什么是 v-model 修饰符?
Vue 在 v-model
上原生支持的修饰符包括:
修饰符 | 功能 |
---|---|
.trim | 去除字符串前后空格 |
.number | 将输入转换为数字 |
.lazy | 延迟到 change 时才更新 |
<input v-model.trim="username" />
在 Vue 原生组件中,这些由 Vue 自动处理。但在自定义组件中,我们需要显式实现支持这些修饰符。
2、核心 API:defineModel()
和修饰符解构
const [model, modifiers] = defineModel()
-
model
是一个响应式引用,类似于以前的modelValue
-
modifiers
是一个对象,包含当前v-model
所使用的所有修饰符
3、基于修饰符处理数据:使用 get
/ set
转换函数
你可以为 defineModel()
提供 get
/ set
选项:
const [model, modifiers] = defineModel({
set(value) {
if (modifiers.capitalize) {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
-
每次外部
v-model
设置值进来时,会通过set()
处理; -
你可以在里面根据
modifiers
条件来转化数据,比如大小写转换、过滤等。
使用示例
父组件:
<MyInput v-model.capitalize="username" />
子组件 MyInput.vue:
<script setup>
const [model, modifiers] = defineModel({
set(value) {
if (modifiers.capitalize && typeof value === 'string') {
return value.charAt(0).toUpperCase() + value.slice(1)
}
return value
}
})
</script>
<template>
<input v-model="model" />
</template>
-
用户输入
john
; -
model
会变成John
并 emit 给父组件; -
父组件中的
username
自动更新为John
。
四、透传
Vue 的透传(Attribute Inheritance / 透传属性),是指当你在使用一个组件时,传递给它的非 prop 属性(未在 props 中声明的属性),会自动或手动传递到子组件内部的某个元素上。
1、什么是透传属性?
透传属性指的是父组件向子组件传递的、子组件没有声明为 props 的属性。
Vue 会将这些属性自动收集到 $attrs
中,你可以选择是否让它们传递到组件的某个元素上。
最常见的例子就是 class
、style
和 id
。
示例说明:
<!-- 父组件 -->
<MyButton class="primary" id="submit-btn" />
如果 MyButton 组件内部没有声明 class 或 id 为 props:
<script setup>
defineProps([]) // 不接收任何 props
</script>
<template>
<button>点击我</button>
</template>
2、默认行为(单根元素):
-
Vue 会自动把
class="primary"
和id="submit-btn"
透传到<button>
上 ✅
结果 DOM 渲染为:<button class="primary" id="submit-btn">点击我</button>
3、$attrs 的作用
$attrs
是一个包含所有未被声明为 props 的属性和事件监听器的对象。
import { useAttrs } from 'vue'
const attrs = useAttrs()
它是一个响应式只读对象,你可以:
-
检查有哪些透传属性;
-
将它们手动绑定到内部元素上。
4、对 class
和 style
的合并
如果一个子组件的根元素已经有了 class
或 style
attribute,它会和从父组件上继承的值合并。
同样的规则也适用于 v-on
事件监听器: 如果原生 button
元素自身也通过 v-on
绑定了一个事件监听器,则这个监听器和从父组件继承的监听器都会被触发。
5、深层组件继承
当你的组件根节点是另一个组件时(比如你的组件是一个“代理组件”),透传属性是如何继续传递的,以及哪些会被消费、哪些会被继续传下去。
根节点是另一个组件的情况
<!-- MyButton.vue -->
<template>
<BaseButton />
</template>
在这个设计中:
-
<MyButton />
是一个外壳(代理)组件; -
它的根节点直接渲染了另一个子组件
<BaseButton />
; -
用户在使用
<MyButton />
时传入的一些属性或事件,可能需要“透传”到底层的<BaseButton />
。
6、透传机制如何处理?
Vue 的透传属性机制如下:
类型 | 是否自动透传给 BaseButton? | 说明 |
---|---|---|
未声明的属性(如 class 、id ) | ✅ 会 | Vue 自动透传到 <BaseButton /> |
未声明的事件监听器(如 @click ) | ✅ 会 | 会被当作 $attrs 继续传递下去 |
声明过的 props(在 MyButton 中声明) | ❌ 不会 | 被 MyButton 自己“消费” |
声明过的 emits(如 defineEmits(['submit']) ) | ❌ 不会 | 被 MyButton 自己监听,Vue 认为你要手动处理 |
如何将消费过的 props/emits 显式“再透传”?
Props 手动传下去:<BaseButton :type="type" />
Emits 手动再触发:<BaseButton @submit="emit('submit', $event)" />
这样你才能让 <MyButton>
代理 type
和 submit
的行为,使它们继续在 <BaseButton>
上生效。
7、禁用 Attributes 继承
如果你不想要一个组件自动地继承 attribute,你可以在组件选项中设置 inheritAttrs: false
。
从 3.3 开始你也可以直接在 <script setup>
中使用defineOptions
<script setup>
defineOptions({
inheritAttrs: false
})
// ...setup 逻辑
</script>
现在我们要再次使用一下 <MyButton>
组件例子。有时候我们可能为了样式,需要在 <button>
元素外包装一层 <div>
:
<div class="btn-wrapper">
<button class="btn" v-bind="$attrs">Click Me</button>
</div>
我们想要所有像 class
和 v-on
监听器这样的透传 attribute 都应用在内部的 <button>
上而不是外层的 <div>
上。我们可以通过设定 inheritAttrs: false
和使用 v-bind="$attrs"
来实现:
<div class="btn-wrapper">
<button class="btn" v-bind="$attrs">Click Me</button>
</div>
8、多根节点的 Attributes 继承
和单根节点组件有所不同,有着多个根节点的组件没有自动 attribute 透传行为。
<template>
<header>头部</header>
<footer>底部</footer>
</template>
这时如果父组件这样使用:
<MyLayout class="layout" />
❌ class="layout" 将不会被传递给 header 或 footer,因为 Vue:
无法判断应该透传到哪一个根节点;
所以默认放弃透传,你必须手动绑定。
如果需要,你可以在 <script setup>
中使用 useAttrs()
API 来访问一个组件的所有透传 attribute:
<script setup>
import { useAttrs } from 'vue'
const attrs = useAttrs()
</script>
需要注意的是,虽然这里的 attrs
对象总是反映为最新的透传 attribute,但它并不是响应式的 (考虑到性能因素)。你不能通过侦听器去监听它的变化。如果你需要响应性,可以使用 prop。或者你也可以使用 onUpdated()
使得在每次更新时结合最新的 attrs
执行副作用。
五、插槽
1、什么是插槽(slot)?
插槽是 Vue 提供的一种机制,用于 父组件将模板内容“注入”到子组件内部指定位置。
可以把插槽看作是“组件内部预留的占位区域”,让父组件在使用时可以自定义渲染内容。
2、默认插槽
最基础的插槽用法,只使用 <slot />
标签。
子组件:
<!-- MyCard.vue -->
<template>
<div class="card">
<slot /> <!-- 插槽位置 -->
</div>
</template>
父组件使用:
<MyCard>
<p>我是通过插槽传进去的内容</p>
</MyCard>
效果:
最终渲染相当于:
<div class="card">
<p>我是通过插槽传进去的内容</p>
</div>
3、具名插槽
你需要插入多个不同区域的内容时,可以使用具名插槽。
子组件:
<template>
<header>
<slot name="header" />
</header>
<main>
<slot />
</main>
<footer>
<slot name="footer" />
</footer>
</template>
父组件:
<MyLayout>
<template #header>
<h1>顶部标题</h1>
</template>
<p>中间内容</p>
<template #footer>
<small>底部版权</small>
</template>
</MyLayout>
#header 是 slot name="header" 的内容;
默认插槽(没有 name)会插到 <slot />。
✅ 语法糖 #xxx 相当于 v-slot:xxx。
4、条件插槽
Vue 提供了 $slots
对象,用来访问传入的插槽内容。
你可以用它判断插槽是否存在,从而决定是否渲染某段结构。
场景:
比如你想封装一个 Card
组件:
-
如果传入了 header 插槽,就显示
.card-header
; -
否则,不渲染这个 DOM 元素;
-
同样的逻辑适用于 footer 和默认插槽。
<template>
<div class="card">
<div v-if="$slots.header" class="card-header">
<slot name="header" />
</div>
<div v-if="$slots.default" class="card-content">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
解释:
$slots.header 是一个函数,只要插槽传入了内容,就返回真;
v-if="$slots.header" 只在插槽内容存在时才渲染对应的包装结构;
这样可以避免不必要的 DOM 结构,组件更轻量、更智能。
补充知识:
-
$slots
是 Vue 运行时提供的插槽访问 API; -
你也可以访问作用域插槽内容(如
$slots.default()
); -
不推荐在
<script setup>
中直接访问$slots
,应使用useSlots()
:
5、动态插槽名
Vue 支持在 v-slot
或其缩写 #
中使用动态参数,让你可以根据变量名选择插入哪个具名插槽。
就像在指令中用
v-bind:[attr]
动态绑定属性名,这里是动态绑定插槽名。
<base-layout>
<template v-slot:[dynamicSlotName]>
<p>插入动态插槽的内容</p>
</template>
<!-- 等价的缩写写法 -->
<template #[dynamicSlotName]>
<p>插入动态插槽的内容</p>
</template>
</base-layout>
dynamicSlotName 是你在 <script setup> 或 data 中定义的变量,如:
const dynamicSlotName = 'main' // 动态决定插槽名
如果子组件定义了 <slot name="main" />,这个内容就会插入那里;
这是动态选择插槽的一种方式,可以根据业务逻辑插入不同位置。
🔒 动态插槽名的语法限制:
表达式必须是字符串或能转换为字符串的值;
不能嵌套复杂表达式(如 a.b.c + '-slot' 需要先赋值为变量);
不能用于默认插槽(因为默认插槽没有名字);
6、作用域插槽
插槽内容可以访问到父组件的数据作用域,因为插槽内容本身是在父组件模板中定义的。插槽内容无法访问子组件的数据。Vue 模板中的表达式只能访问其定义时所处的作用域
如果你希望子组件向插槽暴露一些数据,让父组件在插入内容时可以动态使用这些数据,就要用到作用域插槽。
子组件:
<!-- ListRenderer.vue -->
<template>
<ul>
<li v-for="(item, index) in items" :key="index">
<slot :item="item" :index="index" />
</li>
</ul>
</template>
<script setup>
defineProps({
items: Array
})
</script>
父组件使用:
<ListRenderer :items="['Vue', 'React', 'Svelte']">
<template #default="{ item, index }">
{{ index + 1 }} - {{ item.toUpperCase() }}
</template>
</ListRenderer>
子组件使用 slot 并暴露 :item、:index 数据;
父组件通过解构作用域插槽参数使用这些数据;
父组件可以完全控制每一项如何渲染(定制渲染逻辑)。
子组件传入插槽的 props 作为了 v-slot
指令的值,可以在插槽内的表达式中访问。
<MyComponent v-slot="{ text, count }"> {{ text }} {{ count }} </MyComponent> 父组件也可以通过v-slot指令得到子组件的值
作用域插槽的应用场景总结
应用场景 | 说明 |
---|---|
高级可定制的组件 | 组件封装逻辑,视图交给外部 |
列表、表格、分页等通用结构 | 可插入任意项结构 |
表单、弹窗、布局区域定制 | 分区插槽 |
响应式逻辑封装(鼠标、拖拽) | 无渲染组件,纯逻辑暴露数据 |