Vue.js: 组件基础
最后更新:2026-08-26
Vue 组件是可复用的 Vue 实例——每个组件封装自己的模板、逻辑、样式。Vue 3 的核心架构就是"组件树":根组件(App.vue)→ 页面组件 → 业务组件 → 基础组件(Button/Input)。
掌握组件基础是写大型 Vue 应用的关键一步。本课帮你理解组件的本质、注册方式、命名规范,以及为什么 SFC(单文件组件)是 Vue 推荐的写法。
1. 你将学到
- 组件是什么,组件与 Vue 实例的关系
- 单文件组件 SFC 的 3 部分(template / script / style)
- 全局注册 vs 局部注册的区别和选择
- 组件命名规范(PascalCase / kebab-case)
- 组件复用(避免重复代码)
- props 和 emit 的基础概念(详细下两课)
- 5 个组件设计原则
2. 一个 30 页电商后台的代码重复灾难
(1) 痛点:30 个商品卡片,复制粘贴 30 次
Alice 的电商后台需要在多个页面显示 30 个商品卡片。她最初的方案:
HTML
<!-- ❌ 翻车版:30 个商品卡片复制 30 次 -->
<!-- HomeView.vue 中 -->
<div class="product-card">
<img src="iphone.jpg">
<h3>iPhone 15 Pro</h3>
<p>$999</p>
<button>Add to Cart</button>
</div>
<div class="product-card">
<img src="macbook.jpg">
<h3>MacBook Pro</h3>
<p>$2499</p>
<button>Add to Cart</button>
</div>
<!-- ... 还有 28 个 ... -->
<!-- 同样的代码又出现在 ProductList.vue、SearchResults.vue、Cart.vue 中 -->
产品经理 Charlie 追加了 5 个新需求:
"Alice,我要把按钮文字从'Add to Cart'改成'Add to Bag'。另外卡片要显示库存状态。还需要做移动端响应式。"
Alice 不得不修改 30+ 个 HTML 文件 × 3 次 = 90 次编辑。这根本无法维护。
(2) Vue 组件解法:1 个 ProductCard.vue,30 处复用
VUE
<!-- components/ProductCard.vue - 1 个组件定义 -->
<template>
<div class="product-card">
<img :src="product.image">
<h3>{{ product.name }}</h3>
<p>${{ product.price }}</p>
<button :disabled="product.stock === 0" @click="addToCart">
{{ product.stock === 0 ? 'Out of Stock' : 'Add to Bag' }}
</button>
</div>
</template>
<script setup>
const props = defineProps({ product: Object })
const emit = defineEmits(['add-to-cart'])
function addToCart() {
emit('add-to-cart', props.product.id)
}
</script>
VUE
<!-- HomeView.vue / ProductList.vue / SearchResults.vue 中 -->
<template>
<ProductCard v-for="product in products" :key="product.id" :product="product" @add-to-cart="handleAdd" />
</template>
1 个 ProductCard.vue → 30 处复用。改按钮文字只需改 1 处。
(3) 收益
组件化后:
- 代码量:30 × 15 行 × 3 页 = 1350 行 → 1 个组件 + 30 行 × 3 = 90 行(-93%)
- 改 1 处:30 个产品卡片自动同步更新
- 加新功能:加到组件里,所有页面自动有
- 可测试性:ProductCard.vue 可单独测试
3. 组件是什么?
(1) 定义
组件(Component)是 Vue 中可复用的、有独立逻辑和样式的 UI 单元。每个组件 = 1 个 Vue 实例 + 自己的 data/methods/lifecycle。
graph TB
A[App.vue<br/>根组件] --> B[HomeView.vue<br/>首页]
A --> C[AboutView.vue<br/>关于]
A --> D[DashboardView.vue<br/>仪表盘]
B --> E[ProductCard.vue<br/>商品卡片]
B --> F[ProductList.vue<br/>商品列表]
B --> G[FilterBar.vue<br/>筛选栏]
E --> H[Button.vue<br/>按钮]
E --> I[Badge.vue<br/>徽章]
F --> E
G --> J[Select.vue<br/>下拉]
G --> K[Input.vue<br/>输入]
style A fill:#42b883,color:#fff
style E fill:#42b883,color:#fff
style H fill:#42b883,color:#fff
(2) 5 大核心特点
| 特点 | 说明 |
|---|---|
| 可复用 | 1 个组件可在 N 处使用 |
| 封装性 | 自己的 data / methods / style,互不干扰 |
| 可组合 | 小组件组合成大组件 |
| 可测试 | 单元测试单个组件 |
| 可维护 | 改 1 处 = 改 N 处 |
(3) 组件 vs 函数
| 维度 | 函数 | 组件 |
|---|---|---|
| 复用单位 | 代码逻辑 | UI + 逻辑 |
| 输入 | 参数 (args) | props |
| 输出 | return | emit |
| 状态 | 局部变量 | data / refs |
| 副作用 | 无 | 生命周期钩子 |
4. 单文件组件 SFC
(1) 什么是 SFC?
SFC(Single File Component)= 1 个 .vue 文件 = 1 个组件。把 template、script、style 集中在一个文件里,便于管理。
VUE
<!-- ProductCard.vue - SFC 单文件组件 -->
<template>
<!-- 1. template:HTML 模板(必填) -->
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>${{ product.price }}</p>
</div>
</template>
<script setup>
// 2. script:JS 逻辑(必填)
const { ref } = Vue
const props = defineProps({ product: Object })
const count = ref(0)
</script>
<style scoped>
/* 3. style:CSS 样式(可选) */
.product-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
}
</style>
(2) SFC 3 部分详解
| 部分 | 作用 | 必填? | 语言 |
|---|---|---|---|
<template> |
HTML 模板 | ✅ | HTML + Vue 指令 |
<script setup> |
JS 逻辑 | ✅ | JavaScript / TypeScript |
<style scoped> |
CSS 样式 | ❌ | CSS / SCSS / Less |
(3) SFC vs HTML 字符串
JS
// ❌ 不用 SFC(字符串模板,Vue 2 风格)
Vue.component('my-component', {
template: '<div>{{ msg }}</div>',
data() { return { msg: 'Hello' } }
})
// ✅ 用 SFC(推荐)
// MyComponent.vue
<template>
<div>{{ msg }}</div>
</template>
<script setup>
const msg = 'Hello'
</script>
SFC 优势:
- 编辑器支持(高亮、跳转、自动补全)
- 工具链支持(Vite 热更新、TypeScript 类型检查)
- 关注点分离(HTML/JS/CSS 各 1 块)
5. 组件注册
(1) 全局注册(不推荐大型项目)
JS
// main.js
const { createApp } = Vue
import App from './App.vue'
import ProductCard from './components/ProductCard.vue'
const app = createApp(App)
// 全局注册:所有组件都能用 <ProductCard />
app.component('ProductCard', ProductCard)
app.mount('#app')
(2) 局部注册(推荐)
VUE
<!-- HomeView.vue -->
<script setup>
// 1. 导入
import ProductCard from '@/components/ProductCard.vue'
// 2. 在模板中使用(无需注册,自动可用)
</script>
<template>
<ProductCard :product="product" />
</template>
(3) 两种注册对比
| 维度 | 全局注册 | 局部注册 |
|---|---|---|
| 可用范围 | 整个应用 | 当前组件 |
| 代码位置 | main.js |
组件内 <script setup> |
| Tree-shaking | ❌ 不支持 | ✅ 支持 |
| 类型提示 | 弱 | 强(IDE 自动补全) |
| 推荐度 | ⭐⭐ 插件用 | ⭐⭐⭐⭐⭐ 业务用 |
6. 组件命名规范
(1) 3 种命名风格
| 风格 | 例子 | 用法 | 推荐度 |
|---|---|---|---|
| PascalCase | ProductCard.vue |
JS import / 模板中 | ⭐⭐⭐⭐⭐ |
| kebab-case | product-card.vue |
文件名 / kebab-case 模板 | ⭐⭐⭐ |
| camelCase | productCard.vue |
不推荐 | ⭐ |
(2) 模板中使用
VUE
<template>
<!-- PascalCase(推荐,IDE 友好) -->
<ProductCard />
<UserProfile />
<!-- kebab-case(也支持) -->
<product-card />
<user-profile />
<!-- ✅ 两种都能用,PascalCase 更易读 -->
</template>
(3) 文件夹结构
TEXT
📖 仅展示
src/
├── components/ # 公共组件(跨页面用)
│ ├── ProductCard.vue
│ ├── Button.vue
│ └── Header.vue
├── views/ # 页面组件(路由用)
│ ├── HomeView.vue
│ ├── ProductListView.vue
│ └── DashboardView.vue
├── composables/ # 组合式函数
│ ├── useAuth.ts
│ └── useFetch.ts
├── router/ # 路由配置
└── App.vue # 根组件
7. props 和 emit 基础
(1) props:父传子
VUE
<!-- 父组件 Parent.vue -->
<template>
<ProductCard :product="myProduct" :show-stock="true" />
</template>
<!-- 子组件 ProductCard.vue -->
<template>
<div>
<h3>{{ product.name }}</h3>
<p v-if="showStock">Stock: {{ product.stock }}</p>
</div>
</template>
<script setup>
// 接收父组件传的 props
const props = defineProps({
product: { type: Object, required: true },
showStock: { type: Boolean, default: false }
})
</script>
(2) emit:子传父
VUE
<!-- 子组件 ProductCard.vue -->
<template>
<button @click="handleAdd">Add to Cart</button>
</template>
<script setup>
const emit = defineEmits(['add-to-cart'])
function handleAdd() {
emit('add-to-cart', { id: 1, name: 'iPhone' })
}
</script>
VUE
<!-- 父组件 Parent.vue -->
<template>
<ProductCard @add-to-cart="handleAddToCart" />
</template>
<script setup>
function handleAddToCart(product) {
console.log('Add to cart:', product)
}
</script>
(3) props vs emit 速查
| 方向 | API | 用途 |
|---|---|---|
| 父 → 子 | defineProps() |
数据传递 |
| 子 → 父 | defineEmits() |
事件通知 |
8. 完整示例:5 个 ProductCard 复用
▶ 示例:ProductCard 组件(CDN 多组件演示)
HTML
📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
.product-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin: 0.5rem 0; transition: box-shadow 0.2s; }
.product-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.out-of-stock { opacity: 0.6; }
.price { color: #42b883; font-weight: bold; }
.stock { color: #f59e0b; font-size: 0.85rem; }
button:disabled { background: #ccc; cursor: not-allowed; }
button { background: #42b883; color: white; border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; }
</style>
<div id="app">
<h3>Featured Products</h3>
<product-card
v-for="product in products"
:key="product.id"
:product="product"
@add-to-cart="handleAdd"
></product-card>
<p>购物车商品数: {{ cartCount }}</p>
</div>
<script>
const { createApp, ref } = Vue
// 子组件:ProductCard
const ProductCard = {
props: {
product: {
type: Object,
required: true,
validator: (val) => val.id && val.name && val.price !== undefined
}
},
emits: ['add-to-cart'],
template: `
<div :class="['product-card', { 'out-of-stock': product.stock === 0 }]">
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price }}</p>
<span class="stock" v-if="product.stock < 10 && product.stock > 0">
Only {{ product.stock }} left
</span>
<button
:disabled="product.stock === 0"
@click="$emit('add-to-cart', product.id)"
>
{{ product.stock === 0 ? 'Out of Stock' : 'Add to Cart' }}
</button>
</div>
`
}
// 根组件
const App = {
components: { ProductCard },
setup() {
const products = ref([
{ id: 1, name: 'iPhone 15 Pro', price: 999, stock: 50 },
{ id: 2, name: 'MacBook Pro', price: 2499, stock: 5 },
{ id: 3, name: 'Sold Out Item', price: 99, stock: 0 }
])
const cartCount = ref(0)
function handleAdd(id) {
cartCount.value++
console.log('Added to cart:', id)
}
return { products, cartCount, handleAdd }
}
}
createApp(App).mount('#app')
</script>
▶ 示例:在 3 个页面复用 ProductCard
HTML
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<style>
.product-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 1rem; margin: 0.5rem 0; }
</style>
<div id="app">
<!-- Page 1: 首页 Featured -->
<h3>首页 Featured</h3>
<product-card v-for="p in featured" :key="p.id" :product="p"></product-card>
<!-- Page 2: 列表 All -->
<h3>商品列表 All</h3>
<product-card v-for="p in all" :key="p.id" :product="p"></product-card>
<!-- Page 3: 分类 Phones -->
<h3>分类 Phones</h3>
<product-card v-for="p in phones" :key="p.id" :product="p"></product-card>
</div>
<script>
const { createApp, ref, computed } = Vue
const ProductCard = {
props: ['product'],
template: `
<div class="product-card">
<h4>{{ product.name }}</h4>
<p>${{ product.price }} | 库存: {{ product.stock }}</p>
</div>
`
}
const App = {
components: { ProductCard },
setup() {
const products = ref([
{ id: 1, name: 'iPhone', price: 999, stock: 50, category: 'phone' },
{ id: 2, name: 'MacBook', price: 2499, stock: 20, category: 'laptop' },
{ id: 3, name: 'iPad', price: 599, stock: 30, category: 'tablet' }
])
const featured = computed(() => products.value.slice(0, 2))
const all = computed(() => products.value)
const phones = computed(() => products.value.filter(p => p.category === 'phone'))
return { featured, all, phones }
}
}
createApp(App).mount('#app')
</script>
▶ 示例:全局注册 vs 局部注册对比
HTML
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id="app">
<h3>局部注册演示</h3>
<my-button text="按钮 A" variant="primary"></my-button>
<my-button text="按钮 B" variant="success"></my-button>
<my-button text="按钮 C" variant="danger"></my-button>
</div>
<script>
const { createApp } = Vue
// 子组件
const MyButton = {
props: ['text', 'variant'],
template: `
<button :style="{ background: bgColor, color: 'white', padding: '6px 12px', border: 'none', borderRadius: '4px', margin: '4px' }">
{{ text }}
</button>
`,
computed: {
bgColor() {
return { primary: '#3b82f6', success: '#10b981', danger: '#ef4444' }[this.variant] || '#999'
}
}
}
// ✅ 局部注册(推荐)
const App = {
components: { MyButton },
template: `
<my-button text="局部注册按钮" variant="primary"></my-button>
`
}
const app = createApp(App)
// ❌ 全局注册(不推荐 — 整个应用都可用但 bundle 变大)
// app.component('MyButton', MyButton)
app.mount('#app')
</script>
▶ 示例:props 5 大类型 + 验证器
HTML
📖 仅展示
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<div id="app">
<user-card
:name="'Alice'"
:age="25"
:active="true"
:user="{ id: 1, role: 'admin' }"
:items="['Vue', 'React', 'Angular']"
:title="'管理员'"
:page-size="20"
:email="'alice@example.com'"
:status="'active'"
></user-card>
</div>
<script>
const { createApp } = Vue
const UserCard = {
// props 5 大类型 + 验证器
props: {
// 基础类型
name: String,
age: Number,
active: Boolean,
// 复杂类型
user: Object,
items: Array,
// 必填 + 默认值
title: { type: String, required: true },
pageSize: { type: Number, default: 20 },
// 验证器
email: {
type: String,
validator: (val) => val.includes('@')
},
status: {
type: String,
validator: (val) => ['active', 'inactive'].includes(val)
}
},
template: `
<div style="padding: 1rem; border: 1px solid #eee;">
<h3>{{ title }} - {{ name }}</h3>
<p>年龄: {{ age }} | 状态: {{ active ? '在线' : '离线' }}</p>
<p>角色: {{ user.role }} | 框架: {{ items.join(', ') }}</p>
<p>邮箱: {{ email }} | 每页: {{ pageSize }}</p>
</div>
`
}
const App = { components: { UserCard } }
createApp(App).mount('#app')
</script>
▶ 示例:5 个组件设计原则
| 原则 | 说明 |
|---|---|
| 单一职责 | 1 个组件只做 1 件事 |
| 可复用 | 抽象通用 UI(Button/Input/Modal) |
| 可配置 | props 控制行为,slot 控制内容 |
| 可组合 | 小组件组合成大组件 |
| 易测试 | 组件独立,便于单元测试 |
▶ 示例:5 个常见错误
| 错误 | 现象 | 解决 |
|---|---|---|
| 组件名单文件 | IDE 报错 | 用 PascalCase:ProductCard.vue |
| 全局注册太多 | bundle 变大 | 局部注册支持 tree-shaking |
| 组件太大 | 难维护 | 拆成小组件 |
| props 直接修改 | Vue 警告 | props 只读,要改用 emit |
| 忘记 import 组件 | 模板显示 "unknown component" | 检查 <script setup> 中的 import |
❓ 常见问题
Q 什么时候用全局注册?
A 只在 2 种情况:(1) 插件式组件(如 Element Plus 的
<el-button>);(2) 整个应用都要用的基础组件(Button/Icon)。业务组件一律局部注册,tree-shaking 才能生效。Q SFC 必须 3 部分都有吗?
A
<template> 和 <script> 必填,<style> 可选。如果组件只有 JS(比如只是封装数据),可以没有 template(用 render 函数)。Q 组件文件多大合适?
A 建议 < 200 行。超过 300 行考虑拆分。组件越简单越好维护。
Q SFC 用 .vue 后缀,其他工具怎么识别?
A Vite / Webpack / Vue CLI 都默认识别 .vue 后缀。VS Code 装 "Vue - Official" 扩展。
Q 如何测试单个组件?
A 用 Vitest + Vue Test Utils(@vue/test-utils):
mount(ProductCard, { props: { product: mockProduct } })。本教程 Phase 4.6 详细介绍。Q 组件库(Element Plus)怎么用?
A npm 安装 → main.js 全局注册 → 模板中直接用
<el-button>。具体看 Phase 4.7 章节。Q SFC 和 React JSX 的区别?
A SFC 用 HTML-like 模板(接近 web 标准),React 用 JSX(JS 写 HTML)。SFC 学习曲线平缓,JSX 灵活但需要 build。Vue 3 也支持 JSX(@vitejs/plugin-vue-jsx),但 SFC 是默认推荐。
📖 小节
- 组件是可复用的 Vue 实例,封装 template / script / style 三部分
- SFC(.vue 文件)是 Vue 3 推荐写法,编辑器 + 工具链支持好
- 局部注册 > 全局注册(tree-shaking 友好)
- 命名规范:PascalCase(ProductCard)> kebab-case(product-card)
- props 父传子(defineProps),emit 子传父(defineEmits)
- 组件设计原则:单一职责、可复用、可配置、可组合、易测试
- 文件夹结构:components/ 公共组件、views/ 页面组件、composables/ 组合函数
📝 作业
-
基础题(难度⭐) 写一个 Button 组件
src/components/BaseButton.vue:- props:
text(String),variant(String, 'primary'/'success'/'danger') - 3 种 variant 对应 3 种 class
- emit
click事件 - 在 HomeView.vue 中使用 3 个不同 variant
- props:
-
进阶题(难度⭐⭐) 写一个商品列表 + 卡片系统:
- ProductCard.vue:单个商品卡片(props: product, emit: add-to-cart)
- ProductList.vue:商品列表容器(v-for + 过滤)
- App.vue:根组件,传数据 + 处理 add-to-cart 事件
- 3 个文件 3 个组件嵌套使用
-
挑战题(难度⭐⭐⭐) 实现一个完整的"用户卡片"组件系统:
- Avatar.vue:头像组件(支持 url / initials 两种模式)
- UserCard.vue:用户卡片(用 Avatar)
- UserList.vue:用户列表(用 UserCard)
- App.vue:根组件(数据 + 交互)
- 5 个 props(avatar/name/email/role/active)
- 3 个 emit 事件(edit/delete/select)
- scoped 样式隔离