Vue异步/懒加载组件
异步组件
vue允许你定义异步组件,具体查看官方文档。
准备
- 基础用法
js
import { defineAsyncComponent } from 'vue'
const AsyncComp = defineAsyncComponent(() => {
return new Promise((resolve, reject) => {
// ...从服务器获取组件
resolve(/* 获取到的组件 */)
})
})
// ... 像使用其他一般组件一样使用 `AsyncComp`- 结合ES模块动态导入
js
const AsyncComp = defineAsyncComponent(() =>
import('./components/MyComponent.vue')
)参数除了定义一个异步加载函数,还可以定义是一个对象,对象中包含加载函数、加载提示组件等,类型定义查看官方文档
类型
ts
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
}示例
假设有需要按照接口响应的数据条件选择对应的模板组件:
vue
<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')
},
loadingComponent: () => <div>加载中...</div>,
errorComponent: () => <span style="color:red">加载失败!</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>vue
<script setup>
defineProps(['data'])
</script>
<template>
<h2>{{ data.title }}</h2>
<p>{{ data.description }}</p>
</template>vue
<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>WARNING
如果使用了 onError 参数,则loader异常时会调用该函数,此时 errorComponent 会被忽略
抽离异步组件内的“接口请求”逻辑
js
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')
},
loadingComponent: () => <div>加载中...</div>,
errorComponent: () => <span style="color:red">加载失败!</span>,
})
onMounted(() => show())
async function show() {
try {
data.value = await simulateApi()
reqResolve(data.value)
} catch (err) {
reqReject(err)
}
}以上示例仅用于展示异步组件的使用,这种根据接口响应数据选择加载组件的需求,规范的做法是将其与异步组件解耦,仅供参考:
vue
<script setup>
import AsyncTmplComp from './AsyncTmplComp.vue'
</script>
<template>
<h1>异步组件</h1>
<Suspense>
<AsyncTmplComp />
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</template>vue
<script setup>
import { ref, shallowRef } from 'vue'
import Tmpl1Comp from './Tmpl1Comp.vue'
import Tmpl2Comp from './Tmpl2Comp.vue'
const data = ref()
const comp = shallowRef()
async function getData() {
data.value = await simulateApi()
if(data.value.no === 1) comp.value = Tmpl1Comp
if(data.value.no === 2) comp.value = Tmpl2Comp
}
await getData() // <script setup> 中直接使用 await 会使该组件成为一个异步 setup 组件,这正是 <Suspense> 能捕获的
// 模拟接口
function simulateApi(isResolve = true, delay = 1500) {
// ...
}
</script>
<template>
<component :is="comp" :data />
</template>懒加载组件
相比于异步组件,有时处于性能方面的考虑,会希望某些组件在可视时再加载。
示例
就像原生支持 loading="lazy" 的 img/iframe 标签一样,思路就是使用 IntersectionObserver API 监听组件是否在可视区域,在的话则加载(v-if)组件,否则显示骨架屏。把这个逻辑封装一下,具体业务组件放在默认插槽中。
vue
<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>vue
<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>vue
<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>