Дженерик-компоненты
Что такое
Дженерик-компонент — компонент, работающий с любым типом данных. Тип задаётся при использовании.
Аналогия: Array<T>, Promise<T> — дженерики в TS.
Проблема
Компонент List.vue:
<script setup lang="ts">
interface Props {
items: any[]; // плохо — теряется тип
}
const props = defineProps<Props>();
</script>
<template>
<ul>
<li v-for="item in items">{{ item }}</li>
</ul>
</template>
items: any[] — IDE не знает тип элементов.
Решение — generic
Vue 3.3+:
<script setup lang="ts" generic="T">
interface Props {
items: T[];
keyFn?: (item: T) => string | number;
}
const props = defineProps<Props>();
</script>
<template>
<ul>
<li v-for="item in items" :key="keyFn ? keyFn(item) : undefined">
<slot :item="item" />
</li>
</ul>
</template>
generic="T" — тип-параметр.
Использование
<script setup lang="ts">
import UserList from './UserList.vue';
import type { User, Product } from '@/types';
const users = ref<User[]>([
{ id: 1, name: 'Иван', email: 'ivan@example.com' },
]);
const products = ref<Product[]>([
{ id: 1, title: 'iPhone', price: 1000 },
]);
</script>
<template>
<UserList :items="users" :key-fn="u => u.id">
<template #default="{ item }">
{{ item.name }} — {{ item.email }}
</template>
</UserList>
<UserList :items="products" :key-fn="p => p.id">
<template #default="{ item }">
{{ item.title }} — {{ item.price }}
</template>
</UserList>
</template>
item в шаблоне автоматически получает правильный тип.
Несколько generic
<script setup lang="ts" generic="T, K extends keyof T">
interface Props {
items: T[];
labelKey: K;
}
const props = defineProps<Props>();
</script>
<template>
<li v-for="item in items" :key="String(item[labelKey])">
{{ item[labelKey] }}
</li>
</template>
Использование:
<MyList :items="users" label-key="name" />
<MyList :items="products" label-key="title" />
Generic с extends
Ограничение:
<script setup lang="ts" generic="T extends { id: number }">
interface Props {
items: T[];
}
const props = defineProps<Props>();
</script>
<template>
<li v-for="item in items" :key="item.id">
<slot :item="item" />
</li>
</template>
T обязан иметь id: number.
DataTable
<script setup lang="ts" generic="T extends Record<string, any>">
interface Column<K> {
key: K;
label: string;
width?: string;
}
interface Props {
rows: T[];
columns: Column<keyof T>[];
rowKey: keyof T;
}
const props = defineProps<Props>();
defineSlots<{
[K in `cell-${string & keyof T}`]?: (props: { row: T; value: any }) => any;
}>();
</script>
<template>
<table>
<thead>
<tr>
<th v-for="col in columns" :key="String(col.key)">
{{ col.label }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="String(row[rowKey])">
<td v-for="col in columns" :key="String(col.key)">
<slot :name="`cell-${String(col.key)}`" :row="row" :value="row[col.key]">
{{ row[col.key] }}
</slot>
</td>
</tr>
</tbody>
</table>
</template>
Использование:
<script setup lang="ts">
const users = ref([
{ id: 1, name: 'Иван', email: 'ivan@example.com' },
]);
const columns = [
{ key: 'name' as const, label: 'Имя' },
{ key: 'email' as const, label: 'Email' },
];
</script>
<template>
<DataTable :rows="users" :columns="columns" row-key="id">
<template #cell-email="{ value }">
<a :href="`mailto:${value}`">{{ value }}</a>
</template>
</DataTable>
</template>
TS знает: value в #cell-email — string.
Select с generic
<script setup lang="ts" generic="T extends { value: any; label: string }">
interface Props {
options: T[];
modelValue: T['value'] | null;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:modelValue': [value: T['value']];
}>();
</script>
<template>
<select
:value="modelValue ?? ''"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value as T['value'])"
>
<option value="">— выбери —</option>
<option v-for="opt in options" :key="String(opt.value)" :value="opt.value">
{{ opt.label }}
</option>
</select>
</template>
Использование:
<script setup lang="ts">
const cities = [
{ value: 1, label: 'Москва' },
{ value: 2, label: 'СПб' },
];
const selected = ref<number | null>(null);
</script>
<template>
<MySelect v-model="selected" :options="cities" />
</template>
AsyncList
<script setup lang="ts" generic="T">
import { ref, onMounted } from 'vue';
interface Props {
load: () => Promise<T[]>;
}
const props = defineProps<Props>();
const items = ref<T[]>([]) as Ref<T[]>;
const loading = ref(false);
const error = ref<Error | null>(null);
onMounted(async () => {
loading.value = true;
try {
items.value = await props.load();
} catch (e) {
error.value = e as Error;
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<p v-if="loading">Загрузка...</p>
<p v-else-if="error">{{ error.message }}</p>
<slot v-else :items="items" />
</div>
</template>
Использование:
<AsyncList :load="() => fetchUsers()">
<template #default="{ items }">
<UserCard v-for="u in items" :key="u.id" :user="u" />
</template>
</AsyncList>
Ограничения
1. Нельзя if внутри generic:
<script setup lang="ts" generic="T extends A ? X : Y"> <!-- ошибка -->
2. Только extends:
generic="T extends SomeType"
3. Дефолты не поддерживаются (пока):
<script setup lang="ts" generic="T = string"> <!-- не работает -->
Полезность
Где применять:
- Списки
- Таблицы
- Селекты
- Деревья
- Модалки с данными
- Формы с generic-полями
Плюс: полная типизация без any.
Composable-дженерик альтернатива
Если компонент сложный — делай через composable:
// composables/useList.ts
import { ref, type Ref } from 'vue';
export function useList<T>(initial: T[] = []): {
items: Ref<T[]>;
add: (item: T) => void;
remove: (predicate: (item: T) => boolean) => void;
} {
const items = ref(initial) as Ref<T[]>;
return {
items,
add: (item) => items.value.push(item),
remove: (predicate) => items.value = items.value.filter(i => !predicate(i)),
};
}
Использование:
const { items, add, remove } = useList<User>();
Итог
generic="T"— тип-параметр компонентаextends— ограничение типаdefineSlots<{...}>— типизация слотов- Vue 3.3+ — обязательно
- Полезно для списков, таблиц, селектов
Дальше — кастомные директивы.