inject 不生效?!依賴注入背後的實現原理和執行邏輯是怎樣的?

百瓶技術發表於2022-07-11

公眾號名片
作者名片

一個問題

第一個問題

如上圖所示,我們先來思考一個問題,宿主專案使用了業務元件庫中的元件,然後在宿主專案中向業務元件注入了一個名為 datekey,其值為當前的時間戳,問 業務元件可以拿到宿主專案注入的資料嗎?

在回答這個問題之前,我們先來看一下 provide 和 inject 的使用方式。

依賴注入

provide

要為元件後代供給資料,需要使用到 provide() 函式:

<script setup>
import { provide } from 'vue'

provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
</script>

如果不使用 <script setup>,請確保 provide() 是在 setup() 同步呼叫的:

import { provide } from 'vue'

export default {
  setup() {
    provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
  }
}

provide() 函式接收兩個引數。第一個引數被稱為 注入名,可以是一個字串、數值或 Symbol。後代元件會用注入名來查詢期望注入的值。一個元件可以多次呼叫 provide(),使用不同的注入名,注入不同的依賴值。

第二個引數是供給的值,值可以是任意型別,包括響應式的狀態,比如一個 ref:

import { ref, provide } from 'vue'

const count = ref(0)
provide('key', count)

供給的響應式狀態使後代元件可以由此和供給者建立響應式的聯絡。

應用層 provide

除了供給一個元件的資料,我們還可以在整個應用層面做供給:

import { createApp } from 'vue'

const app = createApp({})

app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')

應用級的供給在應用的所有元件中都可以注入。這在你編寫外掛時會特別有用,因為外掛一般都不會使用元件形式來供給值。

inject

要注入祖先元件供給的資料,需使用 inject() 函式:

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

const message = inject('message')
</script>

如果供給的值是一個 ref,注入進來的就是它本身,而 不會 自動解包。這使得被注入的元件保持了和供給者的響應性連線。

同樣的,如果沒有使用 <script setup>inject() 需要在 setup() 同步呼叫:

import { inject } from 'vue'

export default {
  setup() {
    const message = inject('message')
    return { message }
  }
}

注入的預設值

預設情況下,inject 假設傳入的注入名會被某個祖先鏈上的元件提供。如果該注入名的確沒有任何元件提供,則會丟擲一個執行時警告。

如果在供給的一側看來屬性是可選提供的,那麼注入時我們應該宣告一個預設值,和 props 類似:

// 如果沒有祖先元件提供 "message"
// `value` 會是 "這是預設值"
const value = inject('message', '這是預設值')

在一些場景中,預設值可能需要通過呼叫一個函式或初始化一個類來取得。為了 避免在不使用可選值的情況下進行不必要的計算或產生副作用,我們可以使用工廠函式來建立預設值

const value = inject('key', () => new ExpensiveClass())

配合響應性

當使用響應式 provide/inject 值時,建議儘可能將任何對響應式狀態的變更都保持在 provider 內部。 這樣可以確保 provide 的狀態和變更操作都在同一個元件內,使其更容易維護。

有的時候,我們可能需要在 injector 元件中更改資料。在這種情況下,我們推薦在 provider 元件內提供一個更改資料方法:

<!-- 在 provider 元件內 -->
<script setup>
import { provide, ref } from 'vue'

const location = ref('North Pole')

function updateLocation() {
  location.value = 'South Pole'
}

provide('location', {
  location,
  updateLocation
})
</script>
<!-- 在 injector 元件 -->
<script setup>
import { inject } from 'vue'

const { location, updateLocation } = inject('location')
</script>

<template>
  <button @click="updateLocation">{{ location }}</button>
</template>

最後,如果你想確保從 provide 傳過來的資料不能被 injector 的元件更改,你可以使用 readonly() 來包裝提供的值。

<script setup>
import { ref, provide, readonly } from 'vue'

const count = ref(0)
provide('read-only-count', readonly(count))
</script>

使用 Symbol 作為注入名

至此,我們已經瞭解瞭如何使用字串作為注入名。但如果你正在構建大型的應用程式,包含非常多的依賴供給,或者你正在編寫提供給其他開發者使用的元件庫,建議最好使用 Symbol 來作為注入名以避免潛在的衝突。

建議在一個單獨的檔案中匯出這些注入名 Symbol:

export const myInjectionKey = Symbol()
// 在供給方元件中
import { provide } from 'vue'
import { myInjectionKey } from './keys.js'

provide(myInjectionKey, { /*
  要供給的資料
*/ });
// 注入方元件
import { inject } from 'vue'
import { myInjectionKey } from './keys.js'

const injected = inject(myInjectionKey)

實現原理

在對依賴注入有一個大致的瞭解之後我們來看一下其實現的原理是怎樣的。直接上原始碼:

export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
  if (!currentInstance) {
    if (__DEV__) {
      warn(`provide() can only be used inside setup().`)
    }
  } else {
    let provides = currentInstance.provides
    const parentProvides =
      currentInstance.parent && currentInstance.parent.provides
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides)
    }
    // TS doesn't allow symbol as index type
    provides[key as string] = value
  }
}
export function inject<T>(key: InjectionKey<T> | string): T | undefined
export function inject<T>(
  key: InjectionKey<T> | string,
  defaultValue: T,
  treatDefaultAsFactory?: false
): T
export function inject<T>(
  key: InjectionKey<T> | string,
  defaultValue: T | (() => T),
  treatDefaultAsFactory: true
): T

export function inject(
  key: InjectionKey<any> | string,
  defaultValue?: unknown,
  treatDefaultAsFactory = false
) {
  // fallback to `currentRenderingInstance` so that this can be called in
  // a functional component
  const instance = currentInstance || currentRenderingInstance
  if (instance) {
    // #2400
    // to support `app.use` plugins,
    // fallback to appContext's `provides` if the instance is at root
    const provides =
      instance.parent == null
        ? instance.vnode.appContext && instance.vnode.appContext.provides
        : instance.parent.provides

    if (provides && (key as string | symbol) in provides) {
      // TS doesn't allow symbol as index type
      return provides[key as string]
    } else if (arguments.length > 1) {
      return treatDefaultAsFactory && isFunction(defaultValue)
        ? defaultValue.call(instance.proxy)
        : defaultValue
    } else if (__DEV__) {
      warn(`injection "${String(key)}" not found.`)
    }
  } else if (__DEV__) {
    warn(`inject() can only be used inside setup() or functional components.`)
  }
}
原始碼位置:packages/runtime-core/src/apiInject.ts

先不管開頭提出的問題,我們先來看一下 provide 的原始碼,注意下面這句程式碼:

if (parentProvides === provides) {
  provides = currentInstance.provides = Object.create(parentProvides);
}

這裡要解決一個問題,當父級 key 和 爺爺級別的 key 重複的時候,對於子元件來講,需要取最近的父級別元件的值,那這裡的解決方案就是利用原型鏈來解決。

provides 初始化的時候是在 createComponent 時處理的,當時是直接把 parent.provides 賦值給元件的 provides,所以,如果說這裡發現 provides 和 parentProvides 相等的話,那麼就說明是第一次做 provide(對於當前元件來講),我們就可以把 parent.provides 作為 currentInstance.provides 的原型重新賦值。

至於為什麼不在 createComponent 的時候做這個處理,可能的好處是在這裡初始化的話,是有個懶執行的效果(優化點,只有需要的時候才初始化)。

看完了 provide 的原始碼,我們再來看一下 inject 的原始碼。

inject 的執行邏輯比較簡單,首先拿到當前例項,如果當前例項存在的話進一步判斷當前例項的父例項是否存在,如果父例項存在則取父例項的 provides 進行注入,如果父例項不存在則取全域性的(appContext)的 provides 進行注入。

inject 失效?

在看完 provide 和 inject 的原始碼之後,我們來分析一下文章開頭提出的問題。

第一個問題

我們在業務元件中注入了來自宿主專案的 provide 出來的 key,業務元件首先會去尋找當前元件(instance),然後根據當前元件尋找父元件的 provides 進行注入即可,顯然我們在業務元件中是可以拿到宿主專案注入進來的資料的。

第二個問題

分析完了文章開頭提出的問題,我們再來看一個有意思的問題。下圖中的業務元件能拿到宿主專案注入的資料嗎?

第二個問題

答案可能跟你想的有點不一樣:這個時候我們就拿不到宿主專案注入的資料了!!!

問題出在了哪裡?

問題出在了 Symbol 這裡,事實上在這個場景下,宿主專案引入的 Symbol 和 業務元件庫引入的 Symbol 本質上 並不是同一個 Symbol,因為在 不同應用中建立的 Symbol 例項總是唯一的

如果想要所有的應用共享一個 Symbol 例項,這個時候我們就需要另一個 API 來建立或獲取 Symbol,那就是 Symbol.for(),它可以註冊或獲取一個 window 全域性的 Symbol 例項。

我們的公共二方庫(common)只需要做如下修改即可:

export const date = Symbol.for('date');

總結

我們要想 inject 上層提供的 provide 需要注意以下幾點:

  • 確保 inject 和 provide 的元件在同一顆元件樹中
  • 若使用 Symbol 作為 key 值,請確保兩個元件處於同一個應用中
  • 若兩個元件不處於同一個應用中,請使用 Symbol.for 建立全域性的 Symbol 例項作為 key 值使用

參考

更多精彩請關注我們的公眾號「百瓶技術」,有不定期福利呦!

相關文章