Vue 3 组件完全指南
本文档从零开始讲解 Vue 3 的组件:什么是组件、如何定义和使用、如何通过 props 传数据、如何通过事件与父组件通信、以及插槽、v-model、多根节点等,配有大量示例,适合新手系统学习。
一、什么是组件?
1.1 生活中的类比
可以把组件想象成积木块:每块积木有自己的形状和功能,可以反复使用、随意组合。
在 Vue 里,组件就是一块“可复用的界面 + 逻辑”的单元:例如一个按钮、一张卡片、一个表单输入框。
你把多个组件拼在一起,就得到一整页。
1.2 为什么要用组件?
- 复用:同一个“按钮样式 + 点击逻辑”在多处使用,只写一次组件即可。
- 拆分:页面拆成多个小组件,结构清晰,便于维护。
- 协作:不同人负责不同组件,减少冲突。
1.3 组件长什么样?
在 Vue 3 中,一个组件通常对应一个 .vue 文件,称为单文件组件(SFC)。
文件里一般包含三块:
<template>:HTML 结构(可写 Vue 模板语法)。<script>:逻辑(数据、方法、生命周期等)。<style>:样式(可加scoped只影响当前组件)。
<!-- HelloWorld.vue -->
<template>
<div>
<p>你好,{{ name }}!</p>
</div>
</template>
<script setup>
import { ref } from 'vue'
const name = ref('Vue 3')
</script>
<style scoped>
p {
color: blue;
}
</style>
上面就是一个最简单的组件:有结构、有数据、有样式。
下面从“如何使用组件”开始,再讲“如何通过 props 和事件与外界通信”。
二、如何使用组件?
使用组件分两步:引入、在模板里当标签用。
2.1 在别的组件里使用(局部注册)
- 用
import把组件文件引进来。 - 在
<script setup>里,import 进来的组件会自动在当前组件的模板里可用,不用再写 components 注册(这是<script setup>的便利之处)。 - 在
<template>里用标签的形式写组件,标签名用帕斯卡命名(PascalCase)或短横线(kebab-case)都可以,Vue 会识别。
<!-- App.vue -->
<template>
<div>
<h1>我的应用</h1>
<HelloWorld />
<!-- 也可写成 <hello-world /> -->
</div>
</template>
<script setup>
import HelloWorld from './components/HelloWorld.vue'
</script>
注意:
- 路径
./components/HelloWorld.vue要按你实际文件位置改。 - 组件名推荐用 PascalCase(如
HelloWorld),在模板里可以写成<HelloWorld />或<hello-world />。
2.2 全局注册(可选)
若希望整个应用很多地方都用同一个组件,可以在入口文件里全局注册,这样就不用在每个页面里 import 了。
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import MyButton from './components/MyButton.vue'
const app = createApp(App)
app.component('MyButton', MyButton) // 全局注册,任意模板里可直接用 <MyButton />
app.mount('#app')
一般建议:只有真的到处都要用的组件才全局注册,其它用局部 import,更清晰。
三、Props:父组件向子组件传数据
父组件把数据通过属性的形式传给子组件,子组件用 props 接收。
这样同一个组件在不同地方可以显示不同内容(例如同一个“卡片”组件,显示不同标题和正文)。
3.1 子组件:声明 props
在子组件的 <script setup> 里用 defineProps 声明“我接收哪些属性、类型是什么、是否必填、默认值是什么”。
defineProps 是编译器宏,不需要 import,直接写即可。
只写属性名(简单写法):
<!-- Card.vue -->
<template>
<div class="card">
<h3>{{ title }}</h3>
<p>{{ content }}</p>
</div>
</template>
<script setup>
defineProps(['title', 'content'])
</script>
<style scoped>
.card {
border: 1px solid #ddd;
padding: 16px;
border-radius: 8px;
}
</style>
带类型和默认值(推荐):
<script setup>
defineProps({
title: {
type: String,
required: true
},
content: {
type: String,
default: ''
},
count: {
type: Number,
default: 0
}
})
</script>
这样父组件传过来的 title、content、count 在模板里可以直接用(只读,不要改)。
3.2 父组件:传 props
在父组件里,把子组件当标签用,通过属性把数据传进去。
属性名用 kebab-case(如 user-name)或 camelCase(如 userName)都可以,Vue 会对应到 props 里的 camelCase 名。
<!-- App.vue -->
<template>
<div>
<Card title="第一张卡片" content="这是内容" />
<Card :title="dynamicTitle" :content="dynamicContent" />
<Card title="只有标题" />
</div>
</template>
<script setup>
import { ref } from 'vue'
import Card from './components/Card.vue'
const dynamicTitle = ref('动态标题')
const dynamicContent = ref('动态内容')
</script>
- 静态传字符串:
title="第一张卡片"。 - 动态传变量:用
:title="dynamicTitle"(:即v-bind,后面是表达式)。 - 没传的 prop 会使用子组件里定义的 default。
3.3 单向数据流:不要改 props
Props 是只读的。 父组件传过来的值,子组件不要直接修改(如 props.title = 'x')。
若子组件要根据 prop 做计算或内部状态,应该:
- 用 计算属性 基于 prop 派生新值;或
- 用 ref/reactive 在组件内拷贝一份,改自己的拷贝。
<script setup>
const props = defineProps({ initialCount: { type: Number, default: 0 } })
// 若需要内部可变的“计数”,用 ref 拷贝
const count = ref(props.initialCount)
</script>
四、事件:子组件向父组件“汇报”
子组件通过 触发事件 把“发生了某件事”告诉父组件,父组件监听事件并执行自己的逻辑。
这样点击子组件里的按钮,可以让父组件去请求接口、改状态等。
4.1 子组件:定义并触发事件
在 <script setup> 里用 defineEmits 声明“我会触发哪些事件”,然后在需要的地方调用 emit('事件名', 参数)。
<!-- MyButton.vue -->
<template>
<button @click="handleClick">点击我</button>
</template>
<script setup>
const emit = defineEmits(['click'])
function handleClick() {
emit('click')
}
</script>
若需要传参给父组件,把参数放在 emit 的第二个及之后的参数里:
<script setup>
const emit = defineEmits(['submit'])
function submit() {
emit('submit', { name: '小明', age: 18 })
}
</script>
4.2 父组件:监听事件
在父组件里,给子组件标签上写 @事件名="处理函数",和监听原生 DOM 事件一样。
<!-- App.vue -->
<template>
<div>
<MyButton @click="onClick" />
<MyForm @submit="onSubmit" />
</div>
</template>
<script setup>
import MyButton from './components/MyButton.vue'
import MyForm from './components/MyForm.vue'
function onClick() {
console.log('按钮被点了')
}
function onSubmit(data) {
console.log('提交的数据', data)
}
</script>
这样就把“子组件发生的事”和“父组件的逻辑”连起来了。
五、组件上的 v-model(双向绑定)
我们不仅希望父传子(props),还希望子组件内部变化能同步回父组件(例如输入框的值)。
除了用“prop + 事件”自己实现,还可以用 v-model,让子组件支持 v-model="父组件的变量"。
5.1 子组件:接收 modelValue 并触发 update:modelValue
在 Vue 3 中,组件上的 v-model 默认对应:
- 名为
modelValue的 prop; - 名为
update:modelValue的事件。
子组件内部:用 props 接收 modelValue,在值变化时 emit('update:modelValue', 新值)。
<!-- CustomInput.vue -->
<template>
<input
:value="modelValue"
@input="emit('update:modelValue', $event.target.value)"
/>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
</script>
5.2 父组件:直接 v-model
<template>
<CustomInput v-model="username" />
<p>你输入了:{{ username }}</p>
</template>
<script setup>
import { ref } from 'vue'
import CustomInput from './components/CustomInput.vue'
const username = ref('')
</script>
这样父组件的 username 和子组件输入框就实现了双向绑定。
若需要多个“双向绑定”(例如一个组件同时绑定“用户名”和“密码”),可以用多个 v-model,见下一小节。
5.3 多个 v-model:指定名字
给 v-model 加“名字”:v-model:名字="变量"。
子组件则接收 名字 这个 prop,并触发 update:名字。
<!-- UserForm.vue -->
<template>
<div>
<input :value="username" @input="emit('update:username', $event.target.value)" />
<input :value="age" @input="emit('update:age', Number($event.target.value))" />
</div>
</template>
<script setup>
defineProps({
username: String,
age: Number
})
const emit = defineEmits(['update:username', 'update:age'])
</script>
<!-- 父组件 -->
<template>
<UserForm v-model:username="user.name" v-model:age="user.age" />
</template>
<script setup>
import { reactive } from 'vue'
const user = reactive({ name: '', age: 0 })
</script>
六、插槽:父组件往子组件里“塞内容”
子组件可以在模板里留插槽(<slot>),父组件在使用子组件时,把一段内容(HTML 或其它组件)塞进插槽里。
这样同一个“外壳”组件可以显示不同内容。
插槽分为:默认插槽、具名插槽、作用域插槽。这里只做简要说明,详细用法见《Vue3插槽.md》。
子组件留一个洞:
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-body">
<slot></slot>
</div>
</div>
</template>
父组件塞内容:
<Card>
<p>任意内容、按钮、图片都可以</p>
</Card>
七、组件的根节点:单根与多根(Vue 3 支持多根)
- Vue 2:
<template>里必须有一个根元素。 - Vue 3:
<template>里可以有多个根元素(多个根节点),称为 Fragment。
<!-- Vue 3 允许这样写 -->
<template>
<header>头部</header>
<main>主体</main>
<footer>底部</footer>
</template>
多根时,属性、事件只能写在某一个根上,不会自动继承到所有根;需要时请用一个包裹用的根(如 <div>)或分别写在每个根上。
八、组件名与使用方式
- 定义时:文件名和组件名推荐用 PascalCase,如
UserCard.vue。 - 模板里使用:
- PascalCase:
<UserCard /> - kebab-case:
<user-card />
Vue 会把两者都识别为同一个组件。
- PascalCase:
- 在 DOM 里(非 Vue 模板,如直接写 HTML),只能写小写或短横线,所以最终渲染出来的标签会是小写(如
<user-card>)。
九、完整示例:带 props、事件和插槽的卡片
下面是一个综合了 props、事件、默认插槽 的卡片组件,便于整体理解。
子组件 AppCard.vue:
<template>
<div class="app-card">
<div class="app-card__header">
<h3>{{ title }}</h3>
<button v-if="showClose" @click="emit('close')">关闭</button>
</div>
<div class="app-card__body">
<slot></slot>
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '标题'
},
showClose: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['close'])
</script>
<style scoped>
.app-card {
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
}
.app-card__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
background: #fafafa;
}
.app-card__body {
padding: 16px;
}
</style>
父组件使用:
<template>
<AppCard title="我的卡片" :show-close="true" @close="handleClose">
<p>这里是插槽内容,由父组件传入。</p>
</AppCard>
</template>
<script setup>
import AppCard from './components/AppCard.vue'
function handleClose() {
console.log('卡片关闭')
}
</script>
十、依赖注入:provide / inject(了解即可)
当父组件要向后代(子、孙、曾孙…)跨多层传数据,而不想每一层都写 props 时,可以用 provide(在祖先提供)和 inject(在后代注入)。
祖先组件:
import { provide, ref } from 'vue'
const theme = ref('dark')
provide('theme', theme)
后代组件(任意层级):
import { inject } from 'vue'
const theme = inject('theme')
适合主题、语言、用户信息等“全局性”数据。
新手可先掌握 props + 事件,需要时再查 provide/inject。
十一、生命周期钩子(简要)
组件从创建到销毁会经历多个阶段,Vue 提供了生命周期钩子,让你在某个阶段执行代码。
常用:
- onMounted:组件挂载到 DOM 后(适合请求接口、操作 DOM)。
- onUnmounted:组件即将卸载前(适合清理定时器、解绑事件)。
<script setup>
import { onMounted, onUnmounted } from 'vue'
onMounted(() => {
console.log('组件已挂载')
})
onUnmounted(() => {
console.log('组件即将卸载')
})
</script>
更多钩子(onBeforeMount、onUpdated 等)可在官方文档查阅。
十二、易错点与注意点
12.1 Props 不要改
在子组件里不要写 props.xxx = ...,要用计算属性或内部 ref 拷贝后再改。
12.2 事件名建议用 kebab-case
自定义事件名推荐用 kebab-case(如 update:user-name),在父组件里写 @update:user-name 更一致。
12.3 子组件要根元素或明确多根
若用多根,注意事件、属性只会写在你在模板里写的那一个根上;需要时用单根包一层或每个根分别写。
12.4 组件名不要和原生 HTML 标签重名
自定义组件名避免用 div、span、input 等,以免和原生标签混淆。
十三、组件相关速查表
| 需求 | 做法 |
|---|---|
| 使用组件 | import 后直接在模板写 <ComponentName />(script setup 自动注册) |
| 父传子 | 子组件 defineProps,父组件 :prop名="值" |
| 子通知父 | 子组件 defineEmits + emit('事件名', 参数),父组件 @事件名="处理函数" |
| 组件双向绑定 | 子组件 prop modelValue + emit('update:modelValue', 新值),父组件 v-model="变量" |
| 多 v-model | 子组件 prop名 + emit('update:prop名', 新值),父组件 v-model:prop名="变量" |
| 父往子里塞内容 | 子组件 <slot>,父组件在子组件标签内写内容(详见《Vue3插槽.md》) |
| 跨多层传值 | provide / inject |
十四、学习建议
- 先会定义一个
.vue文件(template + script setup + style),再在父组件 import 并用标签使用。 - 熟练 props(父传子)和 emit(子通知父),这是组件通信的基础。
- 需要“子组件值同步回父”时,用 v-model(modelValue + update:modelValue)。
- 需要“父组件定制子组件某块内容”时,用插槽(见《Vue3插槽.md》)。
- 组件名用 PascalCase,props 只读、事件名用 kebab-case,养成习惯可少踩坑。
把本文档里的示例在项目里敲一遍、改一改,会掌握得更牢。祝你学习顺利。