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>