【业务场景】长列表的处理

长列表的处理

1. 什么是长列表

在前端开发中,经常会遇到列表展示,如果列表项的数量比较多,我们一般选择采用分页的方式来进行处理

但传统的前后翻页方式只适用于后台的管理系统中,而在用户端、尤其是在移动端,为了保障用户体验,往往不适合采用前后翻页。

所谓长列表,就是指这些数据量较大且无法使用分页方式来加载的列表。常见的场景有:订单列表、优惠券列表、评论区等等。

长列表会带来以下两方面的问题:

  1. 数据过多,主要是接口返回的数据过多,首次展示的等待时间较长,且数据不好处理
  2. DOM元素过多,导致页面渲染卡顿,页面中的操作卡顿。

2. 长列表的处理方案

2.1 下拉加载(无限滚动)

实际上就是懒加载的方式,一次只加载列表的一部分,等滚动到底部时,再加载列表的下一部分,相当于在垂直方向上的分页叠加功能。

这里用一个简单的小demo来说明下拉加载的实现原理。

首先简单说一下实现的思路:

  1. 渲染列表数据的div溢出的部分被隐藏掉
  2. 当向上或向下滚动到div的顶部或底部时,说明这些数据已经被浏览完毕了,需要加载新的数据,由于我们使用一个数组来维护所有的数据,所以实际上加载数据时只需要操作数组就行了

问题有两个:

  • 如何判断向上滚动还是向下滚动
  • 如何判断已经滚动到底部了

对于第一个问题,我们可以在每次滚动的时候,将这一次滚动的 scrollTop 记录下来作为 lastScroll,在下一次滚动时,将 lastScroll 与本次的 scrollTop 作比较,就可以得到滚动的方向了,接下来就只需要对两个方向的滚动分别进行处理就行了

对于第二个问题,我们可以看下面这张图:

在这里插入图片描述

scrollHeight 表示元素内容的真实高度,scrollTop 表示元素滚动的距离,而 clientHeight 则是元素内容在视口中展示的高度。当一个元素滚动到底部时,它们之间有这样的关系:scrollTop + clientHeight = scrollHeight

这样问题都解决了,接下来就可以动手实现了。

首先准备好渲染数据的容器:

<template>
  <div class="custom-view">
    <div class="list" ref="scroll">
      <div class="list_item" v-for="item in dataList" :key="item.index">
        <div class="list_item_content">
          <span class="list_item_content_info"
            >{{ item.content }}
          </span>
        </div>
      </div>
    </div>
  </div>
</template>

然后模拟一下我们实际开发中获取数据的方法:

getData() {
    setTimeout(() => {
        this.dataList = [
            ...
        ]
    }, 1000)
},

接下来,监听容器的滚动事件:

<div class="list" @scroll="handleScroll" ref="scroll">
	...
</div>

在处理函数中,我们首先需要拿到滚动元素,并判断其滚动的方向:

const el = this.$refs.scroll
// 判断滚动方向,1 --- 向上滚动,-1 --- 向下滚动, 0 --- 没有滚动
let scrollDirection = Math.sign(this.lastScroll - el.scrollTop)

然后对各滚动方向进行处理,这里只做向下滚动的处理:

  1. 判断是否滚动到底部
  2. 是则加载数据
  3. 记录滚动位置

最后的处理函数如下:

// 滚动
handleScroll() {
    // 获取滚动元素
    const el = this.$refs.scroll
    // 判断滚动方向,1 --- 向上滚动,-1 --- 向下滚动, 0 --- 没有滚动
    let scrollDirection = Math.sign(this.lastScroll - el.scrollTop)
    if (scrollDirection == -1) {
        // 此时向下滚动
        // 判断是否滚动到底部
        if(el.scrollHeight - el.scrollTop - el.clientHeight >= 20) return 
        const _that = this
        // 加载数据
        setTimeout(() => {
            if (_that.dataList.length < 25) {
                _that.dataList.push(
                    ...[
                        {
                            index: 20 + _that.i,
                            content: `这是第${20 + _that.i}条数据`
                        }
                    ]
                )
            } else {
                showMessage(_that, 'success', '所有数据已经加载完毕')
            }
            _that.i++
        }, 1000)
    }
    // 记录滚动位置
    this.lastScroll = el.scrollTop
}

当然,我们还可以为列表加上一个加载动画,这样看起来不会很突兀:

<div class="list" @scroll="handleScroll" ref="scroll">
    <div class="list_item" v-for="item in dataList" :key="item.index">
        <div class="list_item_content">
            <span class="list_item_content_info">{{ item.content }} </span>
        </div>
    </div>
    <div class="loading_container" v-if="isBottom">
        <div class="loading"></div>
        <span>加载中...</span>
    </div>
    <div class="default" v-else></div>
</div>

CSS

.loading_container {
    display: flex;
    justify-content: center;
    align-items: center;
    .loading {
        animation: spin 1s linear infinite;
        border: 4px solid #f3f3f3;
        border-top: 4px solid #02af95;
        border-radius: 50%;
        width: 20px;
        height: 20px;
        margin: 10px 10px;
    }
    @keyframes spin {
        0% {
            transform: rotate(0deg);
        }
        100% {
            transform: rotate(360deg);
        }
    }
}
.default {
    min-height: 40px;
}

为了实现加载动画,我们需要添加两个标识:

  • isBottom — 是否滚动到了底部
  • isLoading — 是否处于加载状态,处于加载状态时,滚动不做处理

接下来对我们的处理函数进行调整:

// 滚动
handleScroll() {
    // 获取滚动元素
    const el = this.$refs.scroll
    // 判断滚动方向,1 --- 向上滚动,-1 --- 向下滚动, 0 --- 没有滚动
    let scrollDirection = Math.sign(this.lastScroll - el.scrollTop)
    if (scrollDirection == -1) {
        // 此时向下滚动
        // 判断是否滚动到底部
        this.isBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 20
        const _that = this
        // 加载数据
        if (this.isBottom && !this.isLoading) {
            this.isLoading = true
            setTimeout(() => {
                if (_that.dataList.length < 25) {
                    _that.dataList.push(
                        ...[
                            {
                                index: 20 + _that.i,
                                content: `这是第${20 + _that.i}条数据`
                            }
                        ]
                    )
                } else {
                    showMessage(_that, 'success', '所有数据已经加载完毕')
                }
                _that.i++
                _that.isBottom = false
                _that.isLoading = false
            }, 1000)
        }
    }
    // 记录滚动位置
    this.lastScroll = el.scrollTop
}

demo的效果如下:

在这里插入图片描述

这只是一个简单的demo,在功能和样式上都有很大的优化空间,如果要在实际项目中实现无限滚动加载数据的话,可以使用:

  • 各组件库中的无限滚动组件
  • vue-infinite-scroll 插件
2.2 虚拟列表

对于上面的下拉加载方式,前面也说了,相当于在垂直方向上的分页叠加功能。但其又与实际的分页有所不同,因为其是将新的数据插入到原有数据的后面,这样,随着加载数据越来越多,浏览器的回流与重绘时的开销会越来越大。

而为了解决这一问题,我们就可以使用虚拟列表。虚拟列表的核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,然后使用padding或者translate来让渲染的列表偏移到可视区域中,给用户平滑滚动的感觉。

要实现虚拟列表,有以下五个步骤:

  1. 获取长列表的数据,但不会一次性将所有列表数据全部直接渲染在页面上
  2. 截取长列表中的一部分数据用于填充我们预留好的可视区域
  3. 长列表的不可视部分,我们使用空白的占位进行填充
  4. 监听滚动事件,根据滚动的位置,动态地改变可视列表中的数据项
  5. 监听滚动事件,根据滚动的位置,动态改变空白填充的大小

即下图

在这里插入图片描述

但是,我们还需要考虑一个问题:列表项的每一项高度是固定的吗?

由此,我们可以分为两种情况进行讨论

2.2.1 列表项固定高度

首先我们需要准备好模板:

HTML

<!-- 最外层的可视区容器 -->
<div ref="list" class="infinite-list-container" @scroll="throttle()">
<!-- <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)"> -->
    <!-- 中间的可滚动区域,z-index=-1,高度和真实列表相同,目的是使得外层出现相同的滚动条 -->
    <div
         class="infinite-list-phantom"
         :style="{ height: listHeight + 'px' }"
         ></div>
    <!-- 最上层的可视区列表,数据和偏移距离随着滚动距离的变化而变化 -->
    <div class="infinite-list" :style="{ transform: getTransform }">
        <div
             class="infinite-list-item"
             v-for="item in visibleData"
             :key="item.id"
             :style="{ height: itemSize + 'px' }"
             >
            {{ item.label }}
        </div>
    </div>
</div>

CSS

.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
}

/* 这里内部的滚动区域和可视区域都需要使用绝对定位,并将滚动区域放置在最底层 */
.infinite-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.infinite-list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
}

.infinite-list-item {
  line-height: 50px;
  text-align: center;
  color: #555;
  border: 1px solid #ccc;
  box-sizing: border-box;
}

当列表项固定时,计算方式比较简单,首先我们需要根据外层视口的大小,计算出可以渲染多少条数据 limit 以及内部可滚动区域的总高度:

// 列表的总高度,用于模拟滚动条!
listHeight() {
    return this.items.length * this.itemSize;
},
// 可视区列表的项数,即视口的高度除去 itemSize 并向上取整
visibleCount() {
    return Math.ceil(this.screenHeight / this.itemSize);
},

然后我们可以得到视口中列表数据的开始索引和结束索引:

this.start = 0;
// 初始化时,同样要多渲染一项,防止滚动时下方出现空白
this.end = this.start + this.visibleCount + 1;

这样我们就可以从列表数据中截取出可视区域中的列表数据:

// 获取可视区列表数据
visibleData() {
    return this.items.slice(
        this.start,
        Math.min(this.end, this.items.length)
    );
},

接下来就可以监听滚动事件并做出相应的处理了:

scrollEvent() {
    console.log(111)
    // 获取当前滚动位置
    let scrollTop = this.$refs.list.scrollTop;
    // 更新开始索引,向下取整
    this.start = Math.floor(scrollTop / this.itemSize);
    // 更新结束索引,这里多渲染一项,防止滚动时下方出现空白
    this.end = this.start + this.visibleCount + 1;
    // 此时的可视区列表向下偏移的距离
    /* 
        在滚动时,最上方的一项尚未离开视口,所以这个偏移距离需要减去多余的滚动距离 rest
        当最上方一项尚未离开视口时,rest 随着 scrollTop 变大但始终小于 itemSize,所以 scrollTop - rest 的结果是不会变化的,即这个偏移距离是不会变化的,也就是说这个偏移距离永远是 itemSize 的 n 倍!
        而 n 就是以及被滚动到视口上方的数据项数量
        当最上方一项刚好离开视口时,rest 为0,此时 scrollTop 就是当前滚动的距离,而 startOffset = scrollTop,相比之前,startOffset 增加了一个 itemSize 的大小。
        即,我们向下滚动一项,偏移就增大一项,从而实现视口跟随我们滚动的效果!
      */
    this.startOffset = scrollTop - (scrollTop % this.itemSize);
},

这里我们最终记录了可视区域应该向下偏移的距离 startOffset

此时只需要为中间的可视区域加上偏移即可:

// 可视区列表偏移距离对应的样式,这里用 translate3d 来实现向下的偏移(实际上就是y轴正方向上的偏移)
getTransform() {
    return `translate3d(0,${this.startOffset}px,0)`;
},

这样就完成了一个简单的固定高度虚拟列表,效果如下:

在这里插入图片描述

可以看到,成功模拟了列表的滚动效果,同时,页面中仅仅只渲染了当前可视区中的7个节点

完整代码如下:

myVirtualScroller.vue

<template>
  <!-- 最外层的可视区容器 -->
  <div ref="list" class="infinite-list-container" @scroll="throttle()">
  <!-- <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)"> -->
    <!-- 中间的可滚动区域,z-index=-1,高度和真实列表相同,目的是使得外层出现相同的滚动条 -->
    <div
      class="infinite-list-phantom"
      :style="{ height: listHeight + 'px' }"
    ></div>
    <!-- 最上层的可视区列表,数据和偏移距离随着滚动距离的变化而变化 -->
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div
        class="infinite-list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px' }"
      >
        {{ item.label }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "MyVirtualList",
  props: {
    //列表数据
    items: {
      type: Array,
      default: () => [],
    },
    //列表项高度
    itemSize: {
      type: Number,
      default: 50,
    },
  },
  computed: {
    // 列表的总高度,用于模拟滚动条!
    listHeight() {
      return this.items.length * this.itemSize;
    },
    // 可视区列表的项数,即视口的高度除去 itemSize 并向上取整
    visibleCount() {
      return Math.ceil(this.screenHeight / this.itemSize);
    },
    // 可视区列表偏移距离对应的样式,这里用 translate3d 来实现向下的偏移(实际上就是y轴正方向上的偏移)
    getTransform() {
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    // 获取可视区列表数据
    visibleData() {
      return this.items.slice(
        this.start,
        Math.min(this.end, this.items.length)
      );
    },
  },
  mounted() {
    // 初始化时,获取可视窗口的高度,用于计算出当前可视窗口中可以渲染几项数据
    this.screenHeight = this.$refs.list.clientHeight;
    this.start = 0;
    // 初始化时,同样要多渲染一项,防止滚动时下方出现空白
    this.end = this.start + this.visibleCount + 1;
  },
  data() {
    return {
      screenHeight: 0, //可视区域高度
      startOffset: 0, //偏移距离
      start: 0, //起始索引
      end: 0, //结束索引
      // 上一次触发的时间
      lastTime: 0,
    };
  },
  methods: {
    scrollEvent() {
      console.log(111)
      // 获取当前滚动位置
      let scrollTop = this.$refs.list.scrollTop;
      // 更新开始索引,向下取整
      this.start = Math.floor(scrollTop / this.itemSize);
      // 更新结束索引,这里多渲染一项,防止滚动时下方出现空白
      this.end = this.start + this.visibleCount + 1;
      // 此时的可视区列表向下偏移的距离
      /* 
        在滚动时,最上方的一项尚未离开视口,所以这个偏移距离需要减去多余的滚动距离 rest
        当最上方一项尚未离开视口时,rest 随着 scrollTop 变大但始终小于 itemSize,所以 scrollTop - rest 的结果是不会变化的,即这个偏移距离是不会变化的,也就是说这个偏移距离永远是 itemSize 的 n 倍!
        而 n 就是以及被滚动到视口上方的数据项数量
        当最上方一项刚好离开视口时,rest 为0,此时 scrollTop 就是当前滚动的距离,而 startOffset = scrollTop,相比之前,startOffset 增加了一个 itemSize 的大小。
        即,我们向下滚动一项,偏移就增大一项,从而实现视口跟随我们滚动的效果!
      */
      this.startOffset = scrollTop - (scrollTop % this.itemSize);
    },

    // 节流函数
    throttle() {
        const now = Date.now()
        // 这里设置的间隔时间一般为 30ms,如果再设置大一点,列表的底部就会出现空白
        if (now - this.lastTime > 30) {
            this.lastTime = now
            // 与 setInterval 不同,window.requestAnimationFrame 不需要指定执行的间隔时间,而是会在浏览器下一次重绘之前执行
            // 至于真正执行的时机,是由我们屏幕的刷新率来决定的
            /* 
                由于这里我们的处理函数中触发了浏览器的重绘,所以使用 window.requestAnimationFrame 相比 setInterval 更有优势
                因为 requestAnimationFrame 会把每一帧中的所有DOM操作集中起来,在一次重绘或回流中就完成,这样看起来更加平滑。
                同时对于 setInterval 而言,由于其在任务队列中会被阻塞,所以实际上每次等待的时间可能会大于我们指定的时间,但 requestAnimationFrame 可以保证在每一帧中都执行回调
                另外,使用 requestAnimationFrame 也有助于性能的提升
            */
            window.requestAnimationFrame(() => this.scrollEvent())
        }
    },
  },
  /* 
    存在的问题:

        滑动过快时仍会出现白屏。
  */
};
</script>

<style scoped>
.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
}

.infinite-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.infinite-list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
}

.infinite-list-item {
  line-height: 50px;
  text-align: center;
  color: #555;
  border: 1px solid #ccc;
  box-sizing: border-box;
}
</style>

App.vue

<template>
  <div class="container">
    <my-virtual-scroller :items="list" />
  </div>
</template>

<script>
import myVirtualScroller from "@/components/myVirtualScroller";
// 模拟一个长列表
const list = [];
for (let i = 0; i < 10000; i++) {
  list.push({
    id: i,
    label: `virtual-list ${i}`,
  });
}
export default {
  components: {
    myVirtualScroller,
  },
  data() {
    return {
      list: list,
    };
  },
};
</script>

<style scoped>
.container {
  height: 300px;
  border: 1px solid #ccc;
}
</style>

当然,这里也存在一个问题,当我们滚动的速度较快时,会出现白屏的现象。

2.2.2 列表项高度不固定

在列表项高度固定时,有很多相关的属性计算都很简单:

  1. 内部滚动区域的总高度 listHeight
  2. 可视窗口偏移量 startOffset
  3. 开始结束索引

但当列表项的高度不固定时,我们该如何计算这些属性呢?要计算这些属性,我们首先至少需要拿到列表项的真实高度,如何拿到?

所以,我们现在有下面几个问题需要解决:

  1. 如何获取列表项的真实高度?
  2. 如何计算相关属性?
  3. 如何渲染?
1. 列表项的真实高度

在实际渲染列表项的内容之前,我们是无从得知列表项的真实高度的,所以我们只能先预估一个高度,等待渲染出真实DOM后,在根据DOM的具体情况来设置高度

最后,我们还需要准备一个数组,将列表项的索引、高度以及定位存放在里面,初始化时,用我们预估的高度来初始化数组,在渲染出真实DOM后,再来更新这个数组。

2. 如何计算相关属性

既然列表项的高度不是固定的,那么我们原本的计算逻辑就都不能使用了,需要根据我们维护的数组来进行调整

3. 列表的渲染

具体的渲染方式不用进行调整,但开始索引的计算逻辑需要修改,现在我们需要在缓存列表中搜索第一个底部定位大于列表垂直偏移量的项并返回它的索引作为开始索引。


接下来就是具体实现了

首先要拿到列表的数据,并为列表项预估一个高度:

props: {
    //所有列表数据
    listData: {
        type: Array,
            default: () => [],
    },
    //预估高度
    estimatedItemSize: {
        type: Number,
        required: true,
    },
    //容器高度 100px or 50vh
    height: {
        type: String,
        default: "100%",
    },
},

然后先将列表数据处理一下,把列表数据的索引单独拿出来存进去,同时根据我们预估的高度,先算出一个大概的可视区域中可渲染列表项数量:

computed: {
    // 处理列表数据,为其加上一个自带的索引
    _listData() {
      return this.listData.map((item, index) => {
        return {
          _index: `_${index}`,
          item,
        };
      });
    },
    // 可视区域的可渲染列表项数量
    visibleCount() {
      return Math.ceil(this.screenHeight / this.estimatedItemSize);
    },
    ...,
}

接下来,准备一个 positions 数组,用于存放列表项的索引、高度以及定位信息,并在组件创建时,用我们预估的高度来初始化这个数组:

data() {
	return {
        ...,
        positions: [],
        ...,
    }
},
method: {
    // 初始化 positions 数组
    initPositions() {
      this.positions = this.listData.map((d, index) => ({
        index,
        height: this.estimatedItemSize, // 用预估高度来初始化
        top: index * this.estimatedItemSize,
        bottom: (index + 1) * this.estimatedItemSize,
      }));
    },
    ...,
},
created() {
    this.initPositions();
},

接下来,我们需要在组件挂载时,初始化我们的视口大小,以及可视区域列表数据的开始索引和结束索引

mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    // 这里我们更新了 screenHeight 后,会触发 visibleCount 重新计算,所以我们这里直接用开始索引加上可视区域中的列表项数量即可
    // 这里可能会多渲染一两项,但是为了避免下方的白屏问题本来就需要多渲染几项,所以正好
    this.end = this.start + this.visibleCount;
},

有了开始索引和结束索引,我们就可以从列表数据中截取出可视区域中的列表数据:

computed: {
    ...,
    // 可视区域中的列表项
    visibleData() {
      return this._listData.slice(this.start, this.end);
    },
}

下一步,准备好数据展示的容器:

HTML

<!-- 最外层的可视区容器 -->
<div
     ref="list"
     :style="{ height }"
     class="infinite-list-container"
     @scroll="scrollEvent($event)"
     >
    <!-- 中间的可滚动区域,z-index=-1,高度和真实列表相同,目的是使得外层出现相同的滚动条 -->
    <div ref="phantom" class="infinite-list-phantom"></div>
    <!-- 最上层的可视区列表,数据和偏移距离随着滚动距离的变化而变化 -->
    <div ref="content" class="infinite-list">
        <div
             class="infinite-list-item"
             ref="items"
             :id="item._index"
             :key="item._index"
             v-for="item in visibleData"
             >
            <p>
                <span style="color: red">{{ item.item.id }}</span
                    >&nbsp;
                <span style="color: blue">{{ item.item.value }}</span>
            </p>
        </div>
    </div>
</div>

CSS

.infinite-list-container {
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}

.infinite-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.infinite-list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
}

.infinite-list-item {
  padding: 5px;
  color: #555;
  box-sizing: border-box;
  border-bottom: 1px solid #999;
  /* height:200px; */
}

其实展示的逻辑与前面的固定高度虚拟列表相比并不需要变化。

接下来的问题,就是需要在渲染后,拿到真实DOM的高度并更新我们的 positions 数组:

// 获取列表项的当前尺寸
updateItemsSize() {
    // 拿到当前可视区域中渲染的节点 NodeList
    let nodes = this.$refs.items;
    nodes.forEach((node) => {
        // 获取该节点相对于视口的上下左右的位置以及自身的宽高信息
        let rect = node.getBoundingClientRect();
        let height = rect.height;
        // 拿到节点的id,实际上就是我们列表项的索引,只不过要从字符串转为number
        let index = +node.id.slice(1);
        // 节点原本的高度
        let oldHeight = this.positions[index].height;
        // 计算出差值
        let dValue = oldHeight - height;
        // 如果存在差值
        if (dValue) {
            /* 
            更新该节点本身的定位信息:
                1. 根据差值更新该节点底部距离滚动区域顶部的距离
                2. 更新该节点的高度信息
          */
            this.positions[index].bottom = this.positions[index].bottom - dValue;
            this.positions[index].height = height;
            // 根据更新后的信息,将该节点后续的所有列表项的信息也进行相应的修改
            for (let k = index + 1; k < this.positions.length; k++) {
                // 直接拿前一项的 bottom 作为这一项的 top
                this.positions[k].top = this.positions[k - 1].bottom;
                // 这一项的 bottom 就直接减去刚刚的差值即可
                this.positions[k].bottom = this.positions[k].bottom - dValue;
            }
        }
    });
},

那么我们在哪里调用这个方法呢?

注意,当我们滚动时,我们会更新DOM以及相关的数据,但上面这些 positons 中的数据,并不是每一次滚动时都需要修改,而是当DOM发生变化时,才需要更新!

所以,我们不能在滚动的处理函数中调用该方法,因为这样会有多余的调用。

这里我们选择在 updated 生命周期中调用该方法,即组件DOM或其中的数据更新时,才触发 positions 中数据的更新!但同时,我们还需要根据最新的 positions 数组中的数据来更新列表的总高度并重新计算可视区域的偏移量:

updated() {
    this.$nextTick(function () {
        if (!this.$refs.items || !this.$refs.items.length) {
            return;
        }
        // 获取当前可视区域中真实元素大小,修改对应的尺寸缓存
        this.updateItemsSize();
        // 更新列表总高度,用列表的最后一项的 bottom 属性,即列表最后一项底部距离滚动区域顶部的距离,来作为列表的总高度
        let height = this.positions[this.positions.length - 1].bottom;
        this.$refs.phantom.style.height = height + "px";
        // 更新真实偏移量
        this.setStartOffset();
    });
},

接下来,就是需要监听滚动事件并更新可视区域的列表数据了

但是,我们还需要准备一个更新开始索引的方法,以及最后重新计算可视区域偏移的方法:

/* 
        获取列表起始索引,由于我们在 positions 数组中存放的数据是有序的
        且我们计算起始索引的方式是:将 positions 数组中 bottom 属性与已滚动距离 scrollTop 相等的列表项的下一项作为起始项
        所以可以使用二分查找的方法来获取起始索引
*/
getStartIndex(scrollTop = 0) {
    // 二分法查找
    return this.binarySearch(this.positions, scrollTop);
},
// 二分法查找
binarySearch(list, value) {
    let start = 0;
    let end = list.length - 1;
    let tempIndex = null;
    while (start <= end) {
        let midIndex = parseInt((start + end) / 2);
        let midValue = list[midIndex].bottom;
        if (midValue === value) {
            return midIndex + 1;
        } else if (midValue < value) {
            start = midIndex + 1;
        } else if (midValue > value) {
            if (tempIndex === null || tempIndex > midIndex) {
                tempIndex = midIndex;
            }
            end = midIndex - 1;
        }
    }
    return tempIndex;
},
    
// 获取当前的偏移量
setStartOffset() {
    // 将开始索引的前一项列表项的 bottom 属性,即距离滚动区域顶部的距离,作为当前的偏移量
    let startOffset =
        this.start >= 1 ? this.positions[this.start - 1].bottom : 0;
    // 仍然使用 translate3d 实现偏移
    this.$refs.content.style.transform = `translate3d(0,${startOffset}px,0)`;
},

最后,我们监听外层容器的滚动事件,并更新开始索引和结束索引,以及偏移量即可:

// 滚动事件
scrollEvent() {
    // 当前滚动位置
    let scrollTop = this.$refs.list.scrollTop;
    // 获取开始索引
    this.start = this.getStartIndex(scrollTop);
    // 获取结束索引
    this.end = this.start + this.visibleCount;
    // 更新偏移量
    this.setStartOffset();
},

这样就实现了一个简单的不固定高度的虚拟列表,效果如下:
在这里插入图片描述

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

最后最后,我们来分析一下滚动的过程:

随着我们的滚动,当可视区域中的第一项尚未离开可视区域时,开始索引并不会发生变化

为什么?

因为我们是用 positions 中 【bottom 等于此时 scrollTop 的列表项的下一项】或【bottom 大于 scrollTop的列表项】 作为起始项,而此时没有满足第一个条件的列表项,且第一项的 bottom 仍然大于 scrollTop

既然开始索引没有变化,则结束索引也不会变化,那么可视区域中渲染的列表项也没有变化,所有 positions 中的高度、定位信息并没有变化,因此,startOffset 也不会变化,即可视区域在滚动区域中的位置不会变化,从而达到列表向上滚动的效果

而一旦第一项离开可视区域,开始索引变化,引起结束索引变化,进而引发 positions 中高度、定位信息的更新,最终导致 startOffset 变化,使得可视区域向下进一步偏移。

这两者效果结合,就模拟出了列表滚动的效果。

但是,对于高度不固定的虚拟列表,存在以下三个问题:

  1. 滚动过快时,会出现白屏
  2. 由于我们估计可视区域中可展示的列表项数量时,是根据我们预估的高度来计算的,如果我们预估的高度比实际高度高出太多,会导致可视区域中渲染的列表项数量过少,导致占不满可视区域的问题
  3. 如果列表项中需要展示图片,由于渲染时图片可能未加载出来,会导致计算高度时不准确

当然,实际开发中我们肯定不会专门为一个虚拟列表写这么多代码,与无限滚动相同,虚拟列表也有成熟的插件可供我们使用:

  • vue-virtual-scroller
2.3 虚拟列表中白屏问题的解决
2.4 分页 + 虚拟列表
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值