Привет, Хабр!
Сегодня разберём один из недооценённых, но крайне полезных инструментов во 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. А чтобы не пропустить ближайшие открытые уроки — загляните в календарь открытых уроков.