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 修饰符的替代方案
  • 复杂场景下的组件通信策略

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

相关推荐
卸任1 小时前
Vibe coding工具使用心得
前端·ai编程
brien92 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance2 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M2 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写2 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李2 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易2 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
清晨Coding2 小时前
删了 Cookie 还能自动登录?Google系网站登录态自动恢复的原理分析
javascript
风骏时光牛马3 小时前
前沿视界
前端
kyriewen3 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员