vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > Vue provide/inject完全指南

Vue的provide/inject从原理到实战完全指南

作者:YahirQ

本文一篇讲透 provide/inject 的技术文章,涵盖它解决什么问题、怎么用、响应式陷阱、查找规则、常见模式,以及什么时候不该用,需要的朋友可以参考下

一、它解决什么问题?

Vue 的组件通信,官方给的原生方案是 props 向下传,emit 向上传。这在「父子直接相邻」时很舒服:

父 ──props──▶ 子
父 ◀──emit─── 子

但一旦出现跨多层的场景,就痛苦了:

A
 └─ B
     └─ C
         └─ D
             └─ E(需要 A 里的某个东西)

如果走 props/emit:

这种「中间层被迫当传声筒」的问题,业内叫 prop drilling(属性透传)。

provide/inject 就是为了消灭 prop drilling 而生的:

A(provide 一个值)
 └─ B(不关心)
     └─ C(不关心)
         └─ D(不关心)
             └─ E(inject 直接拿到 A 的值)

中间层完全不用知道这回事。

二、API 长什么样

Options API

// 祖先组件
export default {
  provide() {
    return {
      theme: 'dark',
      changeTheme: this.changeTheme,   // 可以 provide 方法
      userInfo: this.userInfo          // 也可以 provide 响应式数据
    }
  },
  data() {
    return { userInfo: { name: '张三' } }
  },
  methods: {
    changeTheme(t) { this.theme = t }
  }
}
// 任意后代组件
export default {
  inject: {
    theme: { default: 'light' },       // 带默认值
    changeTheme: { default: () => {} },
    userInfo: { default: null }
  },
  mounted() {
    console.log(this.theme)            // 'dark'
    this.changeTheme('light')          // 直接调祖先的方法
    console.log(this.userInfo.name)    // '张三'
  }
}

也可以用数组简写(没有默认值):

inject: ['theme', 'userInfo']

Composition API

// 祖先
import { provide, ref, readonly } from 'vue'

export default {
  setup() {
    const theme = ref('dark')
    const userInfo = ref({ name: '张三' })
    const changeTheme = (t) => { theme.value = t }

    provide('theme', theme)
    provide('userInfo', readonly(userInfo))  // 只读,防止后代乱改
    provide('changeTheme', changeTheme)
  }
}
// 后代
import { inject } from 'vue'

export default {
  setup() {
    const theme = inject('theme', ref('light'))    // 第二个参数是默认值
    const changeTheme = inject('changeTheme', () => {})
    const userInfo = inject('userInfo')

    return { theme, changeTheme, userInfo }
  }
}

关键区别:provide()里的this

Options API 里 provide 写成函数(不是对象),是为了能访问 this:

provide() {
  return {
    changeTheme: this.changeTheme  // ✅ 这里 this 有值
  }
}

// ❌ 如果写成对象,this 会是 undefined
provide: {
  changeTheme: this.changeTheme
}

三、响应性:这是最大的坑

provide/inject 本身不保证响应性。 是否响应,取决于你 provide 的是什么。

情况 1:provide 一个普通值 → 不响应

provide() {
  return {
    count: this.count   // 传的是快照,不是引用
  }
}

后代拿到的 count 永远是初始值,祖先后来改了也不会更新。

情况 2:provide 一个 ref / reactive → 响应

provide() {
  return {
    count: this.$refs.xxx.countRef   // ref 对象本身
  }
}

后代拿到的就是同一个 ref,.value 变了自动更新。

情况 3:provide 一个响应式对象里的属性 → 会响应

provide() {
  return {
    config: this.config   // config 是 reactive 对象
  }
}

后代改 config.xxx 或祖先改,因为共享同一个 reactive 对象,都响应。

正确姿势总结(Composition API 推荐)

import { provide, ref, reactive, readonly } from 'vue'

setup() {
  const count = ref(0)
  const state = reactive({ theme: 'dark' })
  const setCount = (v) => count.value = v

  provide('count', count)                    // 响应式
  provide('state', readonly(state))          // 响应式 + 只读
  provide('setCount', setCount)              // 方法不需要响应

  return { count }
}

模式:「数据 + 修改方法」成对 provide,后代拿数据响应式读、拿方法改,不要后代直接乱改源数据。

情况 4:provide 的响应式数据被后代解构 → 失去响应

// 后代
setup() {
  const { theme } = inject('state')   // ❌ 解构后失去响应
  return { theme }                    // 永远是初始值
}

用 toRefs 或不解构:

const state = inject('state')
return { state }  // 模板里用 state.theme ✅

四、inject默认值

inject('key', defaultValue)
inject: {
  theme: { default: 'light' },
  // 工厂函数写法(默认值每次是新的对象/数组时用)
  list: {
    from: 'list',
    default: () => []
  }
}

如果没提供默认值、祖先也没 provide,Composition API 里返回 undefined,Options API 里会控制台警告,但不报错。

五、provide/inject不是响应式的响应式替代品

它只是依赖注入,不是状态管理。

维度provide/injectVuex / Pinia
作用范围一棵子树全局
数据可见性祖先 → 任意深度后代(单向)任意组件读写
调试无 devtools有完整 devtools
模块化手动天然分 module/store
适合场景组件库、设计器、主题、局部上下文全局用户态、全局配置、跨页面共享

一个简单的判定:

典型例子:

六、inject的查找规则

// 祖先 A
provide('theme', 'dark')

// 中层 B
provide('theme', 'light')

// 后代 C
inject('theme')   // 'light',不是 'dark'

这意味着可以「覆盖」,这也是组件库常见用法:主题通过多层 provide 覆盖,局部主题化。

七、常见使用模式

模式 1:上下文对象(推荐)

提供一个对象,把相关的东西打包,而不是散着 provide 一堆 key:

provide() {
  return {
    formCtx: {
      model: this.model,
      rules: this.rules,
      validate: this.validate,
      resetFields: this.resetFields
    }
  }
}

后代:

inject: ['formCtx']
// 用 this.formCtx.validate()

好处:避免 key 名冲突,一眼看清依赖了什么。

模式 2:祖先的「上下文哨兵」

只 provide 一个空对象或标识,让子组件判断「我在不在这个上下文里」:

// 设计器
provide() {
  return {
    designMode: true
  }
}

// 子组件
inject: { designMode: { default: false } }
// 根据 this.designMode 决定是否显示编辑按钮

这个模式尤其适用于同一组件在不同上下文里渲染、UI 需要差异化的场景——比如设计器里的表单控件要显示"复制/删除"按钮,而预览态、运行态里则要隐藏。

模式 3:通过 Symbol 作为 key

避免字符串 key 冲突:

// context.js
export const FORM_CTX = Symbol('formCtx')

// 祖先
provide(FORM_CTX, { ... })

// 后代
inject(FORM_CTX)

大型组件库基本都这么做。

模式 4:状态 + 方法分离

provide() {
  return {
    state: readonly(this.state),   // 只读,防止后代乱改
    actions: {
      update: this.update,
      reset: this.reset
    }
  }
}

后代只能读、只能调 actions,形成单向数据流。

八、常见坑与反模式

坑 1:误以为 provide 会自动响应

provide() {
  return { count: this.count }  // count 是 data 里的数字,不是 ref
}

祖先改了 this.count,后代拿到的还是老值。要么 provide ref/reactive,要么 provide 整个响应式对象。

坑 2:后代直接修改 inject 的数据

// 祖先
provide() { return { user: this.user } }

// 后代
this.user.name = '李四'   // ❌ 偷偷改了祖先数据,难调试

用 readonly 包一层,让后代改不了;要改必须走祖先提供的方法。

坑 3:inject 后解构失去响应

// Composition API 里
const { count } = inject('state')  // ❌ 快照

坑 4:把 provide/inject 当全局状态用

不要在 App.vue 里 provide 一大堆东西然后全应用 inject,这跟用全局变量没区别。全局的用 Pinia。

坑 5:provide 一个会变的函数引用

provide() {
  return {
    // 每次 render 都返回新函数,inject 拿到的一直是初始那一份
    handler: () => this.something()
  }
}

provide 只在组件 setup / 首次创建时执行一次,之后不会重新执行。所以 provide 的对象里如果引用了会变的东西,要考虑用 ref 包一层。

九、什么时候该用、什么时候不该用

适合用

不适合用

十、一句话总结

provide/inject 是一套「祖先声明、任意后代按需取用」的依赖注入机制,用来消除 prop drilling。它不保证响应性(取决于你 provide 的是什么),不替代状态管理(它是局部的),最适合组件库内部、设计器、主题这类「某棵子树共享上下文」的场景。

再送你三个「记住就不会错」的口诀:

  1. provide 函数、inject 对象——Options API 里 provide 写成函数才能拿到 this
  2. 要响应就 provide ref / reactive 本身,不要 provide 快照
  3. 要封装就 provide { state: readonly(...), actions: {...} },形成单向数据流

十一、实战案例:在表单设计器中实现"任意层级控件复制"

最后用一个真实场景来把上面所有知识点串一遍——这也是最容易体会 provide/inject 价值的场景之一。

场景:一个表单设计器,控件可以拖拽到画布上,也可以嵌套(比如"分栏"里放子控件、"明细表"里放列)。现在要给每个控件加上"复制"按钮,点击后原地复制一份。

难点:

  1. 顶层控件的复制按钮由主设计器渲染,好办
  2. 分栏/明细表里的子控件是由各自独立的子组件渲染的,主设计器管不到
  3. 复制逻辑(深拷贝 + 递归重建 ID)只应该写一份,放在主设计器里
  4. 但预览弹窗、流程运行态里,同一个子组件不应该显示复制按钮

解法:

// 主设计器 FormDesign.vue
export default {
  provide() {
    return {
      designCtx: {
        cloneItem: this.cloneItem   // 把复制逻辑"广播"下去
      }
    }
  },
  methods: {
    cloneItem(item) {
      const clone = JSON.parse(JSON.stringify(item))
      this.regenerateId(clone)      // 递归重建 ID
      return clone
    },
    regenerateId(item) {
      item.id = this.getId()
      if (item.title) item.title = this.getCopyTitle(item.title)
      // 递归处理嵌套结构
      if (item.name === 'SpanLayout' && item.props.items) {
        item.props.items.forEach(sub => this.regenerateId(sub))
      }
      if (item.name === 'TableList' && item.props.columns) {
        item.props.columns.forEach(col => this.regenerateId(col))
      }
      return item
    }
  }
}
// 子组件 SpanLayout.vue
export default {
  inject: {
    designCtx: { default: null }     // 哨兵:只有在设计器里才非空
  },
  methods: {
    copyItem(index) {
      if (!this.designCtx) return    // 预览态/运行态直接返回
      const clone = this.designCtx.cloneItem(this.items[index])
      this.items.splice(index + 1, 0, clone)
    }
  }
}
<!-- 子组件模板里,复制按钮用 designCtx 判断显隐 -->
<el-tooltip v-if="designCtx" content="复制">
  <el-icon @click.stop="copyItem(index)"><copy-document/></el-icon>
</el-tooltip>

这里用到了文章里的哪些知识点?

知识点在本例中的作用
消灭 prop drilling复制逻辑只写一份,不用层层 emit
任意深度 inject分栏里再嵌套分栏,最内层也能拿到 designCtx
上下文哨兵designCtx 是否存在,就是"是否在设计器里"的天然标志,比传 flag 更可靠
provide 方法不需要响应式cloneItem 是纯方法,不涉及响应性陷阱
就近覆盖如果未来要做"只读设计器",可以在某一层 provide 一个假的 designCtx 屏蔽掉复制功能

这套模式抽象出来就是一句话:

当一个功能只对某棵子树的某一层生效,而子树由子组件自己渲染时,就把逻辑 provide 到根,让子组件自己判断"我是否该显示这个 UI"。

在权限按钮、条件显隐、埋点上报这些场景里,你都会反复用到。

以上就是Vue的provide/inject从原理到实战完全指南的详细内容,更多关于Vue provide/inject完全指南的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文