provide/inject深入学习
阅读本文帮你你可以在使用provide/inject
传递响应式数据时记住一个特性:provide
传递的每一个响应式数据都需要值是一个引用不变的可监听对象。
在开发vue
项目时,不可避免的需要进行组件之间的相互通信。如果是在一个实际的业务项目中, 组件间的通信可以采用采用像vuex
, EventBus
等机制实现跨组件通信。但如果在开发基础组件库时,需要跟业务项目外部环境(vuex
,EventBus
)解耦,不可以使用这些机制。一般的解决方案是模仿事件冒泡和广播来实现在基础库中组合组件的通信,比如element-ui
的emitter。
在vue
的2.2.0版本中, 添加了provide/injectapi
, 该api
参照实现了react
的context
的功能。也为跨组件通信提供了一种新的方式。
从官网文档介绍来看, 这是一种比emitter
优雅的多的机制, 但是有一个特殊的提示.
提示:provide
和inject
绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。
意味着provide/inject
机制对响应式传递有很大的限制, 那它是否可以完全替换掉emitter
机制呢?
但在实际开发中, 组合父子组件都是配套使用,在实现时一般不会使用事件冒泡或者事件广播的方式进行通信, 一般通过递归或者循环获取$parent
的方式的得到祖先组件的数据, 因为这些数据是可响应的,不需要事件传递那么繁琐。如在子组件中有代码:
computed: {
count() {
return (this.$parent || {}).count;
},
user() {
return (this.$parent || {}).user;
},
},
当父组件的count
(基本类型)或者user
(引用类型)发生改变时, 会自动更新子组件的computed
。这在高级组件中非常有用, 可以把一些子组件共有的特性抽取到父组件中去, 父组件为这些子组件的一些特性提供一个默认值, 改变父组件的props
来改变所有的使用默认值的子组件的行为。 如el-form
中的label-width
就可以为所有的目标子组件el-form-item
设置一个默认的label-width
。
实际使用时,不会直接对this.$parent
获取目标组件, 因为这样耦合性太高, 一般通过循环或者递归查找某种特征的组件,具体可以查看element-ui
的 emitter中冒泡中是如何查找目标组件触发事件.
$children
是不支持响应式的.
下面使用provide
在组件树中数据自上向下传递,在父组件中通过provide
中暴露一些数据, 子组件接受这些数据,然后通过子组件渲染在页面上。在父组件中改变这些值, 查看数据流动。
如果父组件中提供:
props: {
count: Number,
user: Object,
},
data() {
return {
grade: {
number: 12,
},
};
},
provide() {
return {
count: this.count,
user: this.user,
grade: this.grade,
};
},
其中, user
是外部组件通过props
传递给父组件的数据, 结构为:
{
name: ''.
age: 0,
address: {
number: 0,
}
}
这些数据user
, grade
都是响应式数据结构,count
不是。
分别改变这些数据结构,依次改变grade.number
, user.address.number
, user.age
, user.address
, 会发现子组件能接受到的响应式数据,但当改变了grade
, user
, count
时, 子组件不能响应式渲染页面, 且改变grade
, user
的属性也会发现不会响应式渲染。
其中count
由于是一个不可监听的对象, 没有在子组件中动态渲染, 符合期望。其后在改变grade.number
, user.address.number
, user.age
, user.address
时, 子组件可以动态渲染改变值,这是由于这些属性都在一个可监听对象中。改变grade
, user
的后,发现子组件没有动态渲染, 证明grade
, user
是不可响应的,这是由于grade
, user
不在一个可监听的对象里面。 grade
, user
在哪呢? 在provide
的声明中, 返回了一个对象:
provide() {
return {
count: this.count,
user: this.user,
grade: this.grade,
};
}
grade
, user
就在这个对象里面, 该对象不是可监听对象, 所以导致grade
, user
不是响应式的. 首先的解决方案就是, 将这个对象作为一个响应式的,可以在data
中声明一个容器字段, 用于包装需要传递的数据, 如:
data() {
return {
context: {
user: '',
count: 0,
grade: '',
},
grade: {
number: 12,
},
};
},
watch: {
user(val) {
this.context.user = val;
},
count(val) {
this.context.count = val;
},
grade(val) {
this.context.grade = val;
},
},
created() {
this.context.user = this.user;
this.context.count = this.count;
this.context.grade = this.grade;
},
为什么这样写, 是由于provide/inject
机制不支持响应式编程的,后续对provide
返回的对象直接修改不会重新刷新provide/inject
机制, 也就是provide
返回的对象的最顶层的响应机制会失效,且无法对对象顶层属性进行操作。这个机制会导致以下三种方式不能实现响应式传递:
- 上文中的
context
不能在computed
中声明。因为每次computed
都会返回一个新的值(引用),而provide
只会记录一开始的context
的那个引用, 后续数据发生变更, 新的context
不会被刷新到provide
中去。 - 上文中的
context
就算data
中声明的, 但如果在某个地方执行了this.context = {...}
, 新的context
也不会被更新在provide
,provide
中的context
永远是在初始化时复制给他的那个引用。这会导致在父组件中context
可以动态刷新, 但是子组件中的context
不会动态刷新。 - 直接在
provide
函数中返回上文中的context
,那么user
,grade
就会成为顶层属性,在created
中进行的重新赋值操作和后续的重新赋值操作都不会响应到provide
中, 将会失去响应式。
按照上面的写法, 发现grade
, user
已经能够动态在子组件中渲染了。由上得到结论:要想provide
传递的数据一直是可响应的, 需要provide
传递的每一个属性的值都是一个引用不变的可监听对象。
每次维护context
太麻烦了, 有没有简便方法? 有的, 可以这样写:
provide() {
return {
group: this,
};
}
直接将父组件的引用放在provide
返回对象的一个属性中, this
代表当前实例, 引用实不会发生变化的, 且this
中大多数属性都是响应式的。但需要注意带$
前缀的属性大多数都不是响应式属性,如$el
,子组件在使用这些属性时, 不会动态渲染。如果父组件有更大的作用域是,比如同时为多种类型子组件服务,或者允许第三方子组件inject
使用时, 建议不要直接传递this
, 而是在provide
中暴露特定的api
。
总体来说, provide/inject
是可以完全代替emitter
机制的, 包括事件冒泡和事件广播,也可以使用provide/inject
实现。且provide/inject
还提供了一种更安全的父组件对子组件暴露api
的方式。
通过阅读vue
源码, 发现实现provide/inject
的机制也是通过$parent
实现的。provide会在初始化时放到一个不支持响应式的_provided
的属性中,子组件在访问inject
的值时, 会通过$parent
属性向上查找所有祖先节点的_provided
获取第一个有目标属性的值。provide
怪异的特性也是由它的实现带来的。
provide
的源码:
// src/core/instance/inject.js
export function initInjections (vm: Component) {
const result = resolveInject(vm.$options.inject, vm)
if (result) {
observerState.shouldConvert = false
Object.keys(result).forEach(key => {
defineReactive(vm, key, result[key])
})
observerState.shouldConvert = true
}
}
inject
的源码:
export function resolveInject (inject: any, vm: Component): ?Object {
if (inject) {
const result = Object.create(null)
const keys = hasSymbol
? Reflect.ownKeys(inject).filter(key => {
/* istanbul ignore next */
return Object.getOwnPropertyDescriptor(inject, key).enumerable
})
: Object.keys(inject)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
const provideKey = inject[key].from
let source = vm
while (source) {
if (source._provided && provideKey in source._provided) {
result[key] = source._provided[provideKey]
break
}
source = source.$parent
}
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)
}
}
}
return result
}
通过阅读inject
的源码, 可以发现, 其实获取inject
的值也是通过$parent
向上查找祖先中的值, 直接取得就是目标组件得_provided
属性得值,以及子组件跟父组件是共享一个变量,在子组件中改变这个值, 父组件中该值也会发生了变化。