跳到主要内容

Teleport 与 Suspense

问题

Vue 3 的 TeleportSuspense 是什么?它们各自解决什么问题?

答案

TeleportSuspense 是 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>

错误处理

配合 onErrorCapturederrorCaptured 处理错误:

<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 TeleportVue 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 仍标记为实验性,但已经相当稳定。

使用建议:

  1. 可以用于简单场景:异步组件加载
  2. 复杂场景需谨慎:多层嵌套、错误边界
  3. 做好错误处理:配合 onErrorCaptured
  4. 准备 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>

相关链接