19. Teleport и Suspense
🌀 Урок 20 — Teleport и Suspense в Vue 3
Заголовок раздела «🌀 Урок 20 — Teleport и Suspense в Vue 3»<Teleport> и <Suspense> — два мощных встроенных компонента Vue 3, которые решают задачи, с которыми раньше приходилось бороться вручную. Teleport телепортирует DOM-узлы за пределы компонентного дерева, а Suspense элегантно управляет асинхронными зависимостями. Разберём оба вдоль и поперёк! 🚀
🧩 Что такое Teleport?
Заголовок раздела «🧩 Что такое Teleport?»Представь: ты делаешь модальное окно внутри глубоко вложенного компонента. Но модалка должна быть прикреплена к <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 в Teleport
Заголовок раздела «🎯 Атрибут to в Teleport»to принимает CSS-селектор или DOM-элемент:
<!-- Телепорт в body --><Teleport to="body">...</Teleport>
<!-- Телепорт в элемент с id --><Teleport to="#modal-container">...</Teleport>
<!-- Телепорт в DOM-элемент динамически --><Teleport :to="targetElement">...</Teleport>Телепорт в #modal-container требует, чтобы этот элемент существовал в DOM до монтирования компонента. Обычно его добавляют в index.html:
<div id="app"></div><div id="modal-container"></div><div id="toast-container"></div>🔧 Отключение Teleport
Заголовок раздела «🔧 Отключение Teleport»Иногда нужно условно отключить телепорт — например, при серверном рендеринге или для тестов. Используй :disabled:
<Teleport to="body" :disabled="isMobile"> <MobileMenu /></Teleport>Когда disabled: true, содержимое рендерится на месте без телепортации. Это удобно для адаптивного поведения.
📬 Уведомления (Toast) через Teleport
Заголовок раздела «📬 Уведомления (Toast) через Teleport»Классический паттерн — тост-уведомления. Они должны быть поверх всего контента и в фиксированной позиции:
<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?
Заголовок раздела «⏳ Что такое Suspense?»<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>🔩 Async Setup компонентов
Заголовок раздела «🔩 Async Setup компонентов»Для работы с Suspense компонент должен иметь асинхронный setup:
<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
Заголовок раздела «🪺 Вложенные 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 не блокирует внешний. Главный контент появится раньше, а рекомендации — когда загрузятся.
❌ Обработка ошибок: onErrorCaptured
Заголовок раздела «❌ Обработка ошибок: onErrorCaptured»Suspense сам по себе не ловит ошибки — для этого используй onErrorCaptured в родительском компоненте:
<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: pending, resolve, fallback
Заголовок раздела «🔄 События Suspense: pending, resolve, fallback»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>🎭 Teleport + Suspense вместе
Заголовок раздела «🎭 Teleport + Suspense вместе»Мощная комбинация — асинхронные модальные окна:
<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> отключён по умолчанию, но работает при гидратации.
🧪 Тестирование Teleport и Suspense
Заголовок раздела «🧪 Тестирование Teleport и Suspense»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('Данные загружены')})