Vue3——第 7 篇:组件基础——从注册到使用的完整流程

本篇定位:组件化是 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 类型和运行时校验

八、思考与练习

  1. 基础练习 :创建一个 Avatar 组件,接收 src、size(small/medium/large)、alt 等 props,并显示不同尺寸的头像。

  2. 实践任务:将上一节注册表单中的部分内容(如输入框组、表单验证错误提示)抽取为可复用的子组件,使用 props 传递配置。

  3. 陷阱排查:以下代码存在什么问题?

    vue 复制代码
    <!-- 子组件 -->
    <script setup>
    const props = defineProps<{ value: string }>()
    function clear() {
      props.value = ''
    }
    </script>
    
    <!-- 父组件 -->
    <template>
      <Child :value="parentData" />
    </template>
  4. 深度思考:为什么 Vue 要强制单向数据流?如果允许子组件直接修改父组件的 props,会带来哪些问题?

下篇预告

第 8 篇我们将全面深入地学习组件通信,包括:

  • Props 和 Emit 的标准通信模式
  • 使用 v-model 在组件间双向绑定
  • .sync 修饰符的替代方案
  • 复杂场景下的组件通信策略

准备好掌握组件间高效协作的能力!📡

相关推荐
ai工具集合2 小时前
图标集用法,前端项目 SVG 图标 icon 完整接入实操
前端
门思科技2 小时前
ThinkLink 触发模型与告警规则实战:数据一小时才来一次,告警怎么做到不迟到、不刷屏、找对人
前端·后端
weixin_BYSJ19872 小时前
【计算机毕设】房屋租赁系统02647
java·vue.js·spring boot·python·django·课程设计
hunter_0762 小时前
从零读懂 SwiftUI 新拖放 API:用 Apple 的纸牌示例理解排序、多项拖拽与移动规则
前端
时间的情敌2 小时前
Electron 设置-webkit-app-region: drag;实现拖拽后, 无法响应鼠标划过事件及点击事件的解决方式
前端·vue.js
程序猿阿森2 小时前
深入解析 NLI:RAG 与大模型防幻觉的“最后一道防线”
前端·人工智能·算法
专业程序开发源3 小时前
springboot校企合作双选会一站式预约与面试系统16010-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·面试·php·课程设计
iCxhust4 小时前
模型:500kW飞轮 10kV输电线路 油田抽油机 同时考虑无功补偿 统计:全年节电量 结果56万度电
前端·css·css3·电力·飞轮