2023 年 5 月 11 日,Vue 发布了新版本 3.3。开发团队这个版本里加入了许多新特性,其中就包括了泛型组件。
许多语言都具备支持泛型的能力,尤其是在后端技术栈中更是不可或缺。举一个非常常见的例子 —— List
,就是泛型集合类型的代表。
当我们需要定义一个集合时,往往是需要限制用户加入到这个集合对象的内容。例如我们需要定义一个数值类型的集合,那么肯定不希望有字符串混进来,在没有泛型支持之前,Java
或 C#
是通过 Object
的方式存取数组中的变量,在放入和取出对象时,还需要额外进行装箱与拆箱操作,并在这个过程中手动完成类型检查的动作。自从 C# 2.0 以及 JDK 5 分别正式的加入了泛型支持后,在编译时就可以通过泛型的方式约束操作对象的类型,在编码阶段即可杜绝类型不一致带来的隐患,对 C# 来说,更是在运行时也获得了充分的泛型能力支持,语法层面上更是盖过 Java 一头。
除此之外,在其他强类型语言中,哪怕是基于弱类型的 js
基础上产生的 Typescript
也早早的支持了泛型。仅仅通过增加一个小小的限制便极大的增加了程序的健壮性,这是一个非常重要的能力。
Vue 自从 3.0 正式发布以来,得益于 Typescript
的类型加持,需要额外书写大量代码保证类型安全的时代已经过去,只要不滥用 any
,通常情况下,仅仅依靠编译器就已经可以给代码带来足够的类型安全,即便是父子组件通信时,也有了更准确的类型提示与检查,很大程度上可以规避因为类型错误而产生的生产故障。
尽管 Vue 3 已经进步了不少,但在少部分的场景下,Vue 所提供的类型支持能力仍然有所欠缺,尤其是在构建公共组件的环节。
假如我们需要在项目中完成一个列表选择弹窗组件,该组件的形式为“弹窗”,当弹窗被流程唤起时,将阻塞流程,并等待用户通过弹窗界面进行列表查询,用户可以根据定制化的检索条件进行翻页查询,也能在弹窗界面上看到定制化的列表页,同时每一行数据的最后一列可以呈现为“选择”页。
以下是一个实际的示例,组件定义了弹窗的布局由三部分组成 —— 查询条件栏目、数据列表、分页组件。图中查询条件中的“用户名称”栏位、数据列表的“用户 ID”、“用户名称”、“最后登录时间”、“帐号创建时间”都是由定制方根据需求填写的,而“查询”按钮、“选择”按钮以及分页控制都是由组件内置实现。
根据以上的需求描述与概念设计,我们只需要定义一个组件,对外暴露一个异步的 打开窗口
,当窗口被打开时,阻塞原流程,等待用户在弹窗中选择一行数据或关闭弹窗后,将用户选择的结果返回给调用侧。
以下为部分代码实现:
示例中实际是一个四层依赖的关系,页面 => 微信/支付宝等用户选择器 => 抽象用户选择器 => 列表选择器组件 => 异步弹窗组件)
为了简化概述流程,仅贴出(列表选择器组件)及(抽象用户选择器)两个文件。
为节省篇幅,仅贴出核心局部代码。如需完整代码,请持续关注本网站/公众号/小程序。
<script lang="ts" setup>
defineProps({
// ... 其他属性略
// searchApi:一个异步方法,用于数据查询,具体实现由子组件定义。
// selectable:一个同步方法,用户判断该行数据有没有被选中,具体实现由子组件定义。
searchApi: { type: Function as PropType<(index: number, size: number) => Promise<models.ResponseModel<models.ResponsePayloadList>>>, required: true },
selectable: { type: Function as PropType<(row: any) => boolean>, default: () => () => true },
});
// 本地保存被选中的数据
const data = reactive({ subject: null });
// dialog 为异步弹窗方法,通过异步的 open 方法可以唤起弹窗,并等待操作完成。
const dialog = ref<InstanceType<typeof AsyncDialog>>();
// list 为列表布局组件,定义了查询-数据列表-分页的公共实现。
const list = ref<InstanceType<typeof ListLayout>>();
// 调用方通过 open 方法打开弹窗,并等待用户的选择。
async function open(): Promise<any> {
data.subject = null;
return new Promise(async (resolve, reject) => {
const promise = dialog.value!.open();
try {
if (list.value) {
list.value.page_index = 1;
await list.value.search();
}
} catch (error) {
dialog.value!.close();
}
await promise;
const subject = data.subject;
data.subject = null;
if (subject) {
resolve(subject);
} else {
reject();
}
});
}
// 当用户在数据列表中选择某行数据时,会将该数据写入本地并触发弹窗完成选择并关闭的动作。
async function select(row: any) {
data.subject = row;
await dialog.value!.ok();
}
defineExpose({ open });
</script>
<script lang="ts" setup>
// 该 dialog 就是前文列表选择器组件的实例
const dialog = ref<InstanceType<typeof AsyncDialogSelector>>();
// 该组件也并非最终的实现,也需要由它的子组件给自己传递具体的查询方法
const props = defineProps({
criteria: { type: Object as PropType<{ user_name: string }>, required: true },
searchApi: { type: Function as PropType<(index: number, size: number) => Promise<models.ResponseModel<models.ResponsePayloadList>>>, required: true },
});
// 传递 open 方法给列表选择器组件,通过它来管理弹窗的显示状态以及等待选择。
async function open(): Promise<any> {
return await dialog.value!.open();
}
// 传递给列表选择器组件的实际查询方法。
async function search(index: number, size: number) {
return await props.searchApi(index, size);
}
defineExpose({ open });
</script>
通过以上代码大家可以看出什么问题吗?
—— 是的,代码中关于被选择对象的类型是通过 any
标记的,这就意味着我们如果想要类型绝对安全,那就必须要手动检查数值的类型。
如果有泛型就好了
前文提到,在 Vue 3.3 时,开发团队新增了泛型组件的能力。只需要增加 generic
标记就能使组件化身为泛型组件。
参考文档:https://cn.vuejs.org/api/sfc-script-setup.html#generics
那么我们根据 Vue 提供的文档进行一下简单的修改,为了节省篇幅仍将仅贴出 script 部分:
<script lang="ts" setup generic="T">
defineProps({
// ... 其他属性略
// searchApi:更精确的定义了每一行的数据类型,与 generic="T" 相匹配。
// selectable:能够确切的定义行数据类型。
searchApi: { type: Function as PropType<(index: number, size: number) => Promise<models.ResponseModel<models.ResponsePayloadList<T>>>>, required: true },
selectable: { type: Function as PropType<(row: T) => boolean>, default: () => () => true },
});
// 我们可以精确的指定我们本地所保存的变量的类型。
const data: { subject: T | null } = reactive({ subject: null });
const dialog = ref<InstanceType<typeof AsyncDialog>>();
const list = ref<InstanceType<typeof ListLayout>>();
// open 方法可以改为返回 T 类型 —— 也就是我们实际选择的那行数据的类型。
async function open(): Promise<T> {
data.subject = null;
return new Promise(async (resolve, reject) => {
const promise = dialog.value!.open();
try {
if (list.value) {
list.value.page_index = 1;
await list.value.search();
}
} catch (error) {
dialog.value!.close();
}
await promise;
const subject = data.subject;
data.subject = null;
if (subject) {
resolve(subject);
} else {
reject();
}
});
}
async function select(row: T) {
data.subject = row;
await dialog.value!.ok();
}
defineExpose({ open });
</script>
<script lang="ts" setup generic="T extends models.oauth.vo.UserInfoThird">
import { AsyncDialogSelector } from "@/components";
import type * as models from "@/models";
import type { PropType } from "vue";
const dialog = ref<InstanceType<typeof AsyncDialogSelector>>();
const props = defineProps({
criteria: { type: Object as PropType<{ user_name: string }>, required: true },
searchApi: { type: Function as PropType<(index: number, size: number) => Promise<models.ResponseModel<models.ResponsePayloadList<T>>>>, required: true },
});
async function open(): Promise<T> {
return await dialog.value!.open();
}
async function search(index: number, size: number) {
return await props.searchApi(index, size);
}
defineExpose({ open });
</script>
简单易懂的改法,好像工作就完成了。但是不对!
—— const dialog = ref<InstanceType<typeof AsyncDialogSelector>>();
这行代码会报错,报错的内容是:
类型“
(__VLS_props: { title?: string | undefined; readonly searchApi: (index: number, size: number) => Promise >>; selectable?: ((row: T) => boolean) | undefined; pageSize?: number | undefined; pageSizes?: number[] | undefined; preTable?: boolean | undefined; } & VNodeP…”不满足约束“abstract new (…args: any) => any”。
铺垫了很多内容,本文的重点现在才要开始。
从报错的内容可以看出,Vue 3.3 虽然支持了泛型组件,但对泛型组件类型导出这块支持的并不是非常完善,我们不知道是因为存在 BUG 还是刻意这么设计的,但如果我们希望 直接使用泛型组件的实例,那就尽量想办法导出它真实的类型。
通过 Vue 官方文档我们看到了 ComponentPublicInstance
的定义,这个类型定义了一个 vue 组件实例的通用类型,但是它缺失了具体组件导出的属性。从这个角度来说,如果我们能够想办法获取到组件导出的内容定义,与 ComponentPublicInstance 组合在一起,不就是我们所要的类型吗?
参考文档:https://cn.vuejs.org/guide/typescript/composition-api.html#typing-component-template-refs
参考 setup
的定义,它提供了 context
参数,用来保存组件上下文。而导出的内容(公共属性)会被写入上下文的 expose
对象中。根据这个推理,我们能够确定前文通过 defineExpose({ open })
定义导出的 open
方法签名我们就有办法获取到了,剩下的问题就是我们去哪里获取组件的类型。
回到单文件组件的定义,当在代码中通过 import
一个 .vue
文件时,默认的导出就是该组件的组件对象。它除了包含有 globalThis.VNode
虚拟节点外,还存在一个名为 __ctx
的属性,在这个属性中,我们可以找到包括 props
、expose
等在内的字段,这些字段恰恰就是我们在 setup
中所定义的那些,因此我们可判断出,如果我们有办法拿到 expose
中的第一个参数的类型定义,那我们就能够构造出完整的组件类型。在 Typescript 2.8 之后,我们可以使用 ReturnType<T>
来获取函数的返回值类型,因此,只需使用 ReturnType<typeof AsyncDialogSelector>()
就可以获得 AsyncDialogSelector
这个组件的“类型定义”。
进一步的,再次解析上文组件类型中的 __ctx
属性的类型,并从中拿到 expose
方法的定义,这个定义中就有对我们来说最重要的内容 —— 组件导出的公开属性。Parameters<T>
可以用来取出一个方法的参数列表,defineExpose({ open })
也好, expose({ open })
也好,反正只需要获得第一个参数类型,拼接到 ComponentPublicInstance
的定义上,就是组件类型的完整内容了。
由于 __ctx
是加了 ?
定义的可空方法,所以我们额外使用 NonNullable<T>
进行一次包装保护,组装成我们需要的类型解析的完全体:
type GenericComponentExports<D extends (...p: any[]) => any> = import("vue").ComponentPublicInstance & Parameters<NonNullable<NonNullable<ReturnType<D>["__ctx"]>["expose"]>>[0];
将这段代码拷贝到代码根目录的 .d.ts
文件中(如 shims-vue.d.ts),我们便可在项目全局中使用 GenericComponentExports 方法来帮我们修复前文中代码报错的问题。
import AsyncDialogSelector from "./async-dialog-selector.vue";
export { AsyncDialogSelector };
// 用来修复的代码如下所示。
export function useAsyncDialogSelector<T>() {
type Instance = GenericComponentExports<typeof AsyncDialogSelector>;
return ref<{ open: () => Promise<T> } & Instance>();
}
<script lang="ts" setup generic="T extends models.oauth.vo.UserInfoThird">
import { AsyncDialogSelector, useAsyncDialogSelector } from "@/components";
import type * as models from "@/models";
import type { PropType } from "vue";
// 相比前文源码,我们修改了这一行。
// const dialog = ref<InstanceType<typeof AsyncDialogSelector>>();
const dialog = useAsyncDialogSelector<T>();
const props = defineProps({
criteria: { type: Object as PropType<{ user_name: string }>, required: true },
searchApi: { type: Function as PropType<(index: number, size: number) => Promise<models.ResponseModel<models.ResponsePayloadList<T>>>>, required: true },
});
async function open(): Promise<T> {
return await dialog.value!.open();
}
async function search(index: number, size: number) {
return await props.searchApi(index, size);
}
defineExpose({ open });
</script>
使用这个方法能够解决我们的问题,但还不够完美。也许是因为对 vue
机制仍有理解不够深入的地方,所以才迫使我们使用这么弯弯绕绕的方法来实现我们的功能。在 issue
中我们也能看到一些类似的问题存在,也许在不远的未来,vue
官方可以通过自身的机制来彻底规避这个问题。
官方 ISSUE:
Generic Component Types does not work with InstanceType
Cannot use InstanceType
相似问题:
Vue3 typescript script setup 获取范型组件的 ref
vue3.3 generic 泛型组件无法获取实例类型解决方案
<- 末尾链接为相似问题中最早提出解决方法的帖子(2023/6/23)