前言
如果我们正在打开一个后台管理系统:
- 我们点击了"数据分析"菜单,但是页面白屏,什么都没发生
- 我们可以怀疑一下自己:"我点了吗?" 于是又点了一下
- 5秒后,页面突然跳出来,吐槽一句:"这什么垃圾系统?"
这就是没有处理好加载状态的结果。用户不知道页面在加载,以为系统坏了。而我们要解决的问题,就是让加载过程变得可见、可预期、可恢复。
为什么需要异步组件?
传统路由懒加载的问题
javascript
// 传统路由懒加载
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue') // 2.5MB
},
{
path: '/analysis',
component: () => import('./views/DataAnalysis.vue') // 3.2MB
}
]
上述代码乍一看没什么问题,但如果遇上网络延迟,加载缓慢等情况,再点击菜单后,就会出现页面白屏的问题,用户也不知道页面正在加载...
异步组件的解决方案
javascript
import { defineAsyncComponent } from 'vue'
const AnalysisPage = defineAsyncComponent({
loader: () => import('./views/DataAnalysis.vue'),
loadingComponent: LoadingSpinner, // 加载时显示
errorComponent: ErrorDisplay, // 出错时显示
delay: 200, // 延迟200ms显示loading,避免闪烁
timeout: 5000, // 5秒超时
onError(error, retry, fail, attempts) {
if (attempts <= 3) {
retry() // 重试
} else {
fail()
}
}
})
异步组件完全指南
基础用法
最简单的异步组件
javascript
import { defineAsyncComponent } from 'vue'
const SimpleAsync = defineAsyncComponent(() =>
import('./components/HeavyComponent.vue')
)
完整配置的异步组件
javascript
import { defineAsyncComponent } from 'vue'
const FullAsync = defineAsyncComponent({
loader: () => import('./components/HeavyComponent.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200,
timeout: 5000,
suspensible: true,
onError(error, retry, fail, attempts) {
if (attempts <= 3 && error.message.includes('network')) {
console.log(`重试第 ${attempts} 次...`)
retry()
} else {
fail()
}
}
})
加载组件的设计
html
<!-- LoadingSpinner.vue -->
<template>
<div class="loading-container">
<div class="spinner"></div>
<p class="loading-text">加载中...</p>
</div>
</template>
<style scoped>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
}
.spinner {
width: 40px;
height: 40px;
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); }
}
</style>
错误组件的设计
html
<!-- ErrorDisplay.vue -->
<template>
<div class="error-container">
<div class="error-icon">⚠️</div>
<h3>加载失败</h3>
<p>{{ error?.message || '未知错误' }}</p>
<button @click="retry" class="retry-btn">重试</button>
</div>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue'
const props = defineProps({
error: Object
})
const emit = defineEmits(['retry'])
const retry = () => {
emit('retry')
}
</script>
Suspense - 管理多个异步依赖
什么是 Suspense?
html
<template>
<Suspense>
<!-- 默认插槽:所有异步依赖加载完成后显示 -->
<template #default>
<AsyncComponent />
<AnotherAsyncComponent />
</template>
<!-- fallback插槽:加载过程中显示 -->
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</template>
工作原理
text
页面渲染
↓
遇到 <Suspense>
↓
检查内部组件是否都准备就绪
↓
有未完成的异步依赖?
├─ 是 → 显示 fallback
│ ↓
│ 等待所有依赖完成
│ ↓
│ 切换到 default
│
└─ 否 → 直接显示 default
在 setup 中使用 async/await
html
<!-- AsyncUserProfile.vue -->
<script setup>
import { ref } from 'vue'
// 直接在 setup 中使用 await
// 这个组件会自动触发 Suspense
const user = await fetch('/api/user').then(r => r.json())
const posts = await fetch(`/api/posts?userId=${user.id}`).then(r => r.json())
// 所有数据都加载完成后才渲染
</script>
<template>
<div>
<h2>{{ user.name }}</h2>
<div v-for="post in posts" :key="post.id">
{{ post.title }}
</div>
</div>
</template>
并行数据加载
html
<!-- Dashboard.vue -->
<script setup>
// 并行加载,提高效率
const [userStats, salesData, recentOrders] = await Promise.all([
fetch('/api/stats').then(r => r.json()),
fetch('/api/sales').then(r => r.json()),
fetch('/api/orders').then(r => r.json())
])
</script>
<template>
<div class="dashboard">
<StatsCard :data="userStats" />
<SalesChart :data="salesData" />
<OrderList :orders="recentOrders" />
</div>
</template>
实战案例
案例一:路由级 Suspense
html
<!-- App.vue -->
<template>
<router-view v-slot="{ Component }">
<Suspense>
<template #default>
<component :is="Component" />
</template>
<template #fallback>
<div class="page-loading">
<div class="spinner"></div>
<p>加载页面中...</p>
</div>
</template>
</Suspense>
</router-view>
</template>
案例二:骨架屏
html
<template>
<Suspense>
<template #default>
<UserProfile :user-id="userId" />
</template>
<template #fallback>
<!-- 骨架屏:形状匹配实际内容 -->
<div class="profile-skeleton">
<div class="skeleton-avatar"></div>
<div class="skeleton-info">
<div class="skeleton-line w-32"></div>
<div class="skeleton-line w-48"></div>
<div class="skeleton-line w-40"></div>
</div>
</div>
</template>
</Suspense>
</template>
<style scoped>
.profile-skeleton {
display: flex;
gap: 20px;
padding: 20px;
}
.skeleton-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-line {
height: 16px;
margin-bottom: 12px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: 4px;
}
.w-32 { width: 128px; }
.w-40 { width: 160px; }
.w-48 { width: 192px; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
</style>
案例三:嵌套 Suspense
html
<template>
<!-- 外层:整个页面的加载状态 -->
<Suspense>
<template #fallback>
<PageSkeleton />
</template>
<template #default>
<div class="page">
<Header />
<!-- 内层:局部区域的加载状态 -->
<Suspense>
<template #fallback>
<ContentSkeleton />
</template>
<template #default>
<AsyncContent />
</template>
</Suspense>
<Footer />
</div>
</template>
</Suspense>
</template>
案例四:预加载
html
<script setup>
import { defineAsyncComponent, shallowRef } from 'vue'
const showModal = ref(false)
const ModalComponent = shallowRef()
// 鼠标悬停时预加载
const preloadModal = () => {
ModalComponent.value = defineAsyncComponent(() =>
import('./components/Modal.vue')
)
}
// 点击时显示
const openModal = () => {
showModal.value = true
}
</script>
<template>
<button
@mouseenter="preloadModal"
@click="openModal"
>
打开弹窗
</button>
<ModalComponent v-if="showModal" />
</template>
性能优化策略
优先级加载
javascript
// 定义加载优先级
const loadQueue = {
critical: [], // 首屏必需,立即加载
normal: [], // 普通优先级
idle: [] // 空闲时加载
}
function loadWithPriority(loader, priority = 'normal') {
if (priority === 'critical') {
// 立即加载
return defineAsyncComponent(loader)
}
// 存入队列
loadQueue[priority].push(loader)
// 返回占位组件
return defineAsyncComponent({
loader: () => new Promise(resolve => {
// 稍后加载
setTimeout(() => loader().then(resolve), 0)
})
})
}
预连接优化
html
<!-- index.html -->
<head>
<!-- 预连接到可能用到的域名 -->
<link rel="preconnect" href="https://api.example.com">
<link rel="preconnect" href="https://cdn.example.com">
<!-- DNS 预解析 -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- 预加载关键资源 -->
<link rel="preload" href="/critical.js" as="script">
</head>
组件缓存
javascript
// 缓存已加载的组件
const componentCache = new Map()
function cachedAsyncComponent(path) {
if (componentCache.has(path)) {
return componentCache.get(path)
}
const component = defineAsyncComponent(() =>
import(path).then(comp => {
componentCache.set(path, comp)
return comp
})
)
componentCache.set(path, component)
return component
}
错误处理与降级
完整的错误处理
html
<template>
<Suspense @fallback="handleFallback">
<template #default>
<AsyncComponent />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
<div v-if="error" class="error-boundary">
<ErrorIcon />
<h3>加载失败</h3>
<p>{{ error.message }}</p>
<button @click="retry">重试</button>
<button @click="useFallback">使用基础版本</button>
</div>
</template>
<script setup>
import { ref, onErrorCaptured } from 'vue'
import BaseVersion from './BaseVersion.vue'
const error = ref(null)
const useBase = ref(false)
onErrorCaptured((err) => {
error.value = err
return false // 阻止继续传播
})
function retry() {
error.value = null
window.location.reload()
}
function useFallback() {
useBase.value = true
error.value = null
}
</script>
自动重试机制
javascript
function withRetry(loader, maxRetries = 3) {
return defineAsyncComponent({
loader: () => {
return new Promise((resolve, reject) => {
let attempts = 0
function attempt() {
loader()
.then(resolve)
.catch(error => {
attempts++
if (attempts < maxRetries) {
// 指数退避
const delay = 1000 * Math.pow(2, attempts - 1)
console.log(`重试 ${attempts}/${maxRetries},等待 ${delay}ms`)
setTimeout(attempt, delay)
} else {
reject(error)
}
})
}
attempt()
})
},
timeout: 10000
})
}
性能监控
加载时间监控
javascript
// composables/useLoadMonitor.js
export function useLoadMonitor(componentName) {
const startTime = performance.now()
onMounted(() => {
const loadTime = performance.now() - startTime
// 上报性能数据
console.log(`[性能] ${componentName} 加载时间: ${loadTime.toFixed(2)}ms`)
if (loadTime > 3000) {
console.warn(`⚠️ ${componentName} 加载时间过长: ${loadTime.toFixed(2)}ms`)
}
})
}
用户体验指标
| 指标 | 目标值 | 含义 |
|---|---|---|
| FCP | < 1.5s | 第一个内容出现的时间 |
| LCP | < 2.5s | 主要内容出现的时间 |
| TTI | < 3.5s | 页面可交互的时间 |
| 加载反馈 | < 100ms | 点击后显示加载状态的时间 |
最佳实践清单
实施检查清单
- 大组件使用异步加载
- 配置 loadingComponent 和 errorComponent
- 设置合理的 delay(200ms)避免闪烁
- 设置 timeout(5-10秒)避免无限等待
- 关键路径考虑预加载
- 设计匹配布局的骨架屏
- 实现错误重试机制
- 监控加载性能指标
决策树
text
组件是否需要异步加载?
├─ 否 → 普通组件
└─ 是 → 是否在首屏?
├─ 是 → 考虑预加载
└─ 否 → 是否需要加载状态?
├─ 是 → 使用 Suspense
└─ 否 → 使用 defineAsyncComponent
结语
好的用户体验 = 立即反馈 + 预期符合 + 可恢复!
当我们优化完加载体验,用户不再抱怨"页面卡",而是觉得"很流畅",那就说明我们成功了!
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!