Создание кастомных компонентов на базе Vuetify

Vuetify представляет собой UI-фреймворк для Vue, содержащий набор готовых компонентов, реализующих принципы Material Design. Несмотря на большое количество встроенных элементов интерфейса, в реальных проектах регулярно возникает необходимость создания собственных компонентов, адаптированных под конкретную логику приложения.

Кастомные компоненты на базе Vuetify обычно выполняют одну из следующих задач:

  • композиция нескольких компонентов Vuetify в единый переиспользуемый блок интерфейса
  • расширение существующих компонентов дополнительной логикой
  • унификация повторяющихся интерфейсных паттернов
  • внедрение бизнес-логики внутрь UI-компонента
  • стандартизация дизайна внутри проекта

При проектировании таких компонентов применяется стандартная архитектура Vue:

  • props — входные параметры
  • events (emit) — взаимодействие с родительским компонентом
  • slots — расширяемость интерфейса
  • computed — производные данные
  • methods — обработчики логики

Vuetify при этом выступает базовым слоем визуальных элементов.


Базовая структура кастомного компонента

Типичная структура компонента на базе Vuetify выглядит следующим образом:

<template>
  <v-card class="custom-card">
    <v-card-title>
      {{ title }}
    </v-card-title>

    <v-card-text>
      <slot />
    </v-card-text>

    <v-card-actions>
      <v-btn color="primary" @click="handleAction">
        {{ actionText }}
      </v-btn>
    </v-card-actions>
  </v-card>
</template>

<script setup>
defineProps({
  title: String,
  actionText: {
    type: String,
    default: "OK"
  }
})

const emit = defineEmits(["action"])

function handleAction() {
  emit("action")
}
</script>

<style scoped>
.custom-card {
  max-width: 500px;
}
</style>

В данном примере создаётся переиспользуемый компонент карточки, основанный на:

  • v-card
  • v-card-title
  • v-card-text
  • v-card-actions
  • v-btn

Компонент инкапсулирует структуру интерфейса и предоставляет API через props и события.


Композиция компонентов Vuetify

Основной подход при разработке кастомных компонентов — композиция. Вместо модификации исходных компонентов Vuetify создаётся новый компонент, объединяющий несколько существующих.

Пример: компонент поля поиска

<template>
  <v-text-field
    v-model="searchValue"
    :label="label"
    prepend-inner-icon="mdi-magnify"
    clearable
    @input="emitSearch"
  />
</template>

<script setup>
import { ref, watch } from "vue"

const props = defineProps({
  modelValue: String,
  label: {
    type: String,
    default: "Поиск"
  }
})

const emit = defineEmits(["update:modelValue", "search"])

const searchValue = ref(props.modelValue)

watch(searchValue, value => {
  emit("update:modelValue", value)
})

function emitSearch(value) {
  emit("search", value)
}
</script>

Преимущества такого компонента:

  • стандартизированное поле поиска
  • единый стиль для всех страниц
  • единая логика обработки поиска

Расширение функциональности компонентов

Иногда требуется добавить дополнительные возможности к существующему компоненту Vuetify.

Пример: расширенный v-select

<template>
  <v-select
    v-model="internalValue"
    :items="items"
    :label="label"
    clearable
  >
    <template #append-item>
      <v-divider></v-divider>

      <v-btn
        variant="text"
        block
        @click="createItem"
      >
        Добавить новый элемент
      </v-btn>
    </template>
  </v-select>
</template>

<script setup>
import { ref, watch } from "vue"

const props = defineProps({
  modelValue: null,
  items: Array,
  label: String
})

const emit = defineEmits([
  "update:modelValue",
  "create-item"
])

const internalValue = ref(props.modelValue)

watch(internalValue, value => {
  emit("update:modelValue", value)
})

function createItem() {
  emit("create-item")
}
</script>

Особенности:

  • используется slot append-item
  • добавлена кнопка создания нового элемента
  • логика взаимодействия вынесена в события

Использование слотов для гибкости компонентов

Slots позволяют создавать максимально гибкие компоненты, которые можно расширять без изменения их кода.

Пример: универсальный контейнер

<template>
  <v-card>

    <v-card-title>
      <slot name="title" />
    </v-card-title>

    <v-divider />

    <v-card-text>
      <slot />
    </v-card-text>

    <v-divider />

    <v-card-actions>
      <slot name="actions" />
    </v-card-actions>

  </v-card>
</template>

Использование:

<CustomCard>

  <template #title>
    Пользователь
  </template>

  Основная информация

  <template #actions>
    <v-btn>Сохранить</v-btn>
  </template>

</CustomCard>

Подобный подход позволяет:

  • разделять логику и представление
  • создавать универсальные контейнеры
  • упрощать переиспользование компонентов

Управление состоянием внутри компонентов

Сложные компоненты могут содержать внутреннее состояние. Важно различать:

Внешнее состояние

управляется родительским компонентом через props.

Внутреннее состояние

управляется самим компонентом.

Пример: компонент диалога

<template>
  <v-dialog v-model="isOpen" width="500">

    <template #activator="{ props }">
      <v-btn v-bind="props">
        {{ buttonText }}
      </v-btn>
    </template>

    <v-card>

      <v-card-title>
        {{ title }}
      </v-card-title>

      <v-card-text>
        <slot />
      </v-card-text>

      <v-card-actions>
        <v-spacer />

        <v-btn text @click="close">
          Отмена
        </v-btn>

        <v-btn color="primary" @click="confirm">
          Подтвердить
        </v-btn>

      </v-card-actions>

    </v-card>

  </v-dialog>
</template>

<script setup>
import { ref } from "vue"

const props = defineProps({
  title: String,
  buttonText: String
})

const emit = defineEmits(["confirm"])

const isOpen = ref(false)

function close() {
  isOpen.value = false
}

function confirm() {
  emit("confirm")
  close()
}
</script>

Такой компонент полностью инкапсулирует:

  • открытие диалога
  • разметку
  • действия подтверждения

Создание формовых компонентов

В больших проектах формы часто повторяются. Создание кастомных компонентов позволяет стандартизировать ввод данных.

Пример: компонент поля формы

<template>
  <v-text-field
    v-model="internalValue"
    :label="label"
    :rules="rules"
    :type="type"
    variant="outlined"
  />
</template>

<script setup>
import { ref, watch } from "vue"

const props = defineProps({
  modelValue: String,
  label: String,
  rules: Array,
  type: {
    type: String,
    default: "text"
  }
})

const emit = defineEmits(["update:modelValue"])

const internalValue = ref(props.modelValue)

watch(internalValue, value => {
  emit("update:modelValue", value)
})
</script>

Преимущества:

  • единый стиль форм
  • единая система валидации
  • упрощённое подключение

Использование composables в кастомных компонентах

С ростом сложности компонентов логика выносится в composables.

Пример composable для формы

import { ref } from "vue"

export function useFormField(initialValue = "") {

  const value = ref(initialValue)
  const error = ref(null)

  function validate(rule) {
    const result = rule(value.value)
    error.value = result === true ? null : result
  }

  return {
    value,
    error,
    validate
  }
}

Использование в компоненте:

const { value, error, validate } = useFormField("")

Такой подход обеспечивает:

  • переиспользование логики
  • упрощение компонентов
  • разделение ответственности

Создание библиотек внутренних компонентов

В крупных проектах формируется внутренняя библиотека компонентов.

Типичная структура:

components/
  ui/
    AppButton.vue
    AppCard.vue
    AppDialog.vue
    AppInput.vue
    AppSelect.vue

Каждый компонент:

  • основан на Vuetify
  • соответствует дизайн-системе проекта
  • имеет единый API

Пример кнопки:

<template>
  <v-btn
    :color="color"
    :variant="variant"
    :loading="loading"
  >
    <slot />
  </v-btn>
</template>

<script setup>
defineProps({
  color: {
    type: String,
    default: "primary"
  },
  variant: {
    type: String,
    default: "flat"
  },
  loading: Boolean
})
</script>

Темизация кастомных компонентов

Vuetify поддерживает глобальную систему тем. Кастомные компоненты должны учитывать эту систему.

Использование CSS-переменных:

.custom-element {
  background-color: rgb(var(--v-theme-primary));
}

Преимущества:

  • автоматическая поддержка светлой и тёмной темы
  • единый визуальный стиль
  • совместимость с системой тем Vuetify

Оптимизация производительности компонентов

При создании большого количества кастомных компонентов важно учитывать производительность.

Основные практики:

Минимизация реактивности

избегать лишних watch.

Использование computed

для кэширования вычислений.

Ленивая загрузка компонентов

const DialogComponent = defineAsyncComponent(() =>
  import("./DialogComponent.vue")
)

Разделение компонентов

крупные компоненты разбиваются на небольшие логические блоки.


Тестирование кастомных компонентов

Компоненты, построенные на Vuetify, можно тестировать с использованием Vue Test Utils.

Пример теста:

import { mount } from "@vue/test-utils"
import CustomButton from "@/components/CustomButton.vue"

test("emits click event", async () => {

  const wrapper = mount(CustomButton)

  await wrapper.trigger("click")

  expect(wrapper.emitted()).toHaveProperty("click")
})

Тестирование позволяет гарантировать:

  • корректность работы API компонента
  • стабильность интерфейса
  • безопасность рефакторинга

Подходы к проектированию компонентов

При разработке кастомных компонентов на базе Vuetify используются несколько архитектурных принципов.

Single Responsibility

каждый компонент выполняет одну задачу.

Переиспользуемость

компоненты проектируются максимально универсально.

Инкапсуляция

внутренняя логика скрыта от внешнего кода.

Предсказуемый API

использование стандартных паттернов:

  • modelValue
  • update:modelValue
  • slots
  • events

Типизация компонентов

При использовании TypeScript компоненты могут быть строго типизированы.

interface Props {
  title: string
  loading?: boolean
}

Это позволяет:

  • уменьшить количество ошибок
  • улучшить автодополнение
  • повысить читаемость кода

Итоговая архитектура кастомных компонентов

Грамотно построенные компоненты на базе Vuetify обладают следующими свойствами:

  • основаны на композиции существующих компонентов
  • имеют чётко определённый API
  • поддерживают слоты для расширяемости
  • используют composables для логики
  • соответствуют дизайн-системе проекта
  • легко тестируются и масштабируются

Такая архитектура позволяет создавать крупные интерфейсы, состоящие из небольших, независимых и переиспользуемых UI-модулей.