Vue 3 introduced two built-in components: Teleport and Suspense. One solves the problem of nested DOM structure, and the other handles the loading state of async components. Both are used very frequently in real projects — Teleport in particular is practically standard for modal components.
Teleport: Rendering Components Anywhere in the DOM
Why Teleport Is Needed
When building overlay components like modals, toasts, and drawers, we often run into a problem: the component's DOM is nested inside its parent, but we need it to render under body, otherwise it gets affected by overflow: hidden or z-index.
The traditional solutions were to manipulate the DOM manually or use a Portal library. Vue 3's Teleport is the native solution.
<template>
<div class="modal-wrapper">
<!-- 这个按钮在当前组件内 -->
<button @click="showModal = true">打开弹窗</button>
<!-- 弹窗 DOM 实际会被渲染到 body 下面 -->
<Teleport to="body">
<div v-if="showModal" class="modal-overlay" @click.self="showModal = false">
<div class="modal-content">
<header>
<h3>确认操作</h3>
<button @click="showModal = false">×</button>
</header>
<section>
<slot />
</section>
<footer>
<button @click="showModal = false">取消</button>
<button @click="handleConfirm">确认</button>
</footer>
</div>
</div>
</Teleport>
</div>
</template>
Teleport Target Selectors
<template>
<!-- 渲染到 body -->
<Teleport to="body">
<Toast message="操作成功" />
</Teleport>
<!-- 渲染到指定的 DOM 元素 -->
<Teleport to="#app-portal">
<NotificationPanel />
</Teleport>
<!-- 动态目标 -->
<Teleport :to="targetSelector">
<DynamicContent />
</Teleport>
</template>
<script>
import { ref } from 'vue'
const targetSelector = ref('#sidebar')
// 可以动态切换目标
function moveToFooter() {
targetSelector.value = '#footer'
}
</script>
Multiple Teleports to the Same Target
Multiple Teleports can render to the same target element; they are appended in declaration order:
<template>
<!-- 第一个通知 -->
<Teleport to="#notification-area">
<Toast message="第一条通知" />
</Teleport>
<!-- 第二个通知,会追加到同一个容器中 -->
<Teleport to="#notification-area">
<Toast message="第二条通知" />
</Teleport>
</template>
Disabling Teleport
Sometimes you need to disable Teleport under certain conditions (for example, during unit testing):
<template>
<!-- disabled 时不会 Teleport,仍在原位渲染 -->
<Teleport to="body" :disabled="isTesting">
<Modal />
</Teleport>
</template>
In Practice: Global Toast Component
{% raw %}
// composables/useToast.ts
import { ref, markRaw } from 'vue'
interface ToastOptions {
message: string
type?: 'success' | 'error' | 'warning' | 'info'
duration?: number
}
interface ToastItem extends ToastOptions {
id: number
}
const toasts = ref<ToastItem[]>([])
let idCounter = 0
export function useToast() {
function show(options: ToastOptions) {
const id = ++idCounter
toasts.value.push({
id,
message: options.message,
type: options.type || 'info',
duration: options.duration || 3000
})
setTimeout(() => {
remove(id)
}, options.duration || 3000)
}
function remove(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
return { toasts, show, remove }
}
// ToastContainer.vue
// <template>
// <Teleport to="body">
// <div class="toast-container">
// <TransitionGroup name="toast">
// <div
// v-for="toast in toasts"
// :key="toast.id"
// :class="['toast', `toast--${toast.type}`]"
// >
// {{ toast.message }}
// <button @click="remove(toast.id)">×</button>
// </div>
// </TransitionGroup>
// </div>
// </Teleport>
// </template>
{% endraw %}
Suspense: Handling Async Dependencies
Basic Usage
Suspense lets us handle the loading state of async components declaratively. When a child component (or the setup function inside it) returns a Promise, Suspense waits for that Promise to resolve:
<template>
<Suspense>
<!-- 默认插槽:异步内容 -->
<template #default>
<UserProfile :user-id="userId" />
</template>
<!-- fallback 插槽:加载中的占位 -->
<template #fallback>
<div class="loading-skeleton">
<div class="skeleton-avatar" />
<div class="skeleton-text" />
<div class="skeleton-text skeleton-text--short" />
</div>
</template>
</Suspense>
</template>
Using with async setup
{% raw %}
<!-- UserProfile.vue -->
<template>
<div class="user-profile">
<img :src="user.avatar" :alt="user.name" />
<h2>{{ user.name }}</h2>
<p>{{ user.bio }}</p>
<div class="stats">
<span>{{ user.followers }} 粉丝</span>
<span>{{ user.following }} 关注</span>
</div>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue'
const props = defineProps<{ userId: string }>()
// setup 可以是 async 的,Suspense 会自动等待
const res = await fetch(`/api/users/${props.userId}`)
const user = await res.json()
// 如果 fetch 失败,错误会被 Suspense 的 error 捕获
</script>
{% endraw %}
Nested Suspense
可以嵌套 Suspense 实现更细粒度的加载控制:
<template>
<Suspense>
<template #default>
<div class="page">
<!-- 先加载页面级数据 -->
<PageHeader />
<!-- 内层 Suspense:独立控制内容区的加载 -->
<Suspense>
<template #default>
<ContentArea />
</template>
<template #fallback>
<ContentSkeleton />
</template>
</Suspense>
<PageFooter />
</div>
</template>
<template #fallback>
<FullPageLoader />
</template>
</Suspense>
</template>
Combining Teleport + Suspense
A common scenario: a modal that loads async data inside it. Teleport handles the render location, while Suspense handles the loading state:
<template>
<Teleport to="body">
<div v-if="visible" class="modal-overlay">
<div class="modal">
<Suspense>
<template #default>
<OrderDetail :order-id="orderId" />
</template>
<template #fallback>
<div class="modal-loading">
<Spinner />
<span>加载订单详情中...</span>
</div>
</template>
</Suspense>
</div>
</div>
</Teleport>
</template>
Error Handling
Suspense doesn't yet have a dedicated error slot (this is still under RFC discussion), so you need to handle errors with onErrorCaptured:
{% raw %}
<template>
<div v-if="hasError" class="error-state">
<p>加载失败: {{ errorMessage }}</p>
<button @click="retry">重试</button>
</div>
<Suspense v-else>
<template #default>
<AsyncComponent :key="retryCount" />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
<script>
import { ref, onErrorCaptured } from 'vue'
const hasError = ref(false)
const errorMessage = ref('')
const retryCount = ref(0)
onErrorCaptured((err) => {
hasError.value = true
errorMessage.value = err.message
return false // 阻止错误继续传播
})
function retry() {
hasError.value = false
retryCount.value++ // 通过 key 变化强制重新渲染
}
</script>
{% endraw %}
Important Notes
- Suspense is still an experimental feature — at the time of Vue 3's official release, Suspense was still in experimental status, and its API may change, so use it with caution in production
- Teleport preserves component instance relationships — although the DOM is rendered elsewhere, the parent-child relationship in the Vue component tree stays the same, and provide/inject continues to work as usual
- Suspense requires an async
setupordefineAsyncComponent— a normalsetupfunction won't trigger Suspense
Summary
- Teleport renders a child component's DOM to a specified location, solving the z-index and overflow issues of overlays like modals and toasts
- Teleport supports dynamic targets, multiple Teleports to the same target, and a disabled mode
- Suspense declaratively handles the loading and error states of async components
- Suspense supports nesting, enabling finer-grained loading control
- Suspense is still experimental, so watch for API changes if you use it in production
- Combining Teleport + Suspense is the best practice for handling async modal content
