背景
今年负责过的多个项目,会经常碰到需要组织大型组件的场合。
这里的大型组件主要指的是数据往往有一个唯一的入口(如请求数据接口的组件),而组件内部依赖的子组件都需要通过入口组件对数据的解构、重组来获得需要的数据信息;如果直接使用props的形式来传递数据,那么整个组件嵌套的逻辑中会出现大量的冗余代码,中间环节出现任何遗漏都会出现问题且不容易定位。
解决方案
按照vue2的开发习惯,很轻易的我们可能就会想到各种全局数据状态管理的方式,例如用vuex;类比到vue3可能就会选择pinia。
why not pinia
这些方案都很好,并且是vue官方提供的数据、状态管理系统,但是存在一个小小小问题:
这里的数据状态是全局的,可我们需要的只是一个局部的数据管理,使用全局的数据管理,反而需要再去单独设计一套维护组件和数据之间映射关系的逻辑关系;不仅如此,如果要考虑性能的问题,还需要有一套完善的数据清理逻辑,相当于还得自己设计一个小小的GC(题外话,其实用WeakMap可以解决)。所以会带来:
- 可能会存在更大的维护成本。
- 按照我们常规的目录划分,store目录跟我们的组件目录是会分割开来的,相应的,一些数据的组织逻辑就会在(物理上)跟组件分割开来,带来更大的阅读成本。
###如何实现?
其实答案就在标题,只需要简单的利用provide和inject就可以轻松实现一个局部的数据管系统,助你代码起飞!
#DataHooks
在***position Api中,provide 和 inject 可以在setup函数中或者
<script setup>
语法糖中使用
用法也非常简单
// 父组件
import { provide } from 'vue'
provide('message', 'test')
// 任意层级的子组件
import { inject } from 'vue'
const value = inject('message', 'default value')
即可实现局部数据的透传
##useDataProvide/useDataInject
在上文的基础上,我们已经可以得出一个局部数据管理的实现方案:
- 给父组件提供一个注册provider的函数,用于提供局部使用的数据/函数;
- 给子组件提供一个注入injector的函数,用于注入父组件提供的数据/函数
我们按照钩子的命名规范,将他们定义为两个函数:
export function useDataProvide() {
const userName = ref('alan');
provide('userName', 'alan');
// return出去,这样父组件也可以使用
return {
userName
}
}
export function useDataInject() {
// 在子组件中调用,注入数据
const userName = inject('userName')
return {
userName
}
}
而后就可以在父/子组件中调用了:
// 父组件
<script setup>
const { userName } = userDataProvide();
</script>
// 子组件
<script setup>
const { userName } = userDataInject();
</script>
注意,这里provide
的内容可以是任何类型的数据,不论是否是响应式的,也可以provide函数
provide('func', () => {
console.log('这里注入了一个函数');
});
到这里,其实利用这个机制已经可以实现很多很有意思的能力了:比如实现一个事件观察者/订阅者模式
export function useEventEmitProvide() {
const handlerSet = new Set();
provide('handlerSet', handlerSet);
const emit = () => {
handlerSet.forEach(func => func());
};
return {
emit,
};
}
export function useEventEmitInject() {
const handlerSet = inject('handlerSet');
const onEmit = (func) => {
handlerSet.set(func);
// 注销组件的时候,也删除事件监听
onUnmounted(() => {
handlerSet.delete(func);
});
};
return {
onEmit,
};
}
是不是很简单?并且还能在绑定事件订阅的时候,自动注册事件注销的逻辑(onUnmounted时),调用方只需要把回调事件做个注册就可以了。
同样的,还可以实现很多其他的模式,比如单例模式等,这里就不再展开,感兴趣的可以自己写写看~
Typescript
目前来看实现这个DataHooks的逻辑还是比较简单顺畅的,但很快的,在ts开发的时候,就又发现了一个问题:我需要花很多的功夫去重复给数据的类型做定义:
没有获取到正确的类型
需要手动补充类型信息
虽然这里看起来只是补充了一个类型的声明,但是假设你的注入的数据多达几十个呢?那这样的代码看起来就是一边混乱,光是类型声明都占据了极大的篇幅。怎么办?
###defineProvider
灵感来源自然是setup模式下的各种定义的语法糖函数,例如deineProps、defineEmits等等。
那这一步的目标就是实现一个defineProvider函数,通过这个define的过程,可以在inject的时候,自动注入各个参数的类型。
// step1--构造注册结构体
interface ProvideOption {
[key: string]: {
// 只有一个属性,就是provide的key的默认值
default: any,
}
}
export function defineProvider<Options extends ProvideOption>(options: Options): Options {
return options;
}
看到这里可能会感觉到疑惑,这个函数啥事没做啊,不就是把入参给直接return了?的确如此,到这里defineProvider的作用就只是为了规范我们定义provider的入参类型,是为了后续的provide和inject服务的,同样的,也可以直接通过声明类型为ProvideOption来限定对象的类型。
但是随后问题就来了:
我们知道像defineProps、defineEmits这种语法糖,是vue-loader在transform vue组件代码的时候,在将里面的内容编译回原来的props、emits对象,这也是我为什么一直称他们的为语法糖的原因。所以理论上来说,我也需要编写一个loader来解析defineProvider的内容。但是这样的开发成本就大了,所以我决定将defineProvider只是作为一个provide和inject的连接桥,在此基础上再提供
registProvider
registInject
两个函数来完成这个逻辑
export type ProviderProps<T extends ProvideOption> = {
[K in keyof T]: (T[K]['default'] extends any ? T[K]['default'] : any)
};
// 根据ProvideOption注册provider
export function registProvider<T extends ProvideOption>(options: Partial<ProviderProps<T>>) {
Object.keys(options).forEach((key) => {
provide(key, options[key]);
});
}
// 根据ProvideOption注册injector
export function registInject<T extends ProvideOption>(options: T): ProviderProps<T> {
const map: Record<string, any> = {};
Object.keys(options).forEach((key) => {
const defaultFunc = options[key].default;
map[key] = inject(key, defaultFunc);
});
return map as ProviderProps<T>;
}
两个核心的函数其实逻辑很简单:
- provider根据defineProvider返回的声明对象类型,限定入参对象的key以及每个key对应的数据类型,然后遍历完成provide
- inject同上,但是过程变成inject,并且将inject的结果打包成一个新的对象返回
完成后,一个完整的局部数据管理创建流程就变成了:
可以看到inject的数据,都会有完整的数据类型:
至此,第一版的局部数据注入逻辑完成
BuildDataHook
既然上面提到了是第一版,自然的会有第二版。是的,在一段时间的使用之后发现,这样写还是非常的麻烦,尤其在碰到我需要provide一个函数的情况之下,我需要把函数的类型准确描述清楚(包括每个参数类型,返回类型)才能让inject方得到比较友好的类型声明提示。其实这也说明了一个事实,并不是所有的模仿都是合适的,defineProvider告诉了我,强扭的瓜不甜。
所以我需要转变这个开发的思路,在前面的阶段中,我太过于纠结provider和injector的编写,是需要明确的区分开来的。如果我不分开写呢?而是根据一个定义函数的return值,来直接完成provide 和 inject的过程。
初步设想,这个buildDataHook函数分三步走
- 创建provider函数
- 创建injector函数
- 返回函数数组(数组便于解构自定义函数名)
创建provider函数
先直接上代码
function defineDataProvide<T extends (...args: any[]) => Record<string, any>, K extends ReturnType<T>>(init: T) {
const keyList: string[] = [];
const res = (...args: any[]): K => {
const hook = init(...args) as K;
// 自动注册provide
Object.keys(hook).forEach((key) => {
provide(key, hook[key]);
// 收集return对象的key
keyList.push(key as string);
});
return hook;
};
return {
useProvide: res,
keyList,
};
}
注意这里用了个闭包,keyList是一个数组,在组件调用useProvide的时候,会根据传入的初始化函数,收集执行函数的结果对象,在遍历对象执行provide的时候,也收集有哪些key,更新到keyList中,由于是个引用类型的数据,这个keyList可供后续做inject的时候,遍历inject使用。
注意这里函数利用了泛型来获取函数的类型,进而获取到函数return值的类型(ReturnType<T>
)
创建inject函数
type InjectProps< T extends Record<string, any>, K extends keyof T = keyof T> = {
[S in K]: T[S]
};
function defineDataInject<
T extends Record<string, any> = Record<string, any>,
K extends keyof T = keyof T>(keys?: K[]) {
const map: Record<string, any> = {};
if (keys) {
keys.forEach((key) => {
map[key as string] = inject(key as string, null);
});
return map as InjectProps<T, K>;
}
return map as InjectProps<T, keyof T>;
}
inject函数相对比较容易理解,根据传入的keyList注入key的内容,并且return。
buildDataHook
在前两步的铺垫之后,就可以将两者组合起来
/** 利用inject、provide封装组件局部的store */
export function buildDataHook<
T extends Record<string, any> = Record<string, any>,
K extends keyof T = keyof T>(provider: (...args: any[]) => T, keys?: K[]):
[(...args: any[]) => T, () => InjectProps<T, K> | InjectProps<T, keyof T>] {
const { useProvide, keyList } = defineDataProvide(provider);
const useInject = () => defineDataInject<T>(keys || keyList);
return [useProvide, useInject];
}
可以看到这部分的内容,就是按照最开始设计的三步走实现的。
实现效果
dataHook注册
provider和injector都能得到相应的数据类型
函数的第二个函数,支持仅inject部分的数据
如这个demo,只inject analysisDate
能过获取到对应的类型
在完成buildDataHook之后,开发就只需要专注需要维护哪些数据,inject和provide的过程不需要再去担心了。
##结尾
本文主要分享了在业务编写的过程中,得到关于provide和inject的一丁点使用心得,当然中间肯定还有不少的边界情况会被遗漏的,比如这里的inject也有可能出于安全考虑,不同的组件希望能够inject不同的key(类似private);恳求大家轻喷~
后续作者会持续把开发中自研的一些小工具和开发思路分享给大家,并且有机会的话会打包成工具包给大家即插即用(当然也需要经历过自己业务的打磨)。
##参考
https://vuejs.org/guide/***ponents/provide-inject.html#prop-drilling