← Курс/Teleport: рендер вне компонентного дерева#229 из 257+25 XP

Teleport: рендер вне компонентного дерева

Проблема: CSS stacking context

Представьте модальное окно внутри глубоко вложенного компонента. Родительские элементы могут иметь overflow: hidden, z-index или transform, которые ограничивают позиционирование модального окна — оно будет обрезано или перекрыто.

Традиционное решение — рендерить модалку напрямую в <body>. Именно это делает Teleport.

Базовый синтаксис

<template>
  <button @click="isOpen = true">Открыть</button>

  <!-- Всё внутри <Teleport> будет отрендерено в <body>, -->
  <!-- но логически остаётся частью этого компонента -->
  <Teleport to="body">
    <div v-if="isOpen" class="modal-overlay">
      <div class="modal">
        <h2>Модальное окно</h2>
        <p>Это содержимое находится в body DOM</p>
        <button @click="isOpen = false">Закрыть</button>
      </div>
    </div>
  </Teleport>
</template>

Атрибут to

Принимает CSS-селектор или DOM-элемент:

<!-- По CSS-селектору -->
<Teleport to="#modal-container">...</Teleport>
<Teleport to=".popup-root">...</Teleport>

<!-- Динамически -->
<Teleport :to="targetElement">...</Teleport>

<!-- В head для мета-тегов -->
<Teleport to="head">
  <title>Динамический заголовок</title>
</Teleport>

disabled — условное отключение

<!-- На мобильных — встроен в дерево, на десктопе — в body -->
<Teleport to="body" :disabled="isMobile">
  <Sidebar />
</Teleport>

При disabled=true содержимое рендерится на месте (без телепортации).

Несколько Teleport в один целевой элемент

<!-- Первый уведомление -->
<Teleport to="#notifications">
  <div class="toast">Файл сохранён</div>
</Teleport>

<!-- Второе уведомление от другого компонента -->
<Teleport to="#notifications">
  <div class="toast">Новое сообщение</div>
</Teleport>

Содержимое добавляется в целевой элемент **в порядке появления в DOM**. Первый Teleport идёт первым.

Deferred Teleport (Vue 3.5+)

Если целевой элемент ещё не существует в момент рендера, используйте defer:

<Teleport defer to="#late-mount-target">
  <MyComponent />
</Teleport>

<!-- Целевой элемент создаётся позже -->
<div id="late-mount-target"></div>

Важные детали

**Реактивность и события сохраняются** — несмотря на другое место в DOM, компонент в Teleport:

  • Получает данные от родителя через props/provide
  • Может эмитировать события
  • Является частью жизненного цикла родителя
  • // Внутри телепортированного компонента — всё работает:
    const emit = defineEmits(['close'])
    const parentData = inject('parentKey')

    **Стили не телепортируются** — CSS из scoped стилей применится корректно, но будьте осторожны с :global стилями.

    Практические применения

  • Модальные окна и диалоги
  • Тосты и уведомления
  • Тултипы и дропдауны (избегание overflow: hidden)
  • Sidebar'ы, выезжающие на весь экран
  • Порталы для рекламы или виджетов
  • Примеры

    Реализация паттерна "портал" — рендеринг контента вне текущего контейнера, аналог Teleport

    // Эмулируем механику Teleport без браузера:
    // компонент логически "живёт" в одном месте,
    // но его вывод перенаправляется в другое.
    
    // --- Виртуальный DOM (упрощённый) ---
    class VNode {
      constructor(tag, props = {}, children = []) {
        this.tag = tag
        this.props = props
        this.children = children
      }
    
      toString(indent = 0) {
        const pad = ' '.repeat(indent)
        const attrs = Object.entries(this.props)
          .map(([k, v]) => ` ${k}="${v}"`)
          .join('')
        const inner = this.children
          .map(c => typeof c === 'string' ? pad + '  ' + c : c.toString(indent + 2))
          .join('\n')
        return `${pad}<${this.tag}${attrs}>${inner ? '\n' + inner + '\n' + pad : ''}</${this.tag}>`
      }
    }
    
    // --- "Дерево DOM" ---
    class DOMTree {
      constructor() {
        this.nodes = new Map()  // id → VNode
        this.root = new VNode('body', { id: 'body' })
        this.nodes.set('body', this.root)
      }
    
      addContainer(id, parentId = 'body') {
        const node = new VNode('div', { id })
        this.nodes.set(id, node)
        const parent = this.nodes.get(parentId)
        if (parent) parent.children.push(node)
        return node
      }
    
      getContainer(selector) {
        // Простейший селектор: #id
        const id = selector.startsWith('#') ? selector.slice(1) : selector
        return this.nodes.get(id)
      }
    
      print() { console.log(this.root.toString()) }
    }
    
    // --- Teleport реализация ---
    class Teleport {
      constructor(dom, options = {}) {
        this.dom = dom
        this.to = options.to || 'body'
        this.disabled = options.disabled || false
        this.content = null
        this._mountedIn = null
      }
    
      render(content, localContainer) {
        this.content = content
    
        const target = this.disabled
          ? localContainer
          : this.dom.getContainer(this.to)
    
        if (!target) {
          throw new Error(`Teleport: целевой элемент "${this.to}" не найден`)
        }
    
        target.children.push(content)
        this._mountedIn = target
    
        console.log(
          this.disabled
            ? `[Teleport disabled] контент добавлен в локальный контейнер`
            : `[Teleport] контент телепортирован в "${this.to}"`
        )
        return this
      }
    
      destroy() {
        if (this._mountedIn && this.content) {
          const idx = this._mountedIn.children.indexOf(this.content)
          if (idx !== -1) this._mountedIn.children.splice(idx, 1)
          console.log('[Teleport] контент удалён из DOM при unmount компонента')
        }
      }
    }
    
    // --- Симуляция компонентного дерева ---
    const dom = new DOMTree()
    
    // Структура страницы
    const app    = dom.addContainer('app')
    const header = dom.addContainer('header', 'app')
    const main   = dom.addContainer('main', 'app')
    const deep   = dom.addContainer('deep-nested', 'main')
    const notifs = dom.addContainer('notifications', 'body')
    
    console.log('=== Исходная структура ===')
    dom.print()
    
    // Компонент рендерит модалку через Teleport в body
    const modal = new VNode('div', { class: 'modal' }, ['Модальное содержимое'])
    const t1 = new Teleport(dom, { to: '#app' })
    t1.render(modal, deep)
    
    // Тосты в #notifications
    const toast1 = new VNode('div', { class: 'toast' }, ['Файл сохранён'])
    const toast2 = new VNode('div', { class: 'toast' }, ['Новое сообщение'])
    new Teleport(dom, { to: '#notifications' }).render(toast1, null)
    new Teleport(dom, { to: '#notifications' }).render(toast2, null)
    
    console.log('\n=== После телепортации ===')
    dom.print()
    
    // disabled — рендер на месте
    console.log('\n=== Teleport disabled ===')
    const inlinePopup = new VNode('div', { class: 'popup' }, ['Встроенный попап'])
    const t2 = new Teleport(dom, { to: '#notifications', disabled: true })
    t2.render(inlinePopup, deep)
    dom.print()
    
    // Destroy — уничтожение компонента убирает контент
    console.log('\n=== После destroy модалки ===')
    t1.destroy()
    dom.print()