如果你在组件层级较深的项目里待过,大概率经历过这种场景:一个配置项从根组件一路向下传,中间隔了五六层完全不关心它的组件,每一层都要写 props、再透传给下一层。代码丑不说,后面加一个字段要动一串文件。Vue 2.2 之后提供了 provide/inject,这个组合就是用来解决这种“跨层级传值”问题的——它允许祖先组件把数据“注入”到任意深度的后代组件里,中间层可以完全不知道这件事。
这一篇接着之前几篇源码分析,专门拆解 Vue 2.x 源码里的 inject.js 文件,把 provide/inject 从注册到解析的完整链路过一遍。适合正在看 Vue 源码的读者,也适合那些在业务里用了 provide/inject 但遇到“为什么改值不生效”“为什么 inject 的值拿不到”之类问题的人。我会尽量把源码细节和实际踩坑点串起来讲,保证你读完不但知道实现原理,还能在业务里避开几个典型的坑。
1. 为什么需要 provide/inject:跨层级传递的痛点
1.1 层层传递 props 的尴尬
props 是 Vue 组件通信最基础的方式,父组件通过 props 把数据传给子组件,这个方向是单向的。但一旦组件层级变深,props 的缺陷就暴露出来了:你有一个根组件,中间包了三层布局组件,最底层是个按钮组件,按钮需要拿到根组件里的一个配置项,比如主题色。这时候你只能在每一层组件里都声明对应 props,再把数据往下传,中间那些完全不需要这个配置的组件也被迫成了“搬运工”。
这种写法不是不能用,但维护成本很高。比如你在最底层想加一个字段,就要从根组件开始,把每一层中间组件的 props 和模板声明全部改一遍。如果再碰上组件复用、插槽嵌套,改动范围会进一步扩大。代码量上去了,可读性却下来了。
1.2 provide/inject 与其它方案的边界
provide/inject 的出现,就是为了避开这条“逐层搬运”的链路。父组件只需要 provide 一份数据,任何层级的后代组件都可以通过 inject 拿到,中间组件完全无感。这个机制很像服务的“依赖注入”:上层提供什么,下层就注入什么,二者不需要直接建立 props 关系。
它和 event bus、Vuex 的定位并不一样。event bus 适合组件间的松耦合通信,但事件多了之后,事件的派发和监听关系会变得很难追踪;Vuex 适合全局共享状态,但为了一个跨两层传值的场景引入 Vuex,多少有点杀鸡用牛刀。provide/inject 的粒度恰好落在“某个祖先节点到它所有后代”这个范围内,更像是“局部全局变量”。它不要求数据是全局的,也不需要引入额外的状态管理库。
当然,它也有自己的限制:它不是为“修改数据”设计的,inject 出来的值在语义上应当被视为只读依赖;它也无法做到兄弟组件之间的通信,因为只能向上找祖先。理解了这几点,后面看源码时会更容易抓住设计意图。
2. 初始化链路:从 _init 入口说起
2.1 先看选项标准化:normalizeInject
在 Vue 2.x 的源码里,所有和注入相关的逻辑都集中在src/core/instance/inject.js这个文件里。打开文件,最先看到的其实是normalizeInject函数,它负责把用户写的各种 inject 写法统一成一种内部格式。
Vue 的 inject 支持两种常见写法。一种是数组:
inject: ['theme', 'locale']另一种是对象:
inject: { theme: 'theme', locale: { from: 'locale', default: 'zh-CN' } }数组写法会被转成{ theme: { from: 'theme' }, locale: { from: 'locale' } }。对象写法里,如果你直接写theme: 'theme',会被转成{ theme: { from: 'theme' } };如果你写的是带from和default的对象,则会被保留,并且from缺省时默认使用 key 本身。
这段标准化的代码并不复杂,但它揭示了一个容易被忽略的事实:所有 inject 写法最终都会统一成“取哪个 key、缺省给什么值”的对象结构。也就是说,from才是真正的查找目标,而 key 只是你在当前组件里用来访问这个值的变量名。你可以把父组件提供的数据叫theme,在自己组件里用myTheme去接收,只要from: 'theme'指向正确即可。
2.2 初始化顺序的讲究
在 Vue 的_init流程里,有一行代码非常关键:
initInjections(vm) // resolve injections before data/props initState(vm) initProvide(vm) // resolve provide after data/props这个顺序不是随便定的,它本身就藏着两个重要设计。
先看initInjections为什么要在initState之前执行。注释写得很明白:“resolve injections before data/props”。因为 inject 注入的值要挂到vm实例上,如果顺序反过来,组件 data 里的字段就无法引用 inject 的值了。举个例子,你完全可以在 data 里这么写:
inject: ['user'], data() { return { greeting: '你好,' + this.user.name } }为了让这种写法成立,inject 的值必须比 data 更早被解析。
再看initProvide为什么放在initState之后。因为 provide 经常要返回当前组件实例上的数据,最典型的是这样写:
data() { return { theme: { color: '#333' } } }, provide() { return { theme: this.theme } }如果 provide 在 data 之前执行,this.theme还是 undefined,拿到手的就是一个空值。Vue 把initProvide排在initState后面,就是为了让 provide 函数可以放心读取实例自己的 data、computed、props。这也是官方文档里“如果你想提供实例状态,需要使用函数返回对象”这句话背后的源码原因。
3. resolveInject:向上查找的核心逻辑
3.1 沿着 $parent 链做匹配
整个 inject.js 里最核心的函数就是resolveInject。它做的事情可以用一句话概括:拿着 inject 配置里的 from 值,从当前实例开始,沿着 $parent 链一路向上找,直到找到一个 provide 了对应 key 的祖先组件。
源码大致是这样:
export function resolveInject (inject, vm) { if (inject) { const result = Object.create(null) const keys = hasSymbol ? Reflect.ownKeys(inject) : Object.keys(inject) for (let i = 0; i < keys.length; i++) { const key = keys[i] if (key === '__ob__') continue const provideKey = inject[key].from let source = vm while (source) { if (source._provided && hasOwn(source._provided, provideKey)) { result[key] = source._provided[provideKey] break } source = source.$parent } // ...默认值与告警逻辑 } return result } }这个while循环就是核心。你可以把它想象成在一栋楼里找一户人家:先从你自己这一层开始,看这一层有没有你要找的东西;没有就上到父楼层,再没有继续往上爬;一直爬到楼顶都没有,那才轮到默认值逻辑。
这里的source._provided就是initProvide阶段挂到实例上的那个对象。每个执行过 provide 的组件实例上都会有一个_provided属性,子孙组件 inject 时就是沿着这个属性链去匹配的。
3.2 默认值与告警的处理
如果一路找到顶层都没匹配到,source会变成 falsy,这时代码进入了默认值分支:
if (!source) { if ('default' in inject[key]) { const provideDefault = inject[key].default result[key] = typeof provideDefault === 'function' ? provideDefault.call(vm) : provideDefault } else if (process.env.NODE_ENV !== 'production') { warn(`Injection "${key}" not found`, vm) } }这里有两个细节值得注意。
第一,default可以是函数,并且函数的this会被绑定到当前组件实例。这意味着你可以在默认值函数里访问this上的其它属性,比如根据当前环境返回不同的默认主题。
第二,如果没有提供default,开发环境下会抛出一条告警,提示Injection "xxx" not found。很多人在业务里遇到“inject 拿到 undefined”的情况,其实只要看一眼控制台就会明白是祖先组件没有提供对应的 key。这个告警只在开发环境出现,生产环境会静默跳过。
3.3 关于 Symbol 键与自身属性
源码里获取 keys 时用了Reflect.ownKeys,判断条件是当前环境是否支持 Symbol。这意味着 inject 的 key 理论上可以是 Symbol 类型,不只是字符串。不过 Vue 2 业务里这么用的人很少,到了 Vue 3 里,Symbol 作为注入 key 才真正变成常见玩法。
还有一个容易被忽略的边角:代码里匹配用的是hasOwn(source._provided, provideKey),也就是只检查_provided对象自身是否拥有这个属性,不会去翻它的原型链。如果你在 provide 时返回了一个 class 实例或者带有原型链属性的对象,某些“继承来的属性”可能匹配不上。正常情况下 provide 返回的都是普通对象字面量,这个边角问题一般不会碰到,但知道有这回事总比踩到了再查源码强。
4. initInjections 与 initProvide:一收一发
4.1 initInjections 里的 defineReactive 与 toggleObserving
resolveInject只是把值查出来,真正把这些值挂到组件实例上的是initInjections:
export function initInjections (vm) { const result = resolveInject(vm.$options.inject, vm) if (result) { toggleObserving(false) Object.keys(result).forEach(key => { defineReactive(vm, key, result[key], () => { warn(`Attempting to mutate injected property "${key}"`, vm) }) }) toggleObserving(true) } }这段代码里最值得琢磨的是toggleObserving(false)和toggleObserving(true)这对调用。toggleObserving控制的是全局的observe开关,defineReactive内部会对值调用observe方法。如果这个开关是开的,defineReactive会尝试把result[key]递归地转换成响应式对象。
但这里为什么要关掉?因为 inject 进来的值,本质上是从父组件那边“拿引用”,那个对象在父组件里早就已经被转换成响应式了,它身上已经有__ob__,你在这里再 observe 一遍也不会重复创建 Observer 实例,但会白白走一遍性能开销。更重要的是,如果这个值是父组件响应式数据里的一个嵌套对象,在子组件里再 observe 一次并不会建立新的依赖关系,反而容易让人误以为“子组件自己也维护了一份响应式副本”。
所以这里用toggleObserving(false)把递归 observe 关掉,让inject定义的 key 只是一个浅层响应式属性,值本身保持原样引用。这个设计非常体现 Vue 源码里那种“能省则省”的性能意识。
4.2 initProvide:为什么 provide 要支持函数写法
initProvide的实现非常简短:
export function initProvide (vm) { const provide = vm.$options.provide if (provide) { vm._provided = typeof provide === 'function' ? provide.call(vm) : provide } }核心逻辑就是:如果 provide 是一个函数,就调用它,把返回值挂到vm._provided;如果直接传对象,就原样挂载。这里再一次印证了前面说的顺序问题——initProvide在initState之后执行,所以provide函数里的this可以安全地访问 data、computed、props 等实例属性。
需要特别注意的是,vm._provided本身是一个普通属性,并没有被设置成响应式。这意味着如果你在父组件里重新给_provided赋一个新对象,子组件已经注入的那些值并不会自动更新。后面我会专门展开讲这个坑。
4.3 开发环境下的“不可修改”警告
initInjections里defineReactive的第四个参数是一个自定义的 setter,在开发环境下,如果你尝试对注入的 key 重新赋值,会触发一条警告:Attempting to mutate injected property "xxx"。
举个例子,父组件 provide 了theme,子组件里写this.theme = something,开发环境下控制台就会冒出这条警告。为什么 Vue 要拦这个操作?因为从语义上说,inject 的值是“从父级注入下来的依赖”,子组件直接修改它,既不会真正改到父组件的数据,又容易让人误以为数据已经被更新了。这个警告就是在提醒你:别这么干,数据流要有方向性。
不过要注意,这个 setter 只拦截“重新赋值整个 key”这个操作。如果你修改的是对象内部的属性,比如this.theme.color = '#fff',是不会触发这条警告的,因为这里的 setter 只管 key 本身,管不到嵌套对象的内部属性。
5. 响应式联动原理与常见坑
5.1 inject 的值到底是不是响应式的
Vue 官方文档对 provide/inject 的响应性描述是:provide 和 inject 绑定本身并不是可响应的,这是刻意为之的。但是,如果你传入了一个可监听的对象,那么该对象的 property 仍然是可响应的。
这句话听起来有点绕,但结合源码就非常好理解了。inject 只是在初始化时从祖先的_provided里拿了一次值,它没有建立“父组件 provide 更新后,子组件重新取”的依赖关系。真正让 UI 更新的,是子组件模板在渲染时访问了那个响应式对象的内部属性。
我把这个过程拆开说一下。父组件里有data: { theme: { color: '#333' } },这个对象在父组件 initState 时已经被observe过了,color属性有自己的dep。子组件 inject 了theme,在模板里写{{ theme.color }}。渲染时,子组件的渲染 watcher 会执行,读取theme.color。这一步触发的是父组件theme对象上color属性的 getter,于是父组件theme.color的dep会收集到子组件当前的渲染 watcher。
之后父组件某处执行this.theme.color = '#fff',触发父组件color属性的 setter,dep 通知子组件渲染 watcher 更新,子组件界面跟着刷新。
所以,响应式链路的关键不是“provide/inject”本身,而是“你取到的那个值是不是父组件里的响应式对象”。取到了响应式对象的引用,子组件读取它内部属性时自然能建立依赖;取不到响应式对象,或者取到的是基本类型值,那整个链路就是一次性的快照。
5.2 整体替换 provide 对象为什么不生效
这个坑我见过不少人在业务里踩。父组件这么写:
provide() { return { settings: this.settings } }, data() { return { settings: { pageSize: 10 } } }, methods: { updateSettings() { this.settings = { pageSize: 20 } } }子组件 inject 了settings,模板里显示settings.pageSize。当父组件执行updateSettings,把settings整体换成一个新对象时,子组件界面并不会更新。原因就在_provided不是响应式:provide()只在初始化时执行了一次,之后this.settings虽然变了,但vm._provided.settings这个引用并没有变,子组件拿到的还是旧对象。
那怎么才能更新?两种方式。
第一种,不要整体替换对象,而是修改对象内部的属性:
this.settings.pageSize = 20这样父组件的settings还是同一个对象,它的pageSize属性触发 setter,依赖了这个属性的子组件会跟着更新。
第二种,如果你确实需要整体替换,可以考虑引入响应式容器,比如把settings升级成 Vuex 管理的状态,或者用一个类似Vue.observable的对象包一层。总之,在 Vue 2 的 provide/inject 里,“换引用”这条路是走不通的,只能“改内部”。
5.3 与 props/data 重名时会发生什么
因为initInjections的执行顺序在initState之前,inject 的 key 会先通过defineReactive挂到实例上。如果数据里存在同名 key,比如 data 里也声明了一个sameName字段,后续initState处理 data 时会把sameName重新定义一遍,从而覆盖掉 inject 的定义。
这个行为给我们的实践指导很简单:inject 的 key 尽量不要和 props、data、computed 重名。重名不会导致报错,但最终生效的可能是后面的定义,inject 的值就变成“名存实亡”了。这种问题排查起来非常隐蔽,因为你看着this.xxx确实有值,但那个值来自 data,而不是来自 inject。
6. 工程实践:几种推荐用法与避坑清单
6.1 推荐写法:provide 用函数、inject 用对象
虽然 Vue 允许 provide 直接传对象,但我建议能写成函数就写成函数。原因前面已经说过:函数写法可以访问当前实例的 data、computed、props,这样你 provide 的数据是“活的”——它来自实例状态,而不是写死的字面量。
inject 方面,我的习惯是尽量用对象写法,并且显式给default。数组写法虽然简洁,但它没有默认值能力,一旦祖先组件哪天忘了 provide,控制台就会报错。给默认值的好处是,组件在独立使用、测试、或者祖先组件还没准备好时,仍然能有一个兜底值,不至于直接崩掉。
一个比较稳妥的写法组合是:
// 祖先组件 provide() { return { themeConfig: this.themeConfig } } // 后代组件 inject: { themeConfig: { from: 'themeConfig', default: () => ({ color: '#1890ff', mode: 'light' }) } }注意这里的default我用的是函数形式,避免多个组件共享同一个对象引用时出现互相污染的问题。
6.2 几个适合 provide/inject 的真实场景
第一个场景是主题配置。根组件维护一个主题对象,下面深浅不一的所有组件都可能需要读取颜色、字号、圆角这些配置。用 props 传递,每层都要声明一遍;用 provide/inject,只有真正需要用的组件 inject 一下就行。而且只要保证修改的是主题对象内部属性,所有使用方都能响应更新。
第二个场景是基础组件库的默认语言包。最外层根据当前语言环境 provide 一份 locale 对象,内部的表单组件、弹窗组件、分页组件各自 inject 自己需要的文案 key。这样既不用把所有文案都塞进 props,也不必引入 i18n 库。
第三个场景是给子组件注入服务实例。比如有一个日志上报服务或者埋点服务,你在根组件里初始化一份实例,子组件通过 inject 拿过来直接用。好处是测试时可以很方便地替换注入的服务实现,不用改子组件代码。
6.3 问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| inject 一直拿到 undefined | 祖先组件没有 provide 对应 key,或 provide 返回值时机不对 | 检查祖先链上是否执行了 provide,并考虑写 default 兜底 |
| 修改 inject 的值后页面不更新 | 对 key 整体重新赋值,没有改对象的内部属性 | 修改原始对象的属性;不要直接替换引用 |
| 整体替换 provide 返回对象后子组件没有反应 | _provided不是响应式,provide 只在初始化时执行一次 | 改为修改对象内部属性,或借助其它状态管理方案 |
| provide 函数里访问 this.xxx 拿到 undefined | 访问的可能是尚未初始化的属性,或用了对象形式而非函数形式 | 确认属性类型,把 provide 写成函数,并确认它运行在 initState 之后 |
| inject 的值有时是旧的 | 组件复用时 inject 结果被缓存,或依赖了非响应式数据 | 明确响应式依赖链,避免依赖一次性快照数据 |
| 控制台报 Injection not found | inject 的 key 在祖先链上没有匹配到 provide | 检查 from 是否拼写一致,或者补充 default |
7. 从 Vue 2 到 Vue 3:差异与迁移思路
7.1 Vue 3 组合式写法
到了 Vue 3,provide/inject 在 Composition API 里有了更直接的表达方式。你不再写inject: ['theme']这种选项,而是在setup里通过函数调用:
// 祖先组件 import { provide, ref } from 'vue' setup() { const theme = ref({ color: '#333' }) provide('theme', theme) } // 后代组件 import { inject } from 'vue' setup() { const theme = inject('theme', { color: '#666' }) return { theme } }Vue 3 里用ref或reactive包装后 provide 出去,子组件拿到的是响应式引用,父组件整体替换theme.value时,子组件也能感知到变化。这比 Vue 2 里“不能换引用,只能改内部”的限制要宽松不少。
还有一个很实用的变化是注入 key 可以传一个 Symbol。你可以在一个单独的文件里定义好 key 并导出,组件之间基于这个 Symbol 建立注入契约,避免字符串命名冲突:
export const ThemeKey = Symbol('theme')7.2 迁移时要留意的行为差异
从 Vue 2 迁到 Vue 3 时,最先要确认的是旧代码里有没有对整体替换 provide 对象的依赖。Vue 3 虽然整体替换响应式对象也能触发更新,但前提是替换的是ref.value而不是原始对象本身。如果你的老代码写的是“替换整个普通对象”,迁移后依然不会有响应效果。
另外,Vue 3 的 Options API 仍然支持 provide/inject,所以存量代码可以在迁移初期继续沿用,不必一上来就全部改成组合式写法。只是新版组合式 API 的表达更灵活,特别是默认值也可以传工厂函数,语义上会更接近源码设计原本的意图。
我个人的建议是:如果你正在做新项目,主题、全局配置这类数据用reactive或ref包装后再 provide 出去,同时约定 key 用 Symbol 统一管理。这样既保留了 provide/inject 的轻量特性,又绕开了 Vue 2 时代那个“只能改内部不能换引用”的坑。
最后再分享一个实测技巧
我在实际调试 provide/inject 问题时,最常用的手段是在浏览器里直接打印组件实例。选中一个 inject 了数据的子组件,在控制台里输入$vm._provided或查找组件链上的_provided,几步就能定位到问题是不是出在“某个祖先组件根本没有把 key 放进去”。这个办法在排查多层嵌套组件时比看代码快得多,不用一层一层去翻父组件文件。
另一个我自己总结的经验是:provide/inject 适合用来传“稳定的依赖”,比如服务实例、全局配置、主题色,而不适合传“频繁变化的数据流”。频繁变化的数据流,要么用 Vuex/Pinia 这类专门的状态管理,要么就老老实实走 props 加事件。把每一个工具用在它最该用的地方,比把某个 API 用出花来更重要。