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

Vue3 inject provide到底好用在哪?为什么要替代Vuex/Pinia的部分功能?

terry 39分钟前 阅读数 7 #Vue

什么是Vue3的provide和inject?别再说是“祖孙通信”这么简单了

首先得明确:Vue3官方文档里对provide/inject的基础定位确实是“跨层级组件通信”,但这俩组合的能力早就不止祖孙这么窄了——兄弟组件、堂兄弟堂姐妹组件、甚至非直接嵌套的跨多模块组件,只要是在同一个Vue应用树里或者同一个独立组件实例的作用域链上,理论上都能用上。

怎么理解“同一个独立组件实例的作用域链”?哦,对,Vue3里有了setup语法糖的组件,还有函数式组件,provide/inject的调用时机和绑定位置都变得更灵活,比如你可以在某个全局组件注册插件的时候provide,甚至可以在某个composition API的自定义Hook里provide临时值,只要Hook是在某个组件的setup阶段调用的,那个provide的作用域就跟着这个Hook的宿主组件走。

先回忆一下基础用法的例子?不过得稍微改改常见的写法,别用太老套的祖孙传静态值或者字符串的,举个稍微实用点的动态传参场景:比如有个全局主题切换组件,要在整个应用里传递当前的主题色和切换函数,这时候基础用法大概是这样的: 在App.vue的setup里用provide(注意用ref/reactive包裹,不然后代inject拿到的是静态值,不能响应式更新哦):

import { ref, provide } from 'vue'
const themeColor = ref('#3b82f6')
const toggleTheme = () => {
  themeColor.value = themeColor.value === '#3b82f6' ? '#8b5cf6' : '#3b82f6'
}
// 可以用Symbol当key,避免命名冲突
const THEME_KEY = Symbol('theme')
provide(THEME_KEY, { themeColor, toggleTheme })

然后不管是深层的Sidebar组件,还是深层深层的SidebarItem组件,或者是和Sidebar同级的Footer组件,都可以用inject拿:

import { inject } from 'vue'
// 建议给Symbol也加上默认值参数?不对,默认值要对应inject传的东西的结构,不然解构会报错
const THEME_KEY = Symbol('theme')
// 或者更规范的做法:把key、默认值都封装在单独的constants或者composables文件里,避免重复写Symbol
const { themeColor, toggleTheme } = inject(THEME_KEY, {
  themeColor: ref('#3b82f6'),
  toggleTheme: () => {}
})

哦对,这个“封装constants/composables放key和默认值”的点非常重要,后面讲最佳实践的时候会重点提,别嫌我啰嗦提前预告。

Vue3里还有个app.provide的全局API哦!这个是给整个应用实例下的所有组件(包括后面动态挂载的组件)提供数据的,比在根组件setup里provide的覆盖范围还要稳——比如有些插件需要给整个应用注入配置,就会用app.provide,这个用法比组件内的provide更适合“全局级轻量状态”或者“全局级工具函数”。

Vue3的provide/inject能实现响应式吗?之前踩过的坑现在能避了吧

这个问题绝对是Vue3开发者刚转过来或者刚开始用inject provide时的高频踩坑点!很多新手写了个provide传普通对象,后代inject拿到修改了之后发现自己组件没更新,或者根组件修改了provide的内容后代没反应,然后就去骂Vue3的响应式机制有问题——别骂,真的是自己写法错了。

Vue3官方文档明确过,provide本身不会自动追踪数据的响应式变化,只有当你provide出去的数据是用ref、reactive、computed这些响应式API包裹的“响应式源”,或者这些源的属性被后代通过inject拿到后直接修改(前提是reactive包裹的对象,ref直接.value修改也行),整个响应式链路才会通。

那踩坑的情况有哪几种?我整理了身边同事和论坛里常遇到的三个,你对照看看有没有中招: 第一种:直接provide了普通的原始值或者普通对象/数组,没有用响应式API包裹,比如你在App.vue里写provide('count', 0),后代inject拿到count修改count++,别说是根组件了,连自己所在组件的视图都不会更新——因为原始值不是响应式的,普通对象Vue3虽然可以用reactive转,但你直接传原始值或者没转的普通对象,inject拿到的就是普通值/对象,根本没有响应式追踪。 第二种:provide的时候对响应式源做了“浅拷贝”或者“重新赋值原始值”,比如你有个ref包裹的count:const count = ref(0),然后你在provide的时候写provide('count', count.value)——哦豁,这里的count.value是个普通的Number原始值,provide出去的就是静态的0,根组件后面修改count.value=1,后代拿到的还是0!正确的写法是直接provide整个ref响应式源:provide('count', count),然后后代inject拿到之后解构或者直接用的时候加.value(如果是用setup语法糖,加{{ count }}或者{{ count.value }}其实都可以,Vue3会自动解包setup里的顶层ref,但解构的时候如果不使用toRefs/toRef,可能会丢失响应式,这个后面提)。 第三种:provide了一个reactive包裹的对象,但你给这个对象重新赋值了整个新的普通对象,比如你有const user = reactive({ name: '张三', age: 18 }),然后provide('user', user),之后在根组件修改user = { name: '李四', age: 20 }——这时候根组件自己的user已经不是原来的响应式源了,而是一个新的普通对象,但是provide出去的还是原来那个响应式源,原来的响应式源里的name和age根本没改!正确的修改方式是修改响应式源的属性user.name = '李四',或者Object.assign(user, { name: '李四', age: 20 }),如果是ref包裹的对象重新赋值没关系:const userRef = ref({ name: '张三', age: 18 })provide('user', userRef)userRef.value = { name: '李四', age: 20 },因为ref重新赋值.value会触发响应式。

还有一个小技巧但也不算太偏门:如果你只想让后代组件拿到provide的数据,不想让它们直接修改,可以用computed把响应式源包一层只读的,然后provide出去这个computed值,同时把修改函数单独provide出去,这样能保证数据流的单向性,和状态管理里的“单向数据流”原则一致,比如刚才的主题切换例子,你可以改成:

import { ref, computed, provide } from 'vue'
const themeColorRef = ref('#3b82f6')
const themeColor = computed(() => themeColorRef.value)
const toggleTheme = () => {
  themeColorRef.value = themeColorRef.value === '#3b82f6' ? '#8b5cf6' : '#3b82f6'
}
const THEME_KEY = Symbol('theme')
provide(THEME_KEY, { themeColor, toggleTheme })

这样后代组件inject拿到的themeColor是computed只读的,不能直接修改.value,只能通过provide的toggleTheme函数来改,完美符合单向数据流,避免数据流混乱。

provide/inject真的能替代Vuex/Pinia的部分功能?哪部分?什么时候用哪个?

这个绝对是现在百度搜索量最高的相关问题之一!很多开发者刚接触Vue3的provide/inject+响应式API,就觉得“哦,原来可以不用状态管理了”,然后所有跨组件通信都用这俩,结果搞的代码逻辑混乱,数据流追踪困难——别冲动,它们俩是互补关系,不是替代关系!

首先说provide/inject能替代Vuex/Pinia的哪部分功能: 第一部分是轻量的全局状态或者跨多(但不是无限多、不是所有模块都频繁交互的)层级组件的状态管理,比如刚才说的全局主题切换、全局用户登录后的基本信息(比如昵称、头像,这些可能只在Header、Sidebar、个人中心这几个组件里用,其他组件很少碰)、全局弹窗的开关状态(比如全局Loading、全局通知栏,这些可能是在整个应用里触发,但只有一两个组件负责渲染)。 第二部分是组件库或者独立组件模块的内部状态共享,比如你开发一个表单组件库,里面有Form、FormItem、Input三个子组件,Form需要给FormItem传递表单的校验规则、提交状态,FormItem需要给Input传递校验状态,这时候用provide/inject比层层props传递要爽太多,而且不需要引入外部的状态管理库,整个组件库的体积会更小,耦合度也会更低——你去看Element Plus或者Ant Design Vue的源码,它们的表单、表格这些复杂的嵌套组件,内部状态共享全都是用provide/inject! 第三部分是临时的、局部的跨组件状态管理,比如你开发一个订单详情页,里面有订单主信息、订单商品列表、订单收货地址编辑弹窗、订单退款申请弹窗,这几个组件都是嵌套在订单详情页这个父组件下面的,可能跨了两三层,但这些状态只在这个订单详情页的生命周期里有用,订单详情页销毁之后这些状态也应该跟着销毁,这时候用provide/inject+在父组件的setup里定义的响应式API,比在Vuex/Pinia里定义一个全局的订单详情状态要合适太多——因为全局状态如果销毁不干净,可能会造成内存泄漏或者下一次打开订单详情页时显示上一次的数据!

然后说什么时候用provide/inject,什么时候用Vuex/Pinia: 这个得看几个核心维度,我整理成了一张表格(虽然没法直接画,但我可以用文字描述清楚),你可以对着这几个维度自己判断: 维度1:状态的覆盖范围和使用频率,如果状态只在某个局部组件树或者某个独立模块里用,使用频率不高(比如一天只打开几次的订单详情页状态),用provide/inject;如果状态是整个应用所有模块都频繁交互的(比如用户的登录凭证token、购物车的商品数量和总价),用Vuex/Pinia。 维度2:状态的复杂度,如果状态的修改逻辑很简单(比如只是切换主题色、修改开关状态、修改基本信息的单个属性),用provide/inject;如果状态的修改逻辑很复杂(比如需要异步请求后端接口、需要处理多个状态的联动、需要做数据的缓存和持久化、需要做状态的时间旅行调试),用Vuex/Pinia——因为Vuex/Pinia已经帮你封装好了异步actions、状态持久化插件(比如Pinia的persistedstate插件)、时间旅行调试(Vue DevTools里可以直接看Pinia的状态变化历史)这些功能,你自己用provide/inject实现这些功能的话,会很麻烦,而且容易出bug。 维度3:团队的开发规范和习惯,如果你们团队之前一直用Vuex/Pinia,对状态管理的流程和规范很熟悉,而且项目的状态复杂度已经达到了需要用状态管理的程度,那就继续用Vuex/Pinia;如果你们团队刚接触Vue3,项目的状态比较轻量,或者想做一个组件库,那就可以试试provide/inject。 维度4:代码的可维护性和可追踪性,如果状态的使用范围很小,数据流的路径很清晰(比如只有两三个组件用到,修改逻辑只有一个函数),用provide/inject没问题;如果状态的使用范围很大,数据流的路径很模糊(比如十几个组件都在修改这个状态),用Vuex/Pinia更好——因为Vuex/Pinia有DevTools的支持,你可以直接在DevTools里看到哪个组件在哪个时间点修改了哪个状态,修改前后的值是什么,非常方便调试和排查问题。

举个具体的例子对比一下: 场景A:电商网站的购物车,购物车的商品数量和总价需要在Header的购物车图标、商品详情页的加入购物车按钮、购物车列表页这三个以上的模块里频繁显示和修改,修改逻辑需要异步请求后端接口(比如加入购物车时需要调用后端API更新数据库),需要做数据的持久化(比如刷新页面后购物车的商品不能丢失),需要做时间旅行调试(比如测试购物车的逻辑时需要回退状态)——这个场景必须用Vuex/Pinia,用provide/inject会非常麻烦,而且可维护性很差。 场景B:电商网站的商品筛选栏,商品筛选栏和商品列表是同级组件,都嵌套在商品列表页这个父组件下面,筛选栏的筛选条件(比如价格区间、品牌、颜色)只在商品列表页的生命周期里有用,筛选条件修改后需要直接更新商品列表的显示——这个场景可以用provide/inject,在商品列表页的setup里定义筛选条件的响应式源,provide给筛选栏和商品列表,筛选栏修改筛选条件,商品列表监听筛选条件的变化重新请求商品列表数据,整个逻辑很清晰,不需要引入外部的状态管理库。

Vue3 provide/inject的最佳实践有哪些?别再写出又乱又难维护的代码了

刚才前面提到过一些小技巧,比如用Symbol当key、封装constants/composables放key和默认值、用computed包只读数据保证单向数据流,现在再系统地整理一下最佳实践,这些都是我自己在项目里踩过坑之后总结出来的,还有参考了一些一线大厂的Vue3开发规范:

必须用Symbol或者单独的枚举/常量对象当provide/inject的key,避免命名冲突

这个绝对是第一个要遵守的最佳实践!因为Vue3的provide/inject是基于“作用域链+字符串/Symbol匹配”的,如果你用普通的字符串当key(count'、'user'),很容易和其他组件或者第三方插件的key冲突,导致inject拿到错误的数据! 举个命名冲突的例子:比如你在自己的App.vue里用provide('user', { name: '张三' }),然后你引入了一个第三方的组件库,这个组件库内部也用了provide('user', { id: 123 }),而且这个第三方组件库的根组件或者某个高级组件嵌套在你的App.vue下面——哦豁,这时候你自己的深层组件inject('user')拿到的可能是第三方组件库的user对象,而不是你自己的! 正确的做法有两种: 第一种:用Symbol当key,最好把Symbol封装在单独的constants文件里,避免重复写Symbol的描述字符串(虽然描述字符串不影响Symbol的唯一性,但统一管理更好):

// src/constants/injectKeys.js
export const THEME_KEY = Symbol('theme')
export const USER_BASIC_INFO_KEY = Symbol('userBasicInfo')
export const GLOBAL_LOADING_KEY = Symbol('globalLoading')

第二种:用一个单独的枚举对象或者常量对象当key的集合,不过这种方式的key是字符串,所以最好给每个字符串加上一个唯一的前缀,比如你的项目名或者组件库名:

// src/constants/injectKeys.js
export const INJECT_KEYS = {
  THEME: 'MY_ECOMMERCE_THEME',
  USER_BASIC_INFO: 'MY_ECOMMERCE_USER_BASIC_INFO',
  GLOBAL_LOADING: 'MY_ECOMMERCE_GLOBAL_LOADING'
}

第一种用Symbol的方式更安全,因为Symbol是完全唯一的,不可能和其他任何key冲突;第二种用带前缀的字符串的方式更方便调试,因为在Vue DevTools里可以直接看到key的名字,而Symbol只能看到描述字符串——你可以根据自己的需求选择,我自己更推荐第一种,调试的时候可以给Symbol的描述字符串写得更清楚一点。

必须给inject加上默认值,避免组件在没有被provide包裹的情况下报错

这个也是非常重要的!比如你开发一个独立的FormItem组件,这个组件需要用到父组件Form provide的校验规则,但如果用户没有把FormItem放在Form里面,而是直接单独使用了FormItem,这时候如果你的FormItem里没有给inject加上默认值,就会报错“Cannot read properties of undefined (reading 'xxx')”——给用户的体验非常不好! 正确的做法是给inject的第二个参数加上默认值,默认值的结构必须和你provide出去的数据的结构完全一致,不然解构或者使用的时候还是会报错:

// 之前提到的主题切换例子的封装版composable
// src/composables/useTheme.js
import { ref, computed, provide, inject } from 'vue'
import { THEME_KEY } from '../constants/injectKeys'
// 定义默认的主题数据和修改函数
const defaultThemeColorRef = ref('#3b82f6')
const defaultThemeColor = computed(() => defaultThemeColorRef.value)
const defaultToggleTheme = () => {}
// 封装provide的函数
export function useThemeProvide() {
  const themeColorRef = ref('#3b82f6')
  const themeColor = computed(() => themeColorRef.value)
  const toggleTheme = () => {
    themeColorRef.value = themeColorRef.value === '#3b82f6' ? '#8b5cf6' : '#3b82f6'
  }
  provide(THEME_KEY, { themeColor, toggleTheme })
  return { themeColor, toggleTheme }
}
// 封装inject的函数
export function useThemeInject() {
  const themeData = inject(THEME_KEY, { themeColor: defaultThemeColor, toggleTheme: defaultToggleTheme })
  return themeData
}

哦对!封装成composable是更好的做法!这样你在父组件里只需要调用useThemeProvide()就可以provide主题数据,在子组件里只需要调用useThemeInject()就可以inject主题数据,不需要每次都写provide/inject的代码,也不需要每次都导入THEME_KEY和默认值,代码的复用性和可维护性都大大提高了!

必须保证数据流的单向性,别让后代组件直接修改provide的响应式源

这个刚才前面也提到过,和状态管理里的“单向数据流”原则一致:只有provide数据的组件(或者封装provide的composable里的函数)才能修改响应式源,后代组件只能通过provide的修改函数来修改数据——这样可以保证数据流的路径很清晰,每个状态的修改都能追踪到源头,避免数据流混乱。 比如刚才封装的useTheme.js里,provide出去的themeColor是computed只读的,后代组件只能通过toggleTheme函数来修改主题色,不能直接修改themeColor.value——完美符合单向数据流。

局部provide的状态,必须在provide的组件销毁时清理(如果需要的话)

这个主要是针对有副作用的局部provide状态的,比如provide的是一个定时器、一个WebSocket连接、或者一个事件监听器——如果在provide的组件销毁时不清理这些副作用,可能会造成内存泄漏! 怎么清理?可以在封装provide的composable里使用onUnmounted钩子:

// 举个有副作用的例子:provide一个实时更新的时间
// src/composables/useCurrentTime.js
import { ref, provide, inject, onUnmounted } from 'vue'
import { CURRENT_TIME_KEY } from '../constants/injectKeys'
// 封装provide的函数
export function useCurrentTimeProvide() {
  const currentTime = ref(new Date())
  // 定时器,每秒更新一次时间
  const timer = setInterval(() => {
    currentTime.value = new Date()
  }, 1000)
  provide(CURRENT_TIME_KEY, currentTime)
  // 组件销毁时清理定时器
  onUnmounted(() => {
    clearInterval(timer)
  })
  return currentTime
}
// 封装inject的函数
export function useCurrentTimeInject() {
  return inject(CURRENT_TIME_KEY, ref(new Date()))
}

这样当provide这个实时时间的组件销毁时,定时器就会被清理,不会造成内存泄漏。

尽量不要用provide/inject传递大型的、复杂的响应式对象,避免不必要的性能损耗

这个主要是因为Vue3的响应式机制是基于Proxy的,虽然Proxy的性能比Object.defineProperty好很多,但如果你provide了一个非常大的、复杂的响应式对象(比如有几百个属性的对象),而且很多深层组件都inject了这个对象,哪怕这些深层组件只用到了这个对象的一两个属性,Vue3还是会对整个对象做响应式追踪——虽然这个性能损耗可能很小,但能避免就避免嘛! 怎么避免?可以用provide多个小的响应式源,或者用computed包出深层组件需要的单个属性,然后provide这个computed值: 比如你有一个大型的用户信息对象:

const user = reactive({
  id: 123,
  name: '张三',
  age: 18,
  address: {
    province: '广东省',
    city: '深圳市',
    district: '南山区'
  },
  orders: [
    { id: 1, name: '商品1', price: 99 },
    { id: 2, name: '商品2', price: 199 }
  ]
  // 还有很多其他属性
})

如果Header组件只需要用到user.name和user.avatar(假设avatar在address里?不对,假设avatar在user里),那你可以provide两个单独的computed值:

const userName = computed(() => user.name)
const userAvatar = computed(() => user.avatar)
provide(USER_NAME_KEY, userName)
provide(USER_AVATAR_KEY, userAvatar)

这样Header组件只需要inject这两个computed值,Vue3只会对user.name和user.avatar做响应式追踪,不会对整个user对象做响应式追踪——性能会更好一点。

结合Vue DevTools调试provide/inject

很多新手不知道Vue DevTools可以调试provide/inject,其实Vue3的Vue DevTools已经完美支持provide/inject的调试了! 打开Vue DevTools,选中某个组件,然后在右侧的“Component”面板里,你可以看到这个组件“Provided”(提供了哪些数据)和“Injected”(注入了哪些数据)的内容——如果是用Symbol当key,你可以看到Symbol的描述字符串;如果是用字符串当key,你可以直接看到key的名字;如果是响应式源,你可以看到响应式源的当前值,还可以直接修改响应式源的当前值(修改只读的computed值是没用的)。 这个功能非常方便调试provide/inject的逻辑,比如你可以看看某个深层组件有没有inject到正确的数据,provide的数据有没有更新,有没有命名冲突等等。

Vue3的provide/inject到底香不香?

肯定香啊!但香归香,你得会用,不能滥用! Vue3的provide/inject是对Vue2跨层级组件通信的巨大改进,加上响应式API和Composition API的组合,它已经成为了轻量状态管理和复杂嵌套组件内部状态共享的首选方案——但它永远替代不了Vuex/Pinia在大型、复杂项目中的作用,它们俩是互补关系,不是替代关系! 最后再给你一个简单的决策树,帮你快速判断什么时候用provide/inject,什么时候用Vuex/Pinia:

  1. 你的状态是局部的还是全局的?局部的→优先用provide/inject;全局的→看第2步。
  2. 你的全局状态使用频率高不高?修改逻辑复杂不复杂?需不需要异步请求、持久化、时间旅行调试?
    • 都不需要→用全局app.provide;
    • 有一个或多个需要→用Vuex/Pinia。

版权声明

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

热门