Vue Teleport: как работает

от автора

Привет, Хабр!

Сегодня разберём один из недооценённых, но крайне полезных инструментов во 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.

Хотите разобраться глубже? Рекомендуем два открытых вебинара:

Оба подойдут как для уверенного старта, так и для расширения представлений о возможностях фреймворка.

Актуальные обучающие программы по программированию найдёте в каталоге курсов OTUS. А чтобы не пропустить ближайшие открытые уроки — загляните в календарь открытых уроков.


ссылка на оригинал статьи https://habr.com/ru/articles/922610/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *