Урок 03

Дженерик-компоненты

Что такое

Дженерик-компонент — компонент, работающий с любым типом данных. Тип задаётся при использовании.

Аналогия: 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+ — обязательно
  • Полезно для списков, таблиц, селектов

Дальше — кастомные директивы.