在前面的文章中,我们学习了
Teleport如何突破 DOM 树的限制。今天,我们将探索 Vue3 中另一个强大的特性:Suspense。它允许我们在等待异步组件时显示备用内容,极大地提升了用户体验。理解它的实现原理,将帮助我们更好地处理异步加载场景。
前言:为什么需要异步组件
从根本上来说,异步组件的实现不需要任何框架层面的支持,我们完全可以自己实现:
javascript
const Component = () => import('Component.vue');
上述代码中,使用动态导入语句 import() 来加载组件,它会返回一个 Promise 实例,这样就实现了异步的方式来渲染页面。但是,这也带来了问题:
- 如果
Component组件加载失败了或者加载超时了怎么办? - 组件加载时,是否需要占位内容,比如 Loading 组件?
- 加载失败后,是否需要重试?
以上这些问题,就是异步组件要真正解决的问题。
异步组件
异步组件的定义
所谓异步组件,就是指在需要时才加载的组件,而不需要在应用初始化时就全部加载。这样可以有效减少首屏加载时间,提升用户体验。
异步组件的基本使用
html
<sctipt setup>
import { defineAsyncComponent } from 'vue'
// 最简单的用法
const AsyncComp = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
)
</script>
<template>
<AsyncComp />
</template>
defineAsyncComponent 解析
工作原理
defineAsyncComponent 的核心是一个高阶组件,它接收一个返回 Promise 的工厂函数,在组件需要渲染时才会执行这个工厂函数。
完整配置
javascript
import { defineAsyncComponent, defineComponent } from 'vue';
import ErrorComponent from './components/Error.vue';
import LoadingComponent from './components/Loading.vue';
const AsyncComponentWithOptions = defineAsyncComponent({
// 加载函数
loader: () => import('./MyComponent.vue'),
// 加载中组件
loadingComponent: LoadingComponent,
// 加载失败组件
errorComponent: ErrorComponent,
// 显示loading组件的延迟(默认200ms)
delay: 200,
// 超时时间(默认Infinity)
timeout: 3000,
// 是否可重试
suspensible: true,
// 错误处理
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
// 重试
retry();
} else {
// 失败
fail();
}
}
});
源码实现核心逻辑
javascript
function defineAsyncComponent(source) {
if (typeof source === 'function') {
source = { loader: source }
}
const {
loader,
loadingComponent,
errorComponent,
delay = 200,
timeout,
suspensible = true,
onError: userOnError
} = source
let resolvedComp = null
return defineComponent({
name: 'AsyncComponentWrapper',
setup() {
const loaded = ref(false)
const error = ref(null)
const loading = ref(false)
let loadingTimer = null
let retries = 0
function load() {
if (resolvedComp) {
return Promise.resolve(resolvedComp)
}
return new Promise((resolve, reject) => {
// 实际加载逻辑
loader()
.then(comp => {
resolvedComp = comp
loaded.value = true
resolve(comp)
})
.catch(err => {
error.value = err
reject(err)
})
})
}
// 返回渲染逻辑
return () => {
if (loaded.value) {
return h(resolvedComp)
} else if (error.value && errorComponent) {
return h(errorComponent, { error: error.value })
} else if (loading.value && loadingComponent) {
return h(loadingComponent)
}
// 返回一个注释节点或空
return null
}
}
})
}
Suspense 的设计哲学
定义与作用
Suspense 是一个内置组件,用于协调组件树中的异步依赖。它可以在等待异步组件解析时显示 fallback 内容。
基本结构
html
<template>
<Suspense>
<!-- 异步组件等待完成时显示的内容 -->
<template #default>
<AsyncComponent />
</template>
<!-- 加载中的回退内容 -->
<template #fallback>
<div>Loading...</div>
</template>
</Suspense>
</template>
工作流程

插槽结构
javascript
/**
* Suspense组件定义
*/
const Suspense = {
name: 'Suspense',
__isSuspense: true,
props: {
timeout: {
type: Number,
default: 0
}
},
setup(props, { slots }) {
// 获取default和fallback插槽
const defaultSlot = slots.default;
const fallbackSlot = slots.fallback;
// 状态管理
const state = ref('pending'); // 'pending' | 'resolving' | 'resolved'
const activeBranch = ref(null);
const pendingBranch = ref(null);
// 异步依赖收集
const asyncDeps = new Set();
const depPromises = [];
// 挂起计数
let pendingCount = 0;
return {
state,
activeBranch,
pendingBranch,
asyncDeps,
depPromises,
pendingCount
};
},
render() {
const { state, activeBranch, pendingBranch } = this;
if (state === 'pending') {
// 显示fallback
return this.fallbackSlot ? this.fallbackSlot() : null;
}
// 显示default内容
return activeBranch || (this.defaultSlot ? this.defaultSlot() : null);
}
};
Suspense 的挂起与恢复机制
异步依赖的类型
Suspense 可以处理两种类型的异步依赖:
- 异步组件:使用
defineAsyncComponent定义的组件 - 异步
setup:setup函数返回Promise的组件
上面的例子展示了如何使用 defineAsyncComponent 定义的组件,下面这个例子用来展示异步 setup:
javascript
import { ref } from 'vue'
export default {
async setup() {
// setup 返回 Promise,Suspense 会等待这个 Promise
const data = ref(null)
// 模拟异步数据获取
data.value = await fetch('/api/data').then(r => r.json())
return {
data
}
}
}
挂起与恢复的源码分析
javascript
// Suspense 组件的简化实现
function processSuspense(n1, n2, container) {
const suspense = {
// 挂起的 Promise 列表
deps: [],
// 正在挂起中的依赖数量
pending: 0,
// 挂起状态
isResolved: false,
// 异步依赖注册
registerDep(instance, setupRender) {
suspense.deps.push(instance)
instance.asyncDep.catch(err => {
// 错误处理
suspense.handleError(err)
}).then(setupRender => {
// 依赖完成
suspense.pending--
if (suspense.pending === 0) {
// 所有依赖都已完成,切换回正常状态
suspense.resolve()
}
})
if (suspense.pending++ === 0) {
// 如果有 pending 状态,显示 fallback
suspense.showFallback = true
}
},
// 解析完成
resolve() {
suspense.isResolved = true
suspense.showFallback = false
// 重新渲染默认内容
}
}
}
手写实现 Suspense 组件
基础实现
javascript
import { defineComponent, h, Fragment } from 'vue'
export default defineComponent({
name: 'Suspense',
props: {
timeout: Number
},
setup(props, { slots }) {
// 异步依赖列表
const asyncDeps = new Set()
let isResolved = false
// 注册异步依赖的方法,暴露给子组件使用
const registerAsyncDep = (asyncDep) => {
if (isResolved) return
asyncDeps.add(asyncDep)
asyncDep
.then(() => {
asyncDeps.delete(asyncDep)
if (asyncDeps.size === 0 && !isResolved) {
resolve()
}
})
.catch((err) => {
console.error('Suspense: 异步依赖执行失败', err)
})
}
// 解析完成
const resolve = () => {
isResolved = true
// 触发重新渲染
}
// 如果设置了超时
if (props.timeout) {
setTimeout(() => {
if (!isResolved) {
console.warn(`Suspense: 超过 ${props.timeout}ms 未完成解析`)
}
}, props.timeout)
}
// 提供 registerAsyncDep 给后代组件使用
provide('registerAsyncDep', registerAsyncDep)
return () => {
// 如果没有异步依赖或已解析,显示 default 内容
if (isResolved || asyncDeps.size === 0) {
return slots.default ? slots.default() : null
}
// 否则显示 fallback
return slots.fallback ? slots.fallback() : null
}
}
})
结合 defineAsyncComponent
javascript
// 增强的异步组件工厂函数
function createAsyncComponent(options) {
const component = defineAsyncComponent(options)
// 包装组件,使其与 Suspense 协作
return defineComponent({
name: 'SuspenseAsyncComponent',
setup(props, { attrs, slots }) {
const registerAsyncDep = inject('registerAsyncDep', null)
const asyncComp = ref(null)
// 异步加载组件
const asyncDep = component().then(comp => {
asyncComp.value = comp
})
// 向 Suspense 注册异步依赖
if (registerAsyncDep) {
registerAsyncDep(asyncDep)
}
return () => {
if (asyncComp.value) {
return h(asyncComp.value, attrs, slots)
}
return null
}
}
})
}
异步依赖的等待与完成
嵌套 Suspense 的处理
html
<template>
<Suspense>
<template #default>
<!-- 父级异步组件 -->
<ParentAsyncComponent>
<!-- 子级 Suspense -->
<Suspense>
<template #default>
<ChildAsyncComponent />
</template>
<template #fallback>
<div>子组件加载中...</div>
</template>
</Suspense>
</ParentAsyncComponent>
</template>
<template #fallback>
<div>父组件加载中...</div>
</template>
</Suspense>
</template>
完成顺序控制
javascript
// 等待多个异步依赖完成
const useSuspense = () => {
const deps = ref([])
const status = ref('pending') // pending | resolved | rejected
const addDep = (promise) => {
deps.value.push(promise)
return promise
}
const waitForAll = async () => {
try {
await Promise.all(deps.value)
status.value = 'resolved'
} catch (err) {
status.value = 'rejected'
throw err
}
}
return {
status,
addDep,
waitForAll
}
}
异步组件错误处理
完整的错误处理策略
javascript
const AsyncComponentWithErrorHandling = defineAsyncComponent({
loader: () => import('./MyComponent.vue'),
errorComponent: defineComponent({
props: ['error', 'retry'],
setup(props) {
return () => h('div', [
h('p', `加载失败: ${props.error.message}`),
h('button', { onClick: props.retry }, '重试')
])
}
}),
onError(error, retry, fail, attempts) {
// 错误分类处理
if (error.name === 'ChunkLoadError') {
// 路由懒加载错误,可能是网络问题
if (attempts <= 3) {
console.log(`重试第 ${attempts} 次...`)
retry()
} else {
console.error('网络异常,请检查网络连接')
fail()
}
} else if (error.name === 'TimeoutError') {
// 超时错误
console.error('组件加载超时')
fail()
} else {
// 其他错误
console.error('未知错误:', error)
fail()
}
}
})
Suspense 的错误边界
javascript
const SuspenseWithErrorBoundary = defineComponent({
setup(props, { slots }) {
const error = ref(null)
const handleError = (err) => {
error.value = err
}
provide('suspenseErrorHandler', handleError)
return () => {
if (error.value) {
return h('div', { class: 'error-boundary' }, [
h('h3', '出错了'),
h('p', error.value.message),
h('button', {
onClick: () => {
error.value = null
// 触发重新加载
}
}, '重试')
])
}
return h(Suspense, null, {
default: slots.default,
fallback: slots.fallback
})
}
}
})
Loading 状态的实现
优雅的 Loading 组件
html
<template>
<div class="loading-container">
<div class="loading-spinner" :style="{ width: size + 'px', height: size + 'px' }">
<div class="spinner"></div>
</div>
<p v-if="text" class="loading-text">{{ text }}</p>
<p v-if="progress !== undefined" class="loading-progress">
{{ Math.round(progress * 100) }}%
</p>
</div>
</template>
<script setup>
defineProps({
size: {
type: Number,
default: 40
},
text: {
type: String,
default: ''
},
progress: {
type: Number,
default: undefined
}
})
</script>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
}
.loading-spinner {
position: relative;
display: inline-block;
}
.spinner {
width: 100%;
height: 100%;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
margin-top: 10px;
color: #666;
font-size: 14px;
}
.loading-progress {
margin-top: 8px;
color: #3498db;
font-size: 16px;
font-weight: bold;
}
</style>
进度跟踪的实现
javascript
// 可跟踪进度的异步组件加载器
function createProgressAsyncComponent(options) {
const { loader, onProgress } = options
return defineAsyncComponent({
loader: () => {
return new Promise((resolve, reject) => {
// 模拟进度更新
let progress = 0
const timer = setInterval(() => {
progress += 0.1
if (progress <= 0.9) {
onProgress?.(progress)
}
}, 100)
loader()
.then(comp => {
clearInterval(timer)
onProgress?.(1)
resolve(comp)
})
.catch(err => {
clearInterval(timer)
reject(err)
})
})
},
loadingComponent: defineComponent({
setup(_, { attrs }) {
return () => h(LoadingComponent, {
progress: attrs.progress
})
}
})
})
}
性能优化与最佳实践
预加载策略
javascript
// 预加载组件
const preloadComponent = (componentFactory) => {
const comp = componentFactory()
// 触发加载但不等待
return comp
}
// 路由级别预加载
const router = createRouter({
routes: [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue'),
// 预加载相关组件
meta: {
preload: () => {
import('./components/Chart.vue')
import('./components/Table.vue')
}
}
}
]
})
// 在路由守卫中执行预加载
router.beforeEach((to, from, next) => {
if (to.meta.preload) {
to.meta.preload()
}
next()
})
Suspense 的最佳实践
html
<template>
<div class="app">
<!-- 为关键路径添加 Suspense -->
<Suspense :timeout="3000" @resolve="handleResolve" @fallback="handleFallback">
<template #default>
<RouterView v-slot="{ Component }">
<Suspense>
<component :is="Component" />
<template #fallback>
<PageLoading />
</template>
</Suspense>
</RouterView>
</template>
<template #fallback>
<AppLoading />
</template>
</Suspense>
</div>
</template>
<script setup>
const handleResolve = () => {
console.log('路由解析完成')
}
const handleFallback = () => {
console.log('显示加载状态')
}
</script>
缓存策略
javascript
// 实现组件缓存
const componentCache = new Map()
function createCachedAsyncComponent(loader, key) {
if (componentCache.has(key)) {
return componentCache.get(key)
}
const asyncComp = defineAsyncComponent({
loader,
loadingComponent: LoadingComponent,
delay: 200
})
componentCache.set(key, asyncComp)
return asyncComp
}
// 在路由中使用
{
path: '/user/:id',
component: (route) => {
const userId = route.params.id
return createCachedAsyncComponent(
() => import('./views/UserProfile.vue'),
`user-profile-${userId}`
)
}
}
结语
Suspense 和异步组件加载机制是 Vue3 中非常重要的特性,它们不仅解决了异步组件加载的显示问题,更重要的是提供了一个统一的异步依赖处理模型。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!