provide/inject深入学习
本文深入探究provide,inject
在官网porivide, inject的介绍中, 有这样的一段话:
provide和inject绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。
本文将深入探究这句话的意思.
父引用和子引用
在无provide和inject时代, 一般通过组件中$parent
(父引用)和$children
(子组件列表)来实现在组件树上进行跨组件状态或者属性的引用, 在子组件中通过递归或者循环获取$parent
的方式的得到祖先组件的数据, 由于获取到的数据是可响应的,使用时自然也就是响应式的。如在子组件中有代码:
computed: {
count() {
return (this.$parent || {}).count;
},
user() {
return (this.$parent || {}).user;
},
},
当父组件的count
(基本类型)或者user
(引用类型)发生改变时, 会自动更新子组件的computed
。这在组合组件(类似select和option)中非常有用, 可以把一些子组件共有的特性抽取到父组件中去进行统一设置, 如el-form
中的label-width
就可以为所有的子组件el-form-item
设置一个默认的label-width
。
实际使用时,不会直接对
this.$parent
获取目标组件, 因为这样耦合性太高, 一般通过循环或者递归查找某种特征的组件,具体可以查看element-ui
的emitter中冒泡中是如何查找目标组件触发事件.
$children
是不支持响应式的.
provide/inject传递数据
利用provide/inject机制也可以在组件树中数据自上而下进行传递: 在祖先组件中通过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,
}
}
在子组件中, 通过inject
获取这些数据:
{
inject: ['count', 'user', 'grade'],
created () {
console.log(this.count);
}
// ...
}
provide/inject响应式特性深入探究
对上章节中provide/inject的案例进行响应式研究, 可以发现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
。但是按照上文中维护一个特定的context
对象太繁琐了,可以使用函数来保证引用不变(推荐使用该方式):
provide() {
return {
getContext: () => ({
user: this.user,
grade: this.grade,
})
};
}
在子组件中:
inject: ['getContext'],
computed: {
context() {
return this.getContext();
}
}
原理
本章节从源码的角度来解密provide为什么有如此怪异的特性.
provide/inject为什么具备响应式, 因为它的实现机制也是通过$parent
实现的。provide
会在初始化时放到一个_provided
的属性中,子组件在访问inject
的值时, 会通过$parent
属性向上查找所有祖先节点的_provided
获取第一个有目标属性的值, 因此子组件跟父组件是共享一个变量,如果这个变量是引用类型的话, 在子组件中改变这个值, 父组件中该值也会发生了变化。
为什么provide
返回的对象的最顶层的响应机制会失效? 在查找inject
的值时, 会将目标_provide
的单个属性拿出来, 放在一个不是响应式的对象中。从设计上也是合理的,provide的是一个数据集, 而不是一个数据,子组件inject
到的数据可能从多个provide
中挑选的集合, 故父组件的provide
没有必要维持这个数据集的响应式 。