Vue 3源码极客之:`Vue`的`dependency injection`:`provide/inject`的底层实现。

嘿,大家好!今天咱们聊聊 Vue 3 里面一个相当有趣,但有时候又容易被忽略的特性:依赖注入,也就是 provide/inject。这玩意儿就像 Vue 组件之间的一个秘密通道,让父组件能够直接给后代组件“空投”一些东西,而不用一层层地 props 传递。咱们就来扒一扒它背后的实现原理,看看这个“空投”到底是怎么实现的。

开场:为啥需要依赖注入?

在深入代码之前,先来聊聊为啥需要这玩意儿。想象一下,你有一个特别大的 Vue 应用,组件层级很深。某个组件,比如根组件,需要给它下面某个孙子组件甚至重孙子组件传递一个数据。最简单的办法就是 props 传递,但这意味着所有中间组件都必须接收这个 props,然后继续往下传。这就有点像传话筒,效率低不说,还污染了中间组件。

依赖注入就是为了解决这个问题。它可以让父组件直接“跳过”中间组件,把数据或者方法直接注入到需要的后代组件中。这就像在组件树上开辟了一条直达的通道,干净利落。

第一部分:provide 的秘密

provide 负责提供依赖。它接收两个参数:一个 key(可以是字符串或 Symbol),和一个 value。这个 value 就是要注入的东西。

咱们先来看看 provide 在 Vue 3 源码中的大致实现(简化版):

// packages/runtime-core/src/apiInject.ts

import { currentInstance } from './component'

export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
  if (!currentInstance) {
    // 在 setup 之外调用 provide 会报错
    if (__DEV__) {
      warn(`provide() can only be used inside setup().`)
    }
  } else {
    let provides = currentInstance.provides
    // 通过原型链向上查找,如果找到父组件的 provides,则说明不是根组件
    const parentProvides =
      currentInstance.parent && currentInstance.parent.provides

    // 初始化 provides
    if (provides === parentProvides) {
      provides = currentInstance.provides = Object.create(parentProvides)
    }

    // 存储 key-value
    provides[key as string] = value
  }
}

这段代码做了几件事:

  1. 检查 currentInstance: provide 必须在 setup 函数里面调用。currentInstance 是一个全局变量,用来指向当前正在执行的组件实例。如果在 setup 之外调用 providecurrentInstance 会是 null,报错。

  2. 获取 provides: 每个组件实例都有一个 provides 属性,用来存储它提供的依赖。

  3. 原型链的妙用: 这是最关键的地方。如果当前组件的 provides 和父组件的 provides 指向同一个对象,说明当前组件还没有提供任何依赖。这时候,会创建一个新的 provides 对象,并把父组件的 provides 设置为它的原型。

    重点来了: provides 是通过原型链连接起来的。这意味着子组件可以沿着原型链向上查找父组件提供的依赖。如果子组件自己也 provide 了相同的 key,那么它会覆盖父组件提供的依赖。

  4. 存储 key-value: 最后,把 keyvalue 存储到当前组件的 provides 对象中。

用表格总结一下:

步骤 描述
1 检查 currentInstance,确保 providesetup 中调用。
2 获取当前组件实例的 provides 属性。
3 如果 provides 和父组件的 provides 相同(说明是第一次 provide),创建一个新的 provides 对象,并将父组件的 provides 设置为新 provides 的原型。
4 keyvalue 存储到当前组件的 provides 对象中。

第二部分:inject 的魔法

inject 负责注入依赖。它接收一个 key(字符串或 Symbol)和一个可选的默认值。如果在组件的祖先组件中找到了这个 key 对应的依赖,就返回这个依赖的值;否则,返回默认值。

再来看看 inject 的大致实现:

// packages/runtime-core/src/apiInject.ts

import { currentInstance } from './component'

export function inject<T>(
  key: InjectionKey<T> | string | number,
  defaultValue?: T,
  treatDefaultAsFactory: boolean = false
): T {
  if (!currentInstance) {
    if (__DEV__) {
      warn(`inject() can only be used inside setup() or functional components.`)
    }
    return defaultValue as T
  }

  // 从父组件开始,沿着原型链向上查找 provides
  let provides = currentInstance.parent && currentInstance.parent.provides

  if (provides && (key as string) in provides) {
    // 找到依赖,直接返回
    return provides[key as string]
  } else if (arguments.length > 1) {
    // 没有找到依赖,返回默认值

    if (treatDefaultAsFactory && typeof defaultValue === 'function') {
      return (defaultValue as Function)()
    }
    return defaultValue as T
  } else if (__DEV__) {
    warn(`Injection "${String(key)}" not found`)
  }
}

这段代码也做了几件事:

  1. 检查 currentInstance: 和 provide 一样,inject 也必须在 setup 函数里面调用。

  2. 沿着原型链查找 provides: 从父组件开始,沿着原型链向上查找 provides 对象。

  3. 找到依赖: 如果在某个 provides 对象中找到了 key 对应的依赖,就直接返回这个依赖的值。

  4. 没有找到依赖: 如果一直找到根组件都没有找到对应的依赖,并且提供了默认值,就返回默认值。如果没提供默认值,在开发环境下会发出警告。

  5. 默认值是工厂函数: 如果 treatDefaultAsFactorytrue 并且 defaultValue 是一个函数,那么会调用这个函数来获取默认值。这允许你使用一些计算成本较高的默认值,只有在真正需要的时候才计算。

用表格总结一下:

步骤 描述
1 检查 currentInstance,确保 injectsetup 中调用。
2 从父组件开始,沿着原型链向上查找 provides 对象。
3 如果在某个 provides 对象中找到了 key 对应的依赖,就直接返回这个依赖的值。
4 如果没有找到依赖,并且提供了默认值,就返回默认值。 如果 treatDefaultAsFactorytrue 并且 defaultValue 是一个函数,那么会调用这个函数来获取默认值。
5 如果没有找到依赖,并且没有提供默认值,在开发环境下会发出警告。

第三部分:Symbol 的妙用

在实际项目中,我们通常会使用 Symbol 作为 provide/inject 的 key。为啥呢?

因为字符串容易冲突。如果两个毫不相关的组件都使用了相同的字符串作为 key,可能会导致意外的依赖注入。而 Symbol 是唯一的,可以避免这种冲突。

// 创建一个 Symbol 作为 key
const myKey = Symbol('myKey')

// 父组件 provide
provide(myKey, 'Hello from parent!')

// 子组件 inject
const message = inject(myKey) // message 的值为 'Hello from parent!'

第四部分:响应式依赖

provide/inject 还可以提供响应式数据。这意味着如果父组件提供的依赖发生变化,子组件也会自动更新。

实现响应式依赖的关键在于使用 refreactive

// 父组件
import { ref } from 'vue'

setup() {
  const count = ref(0)

  provide('count', count)

  setInterval(() => {
    count.value++
  }, 1000)

  return {}
}

// 子组件
import { inject } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue';

setup() {
  const count = inject('count', ref(0)); // 使用 ref 确保是响应式

  return {
    count
  };
}

在这个例子中,父组件提供了一个响应式的 count,子组件通过 inject 获取这个 count。当父组件的 count 变化时,子组件也会自动更新。

第五部分:一些注意事项

  • 单向数据流: 虽然 provide/inject 允许子组件访问父组件的数据,但仍然要遵循单向数据流的原则。子组件不应该直接修改父组件提供的数据,而是应该通过事件或者其他方式通知父组件进行修改。

  • 可维护性: 过度使用 provide/inject 可能会降低代码的可维护性。因为它隐藏了组件之间的依赖关系,使得代码难以理解和调试。只在必要的时候使用它,比如在跨多个层级的组件之间共享配置信息或者状态管理。

  • 类型安全: TypeScript 可以帮助你提高 provide/inject 的类型安全。你可以使用 InjectionKey 类型来定义 key 的类型,这样可以确保 provideinject 使用的 key 类型一致。

第六部分:实战例子

假设我们有一个应用,需要实现一个主题切换功能。我们可以使用 provide/inject 来共享主题信息。

// App.vue (根组件)
<template>
  <div :class="theme">
    <header>
      <h1>My App</h1>
    </header>
    <main>
      <MyComponent />
    </main>
    <footer>
      <button @click="toggleTheme">Toggle Theme</button>
    </footer>
  </div>
</template>

<script>
import { ref, provide } from 'vue';
import MyComponent from './MyComponent.vue';

export default {
  components: {
    MyComponent
  },
  setup() {
    const theme = ref('light');

    const toggleTheme = () => {
      theme.value = theme.value === 'light' ? 'dark' : 'light';
    };

    provide('theme', theme);

    return {
      theme,
      toggleTheme
    };
  }
};
</script>

<style>
.light {
  background-color: #fff;
  color: #000;
}

.dark {
  background-color: #000;
  color: #fff;
}
</style>
// MyComponent.vue (子组件)
<template>
  <div :class="theme">
    <h2>My Component</h2>
    <p>This is a component that uses the theme provided by the parent.</p>
  </div>
</template>

<script>
import { inject } from 'vue';

export default {
  setup() {
    const theme = inject('theme');

    return {
      theme
    };
  }
};
</script>

在这个例子中,根组件 App.vue 提供了一个 theme 依赖,子组件 MyComponent.vue 通过 inject 获取这个 theme 依赖。当根组件的主题切换时,子组件也会自动更新。

总结:依赖注入的艺术

provide/inject 是 Vue 3 中一个强大的特性,可以让你在组件之间共享数据和方法,而无需手动传递 props。但是,要合理使用它,避免过度使用,遵循单向数据流的原则,并注意类型安全。只有这样,才能充分发挥它的优势,提高代码的可维护性和可读性。

好啦,今天的分享就到这里。希望大家对 Vue 3 的依赖注入有了更深入的理解。下次再遇到类似的需求,就可以自信地使用 provide/inject 来解决问题啦! 大家有什么问题可以提出来,我们一起讨论讨论。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注