Code前端首页关于Code前端联系我们

或者yarn add mitt

terry 59分钟前 阅读数 17 #Vue
文章标签 mittYarn

Vue3跨组件通信总踩坑?别只靠props和emit了!这些实用方案你得会 上周接了个小需求重构:电商后台侧边栏的筛选、中间商品列表、底部统计栏三个组件,全层需要联动更新——筛选一改,列表和统计栏马上刷新,统计栏点击重置,筛选和列表也要同步,一开始我抱着老习惯,套了三层props+emit的中转,结果代码写得又臭又长,统计栏重置的时候还差点漏了某个中间组件的参数回传,测试改了三回才稳。

其实不光我,身边用Vue3做项目的新手甚至熟手,都容易在跨组件通信上犯懒或者踩坑:要么只会用props/emit死扛嵌套多层的,要么乱用全局状态把整个store撑得臃肿,要么摸不清provide/inject和mitt这种工具库的适用场景,今天就用最接地气的问答方式,把Vue3跨组件通信的常用、实用方案全理一遍,附上手写代码片段和避坑指南,看完至少能覆盖90%的项目场景。

最基础但最容易出错的方案:props/emit怎么用才算不“坏规矩”?

很多人说props/emit是“父子组件通信的黄金法则”,这话没错,但坏就坏在很多人把它用成了“祖孙甚至更远层的万金油”,或者传参传回调时的方式不对。

首先得明确它的适用范围仅限直接的父子/子父组件单向传递,最好别超过两层——三层开始,中转的代码量会指数级上升,维护性直线下降,比如刚才的电商后台例子,统计栏重置是传给根组件再转侧边栏,中间夹了个容器组件,容器组件本身没用重置的逻辑,就是个“传声筒”,这时候中转完全是多余的。

然后是避坑细节: 第一,props必须是单向数据流,子组件千万不能直接修改props里的对象或数组属性值——虽然Vue3没像Vue2那样直接报错,但会导致数据追踪失效,出现页面渲染不同步的情况,比如父组件传了个商品列表的对象数组,子组件想给某个商品加个“已选”的临时状态,要么深拷贝一份本地修改,要么用emit触发父组件的方法去改原数据。 举个本地深拷贝的小例子(别用JSON.parse(JSON.stringify())这种有缺陷的方法,比如转不了Date、RegExp、函数这些类型,推荐用lodash.cloneDeep,或者Vue3自带的toRefs+reactive组合简单处理小对象):

<!-- 父组件 -->
<template>
  <Child :product="currentProduct" />
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
const currentProduct = ref({ id: 1, name: '苹果15', price: 8999, stock: 100 })
</script>
<!-- 子组件 -->
<template>
  <div>
    <p>商品名:{{ localProduct.name }}</p>
    <p>修改后的本地数量:{{ localProduct.tempCount }}</p>
    <button @click="increaseTempCount">加1</button>
    <button @click="confirmChange">确定修改父组件库存</button>
  </div>
</template>
<script setup>
import { ref, reactive, toRefs, watch } from 'vue'
import cloneDeep from 'lodash.clonedeep'
const props = defineProps(['product'])
const emit = defineEmits(['updateStock'])
// 本地临时修改:lodash深拷贝
const localProduct = reactive(cloneDeep(props.product))
// 监听父组件传过来的product变化,同步到本地
watch(() => props.product, (newVal) => {
  Object.assign(localProduct, cloneDeep(newVal))
}, { deep: true, immediate: true })
const increaseTempCount = () => {
  localProduct.tempCount = (localProduct.tempCount || 0) + 1
}
const confirmChange = () => {
  // 确定修改才触发父组件事件
  emit('updateStock', props.product.id, localProduct.tempCount)
}
</script>

第二,emit的事件名最好用kebab-case(短横线命名),别用camelCase(驼峰命名),因为HTML里的属性和事件名不区分大小写,浏览器会自动把驼峰转成小写,虽然Vue3在template里会做兼容,但用kebab-case更符合Vue的官方规范,也能避免潜在的bug。 第三,defineEmits可以提前声明事件的参数类型和校验规则,这样用TypeScript的话会有类型提示,用JavaScript的话也能在开发模式下看到参数校验失败的警告,减少低级错误,比如刚才的confirmChange事件,可以这样加校验:

// 用TypeScript
const emit = defineEmits<{
  updateStock: [id: number, count: number]
}>()
// 用JavaScript
const emit = defineEmits({
  updateStock: (id, count) => {
    if (typeof id !== 'number' || typeof count !== 'number' || count < 0) {
      console.warn('updateStock事件的参数必须是正整数!')
      return false
    }
    return true
  }
})

嵌套多层但不想用全局状态?试试provide/inject组合式写法!

刚才的电商后台例子,如果是侧边栏→列表→统计栏的顺序嵌套(祖孙关系),或者列表和统计栏是兄弟但父组件没什么用,provide/inject就比中转props/emit香多了。

provide/inject的原理很简单:祖先组件(可以是任意上层,包括根组件)用provide提供数据或方法,后代组件(可以是任意下层,包括孙辈、曾孙辈)用inject接收,不管隔了多少层,都不需要中间组件中转,但要注意,它也是单向数据流的思路——虽然提供的是响应式数据的话,后代可以直接修改属性,但官方强烈不推荐,最好还是提供修改数据的方法,让后代通过调用方法来修改,这样数据的修改源只有祖先组件,更容易追踪。

组合式写法的provide/inject和选项式API的区别不大,但组合式写法里provide默认是非响应式的,如果要提供响应式数据,必须传ref、reactive或者computed的返回值;组合式写法里可以用injectionKey来给注入的内容指定类型,用TypeScript的话体验会更好。

给电商后台的例子加个简化版的provide/inject实现(假设侧边栏、列表、统计栏都在同一个根容器组件的后代里):

<!-- 根容器组件:祖先组件,提供筛选条件、商品列表、统计数据和修改方法 -->
<template>
  <div class="container">
    <Sidebar />
    <ProductList />
    <Statistics />
  </div>
</template>
<script setup>
import { ref, reactive, provide, computed } from 'vue'
import Sidebar from './Sidebar.vue'
import ProductList from './ProductList.vue'
import Statistics from './Statistics.vue'
// 定义全局筛选条件(用injectionKey指定TypeScript类型,这里简化不用)
const filterParams = reactive({
  category: 'all',
  priceRange: [0, 10000],
  isOnSale: true
})
// 模拟商品列表
const allProducts = ref([
  { id: 1, name: '苹果15', category: 'phone', price: 8999, isOnSale: true, stock: 100 },
  { id: 2, name: 'MacBook Pro', category: 'laptop', price: 14999, isOnSale: true, stock: 50 },
  { id: 3, name: 'iPad Air', category: 'tablet', price: 4799, isOnSale: false, stock: 200 },
])
// 计算筛选后的商品列表
const filteredProducts = computed(() => {
  return allProducts.value.filter(product => {
    return (filterParams.category === 'all' || product.category === filterParams.category) &&
           (product.price >= filterParams.priceRange[0] && product.price <= filterParams.priceRange[1]) &&
           (filterParams.isOnSale === product.isOnSale)
  })
})
// 计算统计数据
const statistics = computed(() => {
  return {
    totalCount: filteredProducts.value.length,
    totalPrice: filteredProducts.value.reduce((sum, product) => sum + product.price * product.stock, 0)
  }
})
// 提供修改筛选条件的方法
const updateFilterParams = (newParams) => {
  Object.assign(filterParams, newParams)
}
// 提供重置筛选条件的方法
const resetFilterParams = () => {
  Object.assign(filterParams, {
    category: 'all',
    priceRange: [0, 10000],
    isOnSale: true
  })
}
// 把需要共享的东西都provide出去
provide('filterParams', filterParams)
provide('filteredProducts', filteredProducts)
provide('statistics', statistics)
provide('updateFilterParams', updateFilterParams)
provide('resetFilterParams', resetFilterParams)
</script>
<!-- 侧边栏组件:后代组件,接收并使用updateFilterParams -->
<template>
  <div class="sidebar">
    <select v-model="localCategory" @change="handleCategoryChange">
      <option value="all">全部类别</option>
      <option value="phone">手机</option>
      <option value="laptop">笔记本</option>
      <option value="tablet">平板</option>
    </select>
    <!-- 价格范围滑块省略 -->
    <label><input type="checkbox" v-model="localIsOnSale" @change="handleIsOnSaleChange"> 仅显示在售商品</label>
  </div>
</template>
<script setup>
import { inject, ref, watch } from 'vue'
// 接收祖先提供的内容
const filterParams = inject('filterParams')
const updateFilterParams = inject('updateFilterParams')
// 本地临时绑定UI,避免直接修改filterParams的属性值风险(虽然其实reactive的属性可以直接改,但用方法更规范)
const localCategory = ref(filterParams.category)
const localIsOnSale = ref(filterParams.isOnSale)
// 监听本地临时值变化,调用方法更新祖先的filterParams
const handleCategoryChange = () => updateFilterParams({ category: localCategory.value })
const handleIsOnSaleChange = () => updateFilterParams({ isOnSale: localIsOnSale.value })
// 监听祖先的filterParams变化(比如重置的时候),同步到本地
watch(() => filterParams.category, (newVal) => localCategory.value = newVal)
watch(() => filterParams.isOnSale, (newVal) => localIsOnSale.value = newVal)
</script>
<!-- 统计栏组件:后代组件,接收并使用statistics和resetFilterParams -->
<template>
  <div class="statistics">
    <p>筛选后商品总数:{{ statistics.totalCount }}</p>
    <p>筛选后商品总库存价值:{{ (statistics.totalPrice / 10000).toFixed(2) }}万元</p>
    <button @click="resetFilterParams">重置筛选</button>
  </div>
</template>
<script setup>
import { inject } from 'vue'
// 接收祖先提供的内容
const statistics = inject('statistics')
const resetFilterParams = inject('resetFilterParams')
</script>

这个例子里,中间的ProductList组件就不用说了,直接inject filteredProducts渲染就行,完全不需要中转任何东西,代码比三层props/emit清爽太多了。

provide/inject的避坑细节: 第一,非响应式数据的问题:如果provide的是普通的对象、数组、字符串、数字,后代组件接收后,即使祖先组件修改了原始数据,后代组件的页面也不会重新渲染——必须用ref、reactive或者computed包裹。 第二,injectionKey的使用:如果是多人协作或者大型项目,用字符串作为provide/inject的key很容易出现重名的问题,推荐用Symbol()创建唯一的injectionKey,还能给TypeScript指定类型,具体用法可以查Vue3的官方文档。 第三,避免滥用provide/inject:它虽然方便,但追踪数据的修改源比全局状态还要难(因为全局状态至少有个store文件可以看),所以适合共享一些“组件树内部的、局部的”数据,比如刚才的筛选条件、商品列表,而不是整个应用的用户信息、购物车数据——那种全局的还是用Pinia更合适。

兄弟组件或者完全没关系的跨路由组件?mitt事件总线了解一下!

刚才的电商后台例子,如果侧边栏、列表、统计栏不在同一个组件树里,或者是跨路由的(比如点击侧边栏的筛选,要刷新另一个路由的商品列表),provide/inject就没用了,这时候可以用mitt事件总线

mitt是一个超轻量级的事件发布订阅库,压缩后只有200多字节,完全可以替代Vue2里的$bus(Vue3里移除了全局的$on、$off、$once方法,所以没法直接用选项式API里的方式创建$bus了),它的原理很简单:创建一个全局的mitt实例,需要发送消息的组件用emit发布事件,需要接收消息的组件用on订阅事件,不需要的时候用off取消订阅

首先得安装mitt:

npm install mitt# 或者pnpm add mitt

然后创建一个事件总线的文件,比如src/utils/eventBus.js:

import mitt from 'mitt'
const emitter = mitt()
export default emitter

给电商后台的例子加个跨路由的简化版实现(比如首页的快捷筛选栏和商品页的筛选、列表要联动):

<!-- 首页快捷筛选栏组件:跨路由的,发送消息 -->
<template>
  <div class="home-quick-filter">
    <button @click="filterPhone">筛选手机</button>
    <button @click="filterLaptop">筛选笔记本</button>
  </div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import emitter from '@/utils/eventBus'
const router = useRouter()
const filterPhone = () => {
  // 先跳转到商品页
  router.push('/products')
  // 延迟发布事件,等商品页组件挂载完成再订阅(不然会收不到消息)
  setTimeout(() => {
    emitter.emit('updateFilter', { category: 'phone' })
  }, 100)
}
const filterLaptop = () => {
  router.push('/products')
  setTimeout(() => {
    emitter.emit('updateFilter', { category: 'laptop' })
  }, 100)
}
</script>
<!-- 商品页根容器组件:和之前类似,但增加了事件总线的订阅 -->
<template>
  <!-- 和之前的根容器一样 -->
</template>
<script setup>
import { ref, reactive, provide, computed, onMounted, onUnmounted } from 'vue'
import emitter from '@/utils/eventBus'
// 其他组件引入省略
// 其他数据和provide省略,和之前一样
// 定义订阅事件的回调函数
const handleUpdateFilter = (newParams) => {
  updateFilterParams(newParams)
}
// 组件挂载的时候订阅事件
onMounted(() => {
  emitter.on('updateFilter', handleUpdateFilter)
})
// 组件卸载的时候取消订阅!!!这个非常重要,不然会导致内存泄漏和重复订阅
onUnmounted(() => {
  emitter.off('updateFilter', handleUpdateFilter)
})
</script>

这个例子里,快捷筛选栏和商品页是跨路由的,用mitt就能轻松实现联动。

mitt的避坑细节: 第一,取消订阅的问题:这个是最容易踩坑的!如果组件卸载的时候不取消订阅,下次再挂载的时候会重复订阅,导致回调函数执行多次,而且mitt实例会一直持有对回调函数的引用,导致组件无法被垃圾回收,造成内存泄漏,所以一定要在onUnmounted钩子函数里用off取消订阅,而且要传和on时一样的回调函数(不能传匿名函数,不然off找不到要取消的)。 第二,事件名称的管理:和provide/inject的key一样,事件名称也容易重名,推荐用Symbol()创建唯一的事件名,或者在事件名前面加个前缀,product/'、'user/',这样能避免冲突。 第三,延迟发布的问题:如果是跨路由的组件,发送消息的组件可能先跳转,接收消息的组件后挂载,这时候直接emit事件的话,接收消息的组件还没订阅,会收不到消息,所以最好加个setTimeout延迟发布,或者用路由的afterEach钩子函数确认目标组件挂载完成再发布。 第四,避免滥用mitt:mitt虽然方便,但追踪事件的流向比全局状态和provide/inject还要难(因为事件可以在任何地方发布和订阅),所以适合共享一些“一次性的、触发式的”消息,比如刚才的快捷筛选、弹窗的打开关闭、通知的显示隐藏,而不是“持续的、需要响应式更新的”数据——那种还是用Pinia更合适。

整个应用的全局数据共享?必须用Pinia!

如果是整个应用都需要用到的数据,比如用户信息、登录状态、购物车数据、主题颜色,不管用props/emit、provide/inject还是mitt,都不合适——props/emit太麻烦,provide/inject没法跨组件树,mitt追踪困难,这时候必须用Pinia

Pinia是Vue3官方推荐的状态管理库,替代了之前的Vuex,它的优点很多:

  1. 更简洁的API:没有了mutations,只有state、getters、actions,state可以直接修改(当然最好还是用actions修改,尤其是异步操作);
  2. 更好的TypeScript支持:天生支持TypeScript,类型提示非常完善;
  3. 更轻量级:压缩后只有1KB左右;
  4. 更好的开发体验:支持Vue DevTools,可以直接查看和修改state,时间旅行调试也非常方便;
  5. 模块化设计:可以把store分成不同的模块,比如userStore、cartStore、themeStore,每个模块独立管理,维护性更好。

首先得安装Pinia:

npm install pinia
# 或者yarn add pinia
# 或者pnpm add pinia

然后在main.js里注册Pinia:

import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')

然后创建一个userStore的例子,比如src/store/user.js:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 用组合式API定义store(也可以用选项式API,推荐用组合式,更灵活)
export const useUserStore = defineStore('user', () => {
  // state:响应式数据,用ref或reactive
  const token = ref(localStorage.getItem('token') || '')
  const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || '{}'))
  const isLoggedIn = computed(() => !!token.value)
  // getters:计算属性,直接用computed
  const fullName = computed(() => {
    return userInfo.value.firstName + ' ' + userInfo.value.lastName
  })
  // actions:修改数据的方法,同步异步都可以,不用加async/await的前缀也能处理异步(但最好加上)
  const setToken = (newToken) => {
    token.value = newToken
    localStorage.setItem('token', newToken)
  }
  const setUserInfo = (newUserInfo) => {
    userInfo.value = newUserInfo
    localStorage.setItem('userInfo', JSON.stringify(newUserInfo))
  }
  const logout = () => {
    token.value = ''
    userInfo.value = {}
    localStorage.removeItem('token')
    localStorage.removeItem('userInfo')
  }
  // 异步登录的例子
  const login = async (username, password) => {
    // 模拟API请求
    const res = await new Promise((resolve, reject) => {
      setTimeout(() => {
        if (username === 'admin' && password === '123456') {
          resolve({
            token: 'mock-token-123456',
            userInfo: {
              firstName: '张',
              lastName: '三',
              avatar: 'https://example.com/avatar.jpg'
            }
          })
        } else {
          reject(new Error('用户名或密码错误'))
        }
      }, 1000)
    })
    setToken(res.token)
    setUserInfo(res.userInfo)
  }
  // 把需要暴露的东西return出去
  return {
    token,
    userInfo,
    isLoggedIn,
    fullName,
    setToken,
    setUserInfo,
    logout,
    login
  }
})

然后在任意组件里使用userStore:

<!-- 登录页组件 -->
<template>
  <div class="login">
    <input type="text" v-model="username" placeholder="用户名">
    <input type="password" v-model="password" placeholder="密码">
    <button @click="handleLogin" :disabled="loading">{{ loading ? '登录中...' : '登录' }}</button>
    <p v-if="errorMsg" class="error">{{ errorMsg }}</p>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
const router = useRouter()
const userStore = useUserStore()
const username = ref('')
const password = ref('')
const loading = ref(false)
const errorMsg = ref('')
const handleLogin = async () => {
  loading.value = true
  errorMsg.value = ''
  try {
    await userStore.login(username.value, password.value)
    // 登录成功跳转到首页
    router.push('/')
  } catch (err) {
    errorMsg.value = err.message
  } finally {
    loading.value = false
  }
}
</script>
<!-- 首页导航栏组件 -->
<template>
  <div class="navbar">
    <span v-if="userStore.isLoggedIn">欢迎你,{{ userStore.fullName }}</span>
    <img v-if="userStore.isLoggedIn" :src="userStore.userInfo.avatar" alt="头像">
    <button v-if="userStore.isLoggedIn" @click="userStore.logout">退出登录</button>
    <button v-else @click="router.push('/login')">登录</button>
  </div>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useUserStore } from '@/store/user'
const router = useRouter()
const userStore = useUserStore()
</script>

这个例子里,登录页和导航栏是跨路由的,用Pinia就能轻松实现用户信息的共享和更新,而且数据的修改源只有userStore的actions,非常容易追踪。

Pinia的避坑细节: 第一,state的修改方式:虽然Pinia允许直接修改state(比如userStore.token = 'new-token'),但官方还是强烈推荐用actions修改,尤其是异步操作,这样所有的修改逻辑都集中在actions里,维护性更好,而且时间旅行调试也更准确。 第二,store的持久化:如果需要把store里的某些数据持久化到localStorage或sessionStorage(比如token、userInfo、主题颜色),可以手动在actions里处理(比如刚才的userStore),也可以用第三方库pinia-plugin-persistedstate,更方便。 第三,模块化的命名:defineStore的第一个参数是store的唯一标识符,不能重复,推荐用和文件名一样的名字,user'、'cart'、'theme'。 第四,多个store之间的调用:如果一个store需要调用另一个store的方法或数据,直接在这个store里引入另一个store的use函数,调用后就能使用了,比如cartStore需要调用userStore的isLoggedIn判断用户是否登录:

// cartStore.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', () => {
  const userStore = useUserStore()
  // 其他逻辑省略
  const addToCart = async (product) => {
    if (!userStore.isLoggedIn) {
      alert('请先登录!')
      return
    }
    // 其他添加购物车的逻辑省略
  }
  // 其他逻辑省略
  return {
    // 其他return省略
    addToCart
  }
})

怎么选择合适的跨组件通信方案?

最后给大家整理一个选择方案的流程图(用文字描述),方便大家快速判断:

  1. 首先看组件关系: a. 直接父子/子父:用props/emit; b. 嵌套多层(祖孙及更远)但组件树内部:用provide/inject; c. 兄弟组件或组件树外部:用mitt或Pinia; d. 跨路由或整个应用共享:用Pinia。
  2. 然后看数据类型: a. 一次性的、触发式的消息(比如弹窗打开关闭、通知显示隐藏):用mitt; b. 持续的、需要响应式更新的数据:用props/emit、provide/inject或Pinia。
  3. 最后看数据范围: a. 组件内部或局部组件树内部:用props/emit或provide/inject; b. 整个应用共享:用Pinia。

只要按照这个流程图来选择,基本上不会踩坑,代码的维护性也会更好,没有最好的方案,只有最合适的方案,大家可以根据具体的项目场景灵活调整。

(全文完,共约3800字)

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门