Giter Site home page Giter Site logo

lkangd / infinite-scroll-sample Goto Github PK

View Code? Open in Web Editor NEW
53.0 1.0 14.0 2.11 MB

固定高度与动态高度的虚拟无限滚动实现。online-demo ⏬

Home Page: https://lkangd.github.io/infinite-scroll-sample/

JavaScript 5.51% HTML 2.88% Vue 91.61%
infinite-scroll virtual-scroll

infinite-scroll-sample's Introduction

title date spoiler
剖析无限滚动虚拟列表的实现原理
2020-08-26
长列表渲染的终极优化手段

TL;DR:「虚拟列表」的本质就是仅将需要显示在视窗中的列表节点挂载到 DOM,是一种优化长列表加载的技术手段。其中按照节点的高度是否固定又分为「固定高度的虚拟列表」和「动态高度的虚拟列表」。这是本文对两种虚拟列表场景实现的 demo(页面托管在 github pages 可能需要爬梯子) 和 代码库(基于 Vue 2.x)。

在进行前端业务开发时,很容易遇到需要加载巨大列表的场景。比如微博的信息流、微信的朋友圈和直播平台的聊天框等,这些列表通常具有两个显著的特点:

  • 不能分页;
  • 只要用户愿意就可以无限地滚动下去。

在这种场景下,如果直接加载一个数量级很大的列表,会造成页面假死,使用传统的上拉分页加载模式或者 window.requestAnimationFrame空闲加载模式可以在一定程度上缓解这种情况,但是在加载到一定量级的页面时,会因为页面同时存在大量的 DOM 元素而出现过渡占用内存、页面卡顿等性能问题,带来糟糕的用户体验。因此必须对这种业务场景做相应的加载优化,只加载需要显示的元素是这种情况的唯一解,「虚拟列表」的概念应运而生。

什么是虚拟列表?

首先,来说说「虚拟列表」的定义,它的本质就是仅将需要显示在视窗中的列表节点挂载到 DOM,以达到「减少一次性加载节点数量」和「减少滚动容器内总挂载节点数量」的目的,也即:

通过「单个元素高度」计算当前列表全部加载时的高度作为「滚动容器」的「可滚动高度」,按该「可滚动高度」撑开「滚动容器」。并根据「当前滚动高度」,在「可视区域」内按需加载列表元素。

相关概念

上面的描述提到了几个关键的概念,它们分别是:

  • 单个元素高度:列表内每个独立元素的高度,它可以是固定的或者是动态的。

  • 滚动容器:意指挂载列表元素的 DOM 对象,它可以是自定义的元素或者window对象(默认)。

  • 可滚动高度:滚动容器可滚动的纵向高度。当滚动容器的高度(宽度),小于它的子元素所占的总高度(宽度)且该滚动容器的overflow不为hidden时,此时滚动容器的scrollHeight可滚动高度

  • 可视区域:滚动容器的视觉可见区域。如果容器元素是window对象,可视区域就是浏览器的视口大小(即视觉视口);如果容器元素是某个 ul 元素,其高度是 300,右侧有纵向滚动条可以滚动,那么视觉可见的区域就是可视区域,也即是该滚动容器的offsetHeight

  • 当前滚动高度:与平常的滚动高度概念一致。虽然虚拟列表仅加载需要显示在可视区域内的元素,但是为了维持与常规列表一致的滚动体验,必须通过监听当前滚动高度来动态更新需要显示的元素。

参考下图加深理解:

实现逻辑步骤

因此,实现「虚拟列表」可以简单理解为就是在列表发生滚动时,改变「可视区域」内的渲染元素。大概的文字逻辑步骤如下:

  1. 根据单个元素高度计算出滚动容器的可滚动高度,并撑开滚动容器;
  2. 根据可视区域计算总挂载元素数量;
  3. 根据可视区域和总挂载元素数量计算头挂载元素(初始为 0)和尾挂载元素;
  4. 当发生滚动时,根据滚动差值和滚动方向,重新计算头挂载元素和尾挂载元素。

根据这些步骤,下面开始通过实际代码对「虚拟列表」进行实现。

固定高度的虚拟列表

准备工作

首先,创建列表元素组件,约定它的高度固定为180px

<template>
  <li class="item" ref="item">
    <div class="item__wrapper">
      <div class="item__info">
        <img :src="data.avatar" class="item__avatar" />
        <p class="item__name">{{ index }}. {{ data.name }}</p>
        <p class="item__date">{{ data.dob }}</p>
      </div>
      <p class="item__text">E-mail: {{ data.email }}</p>
      <p class="item__text">Phone: {{ data.phone }}</p>
      <p class="item__text">City: {{ data.address.city }}</p>
      <p class="item__text">Street: {{ data.address.street }}</p>
    </div>
  </li>
</template>
<script>
  export default {
    name: 'item',
    props: {
      index: {
        type: Number, // 元素下标
        default: 0,
      },
      data: {
        type: Object,
        default: () => ({}),
      },
    },
  };
</script>
<style scoped lang="scss">
  .item {
    height: 180px;
    /* ... */
  }
</style>

通过faker.js来生成一些随机数据,以满足分页加载的测试情况:

import faker from 'faker';

export function fetchData(count = 30) {
  const result = [];
  for (let i = 0; i < count; i++) {
    const item = faker.helpers.contextualCard();
    item.paragraph = faker.lorem.paragraph();
    result.push(item);
  }
  return result;
}

最后,创建滚动容器组件,引入item组件和随机数据,渲染列表:

<template>
  <ul class="height-fixed" ref="scroller">
    <item class="height-fixed__item" v-for="item in listData" :data="item" :index="item.index" :key="item.username + item.phone" />
  </ul>
</template>
<script>
  import Item from './components/item';
  import { fetchData } from './helpers';

  const FIXED_HEIGHT = 180;

  export default {
    name: 'height-fixed',
    data() {
      return {
        listData: [],
      };
    },
    mounted() {
      this.fetchData();
    },
    methods: {
      fetchData() {
        this.listData.push(...this.setItemIndex(fetchData()));
      },
      // 给每个列表元素设置固定的下标
      setItemIndex(list) {
        let latestIndex = this.listData.length;
        for (let i = 0; i < list.length; i++) {
          const item = list[i];
          item.index = latestIndex + i;
          Object.freeze(item);
        }
        return list;
      },
    },
    components: { Item },
  };
</script>
<style scoped lang="scss">
  .height-fixed {
    width: 100%;
    height: 100%;
    overflow-x: hidden;
    overflow-y: scroll;
    /* ... */
  }
</style>

通过路由挂载后,完成一个常规列表的渲染,如下图:

计算「可滚动高度」

因为元素高度是固定,所以在拿到列表数据时就可以通过 列表长度 * 元素高度 获得「可滚动高度」,然后使用此高度撑开滚动容器。通过上文图一可以得知,可滚动高度由「可视区域」+「已浏览区域」+「待浏览区域」组成,关于如何撑开「已浏览区域」和「待浏览区域」,有两种常规的做法:

  • 直接使用 padding 撑开列表高度;
  • 在列表可视区域外部放置哨兵元素撑开高度。

为了更好地理解后文「动态高度的虚拟列表」的内容,这里选用第二种方法。

新增scrollRunwayEnd属性,在列表获取后计算总高度:

export default {
  // ...
  data() {
    return {
      // ...
      scrollRunwayEnd: 0,
    };
  },
  methods: {
    fetchData() {
      this.listData.push(...this.setItemIndex(fetchData()));
      this.scrollRunwayEnd = this.listData.length * FIXED_HEIGHT;
    },
  },
  // ...
};

在模板内增加scroll-runway元素,根据scrollRunwayEnd,使用transform: translateY的方式撑开「滚动容器」高度:

<template>
  <ul class="height-fixed" ref="scroller" @scroll="handleScroll">
    <li class="height-fixed__scroll-runway" :style="`transform: translate(0, ${scrollRunwayEnd}px)`"></li>
    <item class="height-fixed__item" v-for="item in listData" :data="item" :index="item.index" :key="item.username + item.phone" />
  </ul>
</template>
<!-- ... -->
<style scoped lang="scss">
  .height-fixed {
    /* ... */
    &__scroll-runway {
      position: absolute;
      width: 1px;
      height: 1px;
      transition: transform 0.2s;
    }
  }
</style>

计算初始「可视元素」

「可视元素」使用visibleData表示,visibleData可使用「头挂载元素」和「尾挂载元素」分别代表的元素下标在原始的listData进行动态截取。

根据固定的元素高度和「滚动容器」的高度,可以轻松得出「可视元素」的个数为 滚动容器高度 / 单个元素高度,使用VISIBLE_COUNT表示。同时,为了在快速滚动的情况下也能获得较为良好的数据现实体验,可以适当设置「缓冲区元素」,使用BUFFER_SIZE表示。

新增visibleData数组,用于「可视元素」的装载。页面初次挂载时,「头挂载元素」firstAttachedItem必定为 0,再根据VISIBLE_COUNTBUFFER_SIZE可得「尾挂载元素」lastAttachedItem

// ...
const BUFFER_SIZE = 3; // 「缓冲区元素」个数
let VISIBLE_COUNT = 0;

export default {
  name: 'height-fixed',
  data() {
    return {
      // ...
      visibleData: [],
      firstAttachedItem: 0, // 「头挂载元素」
      lastAttachedItem: 0, // 「尾挂载元素」
    };
  },
  mounted() {
    VISIBLE_COUNT = Math.ceil(this.$refs.scroller.offsetHeight / FIXED_HEIGHT);
    this.lastAttachedItem = VISIBLE_COUNT + BUFFER_SIZE;
    this.visibleData = this.listData.slice(this.firstAttachedItem, this.lastAttachedItem);
  },
};

listData更改为visibleData

<template>
  <ul class="height-fixed" ref="scroller">
    <li class="height-fixed__scroll-runway" :style="`transform: translate(0, ${scrollRunwayEnd}px)`"></li>
    <item class="height-fixed__item" v-for="item in visibleData" :data="item" :index="item.index" :key="item.username + item.phone" />
  </ul>
</template>

在获得了visibleData后,下一步需要改变列表元素的显示方式。对每个列表元素使用绝对定位,使其脱离文档流,然后使用transform: translateY的方式来对元素进行定位。

setItemIndex方法更改为calItemScrollY,并根据下标,赋值给每个元素固定的scrollY

// setItemIndex(list) {
//   let latestIndex = this.listData.length;
//   for (let i = 0; i < list.length; i++) {
//     const item = list[i];
//     item.index = latestIndex + i;
//     Object.freeze(item);
//   }
//   return list;
// }
calItemScrollY(list) {
  let latestIndex = this.listData.length;
  for (let i = 0; i < list.length; i++) {
    const item = list[i];
    item.index = latestIndex + i;
    item.scrollY = this.scrollRunwayEnd + i * FIXED_HEIGHT;
    Object.freeze(item);
  }
  return list;
},
<template>
  <!-- ... -->
  <item
    class="height-fixed__item"
    v-for="item in visibleData"
    :data="item"
    :index="item.index"
    :key="item.username + item.phone"
    :style="`transform: translate(0, ${item.scrollY}px)`"
  />
  <!-- ... -->
</template>
<!-- ... -->
<style scoped lang="scss">
  .height-fixed {
    /* ... */
    &__item {
      position: absolute;
      contain: layout;
      will-change: transform;
    }
  }
</style>

滚动更新「可视元素」

在处理滚动逻辑之前,先引入一个概念:「锚点元素」,即处于「滚动容器」的「可视区域」内的第一个元素。我们需要在滚动时候,根据每一次滚动事件的滚动差值和方向来更新「锚点元素」,计算出「锚点元素」后,就可以根据新的「锚点元素」下标和缓冲区值BUFFER_SIZEVISIBLE_COUNT来计算「头挂载元素」和「尾挂载元素」。

「锚点元素」= 「当前滚动高度」/ FIXED_HEIGHT // 当偏移量绝对值大于 FIXED_HEIGHT 时需要重新计算;
「头挂载元素」=「锚点元素」- BUFFER_SIZE // 不能小于 0,即第一个元素;
「尾挂载元素」= 「头挂载元素」+ VISIBLE_COUNT + BUFFER_SIZE // 不能大于列表长度,即最后一个元素;

「锚点元素」大部分情况下处于被部分遮盖的状态,被遮盖的部分为它的偏移量offset,其中包含指向具体元素的下标index,如下图所示:


了解了「锚点元素」概念之后,接下来就可以处理「滚动容器」的滚动行为了,首先监听滚动事件:

<template>
  <ul ref="scroller" class="height-fixed" @scroll="handleScroll">
    <!-- ... -->
  </ul>
</template>

根据滚动方向和偏移量,按顺序更新「锚点元素」→「头挂载元素」→「尾挂载元素」→「可视元素」:

// ...
export default {
  // ...
  data() {
    return {
      // ...
      anchorItem: { index: 0, offset: 0 }, // 「锚点元素」初始值
      lastScrollTop: 0, // 记录上次滚动事件时「滚动容器」的「滚动高度」
    };
  },
  methods: {
    // 「锚点元素」更新方法
    updateAnchorItem() {
      const index = Math.floor(this.$refs.scroller.scrollTop / FIXED_HEIGHT);
      const offset = this.$refs.scroller.scrollTop - index * FIXED_HEIGHT;
      this.anchorItem = { index, offset };
    },
    handleScroll() {
      // 滚动差值
      const delta = this.$refs.scroller.scrollTop - this.lastScrollTop;
      this.lastScrollTop = this.$refs.scroller.scrollTop;

      // 更新「锚点元素」偏移量
      this.anchorItem.offset += delta;
      const isPositive = delta >= 0;
      // 判断滚动方向
      if (isPositive) {
        // 1.当「锚点元素」偏移量大于等于固定高度时,说明视图滚动条向下,并超过一个元素,需要更新「锚点元素」
        if (this.anchorItem.offset >= FIXED_HEIGHT) {
          this.updateAnchorItem();
        }
        // 2.计算「头挂载元素」
        if (this.anchorItem.index - this.firstAttachedItem >= BUFFER_SIZE) {
          this.firstAttachedItem = Math.min(this.listData.length - VISIBLE_COUNT, this.anchorItem.index - BUFFER_SIZE);
        }
      } else {
        if (this.$refs.scroller.scrollTop <= 0) {
          // 特殊情况:处理滚动到顶部,更新「锚点元素」为初始值
          this.anchorItem = { index: 0, offset: 0 };
        } else if (this.anchorItem.offset < 0) {
          // 1.当「锚点元素」偏移量小于零时,说明视图滚动条向上,并超过一个元素,需要更新「锚点元素」
          this.updateAnchorItem();
        }
        // 2.计算「头挂载元素」
        if (this.anchorItem.index - this.firstAttachedItem < BUFFER_SIZE) {
          this.firstAttachedItem = Math.max(0, this.anchorItem.index - BUFFER_SIZE);
        }
      }
      // 3.更新「尾挂载元素」
      this.lastAttachedItem = Math.min(this.firstAttachedItem + VISIBLE_COUNT + BUFFER_SIZE * 2, this.listData.length);
      // 4.更新「可视元素」
      this.visibleData = this.listData.slice(this.firstAttachedItem, this.lastAttachedItem);
    },
  },
};

至此,一个简单的「固定高度虚拟滚动」就实现了,打开开发者工具,可以观察到就算滚动条一直向下,列表元素的个数是恒定的:

你可以点击此处进行体验。

动态高度的虚拟列表

因为不再具有固定的元素高度,所以「可滚动高度」和「可视元素」很难像实现固定高度的虚拟列表那样,可以在获取数据后进行一次性计算就完事。下面来说说动态高度虚拟列表的关键难点:

关键点一:如何获得元素的动态高度?

按常规情况,一个列表元素高度为动态的情况大致分为三种:

  1. 列表元素内初始渲染时高度就不确定。比如不定行数的多行文本、列表元素内包含不定长度的内嵌列表等;
  2. 列表元素内初始渲染后因用户操作而高度发生变化。比如展开一个收缩项目删除或增加子元素等;
  3. 列表元素内包含异步渲染元素。比如未缓存过的图片异步组件等。

由于这些复杂的情况可能同时存在一个列表元素内,所以只能够实时监听每一个处于可视区域内的元素的高度。现阶段 ECMA DOM 规范下,有两个 API 可以达到这个目的:MutationObserverResizeObserver

这两个 API 都存在一定的兼容性问题,caniuse#ResizeObserver | caniuse#MutationObserver,可以使用对应的polyfill进行解决,因为ResizeObserver可以更直观地达到监听元素高度变动的目的,所以这里选择使用ResizeObserverResizeObserverpolyfill

关键点二:如何模拟「可滚动高度」?

因为列表元素的高度不再是固定的,所以「可滚动高度」不能再通过「列表元素个数」*「固定元素高度」简单逻辑关系来获得。此时,只能基于业务的实际情况,给每个列表元素定一个「估算高度」:ESTIMATED_HEIGHT

同时,还需要新增一个cachedHeight数组,根据上一关键点提到的元素高度变化事件,以每一个列表元素对应的下标记录最后一次变化的高度。如果元素未渲染或者被略过渲染时,用ESTIMATED_HEIGHT进行暂时代替。

由此可得知,「可滚动高度」scrollRunwayEnd只能是「动态」且「大致准确」的。在 vue 里,可以用一个「计算属性」进行实时估值:

  // ...
  data() {
    return {
      // ...
      // scrollRunwayEnd: 0,
    };
  },
  computed: {
    scrollRunwayEnd() {
      // 根据当前已渲染的元素高度,求得当前所有元素总高度
      const maxScrollY = this.cachedHeight.reduce((sum, h) => (sum += h || ESTIMATED_HEIGHT), 0);
      // 根据当前所有元素总高度,求得元素平均高度
      const currentAverageH = maxScrollY / this.cachedHeight.length;
      // 返回估算高度
      return maxScrollY + (this.listData.length - this.cachedHeight.length) * currentAverageH;
    },
  },
  // ...

关键点三:如何计算每一个元素的「scrollY」?

这一步是最难的,因为除了第一个元素外的每一个元素的「scrollY」可能都会因为下面几种情况而失效:

  1. 当前元素的上一个元素高度发生了变化。 这种情况意味着从当前元素开始,每一个后续元素都需要按上一个元素的高度差值进行「scrollY」计算。
  2. 用户快速拖动滚动条至底部或顶部。 由于略过了中间元素的渲染,cachedHeight会缺少略过元素的真实高度,所以只能用上文的ESTIMATED_HEIGHT进行代替。这种情况下用户再缓慢滚动到顶部时,略过元素的初次渲染会更新cachedHeight中对应的记录。此时更新的高度肯定是大于或者小于ESTIMATED_HEIGHT的,所以当用户持续滚动缓慢滚动到scrollTop为 0 时,可能会出现 上部滚动区域「不足」或者「多余」的情况。因此,必须在保证当前页面滚动情况不变的前提下,提前对这两种情况进行实时修正,也即修正scrollTop的同时重新计算「锚点元素」。
  3. 屏幕宽度发生改变。 手机屏幕横竖方向改变和手动改变浏览器窗口大小都可能导致「滚动容器」的宽度发生变化,「滚动容器」的宽度决定了列表元素的高度,这种情况下每一个元素的「scrollY」都将失效,需要重新计算。同时,为了更好地的用户体验,我们应该在宽度发生变化时,保持「锚定元素」的offset不变,举一个 twitter 例子:

因此,这里我们不再将「scrollY」直接赋予每一个列表元素,而是新增一个cachedScrollY数组用于存储所有列表元素的临时「scrollY」。在每一次滚动事件发生时,根据滚动差值是否超过「锚点元素」对应的cachedHeight去判断是否需要更新「锚点元素」。如果「锚点元素」发生改变,以「锚点元素」为基点,用每一个「可视元素」对应的cachedHeight叠加「锚点元素」的「scrollY」去计算自身的「scrollY」,然后更新每个列表元素对应cachedScrollY,最后渲染到「可视区域」。

准备工作

修改随机数据函数,给每个元素增加随机图片和该图片的随机宽度

export function fetchData(count = 30) {
  const result = [];
  for (let i = 0; i < count; i++) {
    const item = faker.helpers.contextualCard();
    item.paragraph = faker.lorem.paragraph();
    item.img = {
      src: `/images/${faker.random.number({ min: 1, max: 20 })}.jpeg`, // 从给定的 20 张图片内随机
      width: `${faker.random.number({ min: 100, max: 700 })}px`, // 从 100px - 700px 范围内随机
    };
    result.push(item);
  }
  return result;
}

修改item组件,注意加载的两张图片:一张为正常加载的图片,一张为人工延时加载的图片:

<template>
  <li class="item" ref="item">
    <div class="item__wrapper" :class="{ 'is-fixed': fixedHeight }">
      <!-- ... -->
      <template v-if="fixedHeight">
        <!-- ... -->
      </template>
      <template v-else>
        <p class="item__paragraph">{{ data.paragraph }}</p>
        <!-- 模拟延时加载图片 -->
        <img class="item__img" :src="defferImgSrc" :style="{width: data.img.width}" />
        <!-- 正常加载图片 -->
        <img class="item__img" :src="data.img.src" :style="{width: data.img.width}" />
      </template>
    </div>
  </li>
</template>
<script>
  // ...
  export default {
    // ...
    props: {
      // ...
      fixedHeight: {
        type: Boolean,
        default: true,
      },
    },
    data() {
      return {
        defferImgSrc: '',
      };
    },
    created() {
      // 模拟图片加载时间
      if (this.data.img.isDeffer) {
        this.defferImgSrc = this.data.img.src;
      } else {
        setTimeout(() => {
          this.defferImgSrc = this.data.img.src;
          this.data.img.isDeffer = true;
        }, faker.random.number({ min: 300, max: 5000 }));
      }
    },
  };
</script>

最后,在mounted钩子内使用 resize-observer-polyfill 监听元素高度变化:

import ResizeObserver from 'resize-observer-polyfill';

export default {
  // ...
  mounted() {
    if (this.fixedHeight) return;

    const ro = new ResizeObserver((entries, observer) => {
      // 高度发生变化时,将 'size-change' 事件 emit 到父组件
      this.$emit('size-change', this.index);
    });
    ro.observe(this.$refs.item);
    this.$once('hook:beforeDestroy', ro.disconnect.bind(ro));
  },
  // ...
};

通过路由挂载后,完成一个动态高度元素列表的渲染,如下图:

监听元素高度变化

在每一次「可视元素」的高度发生变化时,以「锚点元素」为基点,计算出「锚点元素」的scrollY,然后按「锚点元素」之前和之后的元素进行区别计算,得出所有「可视元素」的最新scrollY

注意:列表元素的初次渲染和后续的高度变化都会触发ResizeObserver事件

<template>
  <ul ref="scroller" class="height-dynamic" @scroll="handleScroll">
    <!-- ... -->
    <item
      class="height-dynamic__item"
      v-for="item in visibleData"
      ref="items"
      :data="item"
      :fixed-height="false"
      :key="item.username + item.phone"
      :index="item.index"
      :style="`transform: translate(0, ${cachedScrollY[item.index]}px)`"
      @size-change="handleSizeChange"
    />
  </ul>
</template>
<script>
  export default {
    // ...
    methods: {
      handleSizeChange(index) {
        this.calItemScrollY();
      },
      // 计算每一个「可视元素」的 scrollY
      async calItemScrollY() {
        await this.$nextTick();
        // 修正 vue diff 算法导致「可视元素」顺序不正确的问题
        this.$refs.items.sort((a, b) => a.index - b.index);

        // 获取「锚点元素」在「可视元素」中的序号
        const anchorDomIndex = this.$refs.items.findIndex(item => item.index === this.anchorItem.index);
        const anchorDom = this.$refs.items[anchorDomIndex];
        const anchorDomHeight = anchorDom.$el.getBoundingClientRect().height;

        // 通过「滚动容器」的「当前滚动高度」和「锚点元素」的 offset 算出其 scrollY
        this.$set(this.cachedScrollY, this.anchorItem.index, this.$refs.scroller.scrollTop - this.anchorItem.offset);
        this.$set(this.cachedHeight, this.anchorItem.index, anchorDomHeight);

        // 计算 anchorItem 后面的列表元素 scrollY
        for (let i = anchorDomIndex + 1; i < this.$refs.items.length; i++) {
          const item = this.$refs.items[i];
          const { height } = item.$el.getBoundingClientRect();
          this.$set(this.cachedHeight, item.index, height);
          // 当前元素的 scrollY 是上一个元素的 scrollY + 上一个元素的高度
          const scrollY = this.cachedScrollY[item.index - 1] + this.cachedHeight[item.index - 1];
          this.$set(this.cachedScrollY, item.index, scrollY);
        }
        // 计算 anchorItem 前面的列表元素 scrollY
        for (let i = anchorDomIndex - 1; i >= 0; i--) {
          const item = this.$refs.items[i];
          const { height } = item.$el.getBoundingClientRect();
          this.$set(this.cachedHeight, item.index, height);
          // 当前元素的 scrollY 是下一个元素的 scrollY - 当前元素的高度
          const scrollY = this.cachedScrollY[item.index + 1] - this.cachedHeight[item.index];
          this.$set(this.cachedScrollY, item.index, scrollY);
        }
      },
      // ...
    },
    // ...
  };
</script>

滚动更新「可视元素」

「可滚动高度」的计算已经在上面提过,而初始「可视元素」和固定高度的虚拟列表的计算是类似的,所以这里跳过这两点,只描述如何处理滚动更新「可视元素」。

根据滚动方向和偏移量,按顺序更新「锚点元素」→「头挂载元素」→「尾挂载元素」→「可视元素」:

// ...
export default {
  // ...
  methods: {
    // ...
    handleScroll() {
      const delta = this.$refs.scroller.scrollTop - this.lastScrollTop;
      this.lastScrollTop = this.$refs.scroller.scrollTop;
      // 1.更新「锚点元素」
      this.updateAnchorItem(delta);
      // 更新「头挂载元素」→「尾挂载元素」→「可视元素」
      this.updateVisibleData();
    },
    async updateAnchorItem(delta) {
      const lastIndex = this.anchorItem.index;
      const lastOffset = this.anchorItem.offset;
      delta += lastOffset;

      let index = lastIndex;
      const isPositive = delta >= 0;
      // 判断滚动方向
      if (isPositive) {
        // 用 delta 一直减去从「锚点元素」开始向下方向的「可视元素」高度,每减一次 index 前进一位
        while (index < this.listData.length && delta > (this.cachedHeight[index] || ESTIMATED_HEIGHT)) {
          // 当 this.cachedHeight[index] 不存在时,说明可能被快速拖动滚动条而略过渲染,此时需要填充估计高度
          if (!this.cachedHeight[index]) {
            this.$set(this.cachedHeight, index, ESTIMATED_HEIGHT);
          }
          delta -= this.cachedHeight[index];
          index++;
        }
        if (index >= this.listData.length) {
          this.anchorItem = { index: this.listData.length - 1, offset: 0 };
        } else {
          this.anchorItem = { index, offset: delta };
        }
      } else {
        // 用 delta 一直叠加从「锚点元素」开始向上方向的「可视元素」高度,每加一次 index 后退一位
        while (delta < 0) {
          // 当 this.cachedHeight[index] 不存在时,说明可能被快速拖动滚动条而略过渲染,此时需要填充估计高度
          if (!this.cachedHeight[index - 1]) {
            this.$set(this.cachedHeight, index - 1, ESTIMATED_HEIGHT);
          }
          delta += this.cachedHeight[index - 1];
          index--;
        }
        if (index < 0) {
          this.anchorItem = { index: 0, offset: 0 };
        } else {
          this.anchorItem = { index, offset: delta };
        }
      }
    },
    updateVisibleData() {
      // 2.更新「头挂载元素」,注意不能小于 0
      const start = (this.firstAttachedItem = Math.max(0, this.anchorItem.index - BUFFER_SIZE));
      // 3.更新「尾挂载元素」
      this.lastAttachedItem = this.firstAttachedItem + VISIBLE_COUNT + BUFFER_SIZE * 2;
      const end = Math.min(this.lastAttachedItem, this.listData.length);
      // 4.更新「可视元素」
      this.visibleData = this.listData.slice(start, end);
    },
    // ...
  },
  // ...
};

修正滚动条

到这一步,这个「动态高度虚拟列表」已经大致可用了,但是还有一个问题,就是当用户快速拖动滚动条,因为「滚动差值」很大,所以会略过中间元素的渲染,此时这些略过的元素在cachedHeight中用ESTIMATED_HEIGHT进行存储,因此会出现两种情况:

  1. 估算的「可滚动高度」小于实际的「可滚动高度」。比如略过了中间 20 个元素,这些略过元素的估算高度总值为 ESTIMATED_HEIGHT(180) * 20 = 3600,而假设实际元素真正渲染时的平均高度为 300,即略过元素的实际高度总值为 300 * 20 = 6000。可以得知差值为 3600 - 6000 = -2400,滚动到顶部时,无法滚动到第一个元素。
  2. 估算的「可滚动高度」大于实际的「可滚动高度」。比如略过了中间 20 个元素,这些略过元素的估算高度总值为 ESTIMATED_HEIGHT(180) * 20 = 3600,而假设实际元素真正渲染时的平均高度为 100,即略过元素的实际高度总值为 100 * 20 = 2000。可以得知差值为 3600 - 2000 = 1600,滚动到顶部时会有空白部分。

考虑在这种情况下,可能会有往回滚动的场景,所以必须在发现「可滚动高度」过小或过大的时候,必须进行及时修正。修改原来的handleScrollupdateAnchorItemcalItemScrollY方法,添加相关逻辑。

export default {
  data() {
    return {
      // ...
      revising: false,
    };
  },
  // ...
  methods: {
    // ...
    handleScroll() {
      if (this.revising) return; // 修正滚动条时,屏蔽滚动逻辑
      // ...
    },
    async updateAnchorItem(delta) {
      // ...
      // 修正拖动过快导致的滚动到顶端滚动条不足的偏差
      if (this.cachedScrollY[this.firstAttachedItem] <= -1) {
        console.log('revising insufficient');
        this.revising = true;
        // 需要的修正的滚动高度为「锚点元素」之前的元素总高度 + 「锚点元素」的 offset
        const actualScrollTop =
          this.cachedHeight.slice(0, Math.max(0, this.anchorItem.index)).reduce((sum, h) => (sum += h), 0) + this.anchorItem.offset;
        this.$refs.scroller.scrollTop = actualScrollTop;
        this.lastScrollTop = this.$refs.scroller.scrollTop;
        if (this.$refs.scroller.scrollTop === 0) {
          this.anchorItem = { index: 0, offset: 0 };
        }
        // 更改了 lastScrollTop 后,需要重新计算「可视元素」的 scrollY
        this.calItemScrollY();
        this.revising = false;
      }
    },
    // 计算每一个「可视元素」的 scrollY
    async calItemScrollY() {
      // ...
      // 修正拖动过快导致的滚动到顶端有空余的偏差
      if (this.cachedScrollY[0] > 0) {
        console.log('revising redundant');
        this.revising = true;
        // 第一个列表元素的 cachedScrollY 即为多出的量
        const delta = this.cachedScrollY[0];
        const last = Math.min(this.lastAttachedItem, this.listData.length);
        for (let i = 0; i < last; i++) {
          this.$set(this.cachedScrollY, i, this.cachedScrollY[i] - delta);
        }
        const scrollTop = this.cachedScrollY[this.anchorItem.index - 1]
          ? this.cachedScrollY[this.anchorItem.index - 1] + this.anchorItem.offset
          : this.anchorItem.offset;
        this.$refs.scroller.scrollTop = scrollTop;
        this.lastScrollTop = this.$refs.scroller.scrollTop;
        this.revising = false;
      }
    },
    // ...
  },
  // ...
};

打完收工,「动态高度虚拟列表」实现完成,打开开发者工具,可以观察到就算滚动条一直向下,列表元素的个数都是恒定的,而且无论是快速拖动滚动条还是实时改变窗口宽度,整个列表都能正确地渲染:

你可以点击此处进行体验。

总结

本文介绍了前端业务开发中长列表的常规优化手段「虚拟列表」的定义和它在 Vue 环境中的实现,就「固定高度虚拟列表」和「动态高度虚拟列表」两个场景下以一个简单的 demo 详细讲述了虚拟列表的实现思路。

阅读完本文后可以发现,以本文的思路实现「虚拟列表」的关键在于「锚点元素」的计算和更新,理解了这一点之后就可以发现后续的实现都是按部就班的。

文字表达可能会有疏漏,建议通过下载本文的代码库(基于 Vue 2.x)运行调试,加深理解。

如果有不正确或难以理解的地方,欢迎通过邮件和留言进行指正讨论。

重要提示: 本文所有代码及示例项目只用于探讨虚拟列表的实现原理,请勿直接使用于生产。

戳这里访问原文地址,以获得更好的阅读体验。

参考

Complexities of an Infinite Scroller

Infinite List and React

浅说虚拟列表的实现原理

infinite-scroll-sample's People

Contributors

lkangd avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

infinite-scroll-sample's Issues

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.