04 · 组件化开发(Component-Based Development)

📅 预计 75 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页练习 shengxia.dev/quiz?module=mv04


1.0 先给直觉:组件 = 乐高积木

拼乐高时,每个积木自成一格:它有自己的形状、颜色、接口(凸点/凹槽),能和其他积木拼在一起,也能重复使用。拼坏了一块,换一块新的就行,不用把整座城堡推倒重来

前端**组件(component)**就是这个思路:把一个页面拆成一个个"独立、可复用、自带结构和样式"的小积木。一个"待办项"是一个组件,一个"导航栏"是一个组件,页面就是这些组件的拼装结果。

💡 记忆口诀:组件 = 自带模板 + 样式 + 逻辑的小积木;一次写好,到处拼装。


1.1 组件:为什么要拆

从"一座大山"到"一堆积木"

不拆组件时,整个页面写在一个巨大的文件里:几千行模板 + 几千行逻辑 + 几千行样式。改一个按钮,要翻半天才知道它在哪;改完还怕碰坏别处。这就是高耦合、低内聚

拆成组件后:

  • 复用(reuse):同一个按钮组件,十个页面都能用,改一处全生效。
  • 隔离(isolation):每个组件有自己的模板、样式、逻辑,互不干扰,样式也不互相污染。
  • 可维护(maintainable):问题定位到具体组件,改需求只动"那一个积木"。

组件的粒度

拆不是越细越好。拆得太碎,一层套一层,传参传得手忙脚乱;拆得太粗,一个组件上千行,等于没拆。经验原则:能独立描述、能被复用、或太复杂想隔离的,就拆出来

⚠️ 常见错误

  1. 组件拆得过碎:为了"看起来组件化"把一行文本也包成组件,通信成本大于收益。
  2. 组件过大:一个组件管整个页面所有逻辑,和没拆一样,无法复用。
  3. 组件间共享状态全靠全局变量:一个组件改了,另一个悄悄读到,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 要求标签名能区分"原生标签"和"自定义组件",大写是通用约定。

⚠️ 常见错误

  1. 只 import 不注册components: {} 里没写,模板里直接用了,Vue 警告"找不到组件"。
  2. 忘记 scoped:样式写在组件里却没加 scoped,会全局生效,影响其他组件。
  3. 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 会在控制台警告,提前暴露问题。

⚠️ 常见错误

  1. 子组件直接改 propsthis.title = 'xxx',Vue 警告且数据源混乱,应通过事件通知父组件改。
  2. props 命名大小写混用:JS 里用驼峰 userName,模板里通常用短横线 user-name,二者要对应。
  3. 忘声明 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>

⚠️ 常见错误

  1. 事件名大小写:模板里 @my-event$emit('myEvent') 不匹配,监听不到。统一用短横线(kebab-case)。
  2. 忘声明 emits:不声明也能跑,但无法校验、维护性差,新版 Vue 建议显式声明。
  3. 让子组件直接改父数据:通过 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>
  • 默认内容:插槽里写的内容在"父组件没传"时显示,作为兜底。

⚠️ 常见错误

  1. 多个无名插槽:组件里写两个 <slot> 且都不具名,父组件内容会被复制填充两份。
  2. 具名插槽忘了用 template v-slot: 包裹:直接把内容写在组件标签内,会进默认插槽。
  3. 没考虑默认内容:插槽可以留空,父组件不传时显示空白,该给个默认文案。

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

⚠️ 常见错误

  1. created 里操作 DOM:此时元素尚未挂载,拿到的是 null。
  2. 定时器/监听器不清理:组件销毁后资源仍在运行,长期运行会内存泄漏。
  3. updated 里再次改数据:改数据又触发更新,形成死循环风险,务必加条件判断。

1.7 组件设计思维:从页面到组件树

把页面"翻译"成组件树

拿到一张设计稿,第一件事不是写代码,而是划分组件。做法:从整体到局部,把页面拆成一棵"组件树"。

页面(根组件)
├── 顶部导航栏 NavBar
│   ├── 品牌 Logo
│   └── 菜单项 MenuItem
├── 主要内容区
│   ├── 文章列表 ArticleList
│   │   └── 文章卡片 ArticleCard
│   └── 侧边栏 Sidebar
│       └── 标签云 TagCloud
└── 页脚 Footer

划分的直觉:能独立描述、能在别处复用、或者一个文件装不下的,就拆成组件。树的层级不宜太深,三层左右通常够用。

组件命名与可读性

  • 组件文件用大驼峰命名:ArticleCard.vueNavBar.vue
  • 组件名要表意明确Card 太泛,UserCard 让人一看就懂是"用户卡片"。
  • 一个组件只做一件事:混杂"展示文章 + 统计访问量 + 登录判断"的组件,趁早拆开。

props 的设计原则

  • 少而清晰:props 过多说明组件职责过杂,考虑拆分。
  • 用类型约束:能声明 typerequireddefault 就声明,把错误挡在开发期。
  • 避免层层传递:一个数据穿过五六层 props 才用上,考虑用"提供注入"或状态管理(下一讲详述)。

事件命名的语义

事件名像"对外声明的接口",要用动词短语表达发生了什么:@increase@select-item@close。避免用 @change 这种含糊的名字——是"值变了"还是"用户选中了",说清楚。

组件树让协作变简单

多人协作时,各人维护自己的组件,接口(props 和事件)约定清楚后,可以并行开发互不阻塞。这也是组件化的工程价值——不仅代码可复用,团队分工也更清晰。

⚠️ 常见错误

  1. 一上来就写代码,不做组件划分:做到一半发现结构乱,返工成本高。
  2. 组件名太泛InfoItem 这种名字换个项目没人知道是干嘛的。
  3. props 传十来个还硬撑:组件职责爆炸,拆成小组件更健康。
  4. 事件名含糊@update 到底更新了什么,要靠读源码才能懂,命名要表意。

1.8 组合式 API:另一种组织组件逻辑的方式

选项式 vs 组合式

前面用的是选项式 API(Options API):把组件的 datamethodscomputedwatch 分块书写。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 };
}

⚠️ 常见错误

  1. 纠结 .value:记住"JS 里用 .value,模板里不用"即可,误用会得到 undefined。
  2. 把组合式当成"新选项":它不是又一个配置块,而是函数式组织逻辑的方式,思路要转换。
  3. 两种风格混用却不统一:一个项目里最好统一风格,至少一个组件内保持一致,否则难维护。

📌 记忆口诀

  • 组件 = 积木:自带模板、样式、逻辑,可复用、可隔离。
  • 数据流动:props 往下传数据,事件往上报行为,数据永远住父组件。
  • 插槽:组件留个洞,内容由使用者填。
  • 生命周期:出生(created)→ 上桌(mounted)→ 更新(updated)→ 告别(unmounted),该清理时清理。

📌 双语术语表(本讲)

中文 English 记忆点
组件 component 可复用的小积木
单文件组件 SFC 一个 .vue 文件
属性 props 父传给子的数据
事件 emit 子传给父的消息
插槽 slot 留白待填充的区域
具名插槽 named slot 有名字的插槽
生命周期 lifecycle 组件的整个生命周期
钩子 hook 特定时机执行的回调
作用域样式 scoped style 只作用于本组件

⭐ 本讲考点清单

  1. 组件化的三好处:复用、隔离、可维护
  2. SFC 三部分:<template> / <script> / <style scoped>
  3. 组件 data 必须用函数返回的原因(实例隔离)
  4. props 单向数据流:子组件不能改 props
  5. 事件 $emit@监听:子传父的完整流程
  6. v-model 是 props + 事件的语法糖
  7. 插槽 slot 与具名插槽 v-slot:name、默认内容
  8. 生命周期钩子顺序与 created / mounted 的时机区别
  9. unmounted 清理定时器与监听器,防内存泄漏