11

provide/inject深入学习

阅读本文帮你你可以在使用provide/inject传递响应式数据时记住一个特性: provide传递的每一个响应式数据都需要值是一个引用不变的可监听对象。

在开发vue项目时,不可避免的需要进行组件之间的相互通信。如果是在一个实际的业务项目中, 组件间的通信可以采用采用像vuex, EventBus等机制实现跨组件通信。但如果在开发基础组件库时,需要跟业务项目外部环境(vuexEventBus)解耦,不可以使用这些机制。一般的解决方案是模仿事件冒泡和广播来实现在基础库中组合组件的通信,比如element-uiemitter

vue的2.2.0版本中, 添加了provide/injectapi, 该api参照实现了reactcontext的功能。也为跨组件通信提供了一种新的方式。

从官网文档介绍来看, 这是一种比emitter优雅的多的机制, 但是有一个特殊的提示.

提示:provideinject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的。

意味着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-uiemitter中冒泡中是如何查找目标组件触发事件.

$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也不会被更新在provideprovide中的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/inject是可以完全代替emitter机制的, 包括事件冒泡和事件广播。且provide/inject还提供了一种更安全的父组件对子组件暴露api的方式。

通过阅读vueprovide/inject源码), 发现实现provide/inject的机制也是通过$parent实现的。provide会在初始化时放到一个_provided的属性中,子组件在访问inject的值时, 会通过$parent属性向上查找所有祖先节点的_provided获取第一个有目标属性的值。为什么provide返回的对象的最顶层的响应机制会失效? 在查找inject的值时, 会将目标_provide的单个属性拿出来), 放在一个不是响应式的对象中。从设计上也是合理的,provide的是一个数据集, 而不是一个数据,子组件inject到的数据可能从多个provide中挑选的集合, 故父组件的provide没有必要维持这个数据集的响应式 。 

通过阅读inject的源码, 可以发现, 其实获取inject的值也是通过$parent向上查找祖先中的值, 直接取得就是目标组件得_provided属性得值,子组件跟父组件是共享一个变量,在子组件中改变这个值, 父组件中该值也会发生了变化。


joyerli
158 声望5 粉丝

前端搬砖一枚,会分享一些对技术的个人理解和思考,还会分享一些自己解决实际碰到的业务需而设计的奇葩技术方案。