本篇定位:组件化是 Vue 最核心的开发范式,也是构建大型应用的基础。本篇将带你从零开始理解 Vue 组件的本质、注册方式、Props 数据传递和类型校验。读完本篇,你将能够独立创建和复用 Vue 组件,并理解组件间数据流动的基本原则。
一、什么是组件?
1.1 组件的本质
Vue 组件是可复用的 Vue 实例 ,它拥有独立的模板、逻辑和样式。从本质上讲,组件就是一个带有特定行为和样式的 UI 片段。
可以这样理解:
-
一个 Vue 应用就像一棵组件树
-
根组件(
App.vue)是树根,其他组件是其枝叶 -
每个组件都是一个独立的"小世界",封装了自己的 HTML、CSS 和 JavaScript
App.vue (根组件)
├── Header.vue
│ ├── Logo.vue
│ └── NavMenu.vue
├── Sidebar.vue
│ └── UserProfile.vue
└── Footer.vue
1.2 为什么要使用组件?
| 优势 | 说明 |
|---|---|
| 复用性 | 同一个组件可以在多个地方使用,避免重复造轮子 |
| 可维护性 | 每个组件职责单一,易于定位和修复问题 |
| 可测试性 | 独立组件可以单独进行单元测试 |
| 协作开发 | 不同开发者可以并行开发不同组件,减少冲突 |
| 关注点分离 | 模板、逻辑和样式集中管理,清晰明了 |
1.3 组件的两种编写风格
Vue 3 支持两种组件编写风格:
| 风格 | 特点 | 适用场景 |
|---|---|---|
| 选项式 API(Options API) | 使用 data、methods、computed 等选项 |
Vue 2 用户过渡,或偏好传统风格 |
| 组合式 API(Composition API) | 使用 setup 和 <script setup> |
Vue 3 推荐,逻辑复用更优雅 |
本系列教程将默认使用组合式 API 的 <script setup> 语法,这是 Vue 3 的官方推荐写法。
二、单文件组件(SFC)的完整结构
Vue 的单文件组件(Single File Component,简称 SFC)使用 .vue 扩展名,一个文件包含三个部分:
vue
<script setup lang="ts">
// 1. 逻辑部分(JavaScript/TypeScript)
// 响应式数据、方法、生命周期等
</script>
<template>
<!-- 2. 模板部分(HTML) -->
<!-- 描述组件的 UI 结构 -->
</template>
<style scoped>
/* 3. 样式部分(CSS) */
/* scoped 表示样式仅作用于当前组件 */
</style>
2.1
<script setup> 是 Vue 3.2 引入的编译时语法糖,它极大地简化了组件的编写:
vue
<script setup lang="ts">
// 导入的组件自动注册,无需手动注册
import MyButton from './MyButton.vue'
// 定义的变量和方法直接暴露给模板
const message = 'Hello Vue!'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<div>
<p>{{ message }}</p>
<MyButton @click="increment">点击次数:{{ count }}</MyButton>
</div>
</template>
<script setup> 的核心特性:
- 顶层绑定自动暴露:所有变量、函数、导入的组件,都可以直接在模板中使用
- 更简洁的代码 :不需要
export default、setup()函数的返回值等 - 更好的运行时性能:编译时优化,减少运行时代码
- 更好的 TypeScript 支持:类型推导更准确
2.2 模板
模板部分使用增强的 HTML 语法,支持 Vue 的指令和插值:
vue
<template>
<!-- 根元素可以是多个(Vue 3 支持多根节点) -->
<h1>{{ title }}</h1>
<p v-if="visible">可见内容</p>
<button @click="handleClick">点击</button>
<slot></slot>
</template>
Vue 3 支持多根节点(Fragment),不再需要包裹一个额外的父元素。
2.3
scoped 属性让样式仅在当前组件中生效:
vue
<style scoped>
/* 这个类名只在这个组件中生效 */
.title {
color: blue;
}
</style>
原理 :Vue 会为组件中的每个元素添加一个唯一属性(如 data-v-7ba5bd90),然后通过属性选择器限制样式范围。
css
/* 编译后 */
.title[data-v-7ba5bd90] {
color: blue;
}
深度选择器 (:deep()):如果需要穿透子组件样式:
vue
<style scoped>
/* 影响子组件中的 .child-class */
:deep(.child-class) {
color: red;
}
</style>
三、组件的注册
使用组件之前,需要先注册。Vue 支持全局注册 和局部注册两种方式。
3.1 局部注册(推荐)
在 <script setup> 中直接导入并使用,组件自动注册:
vue
<!-- Parent.vue -->
<script setup>
// 导入组件,自动注册
import ChildComponent from './ChildComponent.vue'
import AnotherComponent from './AnotherComponent.vue'
</script>
<template>
<div>
<ChildComponent />
<AnotherComponent />
</div>
</template>
非 <script setup> 方式(较少使用):
vue
<script>
import ChildComponent from './ChildComponent.vue'
export default {
components: {
ChildComponent // 显式注册
}
}
</script>
3.2 全局注册
在应用入口(main.ts)中注册,全局组件可以在任何子组件中使用,无需再次导入:
typescript
// main.ts
import { createApp } from 'vue'
import App from './App.vue'
import GlobalButton from './components/GlobalButton.vue'
const app = createApp(App)
// 全局注册
app.component('GlobalButton', GlobalButton)
app.mount('#app')
然后在任意组件中直接使用:
vue
<template>
<GlobalButton />
</template>
全局注册的优缺点:
| 优点 | 缺点 |
|---|---|
| 无需在组件中反复导入 | 即使未使用,也会被打包(增加最终包体积) |
| 适合基础组件(如按钮、输入框) | 依赖关系不明确,不利于维护 |
| 一次注册,到处可用 | 可能造成命名冲突 |
最佳实践 :优先使用局部注册,仅在确实需要全局可用(如 UI 基础组件库)时使用全局注册。
3.3 组件命名规范
| 场景 | 推荐命名 | 示例 |
|---|---|---|
| 组件文件名 | PascalCase(大驼峰) | MyComponent.vue |
| 模板中使用 | PascalCase 或 kebab-case(短横线) | <MyComponent> 或 <my-component> |
| 全局注册名称 | PascalCase 或 kebab-case | app.component('MyButton', ...) |
Vue 官方推荐在模板中使用 PascalCase,因为它更容易与原生 HTML 元素区分。
四、Props:父传子的数据传递
Props(属性) 是父组件向子组件传递数据的主要方式。Props 遵循单向数据流原则:父级 props 的更新会流向子组件,反之则不行。
4.1 基本用法
vue
<!-- 子组件:Child.vue -->
<script setup lang="ts">
// 定义 props,使用 TypeScript 类型声明
defineProps<{
title: string
count: number
isActive: boolean
}>()
</script>
<template>
<div>
<h2>{{ title }}</h2>
<p>数量:{{ count }}</p>
<p>状态:{{ isActive ? '激活' : '未激活' }}</p>
</div>
</template>
vue
<!-- 父组件:Parent.vue -->
<script setup>
import Child from './Child.vue'
import { ref } from 'vue'
const parentTitle = ref('Hello Props')
const parentCount = ref(42)
</script>
<template>
<Child
:title="parentTitle"
:count="parentCount"
:is-active="true"
/>
</template>
关键规则:
- 使用
v-bind(:)绑定动态值;静态值直接写属性名 - 字符串也可以不用
::title="Hello"等价于:title="'Hello'" - 布尔属性不带值默认为
true:is-active等价于:is-active="true"
4.2 类型声明与默认值
在 <script setup> 中,使用类型声明定义 props:
vue
<script setup lang="ts">
// 基本类型声明
defineProps<{
name: string
age: number
isAdmin: boolean
hobbies: string[]
address?: { city: string; street: string } // 可选属性(?)
}>()
</script>
如果需要默认值 ,需要使用 withDefaults 编译宏:
vue
<script setup lang="ts">
interface Props {
title: string
count?: number
items?: string[]
status?: 'pending' | 'success' | 'error'
}
// 为可选属性设置默认值
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => ['默认项1', '默认项2'], // 对象/数组需使用工厂函数
status: 'pending'
})
</script>
注意 :withDefaults 是 Vue 的编译宏,不需要导入,直接在 <script setup> 中使用即可。
4.3 非
vue
<script>
export default {
props: {
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0
},
items: {
type: Array,
default: () => []
},
status: {
type: String,
validator: (value) => ['pending', 'success', 'error'].includes(value)
}
}
}
</script>
4.4 Props 的校验规则
Vue 提供了丰富的 prop 校验能力:
vue
<script setup lang="ts">
// 使用运行时声明(而非仅类型声明),支持校验
defineProps({
// 基础类型
propA: Number,
// 多种类型
propB: [String, Number],
// 必填
propC: {
type: String,
required: true
},
// 默认值
propD: {
type: Number,
default: 100
},
// 对象默认值
propE: {
type: Object,
default: () => ({ name: '默认' })
},
// 自定义校验
propF: {
type: String,
validator: (value: string) => ['small', 'medium', 'large'].includes(value)
}
})
</script>
TypeScript 类型声明 vs 运行时声明:
| 方式 | 优点 | 缺点 |
|---|---|---|
| TS 类型声明 | 类型安全、IDE 提示好 | 无法在运行时校验和提供默认值 |
| 运行时声明 | 支持校验、默认值 | 缺乏 TS 类型推断(可以结合使用) |
最佳实践:同时使用两者,发挥各自优势(用 TS 类型提供静态类型,用运行时声明提供校验和默认值)。
vue
<script setup lang="ts">
interface Props {
size?: 'small' | 'medium' | 'large'
count?: number
}
// 运行时声明 + TS 类型
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
count: 0
})
</script>
4.5 单向数据流原则
Props 是只读的 ,子组件不能直接修改父组件传递的 prop:
vue
<script setup>
// ❌ 错误:直接修改 prop
const props = defineProps<{ count: number }>()
function increment() {
props.count++ // ❌ 会触发警告
}
</script>
修改 Props 的正确方式:
① 子组件触发事件,父组件修改(推荐):
vue
<!-- 子组件 -->
<script setup>
const props = defineProps<{ count: number }>()
const emit = defineEmits<{ (e: 'update:count', value: number): void }>()
function increment() {
emit('update:count', props.count + 1)
}
</script>
② 使用计算属性转换为本地数据:
vue
<script setup>
import { computed } from 'vue'
const props = defineProps<{ initialValue: number }>()
const localCount = computed({
get: () => props.initialValue,
set: (val) => emit('update:initialValue', val)
})
</script>
4.6 Props 的命名约定
在模板中使用 camelCase (驼峰)或 kebab-case(短横线)均可,Vue 会自动转换:
vue
<!-- 父组件两种写法都支持 -->
<Child :userName="name" :user-age="age" />
在 JavaScript 中,始终使用 camelCase:
typescript
defineProps<{
userName: string
userAge: number
}>()
五、组件实例与 props 的关系
5.1 每个组件拥有独立的状态
当你多次使用同一个组件时,每个实例都有独立的数据作用域:
vue
<!-- 父组件 -->
<template>
<Counter />
<Counter />
<Counter />
</template>
每个 Counter 实例都有自己独立的 count 状态,互不影响。
5.2 传递非字符串类型
传递非字符串类型时,必须使用 v-bind:
vue
<template>
<!-- 字符串 -->
<Child name="张三" />
<!-- 数字:必须用 v-bind -->
<Child :age="25" />
<!-- 布尔值 -->
<Child :is-active="true" />
<!-- 数组 -->
<Child :hobbies="['阅读', '编程']" />
<!-- 对象 -->
<Child :user="{ name: '李四', age: 30 }" />
<!-- 方法(很少用) -->
<Child :callback="handleCallback" />
</template>
5.3 动态传递多个 props(对象展开)
如果有一个包含多个属性的对象,可以使用 v-bind 的对象展开一次性传递:
vue
<script setup>
const user = {
name: '王五',
age: 28,
email: 'wangwu@example.com'
}
</script>
<template>
<!-- 等价于传递 :name="user.name" :age="user.age" :email="user.email" -->
<UserCard v-bind="user" />
</template>
六、实战:构建一个卡片组件库
我们通过一个完整的卡片组件示例,综合运用本章所学知识:
vue
<!-- Card.vue - 通用卡片组件 -->
<script setup lang="ts">
interface Props {
title?: string
subtitle?: string
variant?: 'default' | 'primary' | 'success' | 'warning' | 'danger'
size?: 'small' | 'medium' | 'large'
bordered?: boolean
hoverable?: boolean
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
title: '',
subtitle: '',
variant: 'default',
size: 'medium',
bordered: true,
hoverable: false,
loading: false
})
// 计算样式类
const variantClass = computed(() => `card-${props.variant}`)
const sizeClass = computed(() => `card-${props.size}`)
</script>
<template>
<div
class="card"
:class="[
variantClass,
sizeClass,
{ 'card-bordered': bordered },
{ 'card-hoverable': hoverable },
{ 'card-loading': loading }
]"
>
<!-- 标题区域 -->
<div v-if="title || subtitle" class="card-header">
<h3 v-if="title" class="card-title">{{ title }}</h3>
<p v-if="subtitle" class="card-subtitle">{{ subtitle }}</p>
</div>
<!-- 内容区域(插槽) -->
<div class="card-body">
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
</div>
<!-- 加载状态覆盖层 -->
<div v-if="loading" class="card-overlay">
<span class="spinner"></span>
</div>
</div>
</template>
<style scoped>
.card {
background: white;
border-radius: 12px;
padding: 20px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
/* 尺寸变体 */
.card-small { padding: 12px 16px; }
.card-medium { padding: 20px 24px; }
.card-large { padding: 28px 32px; }
/* 颜色变体 */
.card-default { background: white; }
.card-primary { background: #ebf8ff; border-color: #4299e1; }
.card-success { background: #f0fff4; border-color: #48bb78; }
.card-warning { background: #fffff0; border-color: #ecc94b; }
.card-danger { background: #fff5f5; border-color: #fc8181; }
.card-bordered { border: 2px solid #e2e8f0; }
.card-bordered.card-primary { border-color: #4299e1; }
.card-bordered.card-success { border-color: #48bb78; }
/* ... 其他变体 */
.card-hoverable:hover {
transform: translateY(-4px);
box-shadow: 0 10px 30px rgba(0,0,0,0.1);
}
.card-header {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #1a202c;
margin: 0;
}
.card-subtitle {
font-size: 14px;
color: #718096;
margin: 4px 0 0 0;
}
.card-body {
color: #2d3748;
}
.card-loading {
opacity: 0.6;
pointer-events: none;
}
.card-overlay {
position: absolute;
inset: 0;
background: rgba(255,255,255,0.7);
display: flex;
align-items: center;
justify-content: center;
}
.spinner {
width: 32px;
height: 32px;
border: 4px solid #e2e8f0;
border-top-color: #4299e1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
使用卡片组件:
vue
<template>
<div class="app">
<!-- 基础卡片 -->
<Card title="欢迎使用 Vue 3">
<p>这是一个基础的卡片组件,展示内容区域。</p>
</Card>
<!-- 带变体的卡片 -->
<Card
title="成功提示"
subtitle="操作已成功完成"
variant="success"
:bordered="true"
>
<p>您的数据已保存。</p>
<template #footer>
<button @click="handleAction">确认</button>
</template>
</Card>
<!-- 可悬浮的卡片 -->
<Card
title="产品卡片"
variant="primary"
:hoverable="true"
:loading="isLoading"
>
<template #header>
<div class="product-image">🖼️</div>
</template>
<div class="product-info">
<p>商品名称:Vue 3 完全指南</p>
<p>价格:¥99.00</p>
</div>
<template #footer>
<button>加入购物车</button>
</template>
</Card>
</div>
</template>
<script setup>
import Card from './components/Card.vue'
import { ref } from 'vue'
const isLoading = ref(false)
function handleAction() {
alert('已确认!')
}
</script>
七、本篇小结
| 知识点 | 核心内容 | 关键要点 |
|---|---|---|
| 组件本质 | 可复用的 Vue 实例,拥有独立模板、逻辑、样式 | 组件树构成应用 |
| 单文件组件 | .vue 文件包含 <script setup>、<template>、<style scoped> |
<script setup> 是 Vue 3 推荐写法 |
| 局部注册 | 在 <script setup> 中 import 后直接使用 |
优先推荐,按需加载 |
| 全局注册 | app.component() 全局注册 |
适用于基础 UI 组件库 |
| Props 定义 | defineProps<{ name: type }>() |
使用 TS 类型声明 |
| Props 默认值 | withDefaults(defineProps<Props>(), { ... }) |
对象/数组需用工厂函数 |
| 单向数据流 | 子组件不能直接修改 props | 通过事件通知父组件修改 |
| Props 校验 | 运行时声明支持 validator 等 |
可结合 TS 类型和运行时校验 |
八、思考与练习
-
基础练习 :创建一个
Avatar组件,接收src、size(small/medium/large)、alt等 props,并显示不同尺寸的头像。 -
实践任务:将上一节注册表单中的部分内容(如输入框组、表单验证错误提示)抽取为可复用的子组件,使用 props 传递配置。
-
陷阱排查:以下代码存在什么问题?
vue<!-- 子组件 --> <script setup> const props = defineProps<{ value: string }>() function clear() { props.value = '' } </script> <!-- 父组件 --> <template> <Child :value="parentData" /> </template> -
深度思考:为什么 Vue 要强制单向数据流?如果允许子组件直接修改父组件的 props,会带来哪些问题?
下篇预告
第 8 篇我们将全面深入地学习组件通信,包括:
- Props 和 Emit 的标准通信模式
- 使用
v-model在组件间双向绑定 .sync修饰符的替代方案- 复杂场景下的组件通信策略
准备好掌握组件间高效协作的能力!📡