前端性能优化(二):长列表懒加载渲染

134 阅读2分钟

在产品设计中,为了保持用户交互的连贯性以及尽量少的交互操作,前端经常需要将很长的列表数据一次性渲染出来。

如果已知数据量较小,一次性渲染,完全没有问题。

但是当数据量很大,且列表中元素有较高的复杂度时(如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 意味着在每个像素都可见之前,阈值不会被认为已通过。

image.png

示例-流畅渲染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>

关键思路:

  1. 在父容器中初始化一个交叉观察器
  2. 将观察器的实例传递给每一个子组件,进行挂载要观察的对象
  3. 子组件未进入交叉区域内时,子组件只渲染空DIV进行占位,可以极大降低子组件的Dom复杂度,提升渲染效率。同时可以起到撑开滚动视图的作用,保证滚动期间的平滑
  4. 当子组件进入交叉区域内时,通过props 属性,控制挂载其真实渲染的Dom 结构,并隐藏占位。

ccc (1).gif