Компонент в Vue - это автономный блок интерфейса, который содержит собственную разметку, логику и стили. Представьте себе кнопку, форму ввода или даже целую панель навигации - всё это можно оформить в виде компонентов. Каждый из них работает как мини-приложение, но при этом легко взаимодействует с другими компонентами.
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| <template>
<div class="greeting">
<h1>{{ message }}</h1>
<button @click="changeMessage">Нажми меня</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const message = ref('Привет, мир!')
function changeMessage() {
message.value = 'Компоненты Vue 3 потрясающи!'
}
</script>
<style scoped>
.greeting {
text-align: center;
padding: 20px;
}
</style> |
|
Этот простой пример демонстрирует основную структуру компонента. Обратите внимание на три части:
<template> - HTML-разметка с особым синтаксисом Vue.
<script> - JavaScript-логика компонента.
<style> - CSS-стили, которые можно ограничить только этим компонентом.
Что делает компоненты такими мощными?
Во-первых, переиспользуемость. Создав компонент один раз, вы можете вставлять его где угодно и сколько угодно раз. Кнопка, которую вы разработали для формы авторизации, легко переносится в форму регистрации или в корзину интернет-магазина. Во-вторых, изоляция. Каждый компонент существует в своем маленьком мире. Его внутренние переменные не конфликтуют с другими частями приложения, а стили с атрибутом scoped применяются только к нему. В-третьих, поддерживаемость. Когда приложение разбито на логические блоки, гораздо проще находить и исправлять ошибки. Нужно изменить форму входа? Не надо искать её код по всему проекту - он весь собран в одном компоненте.
Vue 3 представил новый способ написания компонентов - Composition API. Это альтернатива старому Options API, который использовался в Vue 2. Главное отличие - в организации кода. Если раньше мы группировали код по типу (data, methods, computed...), то теперь мы группируем его по функциональности. Представьте, что вы разрабатываете компонент, который отображает список товаров и позволяет фильтровать их. С Composition API вся логика загрузки данных может быть собрана вместе, вся логика фильтрации - вместе, и т.д. Это делает код более читаемым и облегчает повторное использование логики между компонентами.
В современной разработке на Vue 3 компоненты обычно создаются как однофайловые (Single File Components, SFC) - все три части (HTML, JavaScript и CSS) находятся в одном файле с расширением .vue. Это упрощает разработку и делает структуру проекта более понятной.
Компоненты и Composition API
Разработка с Vue 3 круто меняет представление о том, как должен быть организован код фронтенд-приложений. В Vue 2 мы использовали Options API, и структура компонента выглядела примерно так:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| export default {
name: 'UserProfile',
props: ['userId'],
data() {
return {
user: null,
loading: true
}
},
methods: {
fetchUserData() {
// Получение данных пользователя
},
updateProfile() {
// Обновление профиля
}
},
computed: {
fullName() {
return this.user ? `${this.user.firstName} ${this.user.lastName}` : ''
}
},
mounted() {
this.fetchUserData()
}
} |
|
В таком подходе есть свои плюсы — он понятен и структурирован. Но представьте, что ваш компонент растёт. Логика, связанная с обработкой пользовательских данных, оказывается разбросанной по разным секциям: часть в data, часть в methods, часть в computed. Отследить все взаимосвязи становится непросто. Composition API переворачивает эту парадигму с ног на голову. Вместо группировки по типам опций мы группируем код по логическим блокам функциональности. Тот же компонент с Composition API будет выглядеть так:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| import { ref, computed, onMounted } from 'vue'
export default {
props: ['userId'],
setup(props) {
const user = ref(null)
const loading = ref(true)
const fetchUserData = async () => {
// Получение данных пользователя
user.value = await fetchUser(props.userId)
loading.value = false
}
const updateProfile = () => {
// Обновление профиля
}
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
onMounted(() => {
fetchUserData()
})
return {
user,
loading,
fullName,
updateProfile
}
}
} |
|
Но это еще не всё! С выходом Vue 3.2 появился <script setup> , который делает синтаксис еще чище:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| <script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps(['userId'])
const user = ref(null)
const loading = ref(true)
const fetchUserData = async () => {
user.value = await fetchUser(props.userId)
loading.value = false
}
const updateProfile = () => {
// Обновление профиля
}
const fullName = computed(() =>
user.value ? `${user.value.firstName} ${user.value.lastName}` : ''
)
onMounted(() => {
fetchUserData()
})
</script> |
|
Синтаксис <script setup> автоматически делает все переменные и функции доступными в шаблоне, без необходимости явно возвращать их из функции setup() .
Базовая структура компонента в Vue 3
Давайте теперь детальнее рассмотрим анатомию компонента Vue 3 с использованием Composition API:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| <template>
<div class="task-item" :class="{ completed: task.completed }">
<input type="checkbox" v-model="task.completed">
<span>{{ task.title }}</span>
<button @click="removeTask">Удалить</button>
</div>
</template>
<script setup>
import { defineProps, defineEmits, toRefs } from 'vue'
const props = defineProps({
task: {
type: Object,
required: true
}
})
const emit = defineEmits(['remove', 'update'])
// Извлекаем реактивные свойства из объекта props
const { task } = toRefs(props)
// Метод удаления задачи
const removeTask = () => {
emit('remove', task.value.id)
}
// Автоматически отправляем событие update при изменении задачи
watch(() => task.value.completed, (newValue) => {
emit('update', { id: task.value.id, completed: newValue })
})
</script>
<style scoped>
.task-item {
display: flex;
padding: 10px;
margin-bottom: 5px;
border-bottom: 1px solid #eee;
}
.completed {
text-decoration: line-through;
color: #999;
}
</style> |
|
В этом компоненте можно выделить несколько ключевых элементов:
1. defineProps - функция для объявления свойств, которые компонент может получать извне
2. defineEmits - объявляет события, которые компонент может генерировать
3. toRefs - превращает свойства объекта в отдельные реактивные ссылки
4. Отсутствие this - больше не нужно использовать контекст для доступа к данным контейнера
Важно понимать, что все, что объявлено в <script setup> , автоматически становится доступным в шаблоне. Это устраняет необходимость в явном экспорте переменных и функций.
Одним из главных преимуществ Composition API является возможность извлечения и повторного использования логики. Например, представьте, что у нас есть несколько компонентов, которым нужна функциональность пагинации. С Options API мы бы копировали один и тот же код из компонента в компонент. С Composition API мы можем создать композитную функцию (composable):
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| // usePagination.js
import { ref, computed } from 'vue'
export function usePagination(items, itemsPerPage = 10) {
const currentPage = ref(1)
const totalPages = computed(() =>
Math.ceil(items.value.length / itemsPerPage)
)
const paginatedItems = computed(() => {
const start = (currentPage.value - 1) * itemsPerPage
const end = start + itemsPerPage
return items.value.slice(start, end)
})
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++
}
}
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--
}
}
const goToPage = (page) => {
if (page >= 1 && page <= totalPages.value) {
currentPage.value = page
}
}
return {
currentPage,
totalPages,
paginatedItems,
nextPage,
prevPage,
goToPage
}
} |
|
Теперь мы можем использовать эту функцию в любом компоненте:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| <script setup>
import { ref } from 'vue'
import { usePagination } from './composables/usePagination'
const allProducts = ref([/* список продуктов */])
const {
currentPage,
totalPages,
paginatedItems: products,
nextPage,
prevPage
} = usePagination(allProducts, 12)
</script>
<template>
<div class="products-grid">
<ProductCard v-for="product in products" :key="product.id" :product="product" />
</div>
<div class="pagination">
<button :disabled="currentPage === 1" @click="prevPage">Предыдущая</button>
<span>{{ currentPage }} из {{ totalPages }}</span>
<button :disabled="currentPage === totalPages" @click="nextPage">Следущая</button>
</div>
</template> |
|
Такой подход значительно повышает повторное использование кода и делает компоненты более чистыми и сфокусированными.
Хотя Composition API и выглядит сложнее на первый взгляд, поверьте моему опыту — как только вы освоитесь, вы уже не захотите возвращаться к старому подходу. Мне потребовалось пару недель, чтобы полностью перестроить мышление, но эффект стоил того — код стал не только более модульным, но и просто радует глаз аккуратной структурой.
Реактивность в Composition API
Ключевым моментом Vue 3 является переработанная система реактивности. Она стала не только мощнее, но и гибче. В основе реактивности лежат несколько функций и концепций:
ref vs reactive
В Composition API есть два основных способа создания реактивных данных:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| import { ref, reactive } from 'vue'
// Используя ref
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
// Используя reactive
const state = reactive({
count: 0,
message: 'Привет'
})
console.log(state.count) // 0
state.count++
console.log(state.count) // 1 |
|
Функция ref оборачивает примитивное значение в объект с геттером и сеттером для свойства value . Это необходимо, потому что в JavaScript примитивы передаются по значению, а не по ссылке, и Vue нужен способ отследить изменения.
Функция reactive создает реактивный прокси для объекта. Она преобразует все вложенные свойства в реактивные. Но тут есть подвох — вы не можете просто заменить весь объект, иначе потеряется реактивность:
JavaScript | 1
2
3
4
5
| // Так работает
state.count = 10
// А так реактивность теряется!
state = reactive({ count: 10, message: 'Новое сообщение' }) |
|
Когда я только начинал работать с Composition API, я часто путался, что использовать — ref или reactive. Со временем выработал такое правило: для примитивов (числа, строки, булевы значения) использую ref , для сложных объектов и массивов — reactive.
Тут нужно быть внимательным. При деструктуризации реактивного объекта, полученные значения теряют свою реактивность:
JavaScript | 1
2
| const state = reactive({ count: 0, message: 'Привет' })
const { count, message } = state // НЕ реактивные значения! |
|
Для решения этой проблемы используется toRefs :
JavaScript | 1
2
3
4
5
6
7
8
| import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, message: 'Привет' })
const { count, message } = toRefs(state) // Теперь это ref-объекты!
console.log(count.value) // 0
count.value++
console.log(state.count) // 1 - реактивная связь сохраняется |
|
Вычисляемые свойства
Вычисляемые свойства (computed properties) — ещё один мощный механизм Vue. В Composition API они создаются через функцию computed :
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
| import { ref, computed } from 'vue'
const firstName = ref('Иван')
const lastName = ref('Петров')
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
console.log(fullName.value) // "Иван Петров"
firstName.value = 'Петр'
console.log(fullName.value) // "Петр Петров" |
|
Вычисляемые свойства кешируют своё значение и пересчитываются только когда изменяются их зависимости. Это намного эффективнее, чем вычислять то же самое прямо в шаблоне.
Наблюдатели (watchEffect и watch)
Vue 3 предоставляет два способа реагировать на изменения реактивных данных: более простой watchEffect и более детальный watch .
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| import { ref, watchEffect, watch } from 'vue'
const count = ref(0)
const message = ref('Привет')
// watchEffect запускается сразу и затем при любом изменении зависимостей
watchEffect(() => {
console.log(`Счетчик: ${count.value}, сообщение: ${message.value}`)
})
// watch требует явного указания отслеживаемой переменной
// и не запускается автоматически (если нет immediate: true)
watch(count, (newValue, oldValue) => {
console.log(`Счетчик изменился с ${oldValue} на ${newValue}`)
})
// Также можно наблюдать за массивом источников
watch([count, message], ([newCount, newMessage], [oldCount, oldMessage]) => {
console.log(`Счётчик: ${oldCount} -> ${newCount}, Сообщение: ${oldMessage} -> ${newMessage}`)
}) |
|
Мне особенно нравится watchEffect за его лаконичность — не нужно дублировать переменные, которые отслеживаешь. Однако для более сложных случаев, когда нужно знать предыдущие значения или контролировать время выполнения, watch просто незаменим.
Жизненный цикл компонента
Хуки жизненного цикла в Composition API немного отличаются от Options API, но концепция та же. Вот сравнение:
Code | 1
2
3
4
5
6
7
8
9
10
11
12
| | Options API | Composition API | Описание |
|-------------|-----------------|----------|
| beforeCreate, created | setup() | Запускается до создания компонента |
| beforeMount | onBeforeMount | Перед монтированием DOM |
| mounted | onMounted | После монтирования DOM |
| beforeUpdate | onBeforeUpdate | Перед обновлением DOM |
| updated | onUpdated | После обновления DOM |
| beforeUnmount | onBeforeUnmount | Перед удалением компонента |
| unmounted | onUnmounted | После удаления компонента |
| errorCaptured | onErrorCaptured | При ошибке в потомке |
| - | onRenderTracked | Новый в Vue 3 (отладка) |
| - | onRenderTriggered | Новый в Vue 3 (отладка) | |
|
Использование выглядит следующим образом:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| <script setup>
import { onMounted, onUnmounted } from 'vue'
// Функция, которая будет вызвана.
const addResizeListener = () => {
window.addEventListener('resize', handleResize)
}
const removeResizeListener = () => {
window.removeEventListener('resize', handleResize)
}
const handleResize = () => {
console.log('Окно изменило размер')
}
// Жизненный цикл компонента
onMounted(() => {
console.log('Компонент смонтирован')
addResizeListener()
})
onUnmounted(() => {
console.log('Компонент демонтирован')
removeResizeListener()
})
</script> |
|
Мой совет: всегда помните о порядке выполнения хуков жизненного цикла и используйте их по назначению. Например, не загружайте данные в onBeforeMount или onBeforeUpdate — эти хуки могут выполняться множество раз, что приведет к лишним запросам.
Если вам надо объединить несколько хуков в один composable, вы можете это легко сделать:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| // useElementSize.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useElementSize(targetRef) {
const width = ref(0)
const height = ref(0)
const updateSize = () => {
if (targetRef.value) {
width.value = targetRef.value.offsetWidth
height.value = targetRef.value.offsetHeight
}
}
onMounted(() => {
window.addEventListener('resize', updateSize)
updateSize()
})
onUnmounted(() => {
window.removeEventListener('resize', updateSize)
})
return { width, height }
} |
|
Эту функцию затем можно использовать в любом компоненте:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| <script setup>
import { ref } from 'vue'
import { useElementSize } from './composables/useElementSize'
const containerRef = ref(null)
const { width, height } = useElementSize(containerRef)
</script>
<template>
<div ref="containerRef" class="container">
<p>Ширина: {{ width }}px, Высота: {{ height }}px</p>
</div>
</template> |
|
Вот это и есть настоящая сила Composition API — возможность создавать переиспользуемые кусочки логики, которые легко комбинировать в разных компонентах.
Компонент текстовое поле, с интерактивными элементами Добрый всем день. Нужно разработать компонент, в виде текстового поля, с возможностью создавать в нём различные интерактивные элементы.
Пример... Передать компонент через props в массиве данных - возможно ли? Есть генератор табличных данных
Vue.component('list-table-data', {
props: {
table: Array
},
template: `
<table>
... Передача строки категории товара в другой компонент Здравствуйте, хочу при нажатии на категорию товара передавались данные из catalog.vue в tovar.vue.
catalog.vue
<template>
<div... Как сделать компонент шаблон, который принимает 3 компонента и от этого реднерит конретный подшаблон-компонент? Как сделать компонент шаблон, который принимает 3 компонента и от этого реднерит конретный подшаблон-компонент?
Если конкретнее.
Есть...
Создание первого компонента
Помню, как я часами корпел над документацией, чтобы понять, с чего начать создание компонентов во Vue 3. Да, есть масса учебников, но все они почему-то опускают важные нюансы. Попробую исправить эту ситуацию и поделиться реальным опытом создания компонентов с нуля.
Синтаксис и структура файлов
Во Vue 3 компоненты обычно создаются в виде файлов с расширением .vue. Базовый шаблон такого файла выглядит так:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
| <template>
<!-- HTML-разметка компонента -->
</template>
<script setup>
// JavaScript-логика компонента
</script>
<style scoped>
/* CSS-стили компонента */
</style> |
|
Для начала давайте создадим простой компонент кнопки, который можно будет переиспользовать по всему приложению:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| <!-- AppButton.vue -->
<template>
<button
:class="['app-button', [INLINE]app-button--${type}[/INLINE]]"
:disabled="disabled"
@click="handleClick"
>
<slot></slot>
</button>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
type: {
type: String,
default: 'primary',
validator: (value) => ['primary', 'secondary', 'danger'].includes(value)
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['click'])
function handleClick(event) {
if (!props.disabled) {
emit('click', event)
}
}
</script>
<style scoped>
.app-button {
padding: 10px 16px;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: opacity 0.2s;
}
.app-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.app-button--primary {
background-color: #3498db;
color: white;
}
.app-button--secondary {
background-color: #ecf0f1;
color: #333;
border: 1px solid #ddd;
}
.app-button--danger {
background-color: #e74c3c;
color: white;
}
</style> |
|
Что тут происходит? Я создал компонент кнопки, который:- Принимает тип (primary, secondary, danger) для разных стилей.
- Может быть отключен через props.
- Генерирует событие click при нажатии.
- Использует слот для передачи текста кнопки.
Однофайловые компоненты (SFC) против опций API
Хотя я только что показал SFC (однофайловый компонент) с Composition API, стоит понимать разницу между разными подходами. Vue 3 поддерживает несколько способов определения компонентов:
1. Однофайловые компоненты (SFC) с Composition API:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| <script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
</script>
<template>
<button @click="increment">Счетчик: {{ count }}</button>
</template> |
|
2. Однофайловые компоненты с Options API:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| <script>
export default {
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
}
}
</script>
<template>
<button @click="increment">Счетчик: {{ count }}</button>
</template> |
|
3. Компоненты через JavaScript с Options API:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Counter.js
export default {
template: '<button @click="increment">Счетчик: {{ count }}</button>',
data() {
return {
count: 0
}
},
methods: {
increment() {
this.count++
}
}
} |
|
4. Компоненты через JavaScript с Composition API:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Counter.js
import { ref } from 'vue'
export default {
setup() {
const count = ref(0)
function increment() {
count.value++
}
return {
count,
increment
}
},
template: '<button @click="increment">Счетчик: {{ count }}</button>'
} |
|
На практике я почти всегда использую первый вариант – однофайловые компоненты с <script setup> , потому что:- Меньше шаблонного кода.
- Лучшая поддержка TypeScript.
- Лучшая производительность (Vue компилирует это более эффективно).
- Более четкая организация кода.
Работа с шаблонами и логикой
В шаблоне Vue можно использовать различные директивы для связывания данных с DOM:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| <template>
<!-- Текстовая интерполяция -->
<p>{{ message }}</p>
<!-- Привязка атрибутов -->
<img :src="imageUrl" :alt="imageAlt">
<!-- Условный рендеринг -->
<div v-if="isVisible">Показывается при isVisible === true</div>
<div v-else>Показывается при isVisible === false</div>
<!-- Циклический рендеринг -->
<ul>
<li v-for="(item, index) in items" :key="item.id">
{{ index + 1 }}. {{ item.name }}
</li>
</ul>
<!-- Обработка событий -->
<button @click="handleClick">Нажми меня</button>
<!-- Двусторонняя привязка данных -->
<input v-model="inputValue">
</template>
<script setup>
import { ref, reactive } from 'vue'
// Реактивные данные
const message = ref('Привет, Vue!')
const imageUrl = ref('https://example.com/image.jpg')
const imageAlt = ref('Описание изображения')
const isVisible = ref(true)
const inputValue = ref('')
// Реактивные массивы/объекты
const items = reactive([
{ id: 1, name: 'Элемент 1' },
{ id: 2, name: 'Элемент 2' },
{ id: 3, name: 'Элемент 3' }
])
// Обработчики событий
function handleClick() {
alert('Кнопка была нажата!')
}
</script> |
|
Одна из сильных сторон Vue - директивы, которые делают связывание данных сDOM интуитивно понятным. Вот некоторые из самых полезных директив:
v-if / v-else / v-else-if - условный рендеринг элементов.
v-show - переключает видимость элемента (через CSS display).
v-for - отрисовка списков.
v-model - двусторонняя привязка данных (особенно полезно для форм).
v-bind или : - привязка атрибутов к реактивным данным.
v-on или @ - привязка обработчиков событий.
v-slot или # - определение именованных слотов.
При разработке я постоянно сталкивался с проблемой выбора: где должна быть логика - в шаблоне или в JavaScript-части? Со временем выработал правило: если логика простая (форматирование текста, простые условия), она может быть в шаблоне. Всё, что сложнее, лучше вынести в computed-свойства или методы. Взгляните на этот пример сложной логики в шаблоне:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <template>
<!-- Слишком сложно для шаблона -->
<div>
{{
items.filter(item => item.active)
.map(item => item.price)
.reduce((total, price) => total + price, 0)
.toFixed(2)
}}
</div>
<!-- Гораздо лучше -->
<div>{{ totalActiveItemsPrice }}</div>
</template> |
|
Решение через computed-свойство:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| <script setup>
import { ref, computed } from 'vue'
const items = ref([
{ id: 1, name: 'Товар 1', price: 100, active: true },
{ id: 2, name: 'Товар 2', price: 200, active: false },
{ id: 3, name: 'Товар 3', price: 300, active: true }
])
const totalActiveItemsPrice = computed(() => {
return items.value
.filter(item => item.active)
.map(item => item.price)
.reduce((total, price) => total + price, 0)
.toFixed(2)
})
</script> |
|
Такой подход делает код более читаемым, тестируемым и поддерживаемым.
Реактивность в компонентах
Основа Vue - реактивность, которая позволяет автоматически обновлять DOM при изменении данных. Во Vue 3 были значительно улучшены механизмы реактивности, что повысило производительность и удобство использования. Пример реактивного счетчика:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| <template>
<div>
<p>Счетчик: {{ count }}</p>
<button @click="increment">Увеличить</button>
<button @click="decrement">Уменьшить</button>
</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
function increment() {
count.value++
}
function decrement() {
if (count.value > 0) {
count.value--
}
}
</script> |
|
Важно помнить о некоторых подводных камнях при работе с реактивностью:
1. Для массивов и объектов лучше использовать reactive() вместо ref() .
2. При изменении массивов нужно использовать методы, изменяющие исходный массив (push, splice и т.д.), или заменять весь массив.
3. При обновлении вложенных свойств объекта Vue автоматически отслеживает изменения (для reactive).
Иногда требуется отследить изменения реактивных данных и выполнить какие-то действия. Для этого используются watchers:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| <script setup>
import { ref, watch } from 'vue'
const searchQuery = ref('')
const searchResults = ref([])
// Простой watch
watch(searchQuery, async (newQuery, oldQuery) => {
if (newQuery.trim()) {
const results = await fetchSearchResults(newQuery)
searchResults.value = results
} else {
searchResults.value = []
}
})
// Можно следить за несколькими источниками
const page = ref(1)
const perPage = ref(10)
watch([page, perPage], async ([newPage, newPerPage]) => {
// Загрузка данных с учетом пагинации
const data = await fetchPaginatedData(newPage, newPerPage)
// ...
})
// Для глубокого отслеживания объектов
const user = reactive({
name: 'Иван',
preferences: {
darkMode: false,
notifications: true
}
})
watch(() => user.preferences, (newPrefs) => {
// Сохранить предпочтения пользователя
saveUserPreferences(newPrefs)
}, { deep: true })
</script> |
|
Локальные стили и CSS модули в компонентах
Когда я только начинал работать с Vue, меня беспокоил вопрос изоляции стилей. Не хотелось, чтобы стили одного компонента случайно влияли на другие части приложения. И тут меня выручил атрибут scoped у тега <style> .
TypeScript | 1
2
3
4
5
6
| <style scoped>
.button {
background: blue;
color: white;
}
</style> |
|
Под капотом Vue добавляет к каждому элементу компонента уникальный атрибут (например, data-v-f3f3eg ), а затем трансформирует CSS-селекторы так, чтобы они применялись только к элементам с этим атрибутом. В итоге стиль .button превращается в что-то типа .button[data-v-f3f3eg] . Но у scoped есть ограничение — он не пробивается к дочерним компонентам. Это и хорошо, и плохо одновременно. Если нужно всё-таки повлиять на стили потомка, можно использовать глубокий селектор:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| <style scoped>
/* Влияет только на элементы внутри текущего компонента */
.card {
background: white;
border-radius: 8px;
}
/* При использовании :deep() селектор проникает в дочерние компоненты */
:deep(.title) {
font-size: 20px;
color: #333;
}
</style> |
|
Кроме scoped есть ещё возможность использовать CSS-модули:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| <template>
<div :class="$style.container">
<h1 :class="$style.title">Привет, мир!</h1>
</div>
</template>
<style module>
.container {
padding: 20px;
background: #f5f5f5;
}
.title {
color: #42b883;
}
</style> |
|
Главное преимущество CSS-модулей в том, что они поддерживаются не только во Vue, но и во множестве других фреймворков и сборщиков. Они работают через автоматическую трансформацию имен классов в уникальные (например, .title превращается в .title_a1b2c3 ).
Можно даже назначать имя модулю и использовать несколько модулей в одном компоненте:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| <template>
<button :class="[styles.button, styles.primary]">Кнопка</button>
</template>
<style module="styles">
.button {
padding: 8px 16px;
border: none;
border-radius: 4px;
}
.primary {
background: #42b883;
color: white;
}
</style> |
|
В реальных проектах я часто комбинирую обычные стили, scoped и иногда CSS-модули в зависимости от задачи. Например, базовые стили и CSS-переменные могут быть глобальными, стили компонентов — с атрибутом scoped, а для особо изолированных частей — CSS-модули.
В Vue 3 также появилась поддержка CSS-переменных непосредственно из состояния компонента:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| <template>
<div class="card" style="--card-color: color">
Цвет карточки меняется динамически
</div>
</template>
<script setup>
import { ref } from 'vue'
const color = ref('#42b883')
</script>
<style scoped>
.card {
background-color: var(--card-color);
padding: 16px;
border-radius: 8px;
}
</style> |
|
Жизненный цикл компонента в Vue 3: хуки и их применение
Жизненный цикл компонента — это набор этапов, через которые проходит компонент от создания до уничтожения. Vue предоставляет хуки (функции обратного вызова), которые позволяют выполнять код на определённых этапах этого цикла. В Composition API хуки жизненного цикла доступны через функции, которые нужно импортировать:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| <script setup>
import { onMounted, onUpdated, onUnmounted } from 'vue'
// 1. Компонент инициализирован (данные уже реактивны)
// Обратите внимание: тут нет специального хука, это
// просто происходит при выполнении кода в <script setup>
// 2. Компонент примонтирован к DOM
onMounted(() => {
console.log('Компонент примонтирован, DOM доступен')
initializeExternalLibrary() // Инициализация сторонней библиотеки
})
// 3. Компонент обновлен
onUpdated(() => {
console.log('Компонент обновлен')
// Осторожно с изменением реактивных данных в onUpdated -
// это может привести к бесконечному циклу!
})
// 4. Компонент будет уничтожен
onUnmounted(() => {
console.log('Компонент будет уничтожен')
cleanupResources() // Освобождение ресурсов
})
</script> |
|
Интересный факт: код внутри <script setup> фактически выполняется в фазе, которая эквивалентна хукам beforeCreate и created в Options API. Поэтому отдельных хуков для этих фаз в Composition API нет.
Наиболее часто я использую onMounted для:- Выполнения запросов к API.
- Инициализации сторонних библиотек.
- Установки обработчиков событий на window или document.
- Выполнения DOM-операций, которые требуют наличия отрендеренных элементов.
И onUnmounted для:- Очистки таймеров и интервалов.
- Отписки от событий.
- Уничтожения экземпляров сторонних библиотек.
Вот реальный пример использования жизненного цикла для создания компонента с картой, использующей стороннюю библиотеку:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| <template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import maplibregl from 'maplibre-gl'
const mapContainer = ref(null)
let map = null
onMounted(() => {
if (mapContainer.value) {
map = new maplibregl.Map({
container: mapContainer.value,
style: 'https://demotiles.maplibre.org/style.json',
center: [37.618423, 55.751244], // Москва
zoom: 10
})
map.on('load', () => {
// Добавление слоёв и маркеров после загрузки карты
})
}
})
onUnmounted(() => {
if (map) {
map.remove() // Правильно уничтожаем объект карты при демонтаже компонента
}
})
</script>
<style scoped>
.map-container {
width: 100%;
height: 400px;
}
</style> |
|
Помимо основных хуков, Vue 3 предоставляет дополнительные, которые могут быть полезны в специфичных случаях:
onBeforeMount - вызывается непосредственно перед монтированием.
onBeforeUpdate - перед обновлением DOM в результате изменения реактивных данных.
onBeforeUnmount - непосредственно перед удалением компонента.
onActivated - когда компонент внутри <keep-alive> активируется.
onDeactivated - когда компонент внутри <keep-alive> деактивируется.
onErrorCaptured - когда ошибка из дочернего компонента была перехвачена.
onRenderTracked - когда реактивная зависимость отслеживается функцией рендера (для отладки).
onRenderTriggered - когда функция рендера запускается (для отладки).
Я помню случай, когда onErrorCaptured спас мне целый проект. Мы использовали компонент, который иногда падал, но вместо того чтобы обрушить всё приложение, мы перехватывали ошибку и деградировали к более простой версии:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| <script setup>
import { ref, onErrorCaptured } from 'vue'
const hasError = ref(false)
const errorMessage = ref('')
onErrorCaptured((err, instance, info) => {
hasError.value = true
errorMessage.value = err.message
console.error(`Ошибка в компоненте: ${info}`, err)
// Возвращаем false, чтобы предотвратить всплытие ошибки выше
return false
})
</script>
<template>
<div>
<!-- Показываем резервный UI при ошибке -->
<div v-if="hasError" class="error-container">
Извините, произошла ошибка: {{ errorMessage }}
</div>
<!-- Иначе показываем обычный контент -->
<slot v-else></slot>
</div>
</template> |
|
Жизненный цикл компонента — это то, что отличает новичка от профессионала во Vue. Понимание того, когда и что выполняется, помогает избежать множества проблем и создавать более эффективные компоненты.
Одна из распространенных ошибок, с которой я часто сталкивался, — изменение реактивных данных в хуках onUpdated или onBeforeUpdate . Это может легко привести к бесконечным циклам обновления, так как изменение данных вызывает новое обновление, которое запускает хук заново.
И еще важное замечание об использовании async/await в хуках жизненного цикла. Так как хуки являются функциями обратного вызова, делать их асинхронными обычно не имеет смысла — Vue все равно не будет ждать их завершения перед переходом к следущей фазе. Однако внутри хука вы можете использовать асинхронные операции:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| <script setup>
import { ref, onMounted } from 'vue'
const users = ref([])
const loading = ref(true)
const error = ref(null)
onMounted(async () => {
try {
loading.value = true
const response = await fetch('https://api.example.com/users')
users.value = await response.json()
} catch (e) {
error.value = e.message
} finally {
loading.value = false
}
})
</script> |
|
Правильное использование жизненного цикла компонентов значительно уменьшает количество багов и делает ваш код более предсказуемым.
Взаимодействие между компонентами
Разработка современных приложений на Vue 3 — это выстраивание целого оркестра компонентов, которые должны общаться между собой. Без эффективной системы коммуникации между компонентами ваше приложение превратится в хаос несвязанных элементов. Давайте разберём, какие инструменты предлагает Vue 3 для организации этой коммуникации.
Props: передача данных сверху вниз
Самый базовый способ передать данные — использовать props. Это своего рода "входные параметры" компонента, которые передаются от родителя к потомку:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| <!-- Родительский компонент -->
<template>
<div>
<UserProfile
:user-id="123"
:is-admin="true"
:settings="userSettings"
/>
</div>
</template>
<script setup>
import { reactive } from 'vue'
import UserProfile from './UserProfile.vue'
const userSettings = reactive({
theme: 'dark',
notifications: true
})
</script> |
|
В дочернем компоненте эти props нужно объявить и принять:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| <!-- UserProfile.vue -->
<template>
<div class="user-profile">
<h2>Профиль пользователя #{{ userId }}</h2>
<!-- Используем принятые props -->
<AdminPanel v-if="isAdmin" />
<ThemeSwitcher :current-theme="settings.theme" />
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import AdminPanel from './AdminPanel.vue'
import ThemeSwitcher from './ThemeSwitcher.vue'
const props = defineProps({
userId: {
type: Number,
required: true
},
isAdmin: {
type: Boolean,
default: false
},
settings: {
type: Object,
default: () => ({
theme: 'light',
notifications: false
})
}
})
</script> |
|
Заметьте, что defineProps автоматически доступен в <script setup> без необходимости импорта, хотя явный импорт помогает с подсказками IDE. Один из подводных камней, на который я часто натыкался: имена props в шаблоне должны использовать kebab-case (например, user-id ), тогда как в JavaScript используется camelCase (userId ). Vue автоматически конвертирует имена между этими форматами.
Валидация props и настройка дефолтных значений
Vue 3 предлагает мощную систему валидации props, которая может спасти вас от многих ошибок во время разработки:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| const props = defineProps({
// Базовая проверка типа
username: String,
// Детальная валидация
age: {
type: Number,
required: true,
// Кастомный валидатор
validator: (value) => value >= 18
},
// Сложный объект с дефолтным значением
profile: {
type: Object,
// Для объектов и массивов дефолтное значение
// должно возвращаться из фабричной функции
default: () => ({
avatar: 'default.jpg',
bio: ''
})
},
// Массив предопределенных значений
role: {
type: String,
default: 'user',
validator: (value) => ['admin', 'user', 'guest'].includes(value)
}
}) |
|
Заметьте, что если вы используете TypeScript, вы можете определять props с помощью обобщенного типа:
TypeScript | 1
2
3
4
5
6
7
8
9
| const props = defineProps<{
username: string
age: number
profile?: {
avatar: string
bio: string
}
role?: 'admin' | 'user' | 'guest'
}>() |
|
Но тут есть ограничение - при использовании этого синтаксиса вы не можете указать дефолтные значения в самом типе. Для решения этой проблемы Vue 3.3 представил вспомогательную функцию withDefaults :
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
| const props = withDefaults(defineProps<{
username: string
age: number
profile?: {
avatar: string
bio: string
}
role?: 'admin' | 'user' | 'guest'
}>(), {
profile: () => ({ avatar: 'default.jpg', bio: '' }),
role: 'user'
}) |
|
Паттерн "компонентная шина" и его замена в Vue 3
В Vue 2 существовал популярный паттерн "событийная шина" (event bus), который позволял компонентам общаться между собой без прямых связей:
JavaScript | 1
2
3
4
5
6
7
8
9
10
| // Vue 2
const eventBus = new Vue()
// Компонент A отправляет событие
eventBus.$emit('user-logged-in', { id: 123 })
// Компонент B слушает событие
eventBus.$on('user-logged-in', (userData) => {
console.log('Пользователь вошел:', userData)
}) |
|
Однако в Vue 3 этот паттерн больше не рекомендуется, так как экземпляр приложения не реализует API событий. Вместо этого существует несколько альтернатив:
1. Использование внешнего хранилища состояния (Vuex или Pinia).
2. Создание отдельной шины событий с помощью внешней библиотеки или пользовательской реализации.
3. Использование provide/inject для глубоких иерархий компонентов.
4. Композитные функции (composables) для разделения и повторного использования логики.
Если вам действительно нужна шина событий в Vue 3, можно использовать пакет mitt или создать свою:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // eventBus.js
import mitt from 'mitt'
export const eventBus = mitt()
// Компонент A
import { eventBus } from './eventBus'
eventBus.emit('user-logged-in', { id: 123 })
// Компонент B
import { eventBus } from './eventBus'
eventBus.on('user-logged-in', (userData) => {
console.log('Пользователь вошел:', userData)
}) |
|
Ref и Reactive: тонкости использования в компонентах
В работе с компонентами Vue 3 очень важно понимать, как правильно использовать ref и reactive для создания реактивных данных. Я часто вижу небрежное отношение к этому, и это приводит к трудноуловимым ошибкам.
При передаче реактивных данных между компонентами нужно помнить:
1. Props всегда немутабельны - вы не должны изменять props внутри компонента.
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // Неправильно:
const props = defineProps(['value'])
props.value = 100 // Это вызовет предупреждение во время выполнения!
// Правильно:
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function updateValue(newValue) {
emit('update:modelValue', newValue)
} |
|
2. Реактивные объекты и массивы сохраняют реактивность при передаче через props, но при деструктуризации эта реактивность теряется:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // Родитель
const user = reactive({ name: 'Анна', age: 28 })
// В дочернем компоненте:
const props = defineProps(['user'])
// Сохраняет реактивность
console.log(props.user.name)
// Теряет реактивность!
const { name, age } = props.user |
|
Для решения этой проблемы можно использовать toRefs :
JavaScript | 1
2
3
4
5
| import { toRefs } from 'vue'
const { user } = toRefs(props)
// или для извлечения отдельных свойств из объекта
const { name, age } = toRefs(user.value) |
|
3. При использовании v-model в компонентах Vue 3 изменил синтаксис по сравнению с Vue 2:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| <!-- Vue 2 -->
<CustomInput v-model="searchText" />
<!-- В компоненте -->
props: ['value'],
methods: {
updateValue(newValue) {
this.$emit('input', newValue)
}
}
<!-- Vue 3 -->
<CustomInput v-model="searchText" />
<!-- В компоненте -->
<script setup>
const props = defineProps(['modelValue'])
const emit = defineEmits(['update:modelValue'])
function updateValue(newValue) {
emit('update:modelValue', newValue)
}
</script> |
|
4. Сложные случаи с v-model для нескольких свойств:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| <UserForm
v-model:name="userData.name"
v-model:email="userData.email"
/>
<!-- В компоненте UserForm -->
<script setup>
const props = defineProps(['name', 'email'])
const emit = defineEmits(['update:name', 'update:email'])
function updateName(name) {
emit('update:name', name)
}
function updateEmail(email) {
emit('update:email', email)
}
</script> |
|
5. Для сложных форм иногда удобнее использовать библиотеки вроде vee-validate или vue-formulate, которые значительно упрощают взаимодействие и валидацию.
Выбор между ref и reactive часто сводится к личным предпочтениям и конкретной ситуации. Я обычно использую ref для примитивов и reactive для объектов, но есть случаи, когда лучше поступить иначе:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // Для сложных объектов с вложенной структурой
const formData = reactive({
user: {
name: '',
email: '',
preferences: {
notifications: true,
theme: 'dark'
}
},
submission: {
status: 'pending',
lastAttempt: null
}
})
// Для массивов объектов, особенно когда нужно заменять весь массив
const users = ref([])
async function fetchUsers() {
const response = await api.getUsers()
users.value = response.data // Замена всего массива - с ref это просто
} |
|
События: коммуникация снизу вверх
В то время как props используются для передачи данных сверху вниз, события - это механизм коммуникации снизу вверх, от дочернего компонента к родительскому. В Vue 3 это реализуется через emit :
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| <!-- ChildComponent.vue -->
<template>
<button @click="sendDataToParent">Отправить данные</button>
</template>
<script setup>
import { defineEmits } from 'vue'
const emit = defineEmits(['data-sent', 'status-changed'])
function sendDataToParent() {
const data = { id: 1, name: 'Тестовые данные' }
emit('data-sent', data)
}
</script> |
|
А в родительском компоненте мы улавливаем эти события:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| <!-- ParentComponent.vue -->
<template>
<div>
<ChildComponent
@data-sent="handleDataFromChild"
@status-changed="updateStatus"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ChildComponent from './ChildComponent.vue'
const receivedData = ref(null)
const status = ref('idle')
function handleDataFromChild(data) {
receivedData.value = data
console.log('Получены данные:', data)
}
function updateStatus(newStatus) {
status.value = newStatus
}
</script> |
|
Эмиты с типизацией: усиление проверки событий
При работе с событиями в TypeScript можно создать дополнительный уровень защиты, используя типизированные эмиты. Такой подход помогает избегать опечаток в названиях событий и гарантирует правильный тип передаваемых данных.
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| <script setup lang="ts">
// Определяем типизированные эмиты
const emit = defineEmits<{
(e: 'update:selectedId', id: number): void
(e: 'item-click', item: { id: number, name: string }): void
(e: 'change-page', page: number): void
}>()
function selectItem(item: { id: number, name: string }) {
emit('update:selectedId', item.id) // ✓ Правильный тип
emit('item-click', item) // ✓ Правильный тип
// Следующие строки вызовут ошибки TypeScript:
// emit('unknown-event', data) ✗ Событие не существует
// emit('update:selectedId', '123') ✗ Неверный тип данных
// emit('item-click', { wrongProp: true }) ✗ Несоответствие типу
}
</script> |
|
Типизированные эмиты особенно полезны в больших командах, где разные разработчики могут работать с одним и тем же компонентом. Они фактически становятся частью документации компонента, объясняя какие события компонент может отправлять и какие данные он при этом передаёт.
Provide/Inject для глубокой передачи
Когда мне нужно передать данные через несколько уровней компонентов, я всегда вспоминаю забавную аналогию: как будто компоненты играют в "испорченный телефон", и с каждым прохождением информации через компонент растёт риск искажений. В Vue для решения этой проблемы есть механизм provide/inject, который позволяет компонентам-предкам "инжектировать" данные в любые глубоко вложенные компоненты без передачи через промежуточные.
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| <!-- App.vue -->
<script setup>
import { provide, readonly, ref } from 'vue'
const theme = ref('light')
const changeTheme = (newTheme) => {
theme.value = newTheme
}
// Предоставляем и значение, и функцию для его изменения
provide('theme', readonly(theme)) // readonly для защиты от прямого изменения
provide('changeTheme', changeTheme)
</script>
<!-- DeepNestedComponent.vue (может быть вложен глубоко) -->
<template>
<div :class="`theme-${theme}`">
<button @click="toggleTheme">
Сменить тему на {{ theme === 'light' ? 'тёмную' : 'светлую' }}
</button>
</div>
</template>
<script setup>
import { inject } from 'vue'
const theme = inject('theme')
const changeTheme = inject('changeTheme')
function toggleTheme() {
changeTheme(theme.value === 'light' ? 'dark' : 'light')
}
</script> |
|
Несколько важных нюансов, которые я обнаружил на практике:
1. Всегда используйте readonly для предоставляемых реактивных объектов, чтобы предотвратить их прямое изменение в потребляющих компонентах.
2. Предоставляйте отдельные функции для изменения данных, что делает поток данных предсказуемым и отслеживаемым.
3. Можно установить значение по умолчанию для случаев, когда provide не найден:
JavaScript | 1
| const theme = inject('theme', ref('light')) // Дефолтное значение, если 'theme' не предоставлен |
|
4. Для сложных случаев используйте символы в качестве ключей, чтобы избежать конфликтов:
JavaScript | 1
2
3
4
5
6
7
8
9
| // keys.js
export const themeKey = Symbol('theme')
// В родительском компоненте
provide(themeKey, theme)
// В дочернем
import { themeKey } from './keys'
const theme = inject(themeKey) |
|
Provide/inject очень удобен для предоставления глобальных настроек, перевода, темы, авторизации и других "сквозных" функций, которые нужны многим компонентам на разных уровнях.
Альтернативные паттерны коммуникации: Vuex и Pinia
Vuex долгое время был стандартным хранилищем для Vue-приложений, но с приходом Vue 3 на сцену вышла Pinia — более легковесная и интуитивная альтернатива.
Pinia превосходит Vuex по нескольким параметрам:- Нет необходимости в вложенных модулях.
- Поддержка TypeScript из коробки.
- Очень легкий вес.
- Более простой API без мутаций и типов действий.
- Поддержка множественных хранилищ.
- Отличная поддержка девтулзов.
Вот пример использования Pinia:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| // stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
doubleCount: (state) => state.count * 2,
// Использование других геттеров
descriptionWithDoubleCount() {
return `${this.name}: ${this.doubleCount}`
}
},
actions: {
increment() {
this.count++
},
async fetchInitialCount() {
const response = await fetch('/api/count')
this.count = await response.json()
}
}
}) |
|
А так это выглядит при использовании в компоненте:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| <template>
<div>
<p>Счётчик: {{ counter.count }}</p>
<p>Удвоенный счётчик: {{ counter.doubleCount }}</p>
<button @click="counter.increment">Увеличить</button>
<button @click="resetToRandom">Сбросить</button>
</div>
</template>
<script setup>
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
function resetToRandom() {
// Можно изменять множество свойств одновременно
counter.$patch({
count: Math.round(Math.random() * 100),
name: 'Случайный счётчик'
})
}
// Подписка на изменения хранилища
counter.$subscribe((mutation, state) => {
console.log('Хранилище изменено:', mutation)
// Здесь можно сохранить изменения в localStorage
})
// Загрузка начальных данных
counter.fetchInitialCount()
</script> |
|
И, что круто, с Composition API можно создавать собственные композитные функции, работающие со хранилищем:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // composables/useCartWithDiscount.js
import { computed } from 'vue'
import { useCartStore } from '@/stores/cart'
export function useCartWithDiscount() {
const cart = useCartStore()
const subtotal = computed(() => {
return cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
const discount = computed(() => {
return subtotal.value > 100 ? subtotal.value * 0.1 : 0
})
const total = computed(() => {
return subtotal.value - discount.value
})
return {
cartItems: computed(() => cart.items),
addToCart: cart.addItem,
removeFromCart: cart.removeItem,
subtotal,
discount,
total
}
} |
|
Такая композитная функция может использоваться в любом компоненте, предоставляя специализированное API для работы с корзиной, включая бизнес-логику расчета скидок.
В сложных приложениях я часто комбинирую разные подходы:- Props/Events для прямых родитель-потомок связей.
- Provide/Inject для "сквозных" функций и глобальных настроек.
- Pinia для сложной бизнес-логики и данных, которые используются многими разрозненными компонентами.
Отказ от старого Vuex был, немного волнительным шагом, но обнаружив, как улучшилась организация кода и поддерживаемость проектов с Pinia, я ни разу не пожалел об этом решении. Что мне особенно понравилось при переходе на Pinia – это отсутствие бойлерплейт-кода. В Vuex для простой функции "увеличить счетчик" приходилось писать константу типа действия, мутацию и действие. В Pinia всё намного лаконичнее: одно действие и всё.
Также стоит отметить, что иногда самое простое решение - самое правильное. Если ваше приложение невелико, возможно, вам вовсе не нужно хранилище, и вполне достаточно props/events или простого reactive-объекта, к которому имеют доступ нужные компоненты.
Продвинутые техники
Когда вы освоили основы Vue 3, самое время копнуть глубже и изучить продвинутые техники.
Переиспользуемость компонентов: стратегии и лучшие практики
Настоящая сила компонентного подхода раскрывается, когда вы начинаете эффективно переиспользовать компоненты. Я выработал несколько стратегий, которые помогают создавать по-настоящему универсальные компоненты:
1. Принцип единственной ответственности
Компонент должен делать что-то одно, но делать это хорошо. Например, вместо создания монолитного компонента ProfileCard с аватаром, информацией и действиями, разделите его на более мелкие компоненты:
TypeScript | 1
2
3
4
5
6
7
| <template>
<div class="profile-card">
<UserAvatar :src="user.avatarUrl" :size="size" />
<UserInfo :user="user" />
<UserActions :userId="user.id" @follow="handleFollow" />
</div>
</template> |
|
2. Компонент-контейнер и презентационные компоненты
Разделяйте компоненты на два типа: контейнеры (содержат логику и получают данные) и презентационные (только отображают данные). Это улучшает переиспользуемость и тестируемость:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| <!-- UserListContainer.vue - контейнерный компонент -->
<script setup>
import { ref, onMounted } from 'vue'
import UserList from './UserList.vue'
const users = ref([])
const isLoading = ref(true)
onMounted(async () => {
try {
const response = await fetch('/api/users')
users.value = await response.json()
} finally {
isLoading.value = false
}
})
</script>
<template>
<div>
<UserList
:users="users"
:is-loading="isLoading"
/>
</div>
</template> |
|
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| <!-- UserList.vue - презентационный компонент -->
<script setup>
defineProps({
users: Array,
isLoading: Boolean
})
</script>
<template>
<div class="user-list">
<LoadingSpinner v-if="isLoading" />
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template> |
|
3. Композитные компоненты с использованием слотов
Один из моих любимых подходов — создание компонентов, которые можно компоновать с помощью слотов. Например, компонент модального окна:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| <template>
<div v-if="isOpen" class="modal-wrapper">
<div class="modal">
<header class="modal-header">
<slot name="header">
<h2>{{ title }}</h2>
</slot>
<button @click="close" class="close-btn">×</button>
</header>
<div class="modal-body">
<slot></slot>
</div>
<footer class="modal-footer">
<slot name="footer">
<button @click="close">Закрыть</button>
</slot>
</footer>
</div>
</div>
</template> |
|
Использование такого компонента очень гибкое:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
| <AppModal :is-open="showModal" @close="showModal = false" title="Редактирование профиля">
<template #header>
<h3>Индивидуальный заголовок</h3>
</template>
<form @submit.prevent="saveProfile">
<!-- Содержимое формы -->
</form>
<template #footer>
<button @click="saveProfile">Сохранить</button>
<button @click="showModal = false">Отмена</button>
</template>
</AppModal> |
|
4. Настраиваемые директивы для поведения
Для добавления определенного поведения к элементам используйте пользовательские директивы. Например, директива для автоматического фокуса на элементе:
JavaScript | 1
2
3
4
| // vFocus.js
export const vFocus = {
mounted: (el) => el.focus()
} |
|
TypeScript | 1
2
3
4
5
6
7
| <script setup>
import { vFocus } from './directives/vFocus'
</script>
<template>
<input v-focus type="text" />
</template> |
|
Слоты и динамические компоненты
Слоты — мощный механизм для создания гибких компонентов. Vue 3 поддерживает несколько типов слотов:
Обычные слоты для вставки содержимого:
TypeScript | 1
2
3
4
5
6
7
| <template>
<div class="card">
<div class="card-body">
<slot></slot>
</div>
</div>
</template> |
|
Именованные слоты для более структурированной композиции:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
| <div class="layout">
<header>
<slot name="header"></slot>
</header>
<main>
<slot></slot>
</main>
<footer>
<slot name="footer"></slot>
</footer>
</div> |
|
Слоты с привязкой данных (scoped slots) позволяют передавать данные из дочернего компонента родителю:
TypeScript | 1
2
3
4
5
6
7
8
| <!-- List.vue -->
<template>
<ul>
<li v-for="(item, index) in items" :key="item.id">
<slot :item="item" :index="index"></slot>
</li>
</ul>
</template> |
|
Использование:
TypeScript | 1
2
3
4
5
| <List :items="fruits">
<template #default="{ item, index }">
<strong>{{ index + 1 }}:</strong> {{ item.name }} - {{ item.color }}
</template>
</List> |
|
Динамические компоненты — еще один способ гибкой организации интерфейса. Они позволяют переключаться между различными компонентами на основе данных:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| <template>
<button
v-for="tab in tabs"
:key="tab.name"
@click="currentTab = tab.name"
>
{{ tab.label }}
</button>
<component :is="currentTabComponent" />
</template>
<script setup>
import { ref, computed } from 'vue'
import HomeTab from './HomeTab.vue'
import ProfileTab from './ProfileTab.vue'
import SettingsTab from './SettingsTab.vue'
const tabs = [
{ name: 'home', label: 'Главная', component: HomeTab },
{ name: 'profile', label: 'Профиль', component: ProfileTab },
{ name: 'settings', label: 'Настройки', component: SettingsTab }
]
const currentTab = ref('home')
const currentTabComponent = computed(() => {
return tabs.find(tab => tab.name === currentTab.value)?.component
})
</script> |
|
Для сохранения состояния компонента при переключении можно обернуть динамический компонент в <keep-alive> :
TypeScript | 1
2
3
| <keep-alive>
<component :is="currentTabComponent" />
</keep-alive> |
|
Асинхронные компоненты
Асинхронная загрузка компонентов — ключевая техника для оптимизации первоначальной загрузки приложения. Вместо того чтобы загружать все компоненты сразу, мы можем загружать их только когда они потребуются:
JavaScript | 1
2
3
4
5
| import { defineAsyncComponent } from 'vue'
const HeavyComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
) |
|
С дополнительными опциями для индикации загрузки и обработки ошибок:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| const AsyncModal = defineAsyncComponent({
// Фабричная функция для загрузки компонента
loader: () => import('./Modal.vue'),
// Компонент, отображаемый во время загрузки
loadingComponent: LoadingSpinner,
// Задержка перед показом компонента загрузки
delay: 200,
// Компонент, отображаемый при ошибке
errorComponent: ErrorDisplay,
// Таймаут загрузки
timeout: 5000
}) |
|
На практике я часто использую это для тяжелых модалов, страниц или сложных интерактивных компонентов, которые не требуются сразу при запуске приложения.
Композитные функции (Composables)
Одно из самых мощных нововведений Vue 3 — композитные функции, позволяющие изолировать и переиспользовать логику между компонентами.
Пример композитной функции для работы с геолокацией:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| // useGeolocation.js
import { ref, onMounted, onUnmounted } from 'vue'
export function useGeolocation() {
const coordinates = ref({ latitude: 0, longitude: 0 })
const error = ref(null)
const isSupported = 'geolocation' in navigator
let watcher = null
function updatePosition(position) {
coordinates.value = {
latitude: position.coords.latitude,
longitude: position.coords.longitude
}
}
function handleError(err) {
error.value = err.message
}
function startTracking() {
if (!isSupported) {
error.value = 'Геолокация не поддерживается вашим браузером'
return
}
watcher = navigator.geolocation.watchPosition(
updatePosition,
handleError
)
}
function stopTracking() {
if (watcher) navigator.geolocation.clearWatch(watcher)
}
onMounted(startTracking)
onUnmounted(stopTracking)
return {
coordinates,
error,
isSupported,
startTracking,
stopTracking
}
} |
|
Использование в компоненте:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| <script setup>
import { useGeolocation } from './composables/useGeolocation'
const { coordinates, error, isSupported } = useGeolocation()
</script>
<template>
<div>
<p v-if="!isSupported">Геолокация не поддерживается</p>
<p v-else-if="error">Ошибка: {{ error }}</p>
<p v-else>
Ваши координаты: {{ coordinates.latitude }}, {{ coordinates.longitude }}
</p>
</div>
</template> |
|
Что делает composables такими мощными:- Они сосредоточены на функциональности, а не на UI.
- Они могут использовать другие composables (композиция).
- Они работают с реактивными данными и хуками жизненного цикла.
- Их легко тестировать изолированно.
Вот еще один пример composable для работы с localStorage:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // useLocalStorage.js
import { ref, watch } from 'vue'
export function useLocalStorage(key, defaultValue) {
// Пытаемся получить значение из localStorage или используем дефолтное
const storedValue = localStorage.getItem(key)
const data = ref(storedValue ? JSON.parse(storedValue) : defaultValue)
// При изменении data обновляем localStorage
watch(data, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
})
// Метод для сброса значения к дефолтному
function reset() {
data.value = defaultValue
}
return [data, reset]
} |
|
Использование:
TypeScript | 1
2
3
4
5
6
7
8
9
| <script setup>
import { useLocalStorage } from './composables/useLocalStorage'
const [darkMode, resetTheme] = useLocalStorage('dark-mode', false)
function toggleTheme() {
darkMode.value = !darkMode.value
}
</script> |
|
Телепортация компонентов с Teleport
Иногда нужно рендерить компонент в другом месте DOM, за пределами иерархии Vue-компонентов. Типичный пример — модальные окна, подсказки или плавающие меню. Для этого в Vue 3 введен компонент Teleport :
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| <template>
<button @click="showModal = true">Открыть модальное окно</button>
<Teleport to="body">
<div v-if="showModal" class="modal">
<h2>Модальное окно</h2>
<p>Это модальное окно, телепортированное в body!</p>
<button @click="showModal = false">Закрыть</button>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const showModal = ref(false)
</script> |
|
Атрибут to принимает любой валидный CSS-селектор или DOM-элемент. Содержимое Teleport будет помещено внутрь выбранного элемента, но при этом сохранит все связи с родительским компонентом — props, события и т.д. Это решает распространенную проблему z-index и переполнения (overflow) для модальных окон и всплывающих подсказок, позволяя размещать их вне контейнеров с overflow: hidden или другими ограничениями CSS.
Настраиваемые директивы для расширения компонентов
Помимо стандартных директив Vue, таких как v-if , v-for и v-model , фреймворк позволяет создавать собственные директивы. Эта возможность часто недооценивается, но она даёт мощный инструмент для добавления нового поведения в элементы DOM.
Собственные директивы особенно полезны, когда надо работать напрямую с DOM-элементами или добавить низкоуровневое поведение. Вот как создать простую директиву для автофокуса:
JavaScript | 1
2
3
4
5
6
| // directives/vAutofocus.js
export default {
mounted(el) {
el.focus()
}
} |
|
Регистрация и использование:
TypeScript | 1
2
3
4
5
6
7
| <script setup>
import vAutofocus from './directives/vAutofocus'
</script>
<template>
<input v-autofocus type="text" placeholder="Этот инпут получит фокус автоматически" />
</template> |
|
Директивы могут получать аргументы, модификаторы и значения:
TypeScript | 1
| <div v-tooltip:top.persistent="'Подсказка при наведении'">Наведите на меня</div> |
|
А вот пример более сложной директивы для создания элемента, который можно перетаскивать:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
| // vDraggable.js
export default {
mounted(el, binding) {
const options = binding.value || {}
el.style.position = 'absolute'
if (options.initial) {
el.style.top = `${options.initial.y}px`
el.style.left = `${options.initial.x}px`
}
let isDragging = false
let offsetX, offsetY
function handleMouseDown(e) {
if (e.button !== 0) return // Только левый клик
isDragging = true
offsetX = e.clientX - el.getBoundingClientRect().left
offsetY = e.clientY - el.getBoundingClientRect().top
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
// Предотвращаем выделение текста при перетаскивании
e.preventDefault()
}
function handleMouseMove(e) {
if (!isDragging) return
el.style.left = `${e.clientX - offsetX}px`
el.style.top = `${e.clientY - offsetY}px`
if (options.onDrag) {
options.onDrag({
x: e.clientX - offsetX,
y: e.clientY - offsetY
})
}
}
function handleMouseUp() {
isDragging = false
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
if (options.onDragEnd) {
options.onDragEnd({
x: parseInt(el.style.left),
y: parseInt(el.style.top)
})
}
}
el.addEventListener('mousedown', handleMouseDown)
// Сохраняем ссылки на функции для последующей очистки
el._draggable = {
handleMouseDown,
handleMouseMove,
handleMouseUp
}
},
beforeUnmount(el) {
// Очистка слушателей событий при удалении элемента
el.removeEventListener('mousedown', el._draggable.handleMouseDown)
document.removeEventListener('mousemove', el._draggable.handleMouseMove)
document.removeEventListener('mouseup', el._draggable.handleMouseUp)
delete el._draggable
}
} |
|
Использование:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| <script setup>
import { ref } from 'vue'
import vDraggable from './directives/vDraggable'
const position = ref({ x: 100, y: 100 })
function handleDragEnd(pos) {
position.value = pos
console.log(`Элемент перемещен в: x=${pos.x}, y=${pos.y}`)
}
</script>
<template>
<div
v-draggable="{
initial: position,
onDragEnd: handleDragEnd
}"
class="draggable-box"
>
Перетащи меня!
</div>
</template> |
|
Пользовательские директивы могут использовать все хуки жизненного цикла компонентов:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| const myDirective = {
beforeMount(el, binding) {
// Вызывается перед тем, как элемент будет вставлен в DOM
},
mounted(el, binding) {
// После вставки элемента в DOM
},
beforeUpdate(el, binding) {
// Перед обновлением содержащего компонента
},
updated(el, binding) {
// После обновления содержащего компонента
},
beforeUnmount(el, binding) {
// Перед удалением элемента из DOM
},
unmounted(el, binding) {
// После удаления элемента из DOM
}
} |
|
Каждый хук получает следущие аргументы:
el : DOM-элемент, к которому применена директива.
binding : объект с информацией о директиве (value, oldValue, arg, modifiers и т.д.).
vnode : виртуальный DOM-узел, представляющий элемент.
prevVnode : предыдущий виртуальный DOM-узел (доступен только в хуках обновления).
Я часто использую директивы для интеграции сторонних библиотек, которые работают напрямую с DOM. Например, вот как можно создать директиву для библиотеки анимации:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| // vAnimate.js
import anime from 'animejs'
export default {
mounted(el, binding) {
const options = binding.value || {}
const defaultAnimation = {
targets: el,
opacity: [0, 1],
translateY: [20, 0],
duration: 500,
easing: 'easeOutQuad'
}
anime({
...defaultAnimation,
...options
})
},
updated(el, binding, vnode, prevVnode) {
if (binding.value !== binding.oldValue) {
const options = binding.value || {}
anime({
targets: el,
...options
})
}
}
} |
|
А вот как можно использовать такую директиву:
TypeScript | 1
2
3
4
| <template>
<h1 v-animate="{ duration: 1000 }">Плавно появляющийся заголовок</h1>
<p v-animate="{ delay: 300, scale: [0.8, 1] }">И текст с задержкой</p>
</template> |
|
Одна из интересных техник — создание директив, взаимодействующих с композитными функциями. Например, директива, которая использует состояние из composable:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| // useIntersectionObserver.js
import { ref } from 'vue'
export function useIntersectionObserver() {
const elements = ref(new Set())
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target._onIntersect?.()
}
})
}, { threshold: 0.1 })
function observe(element, callback) {
if (!element) return
element._onIntersect = callback
observer.observe(element)
elements.value.add(element)
}
function unobserve(element) {
if (!element) return
observer.unobserve(element)
elements.value.delete(element)
delete element._onIntersect
}
return { observe, unobserve, elements }
}
// vInView.js
export default (observer) => {
return {
mounted(el, binding) {
observer.observe(el, () => {
if (typeof binding.value === 'function') {
binding.value(el)
}
})
},
unmounted(el) {
observer.unobserve(el)
}
}
} |
|
И использование:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| <script setup>
import { ref } from 'vue'
import { useIntersectionObserver } from './composables/useIntersectionObserver'
import vInViewDirective from './directives/vInView'
const { observe, unobserve } = useIntersectionObserver()
const vInView = vInViewDirective({ observe, unobserve })
function handleElementVisible(el) {
el.classList.add('animate-in')
}
</script>
<template>
<div class="scroll-container">
<section v-in-view="handleElementVisible" class="section">
Эта секция анимируется при попадании в поле зрения
</section>
<!-- Другие секции -->
</div>
</template> |
|
В крупных проектах я часто создаю набор стандартных директив, которые решают типичные задачи:
1. v-click-outside - закрывает выпадающие меню при клике вне них.
2. v-debounce - ограничивает частоту вызовов обработчика события.
3. v-permission - скрывает или дизейблит элементы в зависимости от прав пользователя.
4. v-tooltip - добавляет всплывающие подсказки.
5. v-resize - отслеживает изменение размера элемента.
Директивы могут быть зарегистрированы глобально или локально. Для глобальной регистрации:
JavaScript | 1
2
3
4
5
6
7
8
| // main.js
import { createApp } from 'vue'
import App from './App.vue'
import vAutofocus from './directives/vAutofocus'
const app = createApp(App)
app.directive('autofocus', vAutofocus)
app.mount('#app') |
|
Для локального использования в компоненте с <script setup> :
TypeScript | 1
2
3
4
5
| <script setup>
import vAutofocus from './directives/vAutofocus'
// Автоматически доступна в шаблоне как v-autofocus
</script> |
|
И всё же, при выборе между директивой и компонентом часто лучше предпочесть компонент, если он может обеспечить нужное поведение. Компоненты обычно проще тестировать, у них чётче API, и они лучше инкапсулируют логику. Директивы лучше использовать для низкоуровневых манипуляций с DOM, особенно когда такое поведение нужно применить к существующим элементам. На практике я комбинирую все эти техники: композитные функции управляют состоянием и логикой, компоненты структурируют интерфейс, а директивы расширяют стандартные возможности HTML-элементов.
Одна из последних задач, где я применил эту комбинацию — создание системы прав доступа:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| // usePermissions.js
import { ref, readonly } from 'vue'
export function usePermissions() {
const userRoles = ref([])
const permissions = ref({})
async function loadUserPermissions() {
// Загрузка с сервера
const response = await fetchPermissions()
permissions.value = response.permissions
userRoles.value = response.roles
}
function hasPermission(permissionKey) {
return !!permissions.value[permissionKey]
}
function hasRole(role) {
return userRoles.value.includes(role)
}
return {
permissions: readonly(permissions),
userRoles: readonly(userRoles),
loadUserPermissions,
hasPermission,
hasRole
}
}
// vPermission.js
export default (permissionsService) => {
return {
mounted(el, binding) {
const permissionKey = binding.value
const modifiers = binding.modifiers
if (!permissionsService.hasPermission(permissionKey)) {
if (modifiers.hide) {
el.style.display = 'none'
} else {
el.disabled = true
el.classList.add('disabled')
}
}
},
updated(el, binding) {
// Переоценка при обновлении прав
const permissionKey = binding.value
const modifiers = binding.modifiers
if (permissionsService.hasPermission(permissionKey)) {
if (modifiers.hide) {
el.style.display = ''
} else {
el.disabled = false
el.classList.remove('disabled')
}
} else {
if (modifiers.hide) {
el.style.display = 'none'
} else {
el.disabled = true
el.classList.add('disabled')
}
}
}
}
} |
|
Использование:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| <script setup>
import { usePermissions } from './composables/usePermissions'
import vPermissionDirective from './directives/vPermission'
import { onMounted } from 'vue'
const permissionsService = usePermissions()
const vPermission = vPermissionDirective(permissionsService)
onMounted(() => {
permissionsService.loadUserPermissions()
})
</script>
<template>
<div class="admin-panel">
<button v-permission="'users.create'">Создать пользователя</button>
<div v-permission.hide="'reports.view'">
Отчёты доступны только с правами на просмотр
</div>
</div>
</template> |
|
Такой подход даёт гибкость и переиспользуемость, при этом сохраняя чистоту и понятность кода.
Подводные камни и оптимизация
Разрабатывая с Vue 3 почти три года, я наступил на столько граблей, что впору открывать музей боли фронтенд-разработчика. Давайте я расскажу о самых коварных проблемах и о том, как их избежать или решить.
Распространенные ошибки
Мутации реактивных объектов в неправильных местах
Одна из самых частых ошибок — пытаться изменить props напрямую. Vue реагирует на это сурово, выбрасывая предупреждение:
TypeScript | 1
2
3
4
5
6
7
8
| <script setup>
// Не делайте так!
const props = defineProps(['user'])
function updateName() {
props.user.name = 'Новое имя' // Изменяем объект props напрямую
}
</script> |
|
Правильный подход — вызвать событие и изменить данные в родительском компоненте:
TypeScript | 1
2
3
4
5
6
7
8
9
| <script setup>
const props = defineProps(['user'])
const emit = defineEmits(['update:user'])
function updateName() {
const updatedUser = { ...props.user, name: 'Новое имя' }
emit('update:user', updatedUser)
}
</script> |
|
Потеря реактивности при деструктуризации
Еще одна распространенная ошибка — деструктуризация реактивных объектов:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| // Так реактивность теряется
const { count } = someReactiveObject
count++ // Это не вызовет обновление UI
// Правильный подход с toRefs
import { toRefs } from 'vue'
const { count } = toRefs(someReactiveObject)
count.value++ // Реактивность работает
// Или прямой доступ
someReactiveObject.count++ |
|
Неправильная работа с асинхронными компонентами
Я часто натыкался на ошибку, когда asyncComponent становится undefined в процессе гидратации при SSR:
JavaScript | 1
2
3
4
5
6
7
8
9
| // Проблемы с гидратацией
const SomeComponent = defineAsyncComponent(() => import('./SomeComponent.vue'))
// Решение
const SomeComponent = defineAsyncComponent({
loader: () => import('./SomeComponent.vue'),
delay: 0, // Минимальная задержка перед попыткой рендеринга
timeout: 30000 // Долгий таймаут для медленных соединений
}) |
|
Забытые ссылки на функции и очистка
Частая причина утечек памяти — регистрация обработчиков в onMounted , но забывание удалить их в onUnmounted :
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Утечка памяти
onMounted(() => {
window.addEventListener('resize', handleResize)
})
// Правильная очистка
onMounted(() => {
window.addEventListener('resize', handleResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
}) |
|
Циклические обновления в watcher и computed
Я раз пять попадался на создание бесконечных циклов обновления:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Бесконечный цикл
const count = ref(0)
watch(count, () => {
count.value++ // Изменение значения, которое мы наблюдаем
})
// Ещё одна распространенная ошибка
const a = ref(0)
const b = computed(() => {
a.value = a.value + 1 // Никогда не мутируйте зависимости внутри computed!
return a.value
}) |
|
Ленивая загрузка и кодсплиттинг компонентов
Если ваше приложение на Vue 3 состоит из множества компонентов, загрузка всех при старте будет замедлять начальную загрузку. Решение — ленивая загрузка компонентов по мере необходимости.
Асинхронные маршруты в Vue Router
В маршрутизаторе Vue Router ленивая загрузка реализуется элементарно:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| const routes = [
{
path: '/profile',
name: 'Profile',
// Асинхронная загрузка компонента страницы
component: () => import('./views/UserProfile.vue')
},
{
path: '/analytics',
name: 'Analytics',
component: () => import('./views/Analytics.vue'),
// Предзагрузка при наведении на ссылку
props: route => ({
preload: true,
params: route.params
})
}
] |
|
Для предзагрузки при наведении можно создать директиву:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // v-preload-view.js
export default {
mounted(el, binding) {
const view = binding.value
let timeout
el.addEventListener('mouseenter', () => {
timeout = setTimeout(() => {
import(`./views/${view}.vue`)
}, 100)
})
el.addEventListener('mouseleave', () => {
clearTimeout(timeout)
})
}
} |
|
Динамический импорт компонентов
Для больших компонентов, которые не нужны сразу при загрузке страницы:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| <script setup>
import { defineAsyncComponent } from 'vue'
// Ленивая загрузка тяжелых компонентов
const HeavyChart = defineAsyncComponent(() =>
import('./components/ComplexChart.vue')
)
const AdvancedEditor = defineAsyncComponent(() =>
import('./components/RichTextEditor.vue')
)
</script>
<template>
<div>
<button @click="showChart = !showChart">
{{ showChart ? 'Скрыть' : 'Показать' }} график
</button>
<!-- Компонент загрузится только при необходимости -->
<HeavyChart v-if="showChart" />
</div>
</template> |
|
Группировка связанных компонентов
Если у вас есть группа компонентов, которые всегда используются вместе, Webpack и Vite позволяют объединять их в один чанк:
JavaScript | 1
2
3
4
5
6
7
8
| // Эти компоненты будут в одном чанке
const AdminFeatures = defineAsyncComponent(() =>
import(/* webpackChunkName: "admin-features" */ './AdminFeatures.vue')
)
const AdminStats = defineAsyncComponent(() =>
import(/* webpackChunkName: "admin-features" */ './AdminStats.vue')
) |
|
Инструменты отладки и Vue DevTools для анализа компонентов
Отладка — критически важная часть работы с компонентами Vue. Без правильных инструментов можно потратить часы на поиск неуловимых багов.
Vue DevTools
Незаменимый инструмент — расширение Vue DevTools для браузера, которое позволяет:- Просматривать дерево компонентов.
- Исследовать и изменять props и состояние компонентов.
- Наблюдать за событиями.
- Профилировать производительность.
- Отлаживать Vuex/Pinia хранилища.
Один трюк, который спас меня много раз — использование пользовательский событий через DevTools. Я добавляю в ключевые места кода:
JavaScript | 1
2
3
| if (process.env.NODE_ENV !== 'production') {
console.log('[Component] Data loaded:', data)
} |
|
Performance API
Для измерения производительности отдельных операций:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| async function loadData() {
if (process.env.NODE_ENV !== 'production') {
console.time('data-loading')
}
// Загрузка данных...
if (process.env.NODE_ENV !== 'production') {
console.timeEnd('data-loading')
}
} |
|
Vue-специфичная отладка с хуками жизненного цикла
Для отладки процесса рендеринга компонентов можно использовать специальные хуки:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| <script setup>
import { onRenderTracked, onRenderTriggered } from 'vue'
// Отслеживает, какие зависимости отслеживаются при рендеринге
onRenderTracked((event) => {
console.log('Рендеринг отслеживает:', event)
})
// Срабатывает, когда зависимость вызывает повторный рендеринг
onRenderTriggered((event) => {
console.log('Рендеринг вызван:', event)
})
</script> |
|
Эти хуки особенно полезны, когда нужно выяснить, почему компонент перерендеривается слишком часто.
Производительность компонентов
Производительность — это не просто скорость работы. Это и скорость первой загрузки, и отзывчивость интерфейса, и плавность анимаций.
Мемоизация компонентов
Если ваш компонент часто перерендеривается с одними и теми же props, используйте memo :
TypeScript | 1
2
3
4
5
6
7
8
9
| <script setup>
import { memo } from 'vue'
const MyExpensiveComponent = memo(ExpensiveToRender)
</script>
<template>
<MyExpensiveComponent :prop1="value1" :prop2="value2" />
</template> |
|
Это предотвратит перерисовки, когда props не изменились.
Виртуализация списков
Для длинных списков виртуализация может сильно повысить производительность:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| <script setup>
import { ref } from 'vue'
import { useVirtualList } from '@vueuse/core'
const longList = ref(Array.from({ length: 10000 }).map((_, i) => ({
id: i,
text: [INLINE]Элемент ${i}[/INLINE]
})))
const { list, containerProps, wrapperProps } = useVirtualList(longList, {
itemHeight: 40
})
</script>
<template>
<div v-bind="containerProps" class="scroll-container">
<div v-bind="wrapperProps">
<div
v-for="item in list"
:key="item.data.id"
class="list-item"
>
{{ item.data.text }}
</div>
</div>
</div>
</template> |
|
Использование shallowRef и shallowReactive
Если у вас есть большие объекты данных, которые не требуют глубокой реактивности, используйте shallowRef или shallowReactive :
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
| import { shallowRef } from 'vue'
// Только верхний уровень объекта будет реактивным
const bigData = shallowRef({
// Много данных...
})
// При обновлении нужно заменить весь объект
function updateData(newData) {
bigData.value = newData
} |
|
Работа с DOM напрямую, когда это оправдано
Иногда производительнее работать с DOM напрямую через refs:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| <template>
<canvas ref="canvasRef"></canvas>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const canvasRef = ref(null)
onMounted(() => {
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
// Работа с canvas API напрямую...
})
</script> |
|
Ленивая загрузка изображений
Для приложений с множеством изображений можно создать компонент ленивой загрузки:
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| <script setup>
import { ref, onMounted, watch, computed } from 'vue'
const props = defineProps({
src: String,
alt: String
})
const imageRef = ref(null)
const isVisible = ref(false)
const imageSrc = computed(() => isVisible.value ? props.src : '')
onMounted(() => {
const observer = new IntersectionObserver((entries) => {
const [entry] = entries
isVisible.value = entry.isIntersecting
})
if (imageRef.value) {
observer.observe(imageRef.value)
}
return () => {
if (imageRef.value) {
observer.unobserve(imageRef.value)
}
}
})
</script>
<template>
<img
ref="imageRef"
:src="imageSrc"
:alt="alt"
class="lazy-image"
/>
</template> |
|
Компоненты Vue 3 могут быть невероятно производительными, но только если вы знаете, как правильно их оптимизировать. Часто одно небольшое изменение в подходе может дать значительный прирост скорости.
Оптимизация режима разработки
При работе над большими проектами на Vue 3 я заметил, что скорость режима разработки может значительно снижаться. Вот несколько приемов, которые помогут ускорить процесс:
1. Используйте опцию skipHydration для компонентов, которые не требуют гидратации при SSR:
TypeScript | 1
2
3
4
5
| <script>
export default {
skipHydration: true
}
</script> |
|
2. Применяйте динамические импорты для неиспользуемых на текущей странице компонентов:
JavaScript | 1
2
| // вместо статического импорта
const DashboardAnalytics = () => import('./DashboardAnalytics.vue') |
|
3. Настройте горячую замену модулей (HMR) для более быстрого обновления при разработке:
JavaScript | 1
2
3
4
5
6
7
8
| // vite.config.js
export default {
server: {
hmr: {
overlay: false // отключите оверлей для ошибок, если он мешает
}
}
} |
|
Профилирование и мониторинг
Знаете, что отличает профессионалов от новичков? Умение анализировать производительность на основе данных, а не догадок. Для полноценного профилирования Vue-приложений используйте:
1. Performance Monitoring API
JavaScript | 1
2
3
| const app = createApp(App)
app.config.performance = true |
|
После включения этой опции вы сможете использовать вкладку Performance в DevTools для анализа времени рендеринга компонентов.
2. Используйте кастомные метрики
Для более глубокого понимания узких мест в приложении создавайте кастомные метрики:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // Для важных операций в приложении
function trackOperation(name, operation) {
if (window.performance && process.env.NODE_ENV !== 'production') {
const startMark = `start_${name}`
const endMark = `end_${name}`
performance.mark(startMark)
const result = operation()
performance.mark(endMark)
performance.measure(name, startMark, endMark)
console.log(`Operation ${name} took ${performance.getEntriesByName(name)[0].duration}ms`)
return result
}
return operation()
} |
|
Предзагрузка критических компонентов
Для улучшения пользовательского опыта можно реализовать умную предзагрузку компонентов:
JavaScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
| // Функция для предзагрузки маршрута при наведении на ссылку
const preloadRouteComponent = (routeName) => {
const route = router.resolve({name: routeName})
// Импортируем компонент маршрута заранее
import(/* @vite-ignore */ route.matched[0].components.default)
}
// Директива для использования в шаблоне
app.directive('route-preload', {
mounted(el, binding) {
el.addEventListener('mouseenter', () => preloadRouteComponent(binding.value))
}
}) |
|
Использование в шаблоне:
TypeScript | 1
2
3
| <router-link to="/dashboard" v-route-preload="'Dashboard'">
Панель управления
</router-link> |
|
Оптимизация рендеринга для больших наборов данных
При рендеринге огромных наборов данных, особенно в графиках или таблицах, можно использовать стратегию "рендеринг по требованию":
TypeScript | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| <script setup>
import { ref, computed, onMounted } from 'vue'
const allData = ref([]) // полный набор данных
const visibleData = ref([]) // данные, которые видны сейчас
const container = ref(null)
onMounted(() => {
// Загружаем первую порцию данных
visibleData.value = allData.value.slice(0, 50)
if (container.value) {
// Создаем Intersection Observer для подгрузки при прокрутке
const observer = new IntersectionObserver(loadMoreIfNeeded, {
root: null,
threshold: 0.1
})
observer.observe(container.value)
}
})
function loadMoreIfNeeded(entries) {
const entry = entries[0]
if (entry.isIntersecting && visibleData.value.length < allData.value.length) {
const currentLength = visibleData.value.length
const nextItems = allData.value.slice(currentLength, currentLength + 50)
visibleData.value = [...visibleData.value, ...nextItems]
}
}
</script> |
|
Вместе с пониманием компонентной модели Vue 3, ее возможностей и ограничений, знание оптимизации делает вас значительно более ценным разработчиком.
Vue-router не отображает компонент по ссылке Здравствуйте, столкнулся с определенной проблемой. Для перехода использую vue-router. Страницы описываю в файлах .vue. Так вот когда перехожу по... Как спрятать один компонент при клике по другому? написала выпадающий список
<div id="ddl-box">
<ps-ddl v-bind:src-arr=""></ps-ddl>
<ps-ddl v-bind:src-arr=""></ps-ddl> ... Как сделать в роуте, что бы родительский компонент редиректил к ошибке, но при этом работали дочерние? Как сделать в роуте, что бы родительский компонент редиректил к ошибке, но при этом работали дочерние?
Естественно, код ниже не работает.... Передача значения переменной c одного компонента в другой компонент Добрый день у меня задача передать в значения модели counterBuy в другой компонент по клике на counterBuy (-) (+)
Как реализовать чтобы ещё во... Передача в компонент и возврат Привет ребят. Только взялся за компоненты, помогите собрать в кучу.
В компонент передаю id вот так.
<td><otdel... Компонент Vue.js, который выводит две кнопки разного цвета с разными текстами, например, “Да”, “Нет” Подскажите как правильно сделайте компонент Vue.js, который выводит две кнопки разного цвета с разными текстами, например, “Да”, “Нет” ??? (Тексты... Реализовать переиспользованый компонент? Как реализовать компонент который может принимать картинку (картинка может быть любой), title, description. Главное чтобы компонент мог... Vue cli, как маштабировать компонент с разными размерами для каждой страницы Здраствуйте, у меня есть компонент vue 2 cli, он используется на разных страницах, как я могу правильно менять масштаб етого компонента на каждой... Как отобразить один компонент, но в разных местах сайта и с разным наполнением Поставлена задача: есть компонент с чекбоксами (с любым контентом), необходимо вывести его на странице сайта в нескольких местах, с разным... Не получается взять значение из input (компонент используется через v-for) Следующий компонент используется через v-for. Как потом обратиться к value из input-ов inputIdRank, inputIdBonus?
Просто обращаться по id не... Не выводится тестовый компонент Vue Я пытаюсь начать использовать Vue в проекте Laravel, но что-то идёт не так.
app.blade.php
<!DOCTYPE html>
<html lang="{{... Как подключить во VUE компонент СВОЙ ! UI? Привет.
Хочу в компоненте VUE работать с привычными js файлами, где могу писать document.querySelector..... и тд, а не в самом компоненте VUE и...
|