Vue的Provide与Inject机制

4,020 阅读5分钟
原文链接: ayase.moe

Vue中父组件到子组件的通信主要由子组件的props属性实现。但是在一些情况下,父组件无法直接向子组件的props传值。比如子组件通过父组件的slot进入父组件,父组件根本不知道子组件是谁,更不用说用子组件的props了。这时应该怎么办呢?Vue2.2.0版本引入了provideinject,正好适合处理这一情况。

什么是provide与inject

文档的话说:

provide/inject需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。如果你熟悉 React,这与 React 的上下文特性很相似。

这就是说从父组件的provide属性传入一个对象,子组件(或者是孙组件,只要是子级组件)可以用inject属性接收父组件的provide属性。比如

// main.vue
<template>
    <c1 message="hello world">
        <c2></c2>
    </c1>
</template>

// c1.vue
<template>
  <div id="c1">
    <slot></slot>
  </div>
</template>

<script>
export default {
  props: ['message'],
  provides () {
    return {
      message: this.message
    }
  }
}
</script>

// c2.vue
<template>
  <div id="c2">
      {{ message }}
  </div>
</template>

<script>
export default {
  inject: ['message']
}
</script>

上面的main组件会被渲染为:

<div id="c1">
  <div id= "c2">hello world</div>
</div>

可以看到,c1组件在不清楚子组件是什么的情况下,将它的props中的message传给了c2组件。在这里c1组件就像是一个数据源一样,为子组件提供数据。但是,c1组件提供的数据仅在c1的子孙组件中可见,因此可以算作是有作用域限定的数据源。

父到子孙组件方向的数据流

父到子孙组件方向是provide/inject机制设计时的数据流方向。我们可能会猜想,在父组件中更改provide的值,子组件会响应式的发生改变。但是注意到文档中话。

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

这意味着,如果provide的值不是可监听对象时,在父组件中更改provide的值,子组件不会发生任何变化。比如模板仍然为上面那个例子的模板,message的值是一个props属性,不是可监听对象,如果我们在c1mounted钩子函数里改变message的值。如:

// c1.vue
<script>
export default {
  //...
  mounted () {
    setTimeout( () => {
      this.message = 'Opps, it would not be rendered'
    }, 1000)
  }
}
</script>

子组件不会响应修改后的值。

但是如果provide的值是一个可监听对象呢?请看一下例子:

<script>
// c1.vue
export default {
  data () {
    return {
      message: 'hello world'
    }
  },
  provide () {
    messageData: this.$data
  },
  mounted () {
    setTimeout(() => {
      this.message = 'I can show in c2.'
    }, 10000)
  }
}
</script>

// c2.vue
<template>
  <div id="c2">
    {{ messageData.message }}
  </div>
</template>

<script>
export default {
  inject: ['messageData']
}
</script>

此时在c1挂载10s后,子组件将会显示I can show in c2。为什么呢?c2messageData实际上就是c1实例的this.$data。而this.$data上有message的响应式gettersetter。所以c2的视图会被messagedep收集,因此在c1中更新messagec2的视图也会更新。如果对此处不熟悉,可以看一看关于Vue数据绑定的文章。

Vue的源码实现

首先我们来看provideinject的初始化,在src/core/instance/init.js里,我们能够看到它们初始化的过程:

//src/core/instance/init.js
Vue.prototype._init = function (options?: Object) {
  // ...
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')
  // ...
}

可以看到在一个组件内,initInjections是先于initProvide调用的,但是从整个组件树的初始化顺序来看,父组件的initProvide的调用要先于子组件的initInjections。为了理解上的方便,我们先来看initProvide

initProvide

initProvide定义在src/core/instance/inject.js中:

// src/core/instance/inject.js
export function initProvide (vm: Component) {
  const provide = vm.$options.provide
  if (provide) {
    vm._provided = typeof provide === 'function'
      ? provide.call(vm) // 如果provide是函数,在本组件上调用
      : provide
  }
}

initProvide函数很简单,就是将组件的provide对象放到vm._provided中。这里兼顾了provide为函数与为对象两种情况。与文档中所述的一致:

类型: provide:Object | () => Object

注意到,initProvide方法中只是进行了简单的复制。在大多数情况下,如果要把父级的响应式属性作为provide,此时只有值被复制进去。Vue并没有对_provided属性做响应式处理(熟悉源码的同学应该知道defineReactive方法)。因此,provide是非响应式的。

initInjections

initInjections定义同一个文件中:

// src/core/instance/inject.js
export function initInjections (vm: Component) {
  const result = resolveInject(vm.$options.inject, vm)
  if (result) {
    // 不会观察vm.key
    toggleObserving(false)
    Object.keys(result).forEach(key => {
      /* istanbul ignore else */
      if (process.env.NODE_ENV !== 'production') {
        // 如果不是`production`环境中,当试图更改inject的值时会报错。
        defineReactive(vm, key, result[key], () => {
          warn(
            `Avoid mutating an injected value directly since the changes will be ` +
            `overwritten whenever the provided component re-renders. ` +
            `injection being mutated: "${key}"`,
            vm
          )
        })
      } else {
        defineReactive(vm, key, result[key])
      }
    })
    toggleObserving(true)
  }
}

这里出现了一个resolveInject函数,还是在这个文件里:

// src/core/instance/inject.js
export function resolveInject (inject: any, vm: Component): ?Object {
  if (inject) {
    // inject is :any because flow is not smart enough to figure out cached
    const result = Object.create(null)
    // 如果支持Symbol
    const keys = hasSymbol
      ? Reflect.ownKeys(inject).filter(key => {
        /* istanbul ignore next */
        return Object.getOwnPropertyDescriptor(inject, key).enumerable
      })
      : Object.keys(inject)

    // 遍历所有inject中的key
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      const provideKey = inject[key].from
      let source = vm
      // 从本级的._provided中查找(注意到inject先于provide初始化,因此事实上是
      // 从父组件开始查找),如果本级没找到,就到父级的._provided中查找
      while (source) {

        if (source._provided && hasOwn(source._provided, provideKey)) {
          result[key] = source._provided[provideKey]
          break
        }
        source = source.$parent
      }
      // 如果整条链都没有找到,尝试使用`default`属性构建fallback值
      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)
        }
      }
    }
    // 返回result对象,它的key是inject中指定的key
    return result
  }
}

实际上,resolveInject是实现provide/inject的核心函数。它从父/祖父组件中把provide的值捕捉下来,之后initInjections利用这一结果,在自身组件上定义响应式属性。注意到在定义响应式属性之前,toggleObserving(false)。这意味着inject的值里面是没有__ob__的。也就是说,当更改inject的值会触发视图的改变,而更改inject对象的属性不会触发视图改变。当然,最佳实践是不要在子组件里更改inject

纵观整个过程,provide/inject机制是非响应式的,即provideinject之间没有绑定。具体的值是在子组件初始化过程中决定的。

总结

provide/inject提供了一种新的组件间通信的方法。它允许父组件向子孙组件间进行跨层级的数据分发。但是provide/inject是非响应式的,如果要子孙组件根据父组件的值进行改变,provide/inject机制不是一个好的选择。此时可以使用Vuex来管理状态。

参考资料

  1. API - Vue.js
  2. Vue源码分析之Observer