MV-04 组件化开发
04 · 组件化开发(Component-Based Development)
📅 预计 75 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页练习shengxia.dev/quiz?module=mv04
1.0 先给直觉:组件 = 乐高积木
拼乐高时,每个积木自成一格:它有自己的形状、颜色、接口(凸点/凹槽),能和其他积木拼在一起,也能重复使用。拼坏了一块,换一块新的就行,不用把整座城堡推倒重来。
前端**组件(component)**就是这个思路:把一个页面拆成一个个"独立、可复用、自带结构和样式"的小积木。一个"待办项"是一个组件,一个"导航栏"是一个组件,页面就是这些组件的拼装结果。
💡 记忆口诀:组件 = 自带模板 + 样式 + 逻辑的小积木;一次写好,到处拼装。
1.1 组件:为什么要拆
从"一座大山"到"一堆积木"
不拆组件时,整个页面写在一个巨大的文件里:几千行模板 + 几千行逻辑 + 几千行样式。改一个按钮,要翻半天才知道它在哪;改完还怕碰坏别处。这就是高耦合、低内聚。
拆成组件后:
- 复用(reuse):同一个按钮组件,十个页面都能用,改一处全生效。
- 隔离(isolation):每个组件有自己的模板、样式、逻辑,互不干扰,样式也不互相污染。
- 可维护(maintainable):问题定位到具体组件,改需求只动"那一个积木"。
组件的粒度
拆不是越细越好。拆得太碎,一层套一层,传参传得手忙脚乱;拆得太粗,一个组件上千行,等于没拆。经验原则:能独立描述、能被复用、或太复杂想隔离的,就拆出来。
⚠️ 常见错误
- 组件拆得过碎:为了"看起来组件化"把一行文本也包成组件,通信成本大于收益。
- 组件过大:一个组件管整个页面所有逻辑,和没拆一样,无法复用。
- 组件间共享状态全靠全局变量:一个组件改了,另一个悄悄读到,bug 难查(后面讲状态管理)。
1.2 定义一个组件
单文件组件(SFC)
Vue 推荐的组件形式是单文件组件(Single File Component),后缀 .vue,一个文件里同时写模板、逻辑、样式:
<template>
<div class="badge">
<span class="badge-text">{{ label }}</span>
</div>
</template>
<script>
export default {
name: 'Badge',
data() {
return { internalState: '这是组件内部状态' };
},
};
</script>
<style scoped>
.badge {
padding: 4px 8px;
background: #e7f5ff;
border-radius: 4px;
}
</style>
<template> 是结构,<script> 是逻辑(data 等),<style scoped> 是样式。scoped 表示样式只作用于本组件,不会污染全局——这正是组件隔离的体现。
注册与使用
在另一个组件里引入并注册,就能当标签用:
<template>
<div>
<Badge label="热门" />
<Badge label="新上架" />
</div>
</template>
<script>
import Badge from './Badge.vue'; // 引入
export default {
components: { Badge }, // 注册
};
</script>
组件名首字母大写(Badge),在模板里以 <Badge> 标签出现。Vue 要求标签名能区分"原生标签"和"自定义组件",大写是通用约定。
⚠️ 常见错误
- 只 import 不注册:
components: {}里没写,模板里直接用了,Vue 警告"找不到组件"。 - 忘记
scoped:样式写在组件里却没加 scoped,会全局生效,影响其他组件。 - data 写成对象:组件必须用函数返回 data(
data() { return {...} }),否则多个实例共享同一份数据,改一个全变。
1.3 props:父组件给子组件传数据 ⭐
单向数据流
props 是父组件向子组件传递数据的通道,类似"积木的凹槽"——积木告诉别人"你可以往这插数据"。在子组件里声明 props,父组件使用时像写属性一样传入:
<!-- Child.vue:子组件声明 props -->
<template>
<p>{{ title }}</p>
</template>
<script>
export default {
props: ['title'], // 声明接收一个名为 title 的 prop
};
</script>
<!-- Parent.vue:父组件传入 -->
<template>
<Child title="这是父组件传来的标题" />
</template>
重要原则——单向数据流:props 只允许父 → 子方向流动。子组件不能直接修改 props 的值,否则数据源混乱。如果子组件需要"基于 prop 的局部状态",用计算属性或再拷贝一份:
<script>
export default {
props: ['count'],
computed: {
double() {
return this.count * 2; // 基于 prop 计算,但不修改它
},
},
};
</script>
定义 props 的类型与必填
更严谨的写法是给 props 声明类型、是否必填、默认值:
<script>
export default {
props: {
title: {
type: String, // 类型:字符串
required: true, // 必填
},
count: {
type: Number,
default: 0, // 缺省时用 0
},
},
};
</script>
传错类型(比如把字符串传给 Number)时 Vue 会在控制台警告,提前暴露问题。
⚠️ 常见错误
- 子组件直接改 props:
this.title = 'xxx',Vue 警告且数据源混乱,应通过事件通知父组件改。 - props 命名大小写混用:JS 里用驼峰
userName,模板里通常用短横线user-name,二者要对应。 - 忘声明 required:父组件忘了传时静默 undefined,运行时才出诡异 bug;能声明必填就声明。
1.4 事件:子组件给父组件传消息 ⭐
$emit:子组件"喊话"
props 只能从上往下,**事件(event)**负责从下往上。子组件用 $emit 抛出一个事件并携带数据,父组件用 @事件名 监听:
<!-- Child.vue -->
<template>
<button @click="$emit('increase', 1)">+1</button>
</template>
<!-- Parent.vue -->
<template>
<Child @increase="handleIncrease" />
</template>
<script>
export default {
methods: {
handleIncrease(num) {
this.count += num; // num 是子组件传来的 1
},
},
};
</script>
流程:子组件点击 → $emit('increase', 1) → 父组件 @increase 触发 handleIncrease(1) → 修改父组件自己的状态。这样数据始终归父组件管,子组件只负责"报告发生了什么"。
对比:props 是数据,事件是行为
- props:把"数据"从父传到子。
- 事件:把"发生了什么/要干什么"从子传给父。
- 数据真正的"家"是父组件,子组件用 props 拿数据、用事件汇报,形成闭环。
v-model 的本质就是 props + 事件
v-model 其实是"value prop + input/update:modelValue 事件"的语法糖。理解后,自定义组件也能支持 v-model:
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
/>
</template>
<script>
export default {
props: ['modelValue'],
emits: ['update:modelValue'],
};
</script>
⚠️ 常见错误
- 事件名大小写:模板里
@my-event与$emit('myEvent')不匹配,监听不到。统一用短横线(kebab-case)。 - 忘声明
emits:不声明也能跑,但无法校验、维护性差,新版 Vue 建议显式声明。 - 让子组件直接改父数据:通过 props 传入一个对象,子组件直接改对象属性,绕过了"单向数据流"约定,时间一长很难追踪数据从哪改的。
1.5 插槽 slot:给组件"留个位置" ⭐
内容分发
**插槽(slot)**是组件里"留给使用者自由填充"的区域,类似"积木上的通用接口"。没有插槽,组件内容是写死的;有了插槽,父组件可以往里面塞任意内容:
<!-- Card.vue:定义带插槽的卡片组件 -->
<template>
<div class="card">
<div class="card-title">{{ title }}</div>
<div class="card-body">
<slot></slot> <!-- 这里放父组件传来的内容 -->
</div>
</div>
</template>
<!-- Parent.vue:使用插槽填充内容 -->
<template>
<Card title="公告">
<p>这是一段由父组件填入的内容。</p>
<button>去处理</button>
</Card>
</template>
父组件 <Card> 标签里的内容,会被"塞进"子组件 <slot> 的位置。这实现了结构复用 + 内容自定义。
具名插槽与插槽默认内容
- 具名插槽(named slot):一个组件有多个插槽时,用
name区分,父组件用template v-slot:name指定。
<!-- Layout.vue -->
<template>
<header><slot name="header">默认页头</slot></header>
<main><slot>默认正文</slot></main>
</template>
<Layout>
<template v-slot:header>
<h1>自定义页头</h1>
</template>
<p>正文内容会进默认插槽</p>
</Layout>
- 默认内容:插槽里写的内容在"父组件没传"时显示,作为兜底。
⚠️ 常见错误
- 多个无名插槽:组件里写两个
<slot>且都不具名,父组件内容会被复制填充两份。 - 具名插槽忘了用
template v-slot:包裹:直接把内容写在组件标签内,会进默认插槽。 - 没考虑默认内容:插槽可以留空,父组件不传时显示空白,该给个默认文案。
1.6 生命周期:组件的"一生" ⭐
从出生到销毁
每个组件都有生命周期(lifecycle)——从创建、挂载、更新到销毁的整个过程。Vue 在关键节点提供钩子函数(hook),让你在特定时机执行代码(Vue 3 选项式写法):
| 钩子 | 时机 | 典型用途 |
|---|---|---|
created |
实例创建后、渲染前 | 初始化数据、请求数据 |
mounted |
挂载到 DOM 后 | 操作 DOM、接入第三方库 |
updated |
数据变化导致重渲染后 | 依赖最新 DOM 的操作 |
unmounted |
组件销毁前 | 清理定时器、解绑监听 |
<script>
export default {
data() {
return { timer: null };
},
created() {
console.log('组件创建了');
},
mounted() {
// 例:启动一个定时器
this.timer = setInterval(() => {
console.log('每秒执行');
}, 1000);
},
unmounted() {
clearInterval(this.timer); // 销毁时清理,防止内存泄漏
console.log('组件销毁了');
},
};
</script>
最常用的记忆:created 适合初始化(DOM 还没好),mounted 适合碰 DOM(DOM 已就绪),unmounted 记得清理资源。
生命周期为什么重要
- 时机错误会直接 bug:在
created里用document.querySelector拿不到元素(还没渲染);定时器在unmounted不清理,组件销毁后还在跑,浪费资源甚至报错。 - 面试常考顺序:
created → mounted → updated → unmounted。
⚠️ 常见错误
- 在
created里操作 DOM:此时元素尚未挂载,拿到的是 null。 - 定时器/监听器不清理:组件销毁后资源仍在运行,长期运行会内存泄漏。
- 在
updated里再次改数据:改数据又触发更新,形成死循环风险,务必加条件判断。
1.7 组件设计思维:从页面到组件树
把页面"翻译"成组件树
拿到一张设计稿,第一件事不是写代码,而是划分组件。做法:从整体到局部,把页面拆成一棵"组件树"。
页面(根组件)
├── 顶部导航栏 NavBar
│ ├── 品牌 Logo
│ └── 菜单项 MenuItem
├── 主要内容区
│ ├── 文章列表 ArticleList
│ │ └── 文章卡片 ArticleCard
│ └── 侧边栏 Sidebar
│ └── 标签云 TagCloud
└── 页脚 Footer
划分的直觉:能独立描述、能在别处复用、或者一个文件装不下的,就拆成组件。树的层级不宜太深,三层左右通常够用。
组件命名与可读性
- 组件文件用大驼峰命名:
ArticleCard.vue、NavBar.vue。 - 组件名要表意明确:
Card太泛,UserCard让人一看就懂是"用户卡片"。 - 一个组件只做一件事:混杂"展示文章 + 统计访问量 + 登录判断"的组件,趁早拆开。
props 的设计原则
- 少而清晰:props 过多说明组件职责过杂,考虑拆分。
- 用类型约束:能声明
type、required、default就声明,把错误挡在开发期。 - 避免层层传递:一个数据穿过五六层 props 才用上,考虑用"提供注入"或状态管理(下一讲详述)。
事件命名的语义
事件名像"对外声明的接口",要用动词短语表达发生了什么:@increase、@select-item、@close。避免用 @change 这种含糊的名字——是"值变了"还是"用户选中了",说清楚。
组件树让协作变简单
多人协作时,各人维护自己的组件,接口(props 和事件)约定清楚后,可以并行开发互不阻塞。这也是组件化的工程价值——不仅代码可复用,团队分工也更清晰。
⚠️ 常见错误
- 一上来就写代码,不做组件划分:做到一半发现结构乱,返工成本高。
- 组件名太泛:
Info、Item这种名字换个项目没人知道是干嘛的。 - props 传十来个还硬撑:组件职责爆炸,拆成小组件更健康。
- 事件名含糊:
@update到底更新了什么,要靠读源码才能懂,命名要表意。
1.8 组合式 API:另一种组织组件逻辑的方式
选项式 vs 组合式
前面用的是选项式 API(Options API):把组件的 data、methods、computed、watch 分块书写。Vue 3 还提供了组合式 API(Composition API),把同一个功能的逻辑聚在一起,适合复杂组件。
<script setup>
import { ref, computed, onMounted } from 'vue';
// 一个"计数功能"的全部逻辑集中在这里
const count = ref(0); // 响应式状态,用 ref 声明
const double = computed(() => count.value * 2);
function increase() { count.value++; }
onMounted(() => console.log('组件挂载了'));
</script>
<template>
<p>计数:{{ count }},翻倍:{{ double }}</p>
<button @click="increase">+1</button>
</template>
注意两点:状态用 ref() 声明,读取时用 .value(模板里自动解包,不用写 .value);<script setup> 是组合式 API 的简化写法,引入的变量和函数在模板里直接用。
两种风格怎么选
- 简单组件:选项式直观清晰,
data / methods / computed分层好懂。 - 复杂组件:组合式能把相关逻辑抽到函数里复用,逻辑集中、可读性好。
- 两者能混用,不必二选一。本课程以选项式为主,你只需认识组合式,知道 Vue 3 里存在这套写法。
组合式的好处:逻辑复用
选项式要把可复用逻辑抽出去很别扭(要用混入 mixin)。组合式直接把逻辑写成普通函数,组件里 import 就能用,来源一目了然:
// useCounter.js —— 一个可复用的"计数逻辑"
import { ref } from 'vue';
export function useCounter(start = 0) {
const count = ref(start);
function increase() { count.value++; }
return { count, increase };
}
⚠️ 常见错误
- 纠结
.value:记住"JS 里用.value,模板里不用"即可,误用会得到 undefined。 - 把组合式当成"新选项":它不是又一个配置块,而是函数式组织逻辑的方式,思路要转换。
- 两种风格混用却不统一:一个项目里最好统一风格,至少一个组件内保持一致,否则难维护。
📌 记忆口诀
- 组件 = 积木:自带模板、样式、逻辑,可复用、可隔离。
- 数据流动:props 往下传数据,事件往上报行为,数据永远住父组件。
- 插槽:组件留个洞,内容由使用者填。
- 生命周期:出生(created)→ 上桌(mounted)→ 更新(updated)→ 告别(unmounted),该清理时清理。
📌 双语术语表(本讲)
| 中文 | English | 记忆点 |
|---|---|---|
| 组件 | component | 可复用的小积木 |
| 单文件组件 | SFC | 一个 .vue 文件 |
| 属性 | props | 父传给子的数据 |
| 事件 | emit | 子传给父的消息 |
| 插槽 | slot | 留白待填充的区域 |
| 具名插槽 | named slot | 有名字的插槽 |
| 生命周期 | lifecycle | 组件的整个生命周期 |
| 钩子 | hook | 特定时机执行的回调 |
| 作用域样式 | scoped style | 只作用于本组件 |
⭐ 本讲考点清单
- 组件化的三好处:复用、隔离、可维护
- SFC 三部分:
<template>/<script>/<style scoped> - 组件 data 必须用函数返回的原因(实例隔离)
- props 单向数据流:子组件不能改 props
- 事件
$emit与@监听:子传父的完整流程 v-model是 props + 事件的语法糖- 插槽
slot与具名插槽v-slot:name、默认内容 - 生命周期钩子顺序与
created/mounted的时机区别 unmounted清理定时器与监听器,防内存泄漏