Vue异步/懒加载组件
异步组件
vue允许你定义异步组件,具体查看官方文档。
准备
- 基础用法
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => {
return new Promise((resolve, reject) => {
// ...从服务器获取组件
resolve(/* 获取到的组件 */)
})
})
// ... 像使用其他一般组件一样使用 `AsyncComp`- 结合ES模块动态导入
const AsyncComp = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
)参数类型有两种,一种是异步加载函数,另一种是包含异步加载函数、加载提示组件的属性的对象,类型定义查看官方文档
类型
function defineAsyncComponent(
source: AsyncComponentLoader | AsyncComponentOptions
): Component
type AsyncComponentLoader = () => Promise<Component>
interface AsyncComponentOptions {
loader: AsyncComponentLoader
loadingComponent?: Component
errorComponent?: Component
delay?: number
timeout?: number
suspensible?: boolean
onError?: (
error: Error,
retry: () => void,
fail: () => void,
attempts: number
) => any
}如果使用了 onError 参数,则loader异常时会调用该函数,此时 errorComponent 会被忽略
性能相关
- 动态导入
由于ES模块的动态导入返回一个promise,可以很好的同defineAsyncComponent结合使用。
在性能优化中,经常会利用动态导入来实现组件的懒加载,动态导入的组件还会自动分包,生成一个对应的chunk文件。
SPA的首屏加载、大组件的加载都可以使用动态导入优化。父组件立即渲染,缩短关键路径、减少CPU阻塞;多chunk文件并行下载,减少整体等待时间。但要注意浏览器请求存在并行上限,分包过多可能是个反优化!
参考:
- 条件渲染
有另一种容易常见的性能优化:使用 v-if 条件渲染“重”组件,代码其实已经被下载下来了(同步引入,打包到当前页面的JS文件中),只是没渲染到页面上,也没有执行该组件内的js逻辑而已。条件渲染优化在于延迟“重”组件的DOM渲染及组件内的逻辑执行。
当然,条件渲染可以同异步组件结合使用,仅在需要时下载该异步组件:
<script setup>
import HeavyChild from './HeavyChild.vue'
</script>
<template>
<div>
<p>组件内容</p>
<HeavyChild />
</div>
</template><script setup>
import { ref } from 'vue'
import HeavyChild from './HeavyChild.vue'
const show = ref(false)
</script>
<template>
<div>
<p>组件内容</p>
<HeavyChild v-if="show" />
</div>
</template><script setup>
import { defineAsyncComponent, ref } from 'vue'
// 动态导入:只有在需要时才加载
const HeavyChild = defineAsyncComponent(() => import('./HeavyChild.vue'))
const show = ref(false)
</script>
<template>
<div>
<p>组件内容</p>
<HeavyChild v-if="show" />
</div>
</template>如果是多组件选择渲染的话,每个组件都很“重”,如何优化呢?
<script setup lang="jsx">
import { shallowRef } from 'vue'
import HeavyChild1 from './HeavyChild1.vue'
import HeavyChild2 from './HeavyChild2.vue'
import HeavyChild3 from './HeavyChild3.vue'
const TmplComp = shallowRef(null)
const tmplMap = {
1: HeavyChild1,
2: HeavyChild2,
3: HeavyChild3
}
const handleClick = no => TmplComp.value = tmplMap[no]
</script>
<template>
<button @click="handleClick(1)">模板1</button>
<button @click="handleClick(2)">模板2</button>
<button @click="handleClick(3)">模板3</button>
<component :is="TmplComp" />
</template><script setup>
import { defineAsyncComponent, shallowRef } from 'vue'
const HeavyChild1 = defineAsyncComponent(() => import('./HeavyChild1.vue'))
const HeavyChild2 = defineAsyncComponent(() => import('./HeavyChild2.vue'))
const HeavyChild3 = defineAsyncComponent(() => import('./HeavyChild3.vue'))
const TmplComp = shallowRef(null)
const tmplMap = {
1: HeavyChild1,
2: HeavyChild2,
3: HeavyChild3
}
const handleClick = no => TmplComp.value = tmplMap[no]
</script>
<template>
<button @click="handleClick(1)">模板1</button>
<button @click="handleClick(2)">模板2</button>
<button @click="handleClick(3)">模板3</button>
<component :is="TmplComp" />
</template>实例
假设有需要按照接口响应的数据条件选择对应的模板组件,每个模板组件都很“重”
实现
<script setup lang="jsx">
import { ref, defineAsyncComponent } from 'vue'
const data = ref()
const TmplComp = defineAsyncComponent({
loader: async () => {
data.value = await simulateApi()
if(data.value.no === 1) return import('./Tmpl1Comp.vue')
if(data.value.no === 2) return import('./Tmpl2Comp.vue')
throw new Error(`未知的模板类型: ${data.value.no}`)
},
loadingComponent: () => <div>加载中...</div>,
errorComponent: (err) => <span style="color:red">加载失败: {err.error.message}</span>,
})
/**
* 模拟接口
* @param {boolean} [isResolve] 响应是否成功
* @param {number} [delay] 延时
*/
function simulateApi(isResolve = true, delay = 1500) {
return new Promise((resolve, reject) => {
const data = Math.random() > 0.5
? { no: 1, title: '模板1', description: 'fdshiao fhuiewao geijoig fwehihio grtojijo weuihi' }
: { no: 2, title: '模板2', list: ['gjoif hiuoegr wfeih', 'ojoiwf uijiofw hjwejouihi'] }
setTimeout(isResolve ? resolve : reject, delay, data)
})
}
</script>
<template>
<h1>异步组件</h1>
<TmplComp :data />
</template><script setup>
defineProps(['data'])
</script>
<template>
<h2>{{ data.title }}</h2>
<p>{{ data.description }}</p>
</template><script setup>
defineProps(['data'])
</script>
<template>
<h2>{{ data.title }}</h2>
<ul>
<li v-for="(item, index) in data.list" :key="index">{{ item }}</li>
</ul>
</template>手动控制异步组件的加载时机
Promise.withResolvers实现
这里使用了 Promise.withResolvers 来手动控制异步组件的加载时机,能实现但不推荐。
这种写法存在耦合度过高的问题(loader 的控制权在外部),也不规范(defineAsyncComponent 的初衷是处理“网络资源加载”)
const data = ref()
const { promise: reqPromise, resolve: reqResolve, reject: reqReject } = Promise.withResolvers()
const TmplComp = defineAsyncComponent({
loader: async () => {
const data = await reqPromise
if(data.no === 1) return import('./Tmpl1Comp.vue')
if(data.no === 2) return import('./Tmpl2Comp.vue')
throw new Error(`未知的模板类型: ${data.value.no}`)
},
loadingComponent: () => <div>加载中...</div>,
errorComponent: (err) => <span style="color:red">加载失败: {err.error.message}</span>,
})
onMounted(() => show())
async function show() {
try {
data.value = await simulateApi()
reqResolve(data.value)
} catch (err) {
reqReject(err)
}
}- 策略模式 + 动态组件
将“数据获取”和“组件加载”解耦,先定义(defineAsyncComponent)好所需异步组件,数据获取后再指向(shallowRef)组件
但数据获取的loading效果就需要父组件控制显示了
虽然这种方案更规范,但从个人直观感受上更喜欢前两种方案😂
<script setup lang="jsx">
import { onMounted, ref, shallowRef, defineAsyncComponent } from 'vue'
const data = ref()
const loading = ref(false)
const TmplComp = shallowRef(null)
const LoadingComp = () => <div>加载中...</div>
const ErrorComp = ({ error }) => <span style="color:red">加载失败: {error.message}</span>
// 工厂函数:统一配置异步组件选项,减少重复代码
const createAsyncComp = loader => defineAsyncComponent({
loader,
loadingComponent: LoadingComp,
errorComponent: ErrorComp,
delay: 200,
timeout: 10000
})
const tmplMap = {
1: createAsyncComp(() => import('./Tmpl1Comp.vue')),
2: createAsyncComp(() => import('./Tmpl2Comp.vue')),
}
onMounted(async () => {
loading.value = true
try {
data.value = await simulateApi()
const tmpl = tmplMap[data.value.no]
if (!tmpl) throw new Error(`未知的模板类型: ${data.value.no}`)
TmplComp.value = tmpl
} catch (err) {
TmplComp.value = () => <ErrorComp error={err} />
}
loading.value = false
})
// ... simulateApi 同上 ...
</script>
<template>
<LoadingComp v-if="loading" />
<component v-else :is="TmplComp" :data />
</template>使用 Suspense 组件替换 defineAsyncComponent
这种方案就更不喜欢了,仅供参考
App.vue:
<script setup>
import TmplLoader from './TmplLoader.vue'
</script>
<template>
<h1>异步组件</h1>
<Suspense>
<TmplLoader />
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</template>TmplLoader.vue:
<script setup>
// script setup 中直接使用 await 会使该组件成为一个异步 setup 组件,这正是 Suspense 能捕获的
const data = await simulateApi()
let compModule
if (data.no === 1) {
compModule = await import('./Tmpl1Comp.vue')
} else if (data.no === 2) {
compModule = await import('./Tmpl2Comp.vue')
} else {
throw new Error(`未知的模板类型: ${data.no}`)
}
const TmplComp = compModule.default
// ... simulateApi 同上 ...
</script>
<template>
<TmplComp :data />
</template>Suspense 组件本身不处理异常。它只有两个插槽:#default(成功/加载完)和 #fallback(加载中)。
<script setup lang="jsx">
let data, TmplComp
try {
data = await simulateApi()
if (data.no === 1) {
TmplComp = (await import('./Tmpl1Comp.vue')).default
} else if (data.no === 2) {
TmplComp = (await import('./Tmpl2Comp.vue')).default
} else {
TmplComp = (props) => <span style="color:red">未知的模板类型: {props.data?.no}</span>
}
} catch (err) {
TmplComp = () => <span style="color:red">加载失败: {err.message}</span>
}
// ... simulateApi 同上 ...
</script>
<template>
<TmplComp :data />
</template>小结
vue太灵活了,光vue3的组件就有多种写法,各种API及特殊组件 defineAsyncComponent、Suspense、component...
懒加载组件
相比于异步组件,有时处于性能方面的考虑,会希望某些组件在可视时再加载。
示例
就像原生支持 loading="lazy" 的 img/iframe 标签一样,思路就是使用 IntersectionObserver API 监听组件是否在可视区域,在的话则加载(v-if)组件,否则显示骨架屏。把这个逻辑封装一下,具体业务组件放在默认插槽中。
<script setup lang="jsx">
import TestComp from './Comp.vue'
import LazyLoadWrapper from './LazyLoadWrapper.vue'
</script>
<template>
<h1>懒加载组件</h1>
<div class="flex-wrap">
<LazyLoadWrapper
v-for="p in 30"
:key="p"
:style="{ height: '200px', width: '200px' }"
>
<TestComp />
</LazyLoadWrapper>
</div>
</template>
<style scoped>
.flex-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
</style><script setup>
function formatTimeWithMs() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const milliseconds = String(now.getMilliseconds()).padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
}
</script>
<template>
<div class="block">创建时间: {{ formatTimeWithMs() }}</div>
</template>
<style scoped>
.block {
width: 200px;
height: 200px;
background-color: cadetblue;
}
</style><template>
<div ref="target" class="lazy-wrapper" :class="{ loaded: inView }">
<slot v-if="inView" />
<!-- 可选:在加载前显示骨架屏 -->
<div v-else class="placeholder" />
</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
const target = ref()
const inView = ref(false) // 状态:是否已进入视口
let observer = null
onMounted(() => {
// 创建 IntersectionObserver 实例
observer = new IntersectionObserver(
([entry]) => {
// 当元素与视口交叉时,entry.isIntersecting 将为 true
if (entry.isIntersecting) {
inView.value = true // 更新状态,触发组件加载
observer.unobserve(target.value) // 停止观察
observer.disconnect() // 完全断开观察器
}
},
{
// rootMargin: '0px', // 可以在视口边缘扩展或收缩交叉区域
threshold: 0.1, // 元素可见度达到10%时触发
}
)
// 开始观察目标元素
if (target.value) {
observer.observe(target.value)
}
})
// 组件卸载时,清理观察器,防止内存泄漏
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
</script>
<style scoped>
.lazy-wrapper {
/* 可以根据需要设置样式 */
position: relative;
}
.lazy-wrapper.loaded {
height: initial;
width: initial;
}
.placeholder {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.01) 33%, rgba(0, 0, 0, 0.04) 50%, rgba(0, 0, 0, 0.01) 66%) #f2f2f2;
background-size: 300% 100%;
animation: loading 1s infinite linear;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: right;
}
}
</style>总结
异步组件及懒加载组件是不错的性能优化、用户体验优化方法,但不是必须的,需要结合具体情况分析选用