Как стать автором
Поиск
Написать публикацию
Обновить
599.28
OTUS
Развиваем технологии, обучая их создателей

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

Уровень сложностиПростой
Время на прочтение5 мин
Количество просмотров4.2K

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

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

Теги:
Хабы:
+14
Комментарии2

Публикации

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS