在产品设计中,为了保持用户交互的连贯性以及尽量少的交互操作,前端经常需要将很长的列表数据一次性渲染出来。
如果已知数据量较小,一次性渲染,完全没有问题。
但是当数据量很大,且列表中元素有较高的复杂度时(如Dom节点的数量较多和较大的深度),一次性渲染需要大量的计算和渲染,绘制会显得很吃力,严重的时候,甚至会进入长时间的等待。这对用户体验是非常不友好的。
因此,我们将讨论一种长列表渲染提速的优化方式-懒加载渲染,使用 Intersection Observer API 实现
IntersectionObserver 交叉观察器
IntersectionObserver() 构造函数的 options 对象,可以控制在什么情况下调用观察器的回调。
root
用作视口的容器元素,基于该元素的可视区域,通过交叉判断,检查目标的可见性。
必须是目标元素的祖先。如果未指定或为 null,则默认为浏览器视口。
rootMargin
视口容器元素的边距,其值可以类似于 CSS margin 属性,例如 "10px 20px 30px 40px"(上、右、下、左)。这些值可以是百分比。
在计算交叉点之前,这组值用于增大或缩小根元素边框的每一侧。默认值为全零。
threshold
一个数字或一个数字数组,表示目标可见度达到多少百分比时,观察器的回调就应该执行。
如果只想在能见度超过 50% 时检测,可以使用 0.5 的值。如果希望每次能见度超过 25% 时都执行回调,则需要指定数组 [0, 0.25, 0.5, 0.75, 1]。
默认值为 0(这意味着只要有一个像素可见,回调就会运行)。值为 1.0 意味着在每个像素都可见之前,阈值不会被认为已通过。
示例-流畅渲染10000个卡片组件
App.vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import CardItem from "./components/Card/index.vue";
import { ICard } from "./components/Card/type";
const cardList = ref<ICard[]>([]);
const observer = ref<IntersectionObserver>();
onMounted(() => {
// 初始化卡片列表
let cards = [];
for (let i = 0; i < 10000; i ++) {
const title = `Card title (${i})`;
const content = `This is card content (${i})`;
cards.push({
id: `${i}`,
title,
content,
render: false,
});
}
cardList.value = cards;
});
onMounted(() => {
let options = {
root: document.querySelector("#cardContainer"),
rootMargin: "0px",
threshold: 0.1, // 目标在root视口内的可见高度跟自身高度的百分比,大于等于这个值时,触发回调,标记为交叉,触发回调
};
// 初始化-交叉观察器
observer.value = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const cardId = (entry.target as any).dataset.itemId;
cardList.value[cardId].render = true;
}
});
}, options);
});
onUnmounted(() => {
observer.value?.disconnect();
})
</script>
<template>
<div class="card-list" id="cardContainer">
<CardItem :observer="observer" v-for="(item) in cardList" :key="item.id" :item="item" />
</div>
</template>
Card.vue
<script setup lang="ts">
import { onMounted, ref } from "vue";
import VueIcon from "../../assets/vue.svg";
const cardRef = ref<HTMLDivElement>();
const props = defineProps({
item: Object,
observer: IntersectionObserver
})
onMounted(() => {
if (cardRef.value) {
props.observer?.observe(cardRef.value);
}
})
const list = ref([0, 1, 2, 3, 4, 5]);
</script>
<template>
<div v-if="props.item?.render" class="card-item flex align-middle mb-4">
<div class="flex">
<img v-for="item in list" :key="item" class="ml-2" :src="VueIcon" />
</div>
<div class="card-item--right ml-8">
<div class="title">{{ props.item?.title }}</div>
<div class="content flex align-middle">
{{ props.item?.content }}
</div>
</div>
</div>
<div v-if="!props.item?.render && !canRender" ref="cardRef" :data-item-id="props.item?.id" class="card-item--virtual mb-4">
</div>
</template>
关键思路:
- 在父容器中初始化一个
交叉观察器 - 将观察器的实例传递给每一个子组件,进行挂载要观察的对象
- 子组件未进入交叉区域内时,子组件只渲染空
DIV进行占位,可以极大降低子组件的Dom复杂度,提升渲染效率。同时可以起到撑开滚动视图的作用,保证滚动期间的平滑 - 当子组件进入交叉区域内时,通过
props属性,控制挂载其真实渲染的Dom结构,并隐藏占位。