ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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扩展名,一个文件包含三个部分:

<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 引入的编译时语法糖,它极大地简化了组件的编写:

<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 的指令和插值:

<template> <!-- 根元素可以是多个(Vue 3 支持多根节点) --> <h1>{{ title }}</h1> <p v-if="visible">可见内容</p> <button @click="handleClick">点击</button> <slot></slot> </template>

Vue 3 支持多根节点(Fragment),不再需要包裹一个额外的父元素。

2.3

scoped属性让样式仅在当前组件中生效:

<style scoped> /* 这个类名只在这个组件中生效 */ .title { color: blue; } </style>

原理:Vue 会为组件中的每个元素添加一个唯一属性(如data-v-7ba5bd90),然后通过属性选择器限制样式范围。

/* 编译后 */.title[data-v-7ba5bd90]{color:blue;}

深度选择器(:deep()):如果需要穿透子组件样式:

<style scoped> /* 影响子组件中的 .child-class */ :deep(.child-class) { color: red; } </style>

三、组件的注册

使用组件之前,需要先注册。Vue 支持全局注册和局部注册两种方式。

3.1 局部注册(推荐)

在<script setup>中直接导入并使用,组件自动注册:

<!-- Parent.vue --> <script setup> // 导入组件,自动注册 import ChildComponent from './ChildComponent.vue' import AnotherComponent from './AnotherComponent.vue' </script> <template> <div> <ChildComponent /> <AnotherComponent /> </div> </template>

非<script setup>方式(较少使用):

<script> import ChildComponent from './ChildComponent.vue' export default { components: { ChildComponent // 显式注册 } } </script>

3.2 全局注册

在应用入口(main.ts)中注册,全局组件可以在任何子组件中使用,无需再次导入:

// main.tsimport{createApp}from'vue'importAppfrom'./App.vue'importGlobalButtonfrom'./components/GlobalButton.vue'constapp=createApp(App)// 全局注册app.component('GlobalButton',GlobalButton)app.mount('#app')

然后在任意组件中直接使用:

<template> <GlobalButton /> </template>

全局注册的优缺点:

优点缺点
无需在组件中反复导入即使未使用,也会被打包(增加最终包体积)
适合基础组件(如按钮、输入框)依赖关系不明确,不利于维护
一次注册,到处可用可能造成命名冲突

最佳实践:优先使用局部注册,仅在确实需要全局可用(如 UI 基础组件库)时使用全局注册。

3.3 组件命名规范

场景推荐命名示例
组件文件名PascalCase(大驼峰)MyComponent.vue
模板中使用PascalCase 或 kebab-case(短横线)<MyComponent>或<my-component>
全局注册名称PascalCase 或 kebab-caseapp.component('MyButton', ...)

Vue 官方推荐在模板中使用PascalCase,因为它更容易与原生 HTML 元素区分。

四、Props:父传子的数据传递

Props(属性)是父组件向子组件传递数据的主要方式。Props 遵循单向数据流原则:父级 props 的更新会流向子组件,反之则不行。

4.1 基本用法

<!-- 子组件: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>
<!-- 父组件: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:

<script setup lang="ts"> // 基本类型声明 defineProps<{ name: string age: number isAdmin: boolean hobbies: string[] address?: { city: string; street: string } // 可选属性(?) }>() </script>

如果需要默认值,需要使用withDefaults编译宏:

<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 非

<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 校验能力:

<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 类型提供静态类型,用运行时声明提供校验和默认值)。

<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:

<script setup> // ❌ 错误:直接修改 prop const props = defineProps<{ count: number }>() function increment() { props.count++ // ❌ 会触发警告 } </script>

修改 Props 的正确方式:

①子组件触发事件,父组件修改(推荐):

<!-- 子组件 --> <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>

②使用计算属性转换为本地数据:

<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 会自动转换:

<!-- 父组件两种写法都支持 --> <Child :userName="name" :user-age="age" />

在 JavaScript 中,始终使用camelCase:

defineProps<{userName:stringuserAge:number}>()

五、组件实例与 props 的关系

5.1 每个组件拥有独立的状态

当你多次使用同一个组件时,每个实例都有独立的数据作用域:

<!-- 父组件 --> <template> <Counter /> <Counter /> <Counter /> </template>

每个Counter实例都有自己独立的count状态,互不影响。

5.2 传递非字符串类型

传递非字符串类型时,必须使用v-bind:

<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的对象展开一次性传递:

<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>

六、实战:构建一个卡片组件库

我们通过一个完整的卡片组件示例,综合运用本章所学知识:

<!-- 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>

使用卡片组件:

<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. 陷阱排查:以下代码存在什么问题?

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

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进