Vuetify представляет собой UI-фреймворк для Vue, содержащий набор готовых компонентов, реализующих принципы Material Design. Несмотря на большое количество встроенных элементов интерфейса, в реальных проектах регулярно возникает необходимость создания собственных компонентов, адаптированных под конкретную логику приложения.
Кастомные компоненты на базе Vuetify обычно выполняют одну из следующих задач:
При проектировании таких компонентов применяется стандартная архитектура Vue:
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-cardv-card-titlev-card-textv-card-actionsv-btnКомпонент инкапсулирует структуру интерфейса и предоставляет API
через props и события.
Основной подход при разработке кастомных компонентов — композиция. Вместо модификации исходных компонентов 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>
Особенности:
append-itemSlots позволяют создавать максимально гибкие компоненты, которые можно расширять без изменения их кода.
<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.
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
Каждый компонент:
Пример кнопки:
<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));
}
Преимущества:
При создании большого количества кастомных компонентов важно учитывать производительность.
Основные практики:
Минимизация реактивности
избегать лишних 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")
})
Тестирование позволяет гарантировать:
При разработке кастомных компонентов на базе Vuetify используются несколько архитектурных принципов.
Single Responsibility
каждый компонент выполняет одну задачу.
Переиспользуемость
компоненты проектируются максимально универсально.
Инкапсуляция
внутренняя логика скрыта от внешнего кода.
Предсказуемый API
использование стандартных паттернов:
modelValueupdate:modelValueslotseventsПри использовании TypeScript компоненты могут быть строго типизированы.
interface Props {
title: string
loading?: boolean
}
Это позволяет:
Грамотно построенные компоненты на базе Vuetify обладают следующими свойствами:
Такая архитектура позволяет создавать крупные интерфейсы, состоящие из небольших, независимых и переиспользуемых UI-модулей.