3. Vue.js 核心概念(下):深入组件化开发
上一节我们聊了 Vue 的响应式系统和模板语法。今天咱们来点更实战的东西——组件化开发。说白了,就是把页面拆成一个个独立的小块,每个块只管自己的事。
我在项目里见过不少新手,一个单文件组件写了两三千行。维护起来?那叫一个酸爽。所以,理解组件通信,是写出可维护代码的第一步。
3.1 Props:父组件给子组件传数据
Props 是父组件向子组件传递数据的标准方式。你可以把它想象成函数的参数——子组件声明它需要什么,父组件就传什么。
核心原则:单向数据流
数据从父组件流向子组件。子组件不能直接修改 props。为什么?因为这样会导致数据流向混乱,调试起来非常痛苦。
来看个例子。假设我们有个语音识别结果展示组件:
<!-- 子组件:ResultDisplay.vue -->
<template>
<div class="result-card">
<h3>识别结果</h3>
<p>{{ text }}</p>
<span>置信度:{{ confidence }}%</span>
</div>
</template>
<script>
export default {
props: {
text: {
type: String,
required: true
},
confidence: {
type: Number,
default: 0,
validator: value => value >= 0 && value <= 100
}
}
}
</script>
父组件使用时:
<ResultDisplay :text="resultText" :confidence="resultConfidence" />
嗯,这里要注意:required 和 default 要配合好。如果一个 prop 是 required,就别给 default 了,不然容易让人困惑。
我的习惯
所有 props 都写完整校验——类型、默认值、必要性和自定义校验器。这能帮你提前发现很多低级错误。我曾经因为漏写类型校验,把一个数字传成了字符串,结果页面渲染出来全是 NaN,排查了半小时。
3.2 $emit:子组件给父组件发消息
子组件不能直接改 props,那它想通知父组件做点事怎么办?用 $emit。它就像子组件给父组件发了个信号:「嘿,我这边有情况,你处理一下。」
继续用语音识别的场景。假设子组件有个「重新识别」按钮:
<!-- 子组件 -->
<template>
<div>
<button @click="handleRetry">重新识别</button>
</div>
</template>
<script>
export default {
methods: {
handleRetry() {
this.$emit('retry', { reason: '用户手动触发' })
}
}
}
</script>
父组件监听这个事件:
<ResultDisplay @retry="onRetry" />
<script>
methods: {
onRetry(payload) {
// 重新调用语音识别接口
this.startRecognition()
}
}
</script>
避坑指南
我曾经在项目里看到有人用 this.$parent 直接调用父组件方法。千万别这么干!这会让组件之间的耦合变得非常紧密,改一个组件可能牵一发而动全身。用 $emit 才是正道。
3.3 插槽(Slot):让组件更灵活
Props 和 $emit 解决的是数据传递问题。但有时候,我们想传递的是模板内容——比如一个通用的卡片组件,不同页面用的卡片内容不一样。这时候就需要插槽了。
插槽说白了就是占位符。子组件留个坑,父组件往里填东西。
3.3.1 默认插槽
<!-- Card.vue -->
<template>
<div class="card">
<div class="card-header">
<slot name="header">默认标题</slot>
</div>
<div class="card-body">
<slot />
</div>
</div>
</template>
使用:
<Card>
<template #header>
<h2>语音识别结果</h2>
</template>
<p>这是识别出来的文本内容...</p>
</Card>
3.3.2 作用域插槽
有时候子组件需要把内部数据暴露给父组件,让父组件决定怎么渲染。这就是作用域插槽的用武之地。
<!-- 子组件 -->
<template>
<ul>
<li v-for="item in items" :key="item.id">
<slot :item="item" :index="index">
{{ item.name }}
</slot>
</li>
</ul>
</template>
父组件可以自定义渲染逻辑:
<ListComponent>
<template #default="{ item, index }">
<span>{{ index + 1 }}. {{ item.name }}</span>
<button @click="deleteItem(item.id)">删除</button>
</template>
</ListComponent>
我的经验
作用域插槽在封装表格、列表这类组件时特别有用。你想想看,表格的列头、列内容、操作按钮都可能不一样,用作用域插槽就能让父组件完全控制渲染,子组件只负责数据和布局。
3.4 组件通信全景图
上面三种是最常用的通信方式。但实际项目中,组件关系可能更复杂——兄弟组件、跨层级组件怎么通信?我整理了一张图,帮你理清思路。
从这张图可以看出:
- 父子通信:props(父→子)、$emit(子→父)
- 兄弟通信:通过共同的父组件中转——子A $emit 给父,父再通过 props 传给子B
- 跨层级通信:用
provide / inject,祖先组件提供数据,后代组件注入使用
注意
provide / inject 虽然方便,但它会让组件之间的依赖关系变得隐式。我建议只在「祖先-后代」关系明确且稳定的场景下使用,比如主题配置、用户信息等全局数据。别用它来替代 props,否则代码会变得很难追踪。
3.5 实战:封装一个语音识别控制组件
说了这么多理论,咱们来写个真实例子。假设我们要封装一个语音识别控制组件,包含开始、停止、状态显示等功能。
<!-- VoiceControl.vue -->
<template>
<div class="voice-control">
<div class="status">
<slot name="status" :status="currentStatus">
<span>{{ statusText }}</span>
</slot>
</div>
<div class="actions">
<button
v-if="currentStatus === 'idle'"
@click="start"
:disabled="loading">
开始识别
</button>
<button
v-if="currentStatus === 'recording'"
@click="stop"
class="danger">
停止识别
</button>
</div>
<div class="result">
<slot :text="resultText" :confidence="confidence">
<p>{{ resultText }}</p>
</slot>
</div>
</div>
</template>
<script>
export default {
props: {
loading: {
type: Boolean,
default: false
}
},
data() {
return {
currentStatus: 'idle',
resultText: '',
confidence: 0
}
},
computed: {
statusText() {
const map = {
idle: '待机中',
recording: '正在录音...',
processing: '正在识别...',
done: '识别完成'
}
return map[this.currentStatus] || '未知状态'
}
},
methods: {
start() {
this.currentStatus = 'recording'
this.$emit('start')
},
stop() {
this.currentStatus = 'processing'
this.$emit('stop')
},
setResult(text, confidence) {
this.resultText = text
this.confidence = confidence
this.currentStatus = 'done'
}
}
}
</script>
父组件使用:
<VoiceControl
:loading="isLoading"
@start="handleStart"
@stop="handleStop">
<template #status="{ status }">
<div :class="['badge', status]">
{{ status === 'recording' ? '🎤 录音中' : '⏸ 待机' }}
</div>
</template>
<template #default="{ text, confidence }">
<div class="result-card">
<p>{{ text }}</p>
<progress :value="confidence" max="100"></progress>
</div>
</template>
</VoiceControl>
我的建议
写组件时,先想清楚「这个组件要暴露哪些 props 和事件」,再想「哪些部分应该让父组件通过插槽自定义」。好的组件设计,就像搭积木——每个块职责清晰,组合起来又很灵活。
3.6 总结
组件化开发的核心就三件事:
- Props:父传子,单向数据流
- $emit:子传父,事件驱动
- Slot:模板分发,灵活组合
你想想看,掌握了这三样,大部分组件通信场景都能搞定。至于更复杂的跨层级通信、状态管理,那是后面章节的事。先把基础打牢,后面学起来就轻松多了。