Vue 3.3 泛型组件类型识别错误的解决

发布于:11/21/2023, 7:25:01 PM @孙博
技术分享 | Typescript,Vue
许可协议:署名-非商业性使用(by-nc)

2023 年 5 月 11 日,Vue 发布了新版本 3.3。开发团队这个版本里加入了许多新特性,其中就包括了泛型组件。

许多语言都具备支持泛型的能力,尤其是在后端技术栈中更是不可或缺。举一个非常常见的例子 —— List,就是泛型集合类型的代表。

当我们需要定义一个集合时,往往是需要限制用户加入到这个集合对象的内容。例如我们需要定义一个数值类型的集合,那么肯定不希望有字符串混进来,在没有泛型支持之前,JavaC# 是通过 Object 的方式存取数组中的变量,在放入和取出对象时,还需要额外进行装箱与拆箱操作,并在这个过程中手动完成类型检查的动作。自从 C# 2.0 以及 JDK 5 分别正式的加入了泛型支持后,在编译时就可以通过泛型的方式约束操作对象的类型,在编码阶段即可杜绝类型不一致带来的隐患,对 C# 来说,更是在运行时也获得了充分的泛型能力支持,语法层面上更是盖过 Java 一头。

除此之外,在其他强类型语言中,哪怕是基于弱类型的 js 基础上产生的 Typescript 也早早的支持了泛型。仅仅通过增加一个小小的限制便极大的增加了程序的健壮性,这是一个非常重要的能力。


Vue 自从 3.0 正式发布以来,得益于 Typescript 的类型加持,需要额外书写大量代码保证类型安全的时代已经过去,只要不滥用 any,通常情况下,仅仅依靠编译器就已经可以给代码带来足够的类型安全,即便是父子组件通信时,也有了更准确的类型提示与检查,很大程度上可以规避因为类型错误而产生的生产故障。

尽管 Vue 3 已经进步了不少,但在少部分的场景下,Vue 所提供的类型支持能力仍然有所欠缺,尤其是在构建公共组件的环节。

假如我们需要在项目中完成一个列表选择弹窗组件,该组件的形式为“弹窗”,当弹窗被流程唤起时,将阻塞流程,并等待用户通过弹窗界面进行列表查询,用户可以根据定制化的检索条件进行翻页查询,也能在弹窗界面上看到定制化的列表页,同时每一行数据的最后一列可以呈现为“选择”页。

以下是一个实际的示例,组件定义了弹窗的布局由三部分组成 —— 查询条件栏目、数据列表、分页组件。图中查询条件中的“用户名称”栏位、数据列表的“用户 ID”、“用户名称”、“最后登录时间”、“帐号创建时间”都是由定制方根据需求填写的,而“查询”按钮、“选择”按钮以及分页控制都是由组件内置实现。

查询选择弹窗组件

根据以上的需求描述与概念设计,我们只需要定义一个组件,对外暴露一个异步的 打开窗口,当窗口被打开时,阻塞原流程,等待用户在弹窗中选择一行数据或关闭弹窗后,将用户选择的结果返回给调用侧。

以下为部分代码实现:

示例中实际是一个四层依赖的关系,页面 => 微信/支付宝等用户选择器 => 抽象用户选择器 => 列表选择器组件 => 异步弹窗组件)
为了简化概述流程,仅贴出(列表选择器组件)及(抽象用户选择器)两个文件。

示例代码(非泛型实现)

为节省篇幅,仅贴出核心局部代码。如需完整代码,请持续关注本网站/公众号/小程序。

列表选择器组件:async-dialog-selector.vue

<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>

抽象用户选择器:profile-selector.vue

<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 部分:

示例代码(泛型实现)

列表选择器组件:async-dialog-selector.vue

<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>

抽象用户选择器:profile-selector.vue

<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 的属性,在这个属性中,我们可以找到包括 propsexpose 等在内的字段,这些字段恰恰就是我们在 setup 中所定义的那些,因此我们可判断出,如果我们有办法拿到 expose 中的第一个参数的类型定义,那我们就能够构造出完整的组件类型。在 Typescript 2.8 之后,我们可以使用 ReturnType<T> 来获取函数的返回值类型,因此,只需使用 ReturnType<typeof AsyncDialogSelector>() 就可以获得 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 方法来帮我们修复前文中代码报错的问题。

示例代码(泛型修复)

列表选择器组件:index.ts (导出 async-dialog-selector.vue)

import AsyncDialogSelector from "./async-dialog-selector.vue";
export { AsyncDialogSelector };

// 用来修复的代码如下所示。
export function useAsyncDialogSelector<T>() {
  type Instance = GenericComponentExports<typeof AsyncDialogSelector>;
  return ref<{ open: () => Promise<T> } & Instance>();
}

抽象用户选择器:profile-selector.vue

<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>> on generic components

相似问题:

Vue3 typescript script setup 获取范型组件的 ref

vue3.3 generic 泛型组件无法获取实例类型解决方案
<- 末尾链接为相似问题中最早提出解决方法的帖子(2023/6/23)