泛型组件与类型工具
TypeScript 与 Vue 3 · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
泛型组件让「列表组件」这种东西真正做到类型安全:传什么类型的数组,作用域插槽里的 item 就是什么类型。这一节掌握 <script setup generic="T"> 的用法、约束与默认值,以及 defineSlots 的类型声明。
问题场景
你写了一个通用 DataTable 组件,props 是 rows: any[],作用域插槽把 row 交给父组件渲染。父组件里 row.titel 拼错了(title),TS 完全不报错,运行时渲染空白。把 any 改成泛型后,父组件里拼错字段立刻红。
核心解释
<script setup> 支持 generic 属性,让整个组件对类型参数 T 泛型化:
<!-- components/DataTable.vue -->
<script setup lang="ts" generic="T extends { id: number | string }">
const props = defineProps<{
rows: T[];
rowKey?: keyof T;
}>();
const emit = defineEmits<{
(e: "row-click", row: T): void;
}>();
defineSlots<{
header(props: { rows: T[] }): unknown;
row(props: { row: T; index: number }): unknown;
}>();
</script>
<template>
<table>
<thead>
<slot name="header" :rows="props.rows" />
</thead>
<tbody>
<tr
v-for="(row, index) in props.rows"
:key="props.rowKey ? String(row[props.rowKey]) : index"
@click="emit('row-click', row)"
>
<slot name="row" :row="row" :index="index" />
</tr>
</tbody>
</table>
</template>
父组件使用时类型自动推导:
<script setup lang="ts">
import DataTable from "../components/DataTable.vue";
const courses = [
{ id: 1, title: "Vue 3 全景", hours: 1 },
{ id: 2, title: "响应式基础", hours: 1.25 },
];
</script>
<template>
<DataTable :rows="courses" row-key="id" @row-click="(row) => console.log(row.title)">
<template #row="{ row }">
<td>{{ row.title }}</td>
<td>{{ row.hours }}h</td>
</template>
</DataTable>
</template>
row 的类型由 courses 推导为 { id: number; title: string; hours: number },拼错字段立即报错;@row-click 的参数类型同样精确。
三个细节:
generic="T extends ..."里的约束是普通 TS 约束,可以写T extends Record<string, unknown>、T extends { id: number },甚至多个参数generic="T, K extends keyof T"。defineSlots声明插槽类型,让父组件模板里的解构(#row="{ row }")获得类型;不声明时插槽 props 是any,等于放弃检查。- 泛型组件的类型推导发生在模板编译期,所以父组件传字面量数组也能推导;传
ref<any[]>则退化为 any——上游类型质量决定下游。
泛型推导失效最常见的两个原因:父组件用 ref<any[]> 或 JSON.parse 的结果(any 会污染下游),以及插槽类型没写 defineSlots(插槽里全是 any)。遇到「泛型不生效」,先检查这两处。
和 React 泛型组件的对照:React 里写 function DataTable<T extends {id: number}>(props: Props<T>),调用时靠 JSX 推导;Vue 里靠 generic 属性声明,机制不同但效果一致:调用处拿到精确类型。
示例拆解
一个「可选列表」组件,演示多个类型参数与默认值:
<script setup lang="ts" generic="T extends { id: string }, V = T">
const props = withDefaults(
defineProps<{
options: T[];
modelValue: V[];
getValue?: (item: T) => V;
label?: (item: T) => string;
}>(),
{
getValue: undefined,
label: undefined,
}
);
const emit = defineEmits<{ (e: "update:modelValue", value: V[]): void }>();
function toggle(item: T) {
const value = props.getValue ? props.getValue(item) : (item as unknown as V);
const exists = props.modelValue.includes(value);
emit(
"update:modelValue",
exists ? props.modelValue.filter((v) => v !== value) : [...props.modelValue, value]
);
}
function text(item: T) {
return props.label ? props.label(item) : String(item.id);
}
</script>
<template>
<ul>
<li v-for="item in props.options" :key="item.id">
<label>
<input
type="checkbox"
:checked="props.modelValue.includes(props.getValue ? props.getValue(item) : (item as unknown as V))"
@change="toggle(item)"
/>
{{ text(item) }}
</label>
</li>
</ul>
</template>
父组件可以只用 T(选中整个对象),也可以指定 V = string(只存 id),类型都由调用处决定。这类组件的复杂度不在运行时,而在类型体操——如果实际只用到一种形态,别过度泛型化。
动手任务
- 完成
DataTable,验证父组件拼错字段会报类型错误。 - 给
DataTable加defineSlots的empty插槽类型,父组件不传时不渲染。 - 写一个泛型
Select<T, V>组件(单选),练习「值类型与显示类型分离」的设计。
常见错误
- 忘了
generic属性直接写defineProps<{ rows: T[] }>():T 未定义,编译报错。 - 作用域插槽类型没声明:
defineSlots缺失导致插槽内row是 any,泛型白做。 - props 里用
any[]:泛型组件传 any 等于放弃一切检查。上游用精确类型。 - 过度泛型化:两个类型参数 + 五个可选回调,组件只有一处使用。泛型按需引入。
检查与复盘
- 能写出带约束的泛型组件并让父组件获得推导类型
- 能说出
defineSlots缺失的后果 - 能判断什么时候不该用泛型组件
参考资料
- Vue 官方文档「TypeScript 与组合式 API」:generic 属性与 defineSlots
- TypeScript 官方手册「泛型」:约束与默认参数
读完这一课了?
标记完成后,工作台会安排接下来的学习。