Teleport и обработка ошибок

Урок про два независимых, но одинаково практичных инструмента: Teleport, переносящий разметку в другое место DOM, и onErrorCaptured, превращающий компонент в границу ошибок.

Teleport — встроенный компонент Vue, который рендерит своё содержимое в указанный элемент DOM (например, в конец <body>), оставляя его при этом логически внутри текущего компонента.

Есть класс UI-элементов, которые логически принадлежат компоненту, но физически должны жить наверху страницы: модальные окна, выпадающие меню, тултипы, всплывающие уведомления. Проблема в том, что родитель часто зажат стилями — overflow: hidden обрезает выпадашку, transform ломает position: fixed, низкий z-index прячет модалку под соседями. Бороться с этим вёрсткой мучительно. Teleport решает задачу элегантно: разметка остаётся в вашем компоненте (с его состоянием и обработчиками), но в реальном DOM рендерится туда, куда вы скажете — обычно прямо в <body>, вне всех ограничивающих контейнеров.

Вторая половина урока — про надёжность. Ошибка в одном компоненте не должна обрушивать весь интерфейс. Vue даёт границы ошибок: компонент-предок может перехватить ошибку из любого потомка через хук onErrorCaptured и показать запасной UI вместо «белого экрана». Разберём оба инструмента — они часто соседствуют (модалка с обработкой сбоя внутри).

Зачем это на практике

Teleport — стандартное решение для всего, что должно «всплывать над» интерфейсом: модальные диалоги, тосты, тултипы, контекстные меню. Без него приходится поднимать такие элементы в корень приложения вручную и тащить туда состояние — Teleport убирает эту боль. Границы ошибок же — про устойчивость: в проде один сбойный виджет (упал сторонний график, пришли битые данные) не должен ронять всю страницу. Граница локализует поломку, показывает «что-то пошло не так» только в проблемной зоне и логирует ошибку в мониторинг, пока остальное приложение работает.

Teleport для модалок и тултипов

Содержимое <Teleport> переносится в элемент, указанный в атрибуте to (CSS-селектор). Чаще всего это body:

<!-- Modal.vue -->
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>

<template>
  <button @click="open = true">Открыть модалку</button>

  <!-- разметка логически здесь, но в DOM окажется в конце body -->
  <Teleport to="body">
    <div v-if="open" class="overlay" @click.self="open = false">
      <div class="dialog">
        <p>Я отрисован в body, поверх всего</p>
        <button @click="open = false">Закрыть</button>
      </div>
    </div>
  </Teleport>
</template>

Ключевая идея: open, @click, реактивность — всё работает, как будто разметка на месте, потому что логически она и есть внутри Modal. Но в реальном DOM этот <div> лежит в конце <body>, вне любых overflow: hidden и transform родителей — поэтому оверлей честно накрывает весь экран, а z-index ведёт себя предсказуемо. Атрибутом :disabled Teleport можно временно «выключить» (рендерить на месте) — например, для адаптива, когда на мобильном перенос не нужен.

Если в один и тот же to телепортируют несколько компонентов (две модалки, тост), их содержимое складывается в этот контейнер в порядке монтирования — это нормально и ожидаемо.

onErrorCaptured и границы ошибок

Хук onErrorCaptured регистрируется в setup предка и вызывается, когда в любом его потомке (на любой глубине) происходит ошибка — при рендере, в обработчике, в хуке жизненного цикла, в отклонённом промисе. Получив ошибку, предок может переключиться в режим «что-то сломалось».

<!-- ErrorBoundary.vue — переиспользуемая граница ошибок -->
<script setup>
import { ref, onErrorCaptured } from 'vue'

const failed = ref(false)
const message = ref('')

onErrorCaptured((err) => {
  failed.value = true
  message.value = err.message
  // вернув false, останавливаем «всплытие» ошибки выше
  return false
})
</script>

<template>
  <div v-if="failed" class="fallback">
    <p>Не удалось отобразить блок: {{ message }}</p>
  </div>
  <!-- пока всё хорошо — показываем настоящий контент -->
  <slot v-else />
</template>

Теперь любой рискованный кусок можно обернуть этой границей, и его падение не тронет остальную страницу:

<template>
  <ErrorBoundary>
    <RiskyWidget />  <!-- если упадёт — увидим fallback, а не белый экран -->
  </ErrorBoundary>
</template>

Важна возвращаемая величина хука. Если onErrorCaptured вернул false, ошибка считается обработанной и не всплывает к более высоким границам и глобальному обработчику. Если ничего не вернуть (или вернуть не-false), ошибка продолжит подниматься вверх по дереву — её сможет поймать ещё одна, внешняя граница. Это позволяет строить вложенные границы разной зернистости.

Как это работает под капотом

Teleport на этапе рендера создаёт «якорь» на исходном месте в виртуальном дереве, а реальные DOM-узлы вставляет в целевой элемент to. Связь между компонентом и его узлами сохраняется: обновления, обработчики событий, реактивность идут через виртуальный DOM как обычно — Vue просто знает, что физически узлы находятся в другом контейнере. Поэтому компонент-владелец продолжает «командовать» телепортированной разметкой, хотя в инспекторе она лежит в <body>. При размонтировании компонента Vue убирает узлы из целевого контейнера — за собой телепорт прибирает сам.

Ошибки распространяются по дереву компонентов снизу вверх. Когда в компоненте возникает ошибка, Vue идёт вверх по цепочке родителей и на каждом ищет зарегистрированный onErrorCaptured, вызывая их по очереди. Хук получает три аргумента: саму ошибку, экземпляр компонента-источника и строку с описанием места (тип хука/операции). Всплытие останавливается, как только какой-то хук вернул false. Если до корня никто не вернул false, ошибка доходит до глобального app.config.errorHandler (а его обычно подключают к системе мониторинга). Этот механизм похож на try/catch, но для дерева компонентов: граница «ловит» сбой поддерева целиком.

Частые ошибки

Думать, что Teleport переносит и состояние/контекст. Переносятся только DOM-узлы. Логически компонент остаётся на месте: provide/inject, доступ к данным, события — всё работает из исходной позиции в дереве, а не из <body>.

Teleport в несуществующий to. Если целевого элемента ещё нет в DOM на момент монтирования, Teleport выдаст предупреждение и ничего не отрисует. Цель (#modals, body) должна существовать заранее.

Ждать, что граница поймает ошибку из своего же setup. onErrorCaptured ловит ошибки потомков, а не того компонента, где сам объявлен. Сбой в собственном setup границы он не перехватит — для этого нужна граница уровнем выше.

Глотать ошибку молча. Вернуть false и не залогировать — значит спрятать проблему. В границе всегда логируйте ошибку (в консоль или мониторинг), показывая пользователю fallback, иначе баги станут невидимыми.

Полагаться, что граница поймает всё асинхронное. Ошибка из «оторванного» колбэка (например, из setTimeout или необработанного промиса вне рендер-контекста Vue) может не попасть в onErrorCaptured. Асинхронные операции оборачивайте в try/catch сами.

Итоги

  • <Teleport to="body"> рендерит содержимое в указанный элемент DOM, оставляя его логически внутри компонента — идеален для модалок, тултипов и тостов, которым мешают стили родителя.
  • Переносятся только DOM-узлы: реактивность, обработчики и provide/inject работают из исходного места в дереве; атрибут :disabled отключает перенос.
  • onErrorCaptured в предке делает его границей ошибок: ловит сбои любого потомка (рендер, обработчики, хуки) и позволяет показать запасной UI.
  • Возврат false из хука останавливает всплытие ошибки; иначе она поднимается к внешним границам и глобальному app.config.errorHandler.
  • Граница ловит ошибки только потомков (не собственного setup) и не гарантирует перехват «оторванных» асинхронных колбэков — их оборачивайте в try/catch.
Проверьте себя
1. Что переносит <Teleport to="body">?
AИ DOM-узлы, и реактивное состояние компонента в body
BТолько DOM-узлы в целевой элемент; логически содержимое остаётся внутри компонента
CВесь компонент целиком, разрывая связь с родителем
DТолько CSS-стили содержимого
2. Какой хук делает компонент границей ошибок для своих потомков?
AonMounted
BonErrorCaptured
ConUnmounted
DwatchEffect
3. Что произойдёт, если onErrorCaptured вернёт false?
AОшибка будет проигнорирована и потеряна навсегда без возможности логирования
BОшибка считается обработанной и не всплывает к внешним границам и глобальному обработчику
CКомпонент перезагрузится автоматически
DОшибка повторно выбросится в том же компоненте