Vue Provide/Inject 使用指南

发布时间 2023-11-27 17:42:12作者: 柯基与佩奇

两个inject工具函数轻松解决严格注入Hook 返回值透传问题!

image.png

image.png

痛苦的经历

自从使用了Provide/Inject代码的组织方式更加灵活了,但是这个灵活性的增加伴随着代码容错性的降低。相信只要是真的在项目中引入Provide/Inject的同学,一定一定有过或者正在经历下面的状况:

  • ? 注入名(Injection key)经常拼错,又或者注入名太多导致注入名取名困难(程序员通病)
  • ? 为了弄清楚inject()注入的是啥,不得不找到对应provide()
  • ? 另一种情况是重复provide()同一值,导致 Injection 覆盖
  • ? 使用inject()时祖先链上未必存在对应的provide(),不得不做空值处理或默认值处理
  • ? 在 hook 中使用provide(),但是调用 hook 的组件无法inject()这个 hook 的provide()
  • ...

Provide/Inject 解决了什么问题?

依赖注入|Vue.js 中提到Provide/Inject这两个 API 主要是用来解决Prop 逐级透传问题(就像下面这样)

image.png

引入Provide/InjectProp就可以直接传入到后代组件(就像下面这样)

image.png

根组件中通过provide提供注入值,示例代码如下:

import { provide } from "vue";

provide(/* 注入名 */ "account", /* 值 */ { name: "youth" });

后代组件中通过inject获取祖先组件注入的值,示例代码如下:

import { inject } from "vue";

const message = inject("account");

当只是在项目中小范围的使用provideinject时,上面示例的写法没什么问题。但是如果项目工程较大,代码量也多的情况下,就会出现一些问题。

注入名冲突

问题是如何保证account不会被其他业务组件覆盖?例如如果某个业务组件也提供了account的信息,就像下面这样:

image.png

中间层的ParentView组件可能是一个用户列表组件,也提供了account数据,这里的account可能是列表选中的用户,而Main中提供的是当前用户。在DeepChild组件中可能即需要当前登录用户信息,又需要列表选中的用户信息,而目前DeepChild中只能获取到ParentView提供的选中用户信息。

当然这种业务场景有很多解决方案,这里先认为只能通过provide/inject解决(不杠哈 ?)

当然完全可以在ParentView中将注入名改写为selectAccount来解决这个问题,但是如果中间层还有其他的组件,这些组件也有selectAccount呢?

实践方案

在项目中创建一个名为injection-key.ts的文件,习惯将该文件创建为src/constants/injection-key.ts。这样在该文件中统一管理项目下的注入名,并且使用Symbol来创建注入名,来回避取名冲突

export const CurAccountKey = Symbol("account");

export const AuthAccountKey = Symbol("account");

用法示例:

Main.vue:

import { provide } from "vue";
import { CurAccountKey } from "@/constants/injectionKeys";

const user = reactive({ id: 1, name: "youth" });
provide(CurAccountKey, user);

ParentView.vue:

import { provide } from "vue";
import { AuthAccountKey } from "@/constants/injectionKeys";

const user = reactive({ id: 1, name: "John Doe" });
provide(AuthAccountKey, user);

DeepChild.vue:

import { inject } from "vue";
import { AuthAccountKey, CurAccountKey } from "@/constants/injectionKeys";

const curAccount = inject(CurAccountKey);
const authAccount = inject(AuthAccountKey);

注入提示

但是使用inject(CurAccountKey)会代码什么样的数据?这就不得不全局查找CurAccountKeyprovide了。这种的使用体验十分不好,这时 Vue 官方推荐使用 TS。

import { inject } from "vue";
import { AuthAccountKey, CurAccountKey } from "@/constants/injectionKeys";

const curAccount = inject(CurAccountKey);
curAccount.name; // curAccount存在name吗?

实践方案

Vue|为 provide / inject 标注类型中提到了InjectionKey类型,使用 TS 和InjectionKey可以有效解决类型提示问题

src/types.ts:

export interface Account {
  name: string;
  id: number;
}

src/constants/injection-key.ts:

import { InjectionKey } from "vue";
import { Account } from "@/types";

export const CurAccountKey: InjectionKey<Account> = Symbol("account");

Main.vue:

import { provide } from "vue";
import { CurAccountKey } from "@/constants/injectionKeys";

const user = reactive({ id: 1, name: "youth" });
provide(CurAccountKey, "name: youth"); // ❌
provide(CurAccountKey, user); // ?

DeepChild.vue:

const curAccount = inject(CurAccountKey);
curAccount?.age; // ❌
curAccount?.id; // ?

严格注入

默认情况下,inject  假设传入的注入名会被某个祖先链上的组件提供。如果该注入名的确没有任何组件提供,则会抛出一个运行时警告

const curAccount = inject(CurAccountKey);
curAccount?.id;

当然有时候可能并不是要求必须在祖先链上提供,这时候 Vue 官方推荐使用默认值来解决祖先链未提供值的情况,这也仅仅是能解决inject值不是必要值的情况

但是有些情况下又要求祖先链上必须提供需要的inject,这种情况更常见的是通用型组件开发中。例如:<ElTable><ElTableColumn>组件,<ElTableColumn>的祖先链上必须存在<ElTable>组件。如果单独使用<ElTableColumn>是不合法的,这时候应该抛出错误 ❌ 而不是警告 ⚠️

要解决上面的严格依赖问题,当然可以在子组件中通过判断inject的值是否为undefined,如果是则抛出异常。这种代码很简单:

const curAccount = inject(CurAccountKey);
if (!curAccount) {
  throw new Error("CurAccountKey必须提供对应的Provide");
}
curAccount.id;

嗯,不错!是解决了问题!如果严格依赖的很多呢?难不成到处都是if判断?

实践方案

创建一个严格注入工具函数,当对应的注入名没有被提供时抛出异常。

export const injectStrict = <T>(
  key: InjectionKey<T>,
  defaultValue?: T | (() => T),
  treatDefaultAsFactory?: false
): T => {
  const result = inject(key, defaultValue, treatDefaultAsFactory);
  if (!result) {
    throw new Error(`Could not resolve ${key.description}`);
  }
  return result;
};

使用injectStrict重写吧:

const curAccount = injectStrict(CurAccountKey);
curAccount.id;

再谈逐级穿透

在 Vue 中 Provide 组件无法使用 provide 值

这个看着有点绕,直观来看使用情况是这样的:

const user = reactive({ id: 1, name: 'youth' });
provide(CurAccountKey, user);

...

inject(CurAccount); // 这里无法获取?提供的user

哎?? 这时候有的同学肯定会说,Provide 组件使用provide的值?有没有搞错啊?怎么会有这种操作?

const user = reactive({ id: 1, name: "youth" });
provide(CurAccountKey, user);

//这里需要user值的时候,直接用不就好了??
user;

逐级透传问题又来了

但是,别忘了自定义hook的情况啊!!如果provide(CurAccountKey, user);是在一个自定义的hook中的呢?

useAccount.ts:

export const useAccount = async () => {
  const user = await fetch("/**/*");
  provide(CurAccountKey, user);
  return { user };
};

如果是直接调用useAccount还不是问题,因为useAccount返回了user。在调用userAccount的地方可以直接解构出user,这样很直观也很方便。

如果useAccount被其他的hook再次封装呢?

useApp.ts:

export const useApp = async () => {
  const account = await useAccount();
  ...
  return {
    account
  }
}

当然,这也不是没有解决方法,可以在useApp中解构account再返回

useApp.ts:

export const useApp = async () => {
  const account = await useAccount();
  ...
  return {
    ...account
  }
}

image.png

等下?有没有觉得这种情况很熟悉?把hook换成组件,情况是不是就是这样:

image.png

不能说相似,只能说一毛一样啊!Provide/Inject的出现就是为了解决这样的问题,但是当在hook中出现透传时,却又成了最初的样子啊!

屠龙勇士最终变成恶龙?

你可能会说那没办法呀!Vue 无法在当前组件中获取到当前组件的provide,还能有什么好招?先 ? 着吧!

实践方案

解决上面问题的方案也很简单,就是获取当前组件实例,然后从组件实例中找到provide的值就好了!

既然 Vue 本身无法支持当前组件获取当前组件的provide,那自己实现一个吧!

import { getCurrentInstance, inject, InjectionKey } from "vue";

export const injectWithSelf = <T>(key: InjectionKey<T>): T | undefined => {
  const vm = getCurrentInstance() as any;
  return vm?.provides[key as any] || inject(key);
};

这里从当前组件的实例中找到对应 key 的provide值,如果不存在就走inject从祖先链组件中获取

使用injectWithSelf重写一下吧:

useAccount.ts:

export const useAccount = async () => {
  const user = await fetch("/**/*");
  provide(CurAccountKey, user);
  return { user };
};

useApp.ts:

export const useApp = async () => {
  const account = await useAccount();
  ...
  return {
    account
  }
}

Main.vue:

useApp();

// 必须在useApp()之后
const user = injectWithSelf(CurAccountKey);

最后

  • 使用Symbol来创建注入名,来回避取名冲突
  • 使用 TS 和InjectionKey可以有效解决类型提示问题
  • 使用自定义injectStrict可以解决严格注入问题
  • 使用自定义injectWithSelf可以解决hook嵌套时的返回值逐级穿透问题