Teleport 与 Suspense
问题
Vue 3 的 Teleport 和 Suspense 是什么?它们各自解决什么问题?
答案
Teleport 和 Suspense 是 Vue 3 新增的两个内置组件,分别解决 DOM 位置 和 异步渲染 的问题。
Teleport
<Teleport> 可以将组件的 DOM 内容"传送"到 DOM 树的其他位置,同时保持组件的逻辑关系不变。
使用场景
- 模态框(Modal):避免 z-index 和 overflow 问题
- 通知/Toast:固定在页面特定位置
- 全屏覆盖层:需要脱离父容器的样式限制
- 工具提示(Tooltip):避免被父元素裁剪
基本用法
<template>
<div class="parent">
<h1>父组件</h1>
<!-- 内容会被传送到 body 下 -->
<Teleport to="body">
<div class="modal" v-if="showModal">
<h2>我是模态框</h2>
<p>虽然在 body 下渲染,但逻辑仍属于父组件</p>
<button @click="showModal = false">关闭</button>
</div>
</Teleport>
<button @click="showModal = true">打开模态框</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const showModal = ref(false);
</script>
渲染结果:
<!-- 组件位置 -->
<div class="parent">
<h1>父组件</h1>
<button>打开模态框</button>
</div>
<!-- 模态框被传送到 body -->
<body>
<div id="app">...</div>
<div class="modal">
<h2>我是模态框</h2>
...
</div>
</body>
to 属性
to 指定目标容器,可以是 CSS 选择器或 DOM 元素:
<template>
<!-- CSS 选择器 -->
<Teleport to="body">...</Teleport>
<Teleport to="#modal-container">...</Teleport>
<Teleport to=".notification-area">...</Teleport>
<!-- DOM 元素引用 -->
<Teleport :to="targetElement">...</Teleport>
</template>
<script setup>
import { ref, onMounted } from 'vue';
const targetElement = ref(null);
onMounted(() => {
targetElement.value = document.getElementById('custom-container');
});
</script>
disabled 属性
动态禁用传送,内容将在原位置渲染:
<template>
<!-- 移动端在原位置渲染,桌面端传送到 body -->
<Teleport to="body" :disabled="isMobile">
<div class="modal">...</div>
</Teleport>
</template>
<script setup>
import { ref } from 'vue';
const isMobile = ref(window.innerWidth < 768);
</script>
多个 Teleport 到同一目标
多个 Teleport 可以传送到同一目标,按顺序追加:
<template>
<Teleport to="#notifications">
<div class="notification">通知 1</div>
</Teleport>
<Teleport to="#notifications">
<div class="notification">通知 2</div>
</Teleport>
</template>
<!-- 渲染结果 -->
<div id="notifications">
<div class="notification">通知 1</div>
<div class="notification">通知 2</div>
</div>
实现原理
// 简化的 Teleport 渲染逻辑
function processTeleport(vnode: VNode, container: Element) {
const target = document.querySelector(vnode.props.to);
if (vnode.props.disabled) {
// 禁用时,渲染到原位置
mount(vnode.children, container);
} else {
// 传送到目标位置
mount(vnode.children, target);
}
}
Suspense
<Suspense> 用于处理异步组件的加载状态,在异步内容准备好之前显示 fallback 内容。
使用场景
- 异步组件加载:显示加载状态
- 数据获取:等待 API 响应
- 代码分割:配合
defineAsyncComponent - SSR:服务端渲染的异步数据
基本用法
<template>
<Suspense>
<!-- 默认插槽:异步内容 -->
<AsyncComponent />
<!-- fallback 插槽:加载中显示 -->
<template #fallback>
<div class="loading">Loading...</div>
</template>
</Suspense>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
// 异步组件
const AsyncComponent = defineAsyncComponent(() =>
import('./HeavyComponent.vue')
);
</script>
配合 async setup
组件使用顶层 await 时自动成为异步组件:
<!-- AsyncUserProfile.vue -->
<script setup>
// 顶层 await:组件自动变成异步组件
const user = await fetch('/api/user').then(r => r.json());
</script>
<template>
<div>{{ user.name }}</div>
</template>
<!-- 父组件 -->
<template>
<Suspense>
<AsyncUserProfile />
<template #fallback>
<div>加载用户信息...</div>
</template>
</Suspense>
</template>
嵌套 Suspense
<template>
<Suspense>
<template #default>
<AsyncLayout>
<!-- 嵌套的 Suspense -->
<Suspense>
<template #default>
<AsyncContent />
</template>
<template #fallback>
<ContentSkeleton />
</template>
</Suspense>
</AsyncLayout>
</template>
<template #fallback>
<LayoutSkeleton />
</template>
</Suspense>
</template>
事件处理
<template>
<Suspense
@pending="onPending"
@resolve="onResolve"
@fallback="onFallback"
>
<AsyncComponent />
<template #fallback>Loading...</template>
</Suspense>
</template>
<script setup>
function onPending() {
console.log('开始加载');
}
function onResolve() {
console.log('加载完成');
}
function onFallback() {
console.log('显示 fallback');
}
</script>
错误处理
配合 onErrorCaptured 或 errorCaptured 处理错误:
<template>
<div v-if="error">加载失败: {{ error.message }}</div>
<Suspense v-else>
<AsyncComponent />
<template #fallback>Loading...</template>
</Suspense>
</template>
<script setup>
import { ref, onErrorCaptured } from 'vue';
const error = ref(null);
onErrorCaptured((err) => {
error.value = err;
return false; // 阻止错误继续传播
});
</script>
配合 Transition
<template>
<RouterView v-slot="{ Component }">
<Suspense>
<template #default>
<Transition name="fade" mode="out-in">
<component :is="Component" />
</Transition>
</template>
<template #fallback>
<div class="loading">Loading...</div>
</template>
</Suspense>
</RouterView>
</template>
<style>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
实现原理
// 简化的 Suspense 逻辑
const Suspense = {
setup(props, { slots }) {
const isResolved = ref(false);
// 渲染默认内容
const defaultContent = slots.default();
// 检查是否有异步依赖
if (hasAsyncDeps(defaultContent)) {
// 显示 fallback
const fallbackContent = slots.fallback?.();
// 等待所有异步依赖完成
Promise.all(getAsyncDeps(defaultContent))
.then(() => {
isResolved.value = true;
});
return () => isResolved.value ? defaultContent : fallbackContent;
}
return () => defaultContent;
}
};
常见面试问题
Q1: Teleport 和 Portal 有什么区别?
答案:
Teleport 是 Vue 3 的官方实现,之前在 Vue 2 需要使用第三方库(如 portal-vue)。
| 特性 | Vue 3 Teleport | Vue 2 Portal |
|---|---|---|
| 官方支持 | ✅ 内置 | ❌ 第三方库 |
| disabled 属性 | ✅ | 取决于实现 |
| 性能 | 更好 | 一般 |
| SSR 支持 | ✅ | 需要处理 |
Q2: Teleport 的内容属于哪个组件?
答案:
逻辑上仍属于原组件,只是 DOM 渲染位置改变:
<template>
<Teleport to="body">
<!-- 这里的 props 和 events 仍然属于当前组件 -->
<Modal :title="title" @close="handleClose" />
</Teleport>
</template>
<script setup>
// title 和 handleClose 来自当前组件
const title = ref('Modal Title');
function handleClose() { /* ... */ }
</script>
- props/emits:正常工作
- provide/inject:可以跨 Teleport 传递
- 生命周期:与父组件关联
- 样式作用域:scoped CSS 仍然生效
Q3: Suspense 目前是实验性功能,生产环境能用吗?
答案:
截至 Vue 3.4,Suspense 仍标记为实验性,但已经相当稳定。
使用建议:
- 可以用于简单场景:异步组件加载
- 复杂场景需谨慎:多层嵌套、错误边界
- 做好错误处理:配合
onErrorCaptured - 准备 fallback 方案:以防 API 变化
<!-- 推荐的稳健写法 -->
<template>
<ErrorBoundary>
<Suspense>
<AsyncComponent />
<template #fallback>
<Skeleton />
</template>
</Suspense>
</ErrorBoundary>
</template>
Q4: 如何实现异步组件的加载状态?
答案:
<!-- 方式 1:Suspense -->
<template>
<Suspense>
<AsyncComponent />
<template #fallback>Loading...</template>
</Suspense>
</template>
<!-- 方式 2:defineAsyncComponent 配置 -->
<script setup>
import { defineAsyncComponent } from 'vue';
const AsyncComponent = defineAsyncComponent({
loader: () => import('./Heavy.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 延迟显示 loading
timeout: 10000 // 超时显示 error
});
</script>
<!-- 方式 3:手动管理状态 -->
<script setup>
import { ref, onMounted, shallowRef, defineAsyncComponent } from 'vue';
const loading = ref(true);
const error = ref(null);
const AsyncComp = shallowRef(null);
onMounted(async () => {
try {
const module = await import('./Heavy.vue');
AsyncComp.value = module.default;
} catch (e) {
error.value = e;
} finally {
loading.value = false;
}
});
</script>
<template>
<div v-if="loading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<component v-else :is="AsyncComp" />
</template>
Q5: Teleport 和 Suspense 可以一起使用吗?
答案:
可以,但需要注意顺序:
<template>
<!-- ✅ Suspense 在外层 -->
<Suspense>
<Teleport to="body">
<AsyncModal />
</Teleport>
<template #fallback>
<Teleport to="body">
<div class="modal-loading">Loading...</div>
</Teleport>
</template>
</Suspense>
<!-- ✅ Teleport 在外层也可以 -->
<Teleport to="body">
<Suspense>
<AsyncModal />
<template #fallback>Loading...</template>
</Suspense>
</Teleport>
</template>