Перейти к содержимому

19. Teleport и Suspense

<Teleport> и <Suspense> — два мощных встроенных компонента Vue 3, которые решают задачи, с которыми раньше приходилось бороться вручную. Teleport телепортирует DOM-узлы за пределы компонентного дерева, а Suspense элегантно управляет асинхронными зависимостями. Разберём оба вдоль и поперёк! 🚀


Представь: ты делаешь модальное окно внутри глубоко вложенного компонента. Но модалка должна быть прикреплена к <body>, чтобы z-index работал корректно и она не обрезалась родительским overflow: hidden. Раньше это требовало хаков — порталы через document.body.appendChild. Vue 3 решает это нативно.

<template>
<button @click="open = true">Открыть модалку</button>
<Teleport to="body">
<div v-if="open" class="modal-overlay">
<div class="modal">
<h2>Я рендерюсь прямо в body!</h2>
<button @click="open = false">Закрыть</button>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue'
const open = ref(false)
</script>

Компонент логически остаётся внутри родителя, но DOM-узел физически перемещается в <body>. Это ключевое отличие — логическое дерево и DOM-дерево разделяются.


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

<!-- Телепорт в body -->
<Teleport to="body">...</Teleport>
<!-- Телепорт в элемент с id -->
<Teleport to="#modal-container">...</Teleport>
<!-- Телепорт в DOM-элемент динамически -->
<Teleport :to="targetElement">...</Teleport>

Телепорт в #modal-container требует, чтобы этот элемент существовал в DOM до монтирования компонента. Обычно его добавляют в index.html:

index.html
<div id="app"></div>
<div id="modal-container"></div>
<div id="toast-container"></div>

Иногда нужно условно отключить телепорт — например, при серверном рендеринге или для тестов. Используй :disabled:

<Teleport to="body" :disabled="isMobile">
<MobileMenu />
</Teleport>

Когда disabled: true, содержимое рендерится на месте без телепортации. Это удобно для адаптивного поведения.


Классический паттерн — тост-уведомления. Они должны быть поверх всего контента и в фиксированной позиции:

ToastContainer.vue
<template>
<Teleport to="#toast-container">
<TransitionGroup name="toast" tag="div" class="toasts">
<div
v-for="toast in toasts"
:key="toast.id"
class="toast"
:class="toast.type"
>
{{ toast.message }}
</div>
</TransitionGroup>
</Teleport>
</template>
<script setup>
import { useToastStore } from '@/stores/toast'
import { storeToRefs } from 'pinia'
const toastStore = useToastStore()
const { toasts } = storeToRefs(toastStore)
</script>

<Suspense> — экспериментальный (но стабильный в Vue 3.x) компонент для управления асинхронными зависимостями. Он показывает fallback-контент пока дочерние компоненты загружаются.

Без Suspense приходилось вручную отслеживать состояние загрузки в каждом компоненте:

<!-- Старый подход 😢 -->
<template>
<div v-if="loading">Загружаем...</div>
<UserProfile v-else :user="user" />
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loading = ref(true)
const user = ref(null)
onMounted(async () => {
user.value = await fetchUser()
loading.value = false
})
</script>

С Suspense это выглядит так:

<!-- Новый подход с Suspense 🎉 -->
<template>
<Suspense>
<template #default>
<UserProfile />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>

Для работы с Suspense компонент должен иметь асинхронный setup:

UserProfile.vue
<template>
<div>
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
<PostList :posts="posts" />
</div>
</template>
<script setup>
// Просто await в setup — и Suspense всё поймёт!
const user = await fetchUser()
const posts = await fetchUserPosts(user.id)
</script>

Vue автоматически обнаруживает await в setup() и сообщает родительскому Suspense о том, что компонент в процессе загрузки.


Suspense можно вкладывать друг в друга для более гранулярного управления загрузкой:

<template>
<Suspense>
<template #default>
<!-- Внешний слой: главные данные -->
<MainLayout>
<Suspense>
<template #default>
<!-- Внутренний слой: вторичные данные -->
<RecommendationPanel />
</template>
<template #fallback>
<SkeletonPanel />
</template>
</Suspense>
</MainLayout>
</template>
<template #fallback>
<PageSkeleton />
</template>
</Suspense>
</template>

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


Suspense сам по себе не ловит ошибки — для этого используй onErrorCaptured в родительском компоненте:

ErrorBoundary.vue
<script setup>
import { ref, onErrorCaptured } from 'vue'
const error = ref(null)
onErrorCaptured((err) => {
error.value = err
return false // предотвращаем дальнейшее распространение
})
</script>
<template>
<div v-if="error" class="error-boundary">
<h2>Что-то пошло не так 😔</h2>
<p>{{ error.message }}</p>
<button @click="error = null">Попробовать снова</button>
</div>
<slot v-else />
</template>

Используй ErrorBoundary вместе с Suspense:

<template>
<ErrorBoundary>
<Suspense>
<template #default>
<AsyncDashboard />
</template>
<template #fallback>
<DashboardSkeleton />
</template>
</Suspense>
</ErrorBoundary>
</template>

Suspense испускает три события:

<Suspense
@pending="onPending"
@resolve="onResolve"
@fallback="onFallback"
>
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<Spinner />
</template>
</Suspense>
<script setup>
function onPending() {
console.log('Начало загрузки...')
}
function onResolve() {
console.log('Загрузка завершена!')
analytics.track('content_loaded')
}
function onFallback() {
console.log('Показываем fallback')
}
</script>

Мощная комбинация — асинхронные модальные окна:

<template>
<Teleport to="body">
<Suspense v-if="showModal">
<template #default>
<AsyncModal @close="showModal = false" />
</template>
<template #fallback>
<div class="modal-skeleton">
<div class="skeleton-line" />
<div class="skeleton-line" />
</div>
</template>
</Suspense>
</Teleport>
</template>
<script setup>
import { ref, defineAsyncComponent } from 'vue'
const showModal = ref(false)
const AsyncModal = defineAsyncComponent(() =>
import('./HeavyModal.vue')
)
</script>

Всегда оборачивай Suspense в ErrorBoundary — асинхронные компоненты могут падать.

Используй timeout через defineAsyncComponent для контроля UX:

<script setup>
import { defineAsyncComponent } from 'vue'
const AsyncChart = defineAsyncComponent({
loader: () => import('./Chart.vue'),
loadingComponent: ChartSkeleton,
errorComponent: ChartError,
delay: 200, // задержка перед показом loading
timeout: 5000 // ошибка, если не загрузился за 5 сек
})
</script>

Teleport и SSR: На сервере <Teleport> отключён по умолчанию, но работает при гидратации.


import { mount } from '@vue/test-utils'
import { nextTick } from 'vue'
test('Modal телепортируется в body', async () => {
const wrapper = mount(MyComponent, {
attachTo: document.body
})
await wrapper.find('button').trigger('click')
await nextTick()
// Телепортированный контент находится в body, не в wrapper
expect(document.body.querySelector('.modal')).toBeTruthy()
})
test('Suspense показывает fallback', async () => {
const wrapper = mount(SuspenseWrapper)
// Сразу виден fallback
expect(wrapper.text()).toContain('Загружаем...')
await flushPromises()
// После загрузки виден контент
expect(wrapper.text()).toContain('Данные загружены')
})