Vue3组件

Vue 3 组件完全指南

本文档从零开始讲解 Vue 3 的组件:什么是组件、如何定义和使用、如何通过 props 传数据、如何通过事件与父组件通信、以及插槽、v-model、多根节点等,配有大量示例,适合新手系统学习。


一、什么是组件?

1.1 生活中的类比

可以把组件想象成积木块:每块积木有自己的形状和功能,可以反复使用、随意组合。
在 Vue 里,组件就是一块“可复用的界面 + 逻辑”的单元:例如一个按钮、一张卡片、一个表单输入框。
你把多个组件拼在一起,就得到一整页。

1.2 为什么要用组件?

  • 复用:同一个“按钮样式 + 点击逻辑”在多处使用,只写一次组件即可。
  • 拆分:页面拆成多个小组件,结构清晰,便于维护。
  • 协作:不同人负责不同组件,减少冲突。

1.3 组件长什么样?

在 Vue 3 中,一个组件通常对应一个 .vue 文件,称为单文件组件(SFC)
文件里一般包含三块:

  1. <template>:HTML 结构(可写 Vue 模板语法)。
  2. <script>:逻辑(数据、方法、生命周期等)。
  3. <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 在别的组件里使用(局部注册)

  1. import 把组件文件引进来。
  2. <script setup> 里,import 进来的组件会自动在当前组件的模板里可用,不用再写 components 注册(这是 <script setup> 的便利之处)。
  3. <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>

这样父组件传过来的 titlecontentcount 在模板里可以直接用(只读,不要改)。

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 会把两者都识别为同一个组件。
  • 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 标签重名

自定义组件名避免用 divspaninput 等,以免和原生标签混淆。


十三、组件相关速查表

需求 做法
使用组件 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

十四、学习建议

  1. 先会定义一个 .vue 文件(template + script setup + style),再在父组件 import 并用标签使用。
  2. 熟练 props(父传子)和 emit(子通知父),这是组件通信的基础。
  3. 需要“子组件值同步回父”时,用 v-model(modelValue + update:modelValue)。
  4. 需要“父组件定制子组件某块内容”时,用插槽(见《Vue3插槽.md》)。
  5. 组件名用 PascalCase,props 只读、事件名用 kebab-case,养成习惯可少踩坑。

把本文档里的示例在项目里敲一遍、改一改,会掌握得更牢。祝你学习顺利。

发表评论