Привет, Хабр!
Сегодня разберём один из недооценённых, но крайне полезных инструментов во Vue 3 — <Teleport>. Это встроенный механизм, который позволяет рендерить часть шаблона вне текущего DOM-контекста. Он нужен при реализации модалок, тултипов и других компонентов, которые должны «выпрыгивать» из дерева компонентов, но при этом сохранять реактивность, фокус и доступность. Без этих костылей, z-index: 9999 и appendChild.
Что такое Teleport
Teleport — это встроенная обёртка, которая даёт Vue-интерпретатору команду: «отрендери вот этот кусок не там, где он в шаблоне, а в указанной точке глобального DOM». Но в отличие от document.body.appendChild(...), Teleport не рвёт реактивность и не теряет context. Он работает внутри Virtual DOM.
На этапе рендера Vue создает vnode специального типа — Teleport. Это отдельный вид виртуальной ноды, такой же, как Fragment, Text или Component. Он содержит внутри себя поддерево с дочерними элементами, а также ссылку на DOM-цель (to), в которую нужно телепортировать контент. Пока что это просто декларация: вот здесь рендерим, но направляем вон туда.
Дальше, на стадии монтирования, Vue начинает действовать. Сначала парсится атрибут to — это может быть селектор (#modal-root, body и т. д.) или нативная DOM-нода. Если указанный элемент не найден — Teleport временно не монтируется (или отложенно, если используется defer). Как только таргет доступен, Vue создаёт два участка DOM: один — “на месте” в компоненте (может остаться пустым или для placeholder), второй — реальный рендер в to. Этот внешний блок подключается к общей реактивной системе.
При этом сохраняется весь родительский компонентный контекст: inject/provide, refs, slots, все реактивные привязки продолжают работать. Вы просто меняете физическое местоположение DOM-ноды, не прерывая логику Vue. При размонтировании Teleport корректно удаляет свои ноды, как и обычный компонент, независимо от того, где они были размещены в реальном дереве.
Допустим, вы рендерите модалку внутри вложенного компонента. Без Teleport:
Модалка унаследует все ограничения по
overflow: hidden,z-index,transform,position: relativeот родителей.Фокус и tab-переходы могут быть ограничены.
aria-*атрибуты могут конфликтовать с вложенностью.В SSR могут возникнуть ошибки при гидрации, особенно если DOM разъезжается.
С Teleport всё проще: вы рендерите модалку физически в body, в корень документа, но при этом оставляете всю реактивную логику и компоненты в том месте, где они определены.
Базовый Teleport:
<template>
<Teleport to="body">
<div class="modal">Я рендерюсь в <body>, но нахожусь внутри компонента</div>
</Teleport>
</template>Атрибуты:
to: обязательный. Селектор или DOM-нода.disabled: еслиtrue— рендерит на месте, не телепортирует.defer: откладывает рендеринг до появления цели .
Практическая реализация модалки
Теперь соберём собственную модалку, которая:
телепортируется в
body, независимо от положения в дереве компонентов;корректно блокирует прокрутку страницы;
управляет фокусом (включая возврат к предыдущему элементу);
закрывается по клику на фон и клавише
Escape;сохраняет доступность (через
aria-*);дружит с SSR и гидрацией;
легко тестируется в
@vue/test-utils;опционально поддерживает focus trap.
Код:
<script setup lang="ts">
import { watch, onMounted, onBeforeUnmount, ref } from 'vue'
import { useFocusTrap } from '@vueuse/core'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: boolean): void }>()
const modal = ref<HTMLElement | null>(null)
const previousFocus = ref<HTMLElement | null>(null)
// Focus trap из @vueuse/core
const trap = useFocusTrap(modal, { immediate: false })
function lockScroll() {
document.body.style.overflow = 'hidden'
}
function unlockScroll() {
document.body.style.overflow = ''
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('update:modelValue', false)
}
}
function handleOpen(open: boolean) {
if (open) {
previousFocus.value = document.activeElement as HTMLElement
lockScroll()
modal.value?.focus()
window.addEventListener('keydown', onKeydown)
trap.value?.activate()
} else {
trap.value?.deactivate()
unlockScroll()
window.removeEventListener('keydown', onKeydown)
previousFocus.value?.focus()
}
}
watch(() => props.modelValue, handleOpen, { immediate: true })
onBeforeUnmount(() => handleOpen(false))
</script>
<template>
<Teleport to="body" :disabled="!modelValue">
<Transition name="fade">
<div
v-if="modelValue"
class="modal-backdrop"
@click.self="emit('update:modelValue', false)"
>
<div
ref="modal"
class="modal-content"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="-1"
>
<slot />
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-content {
background: white;
padding: 24px;
border-radius: 8px;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>Teleport рендерит модалку напрямую в body, обходя ограничения вложенности, z-index и локальных overflow. Атрибут @click.self обеспечивает закрытие по клику на фон, но не на содержимое окна. Роль dialog и aria-modal="true" повышают доступность, делая модалку понятной для скринридеров. tabindex="-1" позволяет вручную захватить фокус, а useFocusTrap() из @vueuse/core удерживает его внутри окна. Блокировка скролла достигается через document.body.style.overflow = 'hidden', а клавиша Escape закрывает модалку глобально, без дополнительной логики в родителях.
Teleport работает и в SSR, но важно учесть:
DOM-цель (
bodyили#modal-root) должна присутствовать на сервере. Если её нет — Vue при гидрации выдаст предупреждение о несовпадении дерева.Чтобы безопасно использовать модалку на клиенте, можно применить
defer.
Пример:
<Teleport to="body" defer>
<MyModal :model-value="open" />
</Teleport>Teleport создаёт реальные DOM-ноды, поэтому при тестировании нужно явно указать attachTo. Без этого @vue/test-utils не увидит телепортированный контент.
import { mount } from '@vue/test-utils'
import Modal from '@/components/Modal.vue'
test('закрывается при клике на фон', async () => {
const wrapper = mount(Modal, {
props: { modelValue: true },
attachTo: document.body
})
const backdrop = wrapper.find('.modal-backdrop')
await backdrop.trigger('click')
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})Можно также проверить поведение фокуса, нажатие Escape и даже срабатывание useFocusTrap (если мокнуть зависимости).
Если вы уже использовали Teleport в своих проектах — поделитесь, с какими кейсами сталкивались и какие проблемы решали.
Если вы работаете с Vue или только планируете перейти на него с JavaScript, обязательно обратите внимание на механизм <Teleport> — он помогает реализовать модальные окна, тултипы и другие компоненты без хака с z-index и appendChild, сохраняя всю мощь реактивности Vue 3.
Хотите разобраться глубже? Рекомендуем два открытых вебинара:
«Как быстро освоить Vue, если уже знаете JavaScript» — 8 июля в 20:00
«Vue умеет проще: пишем игру, пока React грузит стейт» — 16 июля в 20:00
Оба подойдут как для уверенного старта, так и для расширения представлений о возможностях фреймворка.
Актуальные обучающие программы по программированию найдёте в каталоге курсов OTUS. А чтобы не пропустить ближайшие открытые уроки — загляните в календарь открытых уроков.
