Vue3 Teleport 与 Suspense
大约 8 分钟约 2324 字
Vue3 Teleport 与 Suspense
简介
Vue3 的 <Teleport> 将组件内容渲染到 DOM 中的其他位置(如 body),解决了弹窗、抽屉、通知等组件被父元素 overflow:hidden 或 z-index 层级遮挡的问题。<Suspense> 管理异步依赖的加载状态,包括异步组件加载和异步数据获取。两者结合可以实现脱离当前 DOM 层级的组件(如全局弹窗)和优雅的异步加载体验。理解 Teleport 的目标机制、Suspense 的异步等待策略和错误边界处理,是构建高质量 Vue3 应用的关键。
特点
实现
Teleport 弹窗组件
<!-- Modal.vue — 模态弹窗 -->
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="visible" class="modal-overlay" @click.self="handleOverlayClick">
<div class="modal-container" :style="{ width: computedWidth, maxHeight }">
<!-- 头部 -->
<header class="modal-header">
<h3 class="modal-title">{{ title }}</h3>
<button class="modal-close" @click="close" aria-label="关闭">
<span>×</span>
</button>
</header>
<!-- 主体 — 默认插槽 -->
<div class="modal-body" :class="{ 'no-padding': noPadding }">
<slot />
</div>
<!-- 底部 — 具名插槽 -->
<footer v-if="$slots.footer" class="modal-footer">
<slot name="footer">
<button class="btn btn-default" @click="close">取消</button>
<button class="btn btn-primary" @click="handleConfirm">确定</button>
</slot>
</footer>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue';
const props = withDefaults(defineProps<{
visible: boolean;
title?: string;
width?: string | number;
maxHeight?: string;
closeOnOverlay?: boolean;
closeOnEsc?: boolean;
noPadding?: boolean;
}>(), {
title: '提示',
width: 520,
maxHeight: '80vh',
closeOnOverlay: true,
closeOnEsc: true,
noPadding: false,
});
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'close'): void;
(e: 'confirm'): void;
}>();
const computedWidth = computed(() =>
typeof props.width === 'number' ? `${props.width}px` : props.width
);
function close() {
emit('update:visible', false);
emit('close');
}
function handleConfirm() {
emit('confirm');
close();
}
function handleOverlayClick() {
if (props.closeOnOverlay) close();
}
// ESC 关闭
watch(() => props.visible, (val) => {
if (val) {
document.addEventListener('keydown', onEsc);
document.body.style.overflow = 'hidden';
} else {
document.removeEventListener('keydown', onEsc);
document.body.style.overflow = '';
}
});
function onEsc(e: KeyboardEvent) {
if (e.key === 'Escape' && props.closeOnEsc) close();
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-container {
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 30px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
}
.modal-header {
padding: 16px 24px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title { margin: 0; font-size: 16px; font-weight: 600; }
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #999;
padding: 4px;
line-height: 1;
}
.modal-body { padding: 24px; overflow-y: auto; }
.modal-body.no-padding { padding: 0; }
.modal-footer {
padding: 12px 24px;
border-top: 1px solid #e8e8e8;
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* 过渡动画 */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-active .modal-container,
.modal-leave-active .modal-container {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-enter-from, .modal-leave-to { opacity: 0; }
.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
transform: scale(0.95);
opacity: 0;
}
</style><!-- 使用 Modal -->
<template>
<Modal v-model:visible="showModal" title="编辑设备">
<DeviceForm :device="selectedDevice" />
<template #footer>
<button @click="showModal = false">取消</button>
<button variant="primary" @click="saveDevice">保存</button>
</template>
</Modal>
</template>通知系统(多 Teleport 到同一容器)
<!-- Notification.vue — 通知组件 -->
<template>
<Teleport to="#notification-container">
<TransitionGroup name="notification" tag="div" class="notification-list">
<div
v-for="item in notifications"
:key="item.id"
class="notification-item"
:class="[`notification-${item.type}`]"
>
<span class="notification-icon">{{ iconMap[item.type] }}</span>
<span class="notification-message">{{ item.message }}</span>
<button class="notification-close" @click="remove(item.id)">×</button>
</div>
</TransitionGroup>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue';
interface NotificationItem {
id: number;
type: 'info' | 'success' | 'warning' | 'error';
message: string;
duration: number;
}
const notifications = ref<NotificationItem[]>([]);
const iconMap = { info: 'i', success: '✓', warning: '!', error: 'x' };
function show(type: NotificationItem['type'], message: string, duration = 3000) {
const id = Date.now();
notifications.value.push({ id, type, message, duration });
setTimeout(() => remove(id), duration);
}
function remove(id: number) {
notifications.value = notifications.value.filter(n => n.id !== id);
}
defineExpose({ show, remove });
</script>动态禁用 Teleport
<!-- 在某些场景下需要动态控制是否传送 -->
<template>
<Teleport to="body" :disabled="isInline">
<Tooltip content="提示信息" />
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue';
// 在嵌入第三方容器时禁用 Teleport
const isInline = ref(false);
// 检测是否在 iframe 中
if (window.self !== window.top) {
isInline.value = true;
}
</script>Suspense 异步组件加载
<!-- AsyncChartLoader.vue — 异步图表组件 -->
<template>
<Suspense>
<!-- 异步组件 -->
<template #default>
<AsyncDeviceChart :deviceId="selectedDevice" />
</template>
<!-- 加载中骨架屏 -->
<template #fallback>
<div class="skeleton-chart">
<div class="skeleton-line" style="width: 60%; height: 24px;" />
<div class="skeleton-chart-area">
<div class="skeleton-bar" style="height: 40%;" />
<div class="skeleton-bar" style="height: 70%;" />
<div class="skeleton-bar" style="height: 55%;" />
<div class="skeleton-bar" style="height: 85%;" />
<div class="skeleton-bar" style="height: 45%;" />
</div>
</div>
</template>
</Suspense>
</template>
<script setup lang="ts">
import { defineAsyncComponent } from 'vue';
// defineAsyncComponent — 懒加载组件
const AsyncDeviceChart = defineAsyncComponent({
loader: () => import('./DeviceChart.vue'),
loadingComponent: () => null, // Suspense fallback 处理
errorComponent: () => null, // 下面的 onError 处理
delay: 200, // 显示 loading 前的延迟
timeout: 10000, // 超时时间
});
defineProps<{ selectedDevice: string }>();
</script>
<style scoped>
.skeleton-chart { padding: 16px; }
.skeleton-line {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: 4px;
margin-bottom: 16px;
}
.skeleton-chart-area { display: flex; gap: 8px; align-items: flex-end; height: 200px; }
.skeleton-bar {
flex: 1;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: 4px;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>异步 setup 与顶层 await
<!-- AsyncUserProfile.vue — 顶层 await -->
<script setup lang="ts">
const props = defineProps<{ userId: string }>();
// 顶层 await — Suspense 会等待这个完成
const userData = await fetch(`/api/users/${props.userId}`).then(r => r.json());
const userPosts = await fetch(`/api/users/${props.userId}/posts`).then(r => r.json());
</script>
<template>
<div class="user-profile">
<h2>{{ userData.name }}</h2>
<p>{{ userData.email }}</p>
<h3>帖子</h3>
<ul>
<li v-for="post in userPosts" :key="post.id">{{ post.title }}</li>
</ul>
</div>
</template><!-- Suspense 错误处理 -->
<template>
<Suspense @pending="onPending" @resolve="onResolve">
<!-- 默认内容 — 异步组件 -->
<template #default>
<AsyncUserProfile :userId="selectedUserId" />
</template>
<!-- 加载中 -->
<template #fallback>
<div class="loading-state">
<LoadingSpinner />
<p>加载中...</p>
</div>
</template>
</Suspense>
</template>
<script setup lang="ts">
function onPending() {
console.log('Suspense 开始等待');
}
function onResolve() {
console.log('Suspense 等待完成');
}
</script>Suspense 嵌套
<!-- 嵌套 Suspense — 内层 Suspense 优先显示 -->
<template>
<!-- 外层 Suspense — 等待整个页面加载 -->
<Suspense>
<template #default>
<DashboardLayout>
<!-- 内层 Suspense — 等待图表加载 -->
<Suspense>
<template #default>
<AsyncChart />
</template>
<template #fallback>
<div>图表加载中...</div>
</template>
</Suspense>
<!-- 另一个异步组件 -->
<Suspense>
<template #default>
<AsyncTable />
</template>
<template #fallback>
<div>表格加载中...</div>
</template>
</Suspense>
</DashboardLayout>
</template>
<template #fallback>
<div>页面加载中...</div>
</template>
</Suspense>
</template>Teleport 在 SSR 中的处理
<!-- index.html — 预置 Teleport 目标容器 -->
<body>
<div id="app"></div>
<div id="modal-container"></div>
<div id="notification-container"></div>
</body>// SSR 中的 Teleport 注意事项:
// 1. Teleport 的目标元素必须在 HTML 中预置
// 2. SSR 渲染时,Teleport 内容会渲染到目标位置
// 3. 客户端 hydration 时需要目标元素已存在
// 4. 可以使用 disabled 属性在 SSR 时禁用 Teleport
// ssr-teleport-disabled.vue
<template>
<Teleport to="body" :disabled="isServer">
<Modal />
</Teleport>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const isServer = ref(true);
onMounted(() => { isServer.value = false; });
</script>优点
缺点
总结
<Teleport> 将内容渲染到 DOM 其他位置,适合弹窗、抽屉、通知等需要脱离父元素层级的场景。<Suspense> 管理异步组件和数据加载状态,配合顶层 await 使用。动态 disabled 属性控制 Teleport 行为。建议将所有模态弹窗通过 Teleport 渲染到 body,异步组件配合 Suspense 提供骨架屏加载体验。
关键知识点
- Teleport 的 to 属性接受 CSS 选择器或 DOM 元素。
- 多个 Teleport 到同一目标按模板中的顺序渲染。
- Suspense 等待子组件的异步 setup(顶层 await)完成。
- Suspense 的 resolve/pending 事件可用于追踪加载状态。
- Teleport 内容不受父组件 scoped 样式影响。
- index.html 中需要预置 Teleport 目标容器。
项目落地视角
- 封装 Modal/Drawer/Toast 组件使用 Teleport。
- 异步数据加载使用 Suspense 替代手动 loading 状态。
- 确保 Teleport 目标元素在渲染前存在。
- 为 Suspense fallback 设计骨架屏而非简单 spinner。
常见误区
- Teleport 到不存在的 DOM 节点导致错误。
- Suspense 嵌套使用导致 fallback 显示异常。
- 忘记为 Suspense 提供 fallback 模板。
- Teleport 内容的样式使用 scoped 无法生效。
进阶路线
- 学习 Vue3 的异步组件加载机制(defineAsyncComponent)。
- 研究 Teleport 在 SSR 中的处理方式。
- 了解 Suspense 与 Vue Router 的集成(路由级异步加载)。
- 研究 Vue3 的 onErrorCaptured 与 Suspense 错误处理的配合。
适用场景
- 全局弹窗、抽屉、对话框。
- 通知和 Toast 消息堆叠。
- 异步组件的加载状态管理。
- 全局确认框和提示框。
落地建议
- 统一使用 Teleport 渲染弹窗到 body。
- 为异步组件统一提供 loading fallback(骨架屏)。
- 在 index.html 中预置 Teleport 目标容器。
- 封装全局 useModal/useToast composable 管理弹窗和通知。
排错清单
- 确认 Teleport to 目标是否存在(检查 DOM)。
- 检查 Suspense 子组件是否有顶层 await。
- 验证 Teleport 内容的 z-index 是否正确。
- 检查 SSR 场景下 Teleport 目标是否预置。
复盘问题
- Teleport 与 absolute/fixed 定位在什么场景下各更合适?
- Suspense 是否适合用于路由级异步加载?
- 如何为 Teleport 内容保持父组件的作用域样式?
- 多个 Teleport 组件如何管理 z-index 层级?
