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