列表首屏毫秒级加载与自动滚动定位方案

列表首屏毫秒级加载与自动滚动定位方案

场景

<template>
    <div ref="commentsRef">
        <div
            v-for="comment in displayComments"
            :key="comment.id"
            :data-cell-id="comment.id"
            class="card"
        >
            {{ comment.data }}
        </div>
    </div>
</template>

<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));
</script>

说明:需要一个场景,用户首屏需要渲染200条数据,且后续如果comments列表发生变动时,页面上能够保持当前显示的条目不进行滚动。

分批加载

如果直接全量数据加载,200个节点肯定不能在1帧内渲染完毕,这样会出现较长时间的白屏。这时候可以使用时间分片的思路,在每个小时间段内渲染少量节点,提高首屏的渲染效率。

<template>
    <div>
        <div
            v-for="comment in displayComments.slice(0, loadCount)"
            :key="comment.id"
        >
            {{ comment.data }}
        </div>
    </div>
</template>

<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const commentsRef = ref();
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));

// 首屏动态加载的数量
const loadCount = ref<number>(0);
// 如果少于20条,直接全量加载;否则每隔4ms渲染40条数据
watch(displayComments, () => {
  clearInterval(timer);
  loadCount.value = 20;
  if (loadCount.value < nv.length) {
    timer = setInterval(() => {
      loadCount.value = Math.min(loadCount.value + 40, nv.length);
      if (loadCount.value >= nv.length) {
        clearInterval(timer);
      }
    }, 4);
  }
})
</script>

滚动定位

实现在列表数据更新时自动定位到用户当前可视区域,尽可能保证可视区域的内容不因数据更新而发生剧烈抖动。

获取当前可视内容

通过浏览器提供的 IntersectionObserver API 监听列表中的元素,获取元素滚动时在可视区域内的元素,记录他们的id。

const visibleCardIds: Set<string> = new Set();
const intersection: IntersectionObserver = new IntersectionObserver(
  (entries) => {
    entries.forEach((e) => {
      const id = e.target.getAttribute('data-cell-id');
      if (!id) return;
      if (e.isIntersecting && e.intersectionRatio > 0.91) {
        visibleCardIds.add(id);
      } else {
        visibleCardIds.delete(id);
      }
    });
  },
  {
    threshold: [0, 0.9, 1],
  },
);

const addIntersectionObserve = () => {
  visibleCardIds.clear();
  intersection?.disconnect();
  nextTick(() => {
    const nodes = document.body.querySelectorAll('.card') || [];
    for (const node of nodes) {
      intersection?.observe(node);
    }
  });
};

滚动处理

从上一次拿到的可视区域元素中拿到在更新后的数据中仍存在的、最靠近顶部的节点,记录它的位置,并把容器节点滚动到该节点的 scrollTop 高度。

const getNearestVisibleId = () => {
    return displayComment.value.find(c => visibleCardIds.has(c.id));
}

const silenceScroll = () => {
  const id = getNearestVisibleId();
  nextTick(() => {
    commentScrollTop(id);
  });
};

const commentScrollTop = (id: string) => {
  const n = commentsRef.value?.querySelector(
    `[data-cell-id="${id}"]`,
  ) as HTMLElement;
  if (n) {
    commentsRef.value?.scrollTo({
      top: n.offsetTop,
      behavior: 'instant',
    });
  }
};

结合起来

在数据更新后的下一帧触发滚动即可实现定位效果

<template>
    <div>
        <div
            v-for="comment in displayComments.slice(0, loadCount)"
            :key="comment.id"
        >
            {{ comment.data }}
        </div>
    </div>
</template>

<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const commentsRef = ref();
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));

// 首屏动态加载的数量
const loadCount = ref<number>(0);
// 如果少于20条,直接全量加载;否则每隔4ms渲染40条数据
watch(displayComments, () => {
  clearInterval(timer);
  loadCount.value = 20;
  if (loadCount.value < nv.length) {
    timer = setInterval(() => {
      loadCount.value = Math.min(loadCount.value + 40, nv.length);
      if (loadCount.value >= nv.length) {
        clearInterval(timer);
        silenceScroll();
        addIntersectionObserve();
      }
    }, 4);
  } else {
    silenceScroll();
    addIntersectionObserve();
  }
})
</script>

定位优化

首屏加载速度上去了,也实现了自动滚动到指定的条目位置。但这时候遇到个新问题,如果列表的数据发生了变动,比如从前面插入了一条新数据,那么这时候可视范围内的数据肯定会发生改变。

如果使用批量重绘,即使本次修改只有一条数据更新,会出现页面先重新渲染,等10+ms后再滚动到指定位置,中间会出现画面的闪动。

这时候可以利用vue VDom算法的一些小Trick。先判断前后数据的差异程度,如果差异数量小于一个阈值(比如小于10),那么可以借用vue的diff算法,尽可能保留原有节点,渲染少量新节点。这时候可以大大提升页面的渲染效率,即使全量渲染也不会有性能问题。

<template>
    <div>
        <div
            v-for="comment in displayComments.slice(0, loadCount)"
            :key="comment.id"
        >
            {{ comment.data }}
        </div>
    </div>
</template>

<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const commentsRef = ref();
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));

// 首屏动态加载的数量
const loadCount = ref<number>(0);
// 如果少于20条,直接全量加载;否则每隔4ms渲染40条数据
watch(displayComments, () => {
  clearInterval(timer);
  const nSet = new Set(nv.map((v) => v.id));
  const oSet = new Set(ov.map((v) => v.id));
  const n = new Set([...nSet].filter((x) => !oSet.has(x)));
  if (n.size <= 10) {
    loadCount.value = nv.length;
    silenceScroll();
    addIntersectionObserve();
  } else {
    loadCount.value = 20;
    if (loadCount.value < nv.length) {
      timer = setInterval(() => {
        loadCount.value = Math.min(loadCount.value + 40, nv.length);
        if (loadCount.value >= nv.length) {
          clearInterval(timer);
          silenceScroll();
          addIntersectionObserve();
        }
      }, 4);
    } else {
      silenceScroll();
      addIntersectionObserve();
    }
  }
})
</script>

效果预览

在前面插入一个div节点,保持现有节点不动