第 5 章 组件基础

0 阅读8分钟

第 5 章 组件基础

本章导读

  • 你将学会:SFC 单文件组件的组织、组件注册与使用、props 父传子、defineEmits 子传父、插槽 slot——组件化开发的四件套
  • 前置要求:第 4 章
  • 预计用时:1~1.5 天。组件通信是业务开发的日常主线,务必练熟

ch05-communication.png

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

两条铁律:

  1. props 是只读的,子组件绝不允许修改 props(改了控制台报警告,且数据流会乱)。
  2. 单向数据流:数据从父流向子,子想改只能"通知父"(下一节 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>,父组件往坑里塞内容——这样同一个组件外壳可以套完全不同的内容(卡片、弹窗、布局都是插槽重度用户)。

三种插槽:

  1. 默认插槽:一个坑,塞什么显示什么。
  2. 具名插槽:多个坑,<slot name="header">,父组件用 <template #header> 对号入座。页面布局组件的标准形态
  3. 作用域插槽:坑里默认拿不到子组件内部数据;子组件 <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>&larr; 返回</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 分区、作用域插槽让父组件拿到子组件数据


企业踩坑实录

  1. 子组件直接修改 props:控制台会报警告,且数据流向混乱、无法追溯谁改了数据。铁律:数据归谁,修改权就归谁——子组件想改,emit 事件让父组件改。
  2. props 默认值是对象/数组时没用工厂函数default: [] 是错误写法,所有组件实例会共享同一个数组的引用。必须写 default: () => []
  3. 组件命名随意:企业规约——组件名至少两个单词(避免和原生 HTML 标签撞车),文件名用 PascalCase(UserCard.vue);模板里使用时统一一种风格即可。
  4. emit 的自定义事件没有在 defineEmits 里声明:不声明也能触发,但声明是父子之间的显式契约,并且能避免事件名被当作透传属性挂到根元素上。始终声明。

思考题(先自己答,再对照参考答案)

Q1:父传子、子传父分别用什么?

参考答案:父传子用 props(单向数据流),子传父用 emit 自定义事件。数据永远只朝一个方向流动,修改权始终在数据持有方(父组件)手里。

Q2:组件上的 v-model 本质是什么?

参考答案:是 modelValue prop 加 update:modelValue 事件的语法糖。子组件不真正修改父数据,只是把自己的输入通过事件回传给父组件。

Q3:作用域插槽解决什么问题?

参考答案:插槽内容由父组件提供,但数据在子组件手里(比如列表的每一项)。子组件通过 slot 把数据"递出去",父组件用 v-slot="slotProps" 接住后再决定怎么渲染——数据和模板的分离复用。

Q4:什么时候该拆组件?

参考答案:同一段模板准备复制第二遍时、单文件超过 300 行且包含多个独立功能块时、有一段 UI 需要在多个页面复用时。拆分以"单一职责"为准,不是越细越好。

面试高频

问:props 如何做校验?

答:defineProps 用对象写法,声明 typerequireddefaultvalidator;校验不通过时开发环境控制台会给出警告,帮助在编码期发现问题。