ArticleCard会写了,但组件之间还是"孤岛 "------早报站的页面结构要正式开搭,先解决组件怎么说话:父传子(props)、子传父(emit)、注入内容(slot)、双向绑定(v-model)。
🎯 本篇产出 :ArticleCard v2(收藏按钮)、SearchInput搜索框组件、插槽使用------三大通信姿势一次讲全。含代码约100行。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | 打通Vue组件之间的通信 |
| 代码行数 | ~100行(含注释) |
| 依赖 | 无新增(脚手架已就位) |
| 核心功能 | emit收藏 + v-model搜索 + 具名插槽 |
| 跑起来的命令 | npm run dev → http://localhost:5173 |
| 核心知识点 | 单向数据流、defineEmits、v-model原理、slot |
| 做完你能得到 | 一套组件通信的完整姿势 |
一、概念课:组件通信的三种姿势 + 一条铁律
组件之间的关系只有一种:父子 。通信方式就三种,外加一个表单专用语法糖:

🚨 先立那条铁律------单向数据流
📌 数据只有一个主人:谁拥有它,谁才能修改它。
子组件想改父组件的数据?把"请求"发上去(emit),让主人自己改。
🔄 对后端读者的翻译
| Vue概念 | Python对应物 | 说明 |
|---|---|---|
| props | 函数参数(只读) | 父传子 |
| emit | 事件通知 | 父组件把handler传下来,子组件在恰当时机触发它------近似"把回调传下去" |
| slot | 模板里的"占位插槽" | 父组件注入内容 |
📌 数据流永远自上而下、事件永远自下而上------方向不乱,代码就乱不了。这和第12篇"底层抛异常、顶层接异常"的分层纪律是同一个思想。
二、第1步:ArticleCard v2------加收藏按钮(emit实战)
给卡片升级:点收藏 → 卡片自己不知道收藏存哪(那是父组件和将来的API的事),它只负责把事件报上去。
src/components/ArticleCard.vue(v2关键改动):
vue
<script setup>
import { ref } from 'vue'
const props = defineProps({
id: Number, // 新增:上报时要带上"是谁"(id就是载荷的一部分)
title: String,
summary: String,
url: String,
})
// ① 声明本组件会发出的事件------声明过的才算数(第六节①会验证这点)
const emit = defineEmits(['favorite'])
const favorited = ref(false)
function onFavorite() {
favorited.value = !favorited.value
// ② 上报事件 + 载荷(对象里带id和最新状态)
emit('favorite', { id: props.id, state: favorited.value })
}
</script>
<template>
<article class="card">
<h2><a :href="url" target="_blank">{{ title }}</a></h2>
<p>{{ summary }}</p>
<button @click="onFavorite">
{{ favorited ? '★ 已收藏' : '☆ 收藏' }}
</button>
<!-- ③ 具名插槽出口:父组件可以往这里注入内容(如"阅读原文"链接) -->
<slot name="actions"></slot>
</article>
</template>
📌 两个关键点
| # | 关键点 | 说明 |
|---|---|---|
| ① | defineEmits(['favorite'])声明事件清单 |
声明即契约,父组件按这个名字监听 |
| ② | emit('favorite', 载荷)把id和状态一起报上去 |
子组件不决定"收藏后干嘛",只负责"告诉爸爸:用户收藏了id=3的文章" |
💡
<slot name="actions">是插槽出口:组件留好"插座",父组件往里面插内容(见第3步)。
三、第2步:App.vue------监听事件、持有状态
src/App.vue(关键改动):
vue
<script setup>
import { ref } from 'vue'
import ArticleCard from './components/ArticleCard.vue'
import SearchInput from './components/SearchInput.vue'
const articles = [
{ id: 1, title: '重构教会我的事', summary: '......', url: '#' },
// ... 同第09篇
]
// 收藏计数:状态的主人在这里(App层)
const favorites = ref(0)
function handleFavorite({ id, state }) {
favorites.value += state ? 1 : -1
console.log('收藏事件:', id, state) // 第13篇这里换成调API
}
const keyword = ref('')
</script>
<template>
<h1>Python 早报</h1>
<SearchInput v-model="keyword" />
<p v-if="keyword">正在搜索:{{ keyword }}(第13篇接真搜索)</p>
<p>已收藏 {{ favorites }} 篇</p>
<ArticleCard
v-for="a in articles"
:key="a.id"
v-bind="a"
@favorite="handleFavorite"
>
<!-- 具名插槽的使用:往actions插座里插入"阅读原文"链接 -->
<template #actions>
<a :href="a.url" target="_blank">阅读原文 ↗</a>
</template>
</ArticleCard>
</template>
🎯 看清楚数据的流向
text
卡片里点收藏 → emit上报 → App的handleFavorite修改favorites → 顶部的"已收藏N篇"自动更新
📌 数据的主人(App)始终只有一个,修改永远发生在主人手里------单向数据流完整闭环。
💡 v-bind="a"的妙用
把对象的所有字段展开传给props的简写(等价于:id="a.id" :title="a.title"......),列表场景省一大截代码。
四、第3步:SearchInput------v-model的双向绑定原理
v-model是"双向绑定"语法糖。先拆穿它的真面目:
text
v-model="keyword"
≡ :model-value="keyword" + @update:model-value="keyword = $event"
📌 一个输入框组件要实现
v-model,只需遵守两条约定:prop叫modelValue,事件叫update:modelValue。
src/components/SearchInput.vue:
vue
<script setup>
defineProps({ modelValue: String }) // 约定1:prop名
const emit = defineEmits(['update:modelValue']) // 约定2:事件名
function onInput(e) {
emit('update:modelValue', e.target.value) // 输入 → 上报给主人
}
</script>
<template>
<input type="text" :value="modelValue"
@input="onInput"
placeholder="搜索文章..." />
</template>
🎯 数据流依旧单向
text
输入事件 → emit上报 → App改keyword → keyword流回:value
📌 "双向绑定"是错觉,底下还是单向流 + 语法糖 ------理解这一点,将来调试任何
v-model问题都不慌(第六节④)。
✅ 浏览器实测
- 搜索框打字 → "正在搜索:xxx"实时跟上;
- 点卡片收藏 → 顶部计数+1。
🎉 全部无刷新、无后端------纯前端状态流的胜利。
五、验收清单
bash
1. 点收藏 → ★切换 + 顶部"已收藏N篇"实时更新
2. 连点两次同一张卡 → 计数先+1再-1(状态上报正确)
3. 搜索框输入 → "正在搜索"文案实时跟上
4. 卡片底部出现"阅读原文"链接(具名插槽生效)
5. 控制台无Vue warn(事件已声明、props未被修改)
6. 全程无报错后提交Git
bash
git add .
git commit -m "组件通信:emit收藏 + v-model搜索 + 具名插槽"
六、常见报错:这6个,组件通信的标配(重点!)
① 控制台警告Component emitted event "favorite" but it is neither declared nor listened to
🔍 原因 :emit了没在defineEmits声明的事件(或父组件没监听)。
✅ 解法 :defineEmits声明 + 父组件@favorite监听,两头对齐。
📌 事件和props一样是契约,先声明后使用。
② 父组件监听没反应:@favorite写了但handler不触发
🔍 原因 :事件名拼写不一致------emit('favorite')与@favorite的大小写/拼写差一个字符。
✅ 解法:统一用camelCase命名。
📌 事件名是字符串,字符串的匹配没有任何容错。
③ [Vue warn]: Component is mutating props
🔍 原因 :子组件直接改了props(比如props.summary = 'xx')------违反单向数据流。
✅ 解法:
- 想改数据?emit上报让父组件改;
- 只想内部用?先拷贝成本地ref。
④ v-model绑定的输入框不响应 / 输入后立刻"弹回"
🔍 原因 :子组件没emit update:modelValue(或prop名不是modelValue)------输入了但主人没收到,value被旧值弹回。
✅ 解法 :对照第4节的两条约定逐字检查。
📌 v-model不是魔法,是两条约定。
⑤ 插槽内容死活不显示
🔍 原因 :子组件模板里没放<slot />出口------内容没地方插。
✅ 解法 :检查子组件有没有"插座";具名插槽要配对:<slot name="actions"> ↔ <template #actions>。
⑥ 具名插槽内容显示在了错误位置 / 和默认内容混在一起
🔍 原因 :#actions写成了普通内容,或子组件默认slot与具名slot混淆。
✅ 解法:
- 具名插槽一律用
<template #名字>包裹; - 默认内容放
<slot>(不带name)。
七、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | 状态提升:把点赞的liked状态也提升到App(favorites列表存id),刷新页面后计数不丢 | ⭐⭐⭐ | 状态提升是组件设计的第一课 |
| 2 | 删除卡片 :给卡片加删除按钮,emit('remove', id),App里从数组移除 |
⭐⭐ | 复习"主人改数据" |
| 3 | 双v-model:搜索框加"区分大小写"复选框 | ⭐⭐⭐ | :checked + @change,或v-model第二个绑定 |
| 4(选做) | 给emit加校验 :v-model组件的modelValue prop加required: true与类型校验 |
⭐⭐ | props深入的最后一块拼图 |
📦 配套代码
完整组件已上传Git(python_daily/,node_modules不入仓):【gitee仓库地址】