Vue组件通信保姆级案例教程:每个方法一个例子,看完直接上手用
Vue组件通信保姆级案例教程:每个方法一个例子,看完直接上手用
之前我们聊了Vue的基础、路由和状态管理。今天要解决的,是你一旦开始用组件写页面,就一定会碰上的问题:组件之间怎么传数据、怎么互相通知?
很多新手在这里卡住,因为Vue提供了好几种通信方式,不知道该用哪个。今天我就用一个一个独立的小案例,把每种方式讲清楚。每个案例都是一个完整的小场景,代码全部有注释,你照着抄一遍就能跑起来。
案例一:父传子 Props——展示用户卡片
场景:父组件里有一份用户信息(姓名、年龄),子组件负责把这张卡片画出来。
1. 父组件 App.vue
vue
<template> <div class="app"> <h1>父组件</h1> <!-- 使用子组件,并传递两个属性:userName 和 userAge --> <!-- 注意:userAge 前面有冒号,表示传的是数字 25;不加冒号会被当成字符串 "25" --> <UserCard :user-name="name" :user-age="25" /> </div> </template> <script setup> // 引入子组件 import UserCard from './components/UserCard.vue' // ref 用来创建一个响应式数据 import { ref } from 'vue' // 父组件自己的数据:姓名 const name = ref('小明') </script>2. 子组件 UserCard.vue
vue
<template> <div class="user-card"> <h3>用户卡片</h3> <!-- 在模板中直接使用 props 里声明的变量名 --> <p>姓名:{{ userName }}</p> <p>年龄:{{ userAge }}</p> </div> </template> <script setup> // defineProps 是一个内置的宏,用来声明子组件可以接收哪些属性 // 属性名从父组件传过来的 kebab-case (短横线) 会自动转成 camelCase (驼峰) const props = defineProps({ userName: { type: String, // 类型校验:必须是字符串 required: true // 这个属性必须传,否则控制台会警告 }, userAge: { type: Number, // 类型校验:必须是数字 default: 18 // 如果父组件没传,就用默认值 18 } }) // 在 JavaScript 里使用 props 时,用 props.xxx console.log('接收到的姓名:', props.userName) console.log('接收到的年龄:', props.userAge) </script>案例小结:Props 是单向的,数据只能从父组件流向子组件。子组件不能直接修改props 的值,要改只能让父组件改。
案例二:子传父 Emit——点赞按钮
场景:子组件是一个点赞按钮,用户点一下,父组件统计的总点赞数加一。
1. 父组件 App.vue
vue
<template> <div> <h2>当前总点赞数:{{ likeCount }}</h2> <!-- 监听子组件发出的 "like" 事件,一旦触发就调用 handleLike 方法 --> <LikeButton @like="handleLike" /> </div> </template> <script setup> import { ref } from 'vue' import LikeButton from './components/LikeButton.vue' // 点赞数初始化为 0 const likeCount = ref(0) // 处理点赞事件的方法 function handleLike() { likeCount.value++ // 点赞数加 1 } </script>2. 子组件 LikeButton.vue
vue
<template> <div> <!-- 点击按钮时,调用 onLike 函数 --> <button @click="onLike">❤️ 点赞</button> </div> </template> <script setup> // defineEmits 用来声明这个组件可以发出哪些事件,返回一个 emit 函数 const emit = defineEmits(['like']) // 点击后,触发 'like' 事件,父组件就能收到 function onLike() { // 这里还可以传参数,比如 emit('like', '参数内容') emit('like') } </script>案例小结:子传父的核心就是 emit。子组件声明事件并触发,父组件在模板里用@事件名监听。
案例三:v-model 双向绑定——自定义输入框
场景:封装一个输入框组件,父组件用 v-model 就能直接拿到输入的内容。
1. 父组件 App.vue
vue
<template> <div> <!-- v-model 绑定到 inputText,跟原生的 input 一样好用 --> <MyInput v-model="inputText" /> <p>你输入的内容:{{ inputText }}</p> </div> </template> <script setup> import { ref } from 'vue' import MyInput from './components/MyInput.vue' // 用来存放输入框的内容 const inputText = ref('') </script>2. 子组件 MyInput.vue
vue
<template> <div> <!-- :value="modelValue" 将接收到的值显示在输入框中 @input 事件触发时,执行更新操作 --> <input :value="modelValue" @input="$emit('update:modelValue', $event.target.value)" placeholder="请输入内容" /> </div> </template> <script setup> // v-model 默认传过来的 prop 名字必须是 modelValue defineProps({ modelValue: { type: String, default: '' } }) // v-model 默认监听的事件名必须是 update:modelValue defineEmits(['update:modelValue']) </script>案例小结:v-model 本质上是:modelValue+@update:modelValue的组合。你也可以用v-model:xxx传多个值,原理一样。
案例四:兄弟组件通信——搜索框与列表联动
场景:一个搜索框组件,一个列表组件。在搜索框输入关键词,列表组件根据关键词筛选显示内容。它们没有父子关系,是兄弟。
解决方法:借助它们共同的父组件作为“中转站”。
1. 父组件 App.vue(中转站)
vue
<template> <div> <!-- 搜索框组件:输入时发出 search 事件,携带输入的内容 --> <SearchBox @search="handleSearch" /> <!-- 列表组件:接收 filterKeyword 作为筛选条件 --> <ContentList :keyword="filterKeyword" /> </div> </template> <script setup> import { ref } from 'vue' import SearchBox from './components/SearchBox.vue' import ContentList from './components/ContentList.vue' // 父组件维护一个关键词 const filterKeyword = ref('') // 当搜索框发出 search 事件时,更新关键词 function handleSearch(keyword) { filterKeyword.value = keyword } </script>2. 搜索框组件 SearchBox.vue
vue
<template> <div> <input :value="keyword" @input="onInput" placeholder="请输入搜索词" /> </div> </template> <script setup> import { ref } from 'vue' const keyword = ref('') const emit = defineEmits(['search']) function onInput(e) { keyword.value = e.target.value // 每次输入内容变化,就发出 search 事件,把内容传给父组件 emit('search', keyword.value) } </script>3. 列表组件 ContentList.vue
vue
<template> <div> <ul> <!-- 遍历筛选后的列表并显示 --> <li v-for="item in filteredList" :key="item">{{ item }}</li> </ul> </div> </template> <script setup> import { computed } from 'vue' // 接收父组件传来的 keyword const props = defineProps({ keyword: { type: String, default: '' } }) // 假数据 const allItems = ['苹果', '香蕉', '橘子', '葡萄', '西瓜'] // 计算属性:根据 keyword 筛选数据 const filteredList = computed(() => { // 如果没输入关键词,就显示全部 if (!props.keyword) return allItems // 否则,返回名字里包含关键词的项 return allItems.filter(item => item.includes(props.keyword)) }) </script>案例小结:兄弟组件通信,说白了就是把一个组件的 emit 和另一个组件的 props 结合起来,通过父组件中转一下。
案例五:跨级通信 provide/inject——一键切换主题
场景:顶层组件提供一个“主题色”,所有子孙组件都能直接拿到,不用一层层通过 props 往下传。
1. 祖先组件 App.vue
vue
<template> <div> <h2>主题:{{ theme }}</h2> <button @click="toggleTheme">切换主题</button> <ChildLevel /> </div> </template> <script setup> import { ref, provide } from 'vue' import ChildLevel from './components/ChildLevel.vue' const theme = ref('浅色') // 使用 provide 提供数据:第一个参数是 key(字符串),第二个是值 provide('appTheme', theme) function toggleTheme() { theme.value = theme.value === '浅色' ? '深色' : '浅色' } </script>2. 中间层组件 ChildLevel.vue
vue
<template> <div> <h3>中间层组件</h3> <GrandChild /> </div> </template> <script setup> import GrandChild from './GrandChild.vue' </script>
3. 孙子组件 GrandChild.vue
vue
<template> <div> <p>孙子组件拿到的主题:{{ theme }}</p> </div> </template> <script setup> import { inject } from 'vue' // 使用 inject 获取祖先提供的 'appTheme' 数据 const theme = inject('appTheme') </script>案例小结:provide/inject 很适合全局配置、主题、语言这类深层传递的数据。但不要滥用,因为它会让数据流向不那么清晰。
案例六:父调子方法 defineExpose——倒计时控制
场景:子组件内部有一个倒计时功能。父组件想直接调用子组件的“开始”和“重置”方法。
1. 子组件 Countdown.vue
vue
<template> <div> <p>倒计时:{{ count }}</p> </div> </template> <script setup> import { ref } from 'vue' const count = ref(10) // 倒计时数字 let timer = null // 定时器 ID function start() { // 如果已经有定时器在跑,先清除 if (timer) clearInterval(timer) timer = setInterval(() => { if (count.value > 0) { count.value-- } else { clearInterval(timer) // 到 0 停止 } }, 1000) } function reset() { // 清除定时器,重置数字 clearInterval(timer) count.value = 10 } // 把想要暴露给父组件调用的东西列出来 defineExpose({ start, reset }) </script>2. 父组件 App.vue
vue
<template> <div> <!-- 给子组件加个 ref,这样就能拿到子组件实例 --> <Countdown ref="countdownRef" /> <button @click="startCountdown">开始</button> <button @click="resetCountdown">重置</button> </div> </template> <script setup> import { ref } from 'vue' import Countdown from './components/Countdown.vue' // 这个 ref 的名字要和模板里 ref="countdownRef" 匹配 const countdownRef = ref(null) function startCountdown() { // 通过 .value 拿到子组件实例,然后调用它暴露出来的方法 countdownRef.value.start() } function resetCountdown() { countdownRef.value.reset() } </script>案例小结:defineExpose+ref可以让父组件直接操作子组件,但不要大面积使用,否则组件耦合太紧。
案例七:全局状态管理 Pinia——购物车
场景:多个页面、多个组件都需要读写购物车数据。这时候用 Pinia 最合适。
1. 安装 Pinia(如果还没装)
bash
npm install pinia
在 main.js 中注册:
javascript
import { createPinia } from 'pinia' const app = createApp(App) app.use(createPinia()) app.mount('#app')2. 定义 store(stores/cart.js)
javascript
import { defineStore } from 'pinia' import { ref, computed } from 'vue' export const useCartStore = defineStore('cart', () => { // state:购物车商品列表,每项 { id, name, price, count } const items = ref([]) // getter:总价 const totalPrice = computed(() => { return items.value.reduce((sum, item) => sum + item.price * item.count, 0) }) // action:添加商品 function addItem(product) { const exist = items.value.find(item => item.id === product.id) if (exist) { exist.count++ } else { items.value.push({ ...product, count: 1 }) } } // action:清空购物车 function clearCart() { items.value = [] } return { items, totalPrice, addItem, clearCart } })3. 页面组件中使用
vue
<template> <div> <h2>购物车(总价:{{ cart.totalPrice }} 元)</h2> <ul> <li v-for="item in cart.items" :key="item.id"> {{ item.name }} - {{ item.price }} 元 × {{ item.count }} </li> </ul> <button @click="addSample">添加一个苹果</button> <button @click="cart.clearCart">清空</button> </div> </template> <script setup> import { useCartStore } from '@/stores/cart' const cart = useCartStore() function addSample() { // 添加示例商品 cart.addItem({ id: 1, name: '苹果', price: 5 }) } </script>案例小结:Pinia 把共享的数据和方法集中管理,任何组件直接引入就能用,是解决复杂通信的终极方案。
练习题
选择题
父组件向子组件传递数据,应该使用( )
A. emit
B. props
C. provide
D. v-model子组件通知父组件“数据已变化”,应该使用( )
A. 直接修改 props
B. defineExpose
C. emit
D. providev-model 在组件上默认绑定的 prop 名称是( )
A. value
B. modelValue
C. vModel
D. inputValue
判断题
子组件可以直接修改父组件传来的 props 的值。( )
provide/inject 适用于任意层级的组件通信,且数据流向容易追踪。( )
编程题
实现一个“收藏”功能:
父组件显示收藏状态(已收藏/未收藏)
子组件是一个按钮,点击切换收藏状态
要求使用 props 和 emit
使用 Pinia 实现一个笔记便签:
store 里维护一个 notes 数组,每个 note 包含 id 和 text
一个组件负责添加新便签
另一个组件负责展示便签列表
答案
B
C
B
错误,props 是只读的。
错误,provide/inject 的数据流向较隐晦,不便于追踪调试。
实现参考:
父组件
vue
<template> <div> <p>状态:{{ isCollected ? '已收藏' : '未收藏' }}</p> <CollectButton :collected="isCollected" @toggle="isCollected = !isCollected" /> </div> </template> <script setup> import { ref } from 'vue' import CollectButton from './CollectButton.vue' const isCollected = ref(false) </script>子组件 CollectButton.vue
vue
<template> <button @click="$emit('toggle')"> {{ collected ? '❤️ 取消收藏' : '🤍 收藏' }} </button> </template> <script setup> defineProps({ collected: Boolean }) defineEmits(['toggle']) </script>参考:
stores/notes.js
javascript
import { defineStore } from 'pinia' import { ref } from 'vue' export const useNotesStore = defineStore('notes', () => { const notes = ref([]) let nextId = 1 function addNote(text) { notes.value.push({ id: nextId++, text }) } return { notes, addNote } })添加组件
vue
<template> <input v-model="text" placeholder="输入便签" /> <button @click="notes.addNote(text); text=''">添加</button> </template> <script setup> import { ref } from 'vue' import { useNotesStore } from '@/stores/notes' const notes = useNotesStore() const text = ref('') </script>列表组件
vue
<template> <ul><li v-for="n in notes.notes" :key="n.id">{{ n.text }}</li></ul> </template> <script setup> import { useNotesStore } from '@/stores/notes' const notes = useNotesStore() </script>最后唠叨两句
组件通信是Vue里最有“工程感”的知识点,初学容易晕,但只要把上面的案例一个个敲一遍,你就能摸清每种方式的脾气。遇到实际问题先想:数据是谁的?谁要听谁的话?再按上面这些套路去选方案,十有八九都能搞定。
有问题评论区直接问,看到必回。下篇见。
