Телепортація у Vue.js
Teleport - це вбудований компонент Vue 3, який рендерить контент у будь-який DOM-вузол поза деревом компонентів.
Теорія
TL;DR
- Teleport схожий на пряму доставку: контент пишеш в одному місці, Vue відносить DOM до потрібної адреси
- Контент переїжджає до
to-цілі, але залишається під управлінням Vue (реактивність, пропси, події, provide/inject) - Дерево компонентів Vue не змінюється, змінюється тільки позиція в DOM
- Використовуй коли батьківський CSS (overflow, z-index stacking context) ламає перекриваючі елементи
- Пропускай коли
position: fixedвирішує задачу без конфліктів стилів
Швидкий приклад
<template>
<button @click="show = true">Відкрити</button>
<Teleport to="body">
<div v-if="show" class="modal" @click.self="show = false">
Клік за межами закриє вікно
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const show = ref(false)
</script>
<style scoped>
.modal { position: fixed; inset: 0; background: rgba(0,0,0,0.5); }
</style>Модальне вікно рендериться як прямий нащадок <body>, а не всередині батьківського компонента. При цьому v-if і обробник кліку реагують на show з оригінального скоупу компонента.
Ключова різниця
Teleport переміщує реальний DOM до to-селектора під час монтування. Інстанс компонента і реактивність залишаються в оригінальному дереві Vue. Тобто v-model, пропси, події та provide/inject працюють як звичайно. CSS-успадкування починається від нового батьківського елемента, а не від оригінального, що звільняє телепортований контент від стилів батька.
Коли використовувати
- Модальні вікна і діалоги:
to="body"вирішує проблеми z-index, які виникають через батьківські stacking context - Тултіпи і поповери: виходить за межі
overflow: hiddenв контейнерах-предках - Сповіщення:
to="#notifications"складає їх без впливу на потік лейауту - Дропдауни в rich-text редакторах: не обрізаються контейнером редактора
Переважна більшість z-index проблем у Vue-дашбордах трапляється через модалки всередині карточок з overflow: hidden. Один <Teleport to="body"> вирішує це за секунду. Якщо position: fixed справляється без конфліктів - Teleport не потрібен.
Як Vue обробляє Teleport всередині
Під час монтування Vue-рендерер витягує дочірні VNode з Teleport, викликає document.querySelector(to) щоб знайти ціль, і додає реальний DOM через appendChild або insertBefore. Оновлення реактивності все одно потрапляють до переміщених вузлів через внутрішній прапор vnode. При розмонтуванні Vue автоматично видаляє DOM з цілі. Якщо :disabled="true" - переміщення не відбувається, контент рендериться на місці.
Часті помилки
1. Телепортація до селектора який не існує
<!-- Неправильно: контент стає невидимим -->
<Teleport to=".missing-class">
<div class="modal">...</div>
</Teleport>querySelector повертає null, контент рендериться inline але може бути повністю невидимим. Використовуй body або гарантовано існуючий елемент. Це найпоширеніша помилка з Teleport на StackOverflow.
2. Очікування що scoped-стилі підуть за телепортованим контентом
<!-- Неправильно: стиль .modal не спрацює після телепортації -->
<Teleport to="body">
<div class="modal">...</div>
</Teleport>
<style scoped>
.modal { opacity: 1; } /* CSS залишається прив'язаним до оригінального місця монтування */
</style>Scoped-стилі додають атрибут data-v-xxxxx до DOM-вузла і CSS-селектора. Телепортований вузол зберігає атрибут, але стилі на рівні body можуть перекривати твої. Рішення: глобальний блок <style> або :deep() для всього що має застосовуватись після переміщення.
3. Ігнорування SSR
На сервері немає body для пошуку. Vue рендерить контент Teleport inline під час SSR. Якщо клієнт потім телепортує, виникає помилка гідратації (hydration mismatch). Вимикай Teleport до завершення монтування:
<script setup>
import { ref, onMounted } from 'vue'
const isMounted = ref(false)
onMounted(() => { isMounted.value = true })
</script>
<template>
<Teleport to="body" :disabled="!isMounted">
<div class="toast">Збережено!</div>
</Teleport>
</template>4. Вкладені Teleport без disabled в SSR
Внутрішній Teleport визначає to-селектор відносно DOM-цілі зовнішнього Teleport, а не оригінальної позиції в шаблоні. В SSR це призводить до помилок гідратації. Вимикай внутрішні Teleport на сервері так само як описано вище.
Де зустрічається в реальних проектах
- Vuetify 3: всі
v-dialogіv-menuвикористовують<Teleport to="body"> - Element Plus:
ElMessageіElNotificationтелепортуються до кастомного#app-кореня - Naive UI:
n-messageдодається напряму доdocument.body - Quasar:
QDialogпід капотом використовує body як ціль
У React є createPortal для тієї ж задачі, але він потребує ручного очищення при розмонтуванні. Vue Teleport видаляє DOM автоматично.
Запитання на співбесіді
Q: Яка різниця між Teleport і position: fixed?
A: position: fixed все одно залежить від батьківського stacking context якщо батько має transform, filter або will-change. Teleport фізично переміщує DOM до цільового елемента, тому залежить тільки від його контексту накладання.
Q: Як Teleport поводиться якщо to-селектор відповідає кільком елементам?
A: Додає до першого елемента з querySelector. Використовуй унікальні ID для передбачуваного розміщення.
Q: Що відбувається під час SSR?
A: Vue рендерить контент Teleport inline на сервері. На клієнті переміщує DOM під час монтування. Прив'яжи :disabled до прапора з onMounted щоб уникнути помилок гідратації.
Q: Чи впливає Teleport на продуктивність?
A: Мінімально. Кожен Teleport виконує одне переміщення DOM при монтуванні. Уникай створення сотень окремих Teleport - краще об'єднуй сповіщення в один спільний контейнер.
Q: (Senior) Чому вкладений Teleport визначає to відносно цілі зовнішнього Teleport?
A: to внутрішнього Teleport обчислюється відносно живого DOM в момент монтування. Оскільки зовнішній Teleport вже перемістив своє піддерево, внутрішній резолвиться відносно того переміщеного DOM, а не оригінальної позиції в дереві компонентів.
Приклади
Базовий: модальне вікно що виходить за межі контейнера
<template>
<div class="card" style="overflow: hidden; position: relative;">
<button @click="open = true">Редагувати</button>
<Teleport to="body">
<div v-if="open" class="overlay" @click.self="open = false">
<div class="dialog">
<h2>Редагування</h2>
<button @click="open = false">Закрити</button>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
display: flex;
align-items: center;
justify-content: center;
}
.dialog {
background: white;
padding: 24px;
border-radius: 8px;
}
</style>Без Teleport overflow: hidden на .card повністю обріже модальне вікно. Teleport переміщує діалог до <body> і він рендериться коректно. Стиль тут глобальний, не scoped, бо після переміщення scoped-стилі не допомагають.
Середній: тост-сповіщення з захистом від SSR
<!-- NotificationToast.vue -->
<template>
<Teleport to="#notifications" :disabled="!mounted">
<Transition name="fade">
<div v-if="visible" class="toast" role="alert">{{ message }}</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({ message: String })
const visible = ref(true)
const mounted = ref(false)
onMounted(() => {
mounted.value = true
setTimeout(() => { visible.value = false }, 3000)
})
</script><!-- index.html -->
<div id="app"></div>
<div id="notifications"></div>Кілька тостів можуть телепортуватись до одного #notifications і складатись у порядку монтування. Transition працює коректно бо Vue керує станом анімації на рівні компонента, а не переміщеного DOM. Прапор mounted запобігає помилкам гідратації в SSR.
Просунутий: перевикористовуваний Modal з правильним lifecycle
<!-- Modal.vue -->
<template>
<Teleport to="body" :disabled="!isMounted">
<Transition name="fade">
<div v-if="isOpen" class="modal-overlay" @click.self="emit('close')">
<div class="modal-content" role="dialog">
<button class="close-btn" @click="emit('close')">x</button>
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({ isOpen: Boolean })
const emit = defineEmits(['close'])
const isMounted = ref(false)
onMounted(() => { isMounted.value = true })
</script>
<style>
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 8px;
max-width: 500px;
position: relative;
}
.close-btn {
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
}
</style>Цей патерн закриває три проблеми які часто зустрічаються в продакшені. Teleport виходить за межі батьківських overflow і z-index. Transition анімується правильно бо Vue відстежує стан анімації на рівні компонента. А :disabled="!isMounted" синхронізує SSR і клієнтський рендеринг. Саме такий підхід використовує Vuetify 3 для всіх v-dialog.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.