第 5 章 组件基础
本章导读
- 你将学会:SFC 单文件组件的组织、组件注册与使用、props 父传子、defineEmits 子传父、插槽 slot——组件化开发的四件套
- 前置要求:第 4 章
- 预计用时:1~1.5 天。组件通信是业务开发的日常主线,务必练熟
5.1 SFC 单文件组件与组件注册使用
【知识点讲解】
一个 .vue 文件就是一个组件(SFC,Single File Component),固定三段结构:
<script setup>:逻辑(JS)<template>:结构(HTML)<style scoped>:样式,scoped表示样式只作用于当前组件,互不污染
<script setup> 是语法糖:里面 import 进来的组件自动注册,模板里直接用,不需要 components: {} 选项——这是 Vue3 企业项目标准写法。
组件名即标签名:import UserCard from ... → <UserCard />(模板里也支持小写连字符 <user-card />,但企业里统一大驼峰)。
组件设计心法(面试也问):页面组件放 views/,可复用组件放 components/;一个组件只做一件事。判断标准——这个 UI/逻辑在第二个地方还要用吗?要,就抽组件。
【示例代码】
<script setup>
// 组件自己的数据
import { ref } from 'vue'
const nickname = ref('小明')
</script>
<template>
<div class="user-card">
<h3>{{ nickname }}</h3>
<p>前端工程师</p>
</div>
</template>
<style scoped>
.user-card {
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 16px;
width: 200px;
}
</style>
<script setup>
// import 即注册,模板直接用,无需任何额外声明
import UserCard from '@/components/UserCard.vue'
</script>
<template>
<div class="page">
<h2>组件使用</h2>
<UserCard />
<UserCard />
</div>
</template>
// 文件:src/router/index.js(routes 追加)
{ path: '/comp', name: 'comp', component: () => import('@/views/CompView.vue') }
【练习Demo】
需求:封装一个 PriceTag 价格标签组件(红色价格 + "元"),页面里用它展示 3 个不同商品价格(价格先在组件里写死即可,下一节学动态传参)。
参考实现:
<script setup>
</script>
<template>
<span class="price">¥ 99.00</span>
</template>
<style scoped>
.price {
color: #ff4d4f;
font-weight: bold;
}
</style>
<script setup>
import PriceTag from '@/components/PriceTag.vue'
</script>
<template>
<div class="page">
<h2>商品列表</h2>
<p>键盘 <PriceTag /></p>
<p>鼠标 <PriceTag /></p>
<p>显示器 <PriceTag /></p>
</div>
</template>
5.2 props:父传子
【知识点讲解】
props 是父组件向子组件传数据的唯一正规通道。子组件用 defineProps 声明"我要接收什么",父组件用同名属性传入。
企业规范:props 声明一律用对象写法(带类型 + 默认值 + 是否必填),相当于自带接口文档:
const props = defineProps({
title: { type: String, required: true }, // 必传字符串
count: { type: Number, default: 0 } // 可选,默认 0
})
两条铁律:
- props 是只读的,子组件绝不允许修改 props(改了控制台报警告,且数据流会乱)。
- 单向数据流:数据从父流向子,子想改只能"通知父"(下一节 emit)。
<script setup> 中 defineProps 是编译器宏,不需要 import。
【示例代码】
<script setup>
// 对象写法:类型 + 必填 + 默认值,企业标准
const props = defineProps({
name: { type: String, required: true },
price: { type: Number, required: true },
tags: { type: Array, default: () => [] }, // 引用类型默认值必须用函数返回
showStock: { type: Boolean, default: true }
})
console.log(props.name) // JS 里通过 props.xxx 访问
</script>
<template>
<div class="product-card">
<h3>{{ name }}</h3>
<p class="price">¥{{ price.toFixed(2) }}</p>
<p v-if="showStock">库存充足</p>
<p>
<span v-for="t in tags" :key="t" class="tag">{{ t }}</span>
</p>
</div>
</template>
<style scoped>
.product-card { border: 1px solid #eee; border-radius: 8px; padding: 16px; width: 240px; }
.price { color: #ff4d4f; font-weight: bold; }
.tag { background: #f0f9ff; border: 1px solid #bae6fd; border-radius: 4px; padding: 2px 6px; margin-right: 4px; font-size: 12px; }
</style>
<script setup>
import { ref } from 'vue'
import ProductCard from '@/components/ProductCard.vue'
const product = ref({
name: '机械键盘',
price: 399,
tags: ['办公', '静音']
})
</script>
<template>
<div class="page">
<h2>父传子</h2>
<ProductCard :name="product.name" :price="product.price" :tags="product.tags" />
<ProductCard :name="'显示器'" :price="1299" :show-stock="false" />
</div>
</template>
【练习Demo】
需求:升级 5.1 的 PriceTag:接收 value(数字,必填)和 size(字符串,默认 middle,可选 small/large),显示对应字号的价格。
参考实现:
<script setup>
const props = defineProps({
value: { type: Number, required: true },
size: { type: String, default: 'middle' } // small / middle / large
})
</script>
<template>
<span class="price" :class="size">¥ {{ value.toFixed(2) }}</span>
</template>
<style scoped>
.price { color: #ff4d4f; font-weight: bold; }
.small { font-size: 12px; }
.middle { font-size: 16px; }
.large { font-size: 24px; }
</style>
<script setup>
import PriceTag from '@/components/PriceTag.vue'
</script>
<template>
<div class="page">
<h2>商品列表</h2>
<p>键盘 <PriceTag :value="399" size="small" /></p>
<p>鼠标 <PriceTag :value="199" /></p>
<p>显示器 <PriceTag :value="5299" size="large" /></p>
</div>
</template>
5.3 defineEmits:子传父
【知识点讲解】
子组件不能直接改父组件的数据,正确姿势是子组件"喊话",父组件"听到后自己改" 。喊话就是 defineEmits。
流程三步:子组件 const emit = defineEmits(['事件名']) → 子组件合适时机 emit('事件名', 参数) → 父组件模板上 @事件名="处理函数" 接收。
配套技能 v-model 简化:父组件写 <Child v-model="msg" /> 等价于 :modelValue + @update:modelValue,子组件 emit update:modelValue 即可——封装表单控件的标准做法,企业大量使用。
【示例代码】
<script setup>
import { ref } from 'vue'
// 声明本组件会触发哪些事件(数组写法,简单场景够用)
const emit = defineEmits(['change', 'update:num'])
const inner = ref(0)
function plus() {
inner.value++
// 触发 change 事件,把值带出去给父组件
emit('change', inner.value)
}
// v-model 配套:通知父组件更新 num
function updateNum(val) {
emit('update:num', val)
}
</script>
<template>
<div>
<button @click="plus">点我(已点 {{ inner }} 次)</button>
<button @click="updateNum(inner)">同步次数给父组件</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
import CounterButton from '@/components/CounterButton.vue'
const total = ref(0)
const num = ref(0) // 供 v-model:num 使用
function onChange(val) {
total.value = val
console.log('子组件喊话,当前值:', val)
}
</script>
<template>
<div class="page">
<h2>子传父</h2>
<CounterButton @change="onChange" />
<p>父组件收到的值:{{ total }}</p>
<CounterButton v-model:num="num" />
<p>v-model 同步过来的 num:{{ num }}</p>
</div>
</template>
【练习Demo】
需求:封装 ConfirmDialog 确认弹窗组件:接收 title(标题)和 visible(是否显示);点"确定"触发 confirm 事件、点"取消"或遮罩触发 cancel 事件;父组件控制显示隐藏并在按钮旁显示用户点了什么。
参考实现:
<script setup>
const props = defineProps({
title: { type: String, default: '提示' },
visible: { type: Boolean, default: false }
})
const emit = defineEmits(['confirm', 'cancel'])
</script>
<template>
<div v-if="visible" class="mask" @click.self="emit('cancel')">
<div class="dialog">
<h3>{{ title }}</h3>
<p>确定要执行此操作吗?</p>
<button class="ok" @click="emit('confirm')">确定</button>
<button @click="emit('cancel')">取消</button>
</div>
</div>
</template>
<style scoped>
.mask { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.45); display: flex; align-items: center; justify-content: center; }
.dialog { background: #fff; border-radius: 8px; padding: 24px; width: 320px; }
.ok { background: #1677ff; color: #fff; margin-right: 8px; }
</style>
<script setup>
import { ref } from 'vue'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
const visible = ref(false)
const action = ref('')
function onConfirm() {
action.value = '用户点了确定'
visible.value = false
}
function onCancel() {
action.value = '用户点了取消'
visible.value = false
}
</script>
<template>
<div class="page">
<h2>确认弹窗</h2>
<button @click="visible = true">删除数据</button>
<p>{{ action }}</p>
<ConfirmDialog title="删除确认" :visible="visible" @confirm="onConfirm" @cancel="onCancel" />
</div>
</template>
5.4 插槽 slot
【知识点讲解】
props 传的是数据,插槽传的是结构(一段模板) 。子组件在模板里挖坑 <slot>,父组件往坑里塞内容——这样同一个组件外壳可以套完全不同的内容(卡片、弹窗、布局都是插槽重度用户)。
三种插槽:
- 默认插槽:一个坑,塞什么显示什么。
- 具名插槽:多个坑,
<slot name="header">,父组件用<template #header>对号入座。页面布局组件的标准形态。 - 作用域插槽:坑里默认拿不到子组件内部数据;子组件
<slot :data="xxx">把数据"递出来",父组件#default="{ data }"接住——典型场景:表格组件把每行数据递给父组件自定义渲染。
【示例代码】
<script setup>
</script>
<template>
<div class="card">
<div class="card-header">
<slot name="header">默认标题</slot>
</div>
<div class="card-body">
<slot>默认内容</slot>
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<style scoped>
.card { border: 1px solid #e5e5e5; border-radius: 8px; overflow: hidden; width: 360px; }
.card-header { background: #fafafa; padding: 12px; font-weight: bold; border-bottom: 1px solid #eee; }
.card-body { padding: 16px; }
.card-footer { padding: 12px; border-top: 1px solid #eee; }
</style>
<script setup>
const students = [
{ id: 1, name: '张三', score: 92 },
{ id: 2, name: '李四', score: 55 },
{ id: 3, name: '王五', score: 78 }
]
</script>
<template>
<ul>
<li v-for="s in students" :key="s.id">
<slot :student="s" :index="s.id">
{{ s.name }}({{ s.score }} 分)
</slot>
</li>
</ul>
</template>
<script setup>
import CardLayout from '@/components/CardLayout.vue'
import StudentList from '@/components/StudentList.vue'
</script>
<template>
<div class="page">
<h2>插槽</h2>
<CardLayout>
<template #header>
<span style="color: #1677ff">📌 成绩榜</span>
</template>
<StudentList>
<template #default="{ student }">
<span :style="{ color: student.score >= 60 ? 'green' : 'red' }">
{{ student.name }} - {{ student.score >= 60 ? '及格' : '不及格' }}
</span>
</template>
</StudentList>
<template #footer>
<small>数据更新于今天</small>
</template>
</CardLayout>
</div>
</template>
【练习Demo】
需求:封装一个 PageHeader 组件:左侧返回按钮区(具名插槽 left)、中间标题(prop title)、右侧操作区(具名插槽 right);页面中使用它,右侧放一个"导出"按钮。
参考实现:
<script setup>
defineProps({
title: { type: String, required: true }
})
</script>
<template>
<div class="page-header">
<div class="left">
<slot name="left">
<button>← 返回</button>
</slot>
</div>
<h3 class="title">{{ title }}</h3>
<div class="right">
<slot name="right" />
</div>
</div>
</template>
<style scoped>
.page-header { display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid #eee; padding: 12px 0; }
.title { flex: 1; text-align: center; }
</style>
<script setup>
import PageHeader from '@/components/PageHeader.vue'
function goBack() {
alert('返回上一页')
}
</script>
<template>
<div class="page">
<PageHeader title="订单详情">
<template #left>
<button @click="goBack">← 订单列表</button>
</template>
<template #right>
<button style="background: #1677ff; color: #fff">导出</button>
</template>
</PageHeader>
<p>页面正文内容</p>
</div>
</template>
第 5 章小结
- SFC 三段式:
<script setup>+<template>+<style scoped>;import 即注册 - 组件拆分原则:views/ 放页面、components/ 放可复用件,一个组件只做一件事
- 父传子用
defineProps(对象写法带类型/默认值),props 只读不可改 - 子传父用
defineEmits:声明事件 → emit 触发 → 父组件@事件监听;表单控件封装用v-model - 传结构用插槽:默认插槽塞内容、具名插槽
#name分区、作用域插槽让父组件拿到子组件数据
企业踩坑实录
- 子组件直接修改 props:控制台会报警告,且数据流向混乱、无法追溯谁改了数据。铁律:数据归谁,修改权就归谁——子组件想改,emit 事件让父组件改。
- props 默认值是对象/数组时没用工厂函数:
default: []是错误写法,所有组件实例会共享同一个数组的引用。必须写default: () => []。 - 组件命名随意:企业规约——组件名至少两个单词(避免和原生 HTML 标签撞车),文件名用 PascalCase(UserCard.vue);模板里使用时统一一种风格即可。
- emit 的自定义事件没有在 defineEmits 里声明:不声明也能触发,但声明是父子之间的显式契约,并且能避免事件名被当作透传属性挂到根元素上。始终声明。
思考题(先自己答,再对照参考答案)
Q1:父传子、子传父分别用什么?
参考答案:父传子用 props(单向数据流),子传父用 emit 自定义事件。数据永远只朝一个方向流动,修改权始终在数据持有方(父组件)手里。
Q2:组件上的 v-model 本质是什么?
参考答案:是 modelValue prop 加 update:modelValue 事件的语法糖。子组件不真正修改父数据,只是把自己的输入通过事件回传给父组件。
Q3:作用域插槽解决什么问题?
参考答案:插槽内容由父组件提供,但数据在子组件手里(比如列表的每一项)。子组件通过 slot 把数据"递出去",父组件用 v-slot="slotProps" 接住后再决定怎么渲染——数据和模板的分离复用。
Q4:什么时候该拆组件?
参考答案:同一段模板准备复制第二遍时、单文件超过 300 行且包含多个独立功能块时、有一段 UI 需要在多个页面复用时。拆分以"单一职责"为准,不是越细越好。
面试高频
问:props 如何做校验?
答:defineProps 用对象写法,声明 type、required、default、validator;校验不通过时开发环境控制台会给出警告,帮助在编码期发现问题。