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

长列表的处理

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 分页 + 虚拟列表

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/144613.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

OSCNet: Orientation-Shared Convolutional Network for CT Metal Artifact Learning

OSCNet: 面向共享的CT金属伪影学习卷积网络 论文链接&#xff1a;https://ieeexplore.ieee.org/document/10237226 项目链接&#xff1a;https://github.com/hongwang01/OSCNet&#xff08;目前不会开源&#xff09; Abstract X射线计算机断层扫描(CT)已广泛应用于疾病诊断和…

计算机毕业设计选题推荐-记录生活微信小程序/安卓APP-项目实战

✨作者主页&#xff1a;IT研究室✨ 个人简介&#xff1a;曾从事计算机专业培训教学&#xff0c;擅长Java、Python、微信小程序、Golang、安卓Android等项目实战。接项目定制开发、代码讲解、答辩教学、文档编写、降重等。 ☑文末获取源码☑ 精彩专栏推荐⬇⬇⬇ Java项目 Python…

GB28181/GB35114国标平台LiveGBS适配国产信创环境,使用国产数据库达梦数据库、高斯数据库、瀚高数据库的配置方法...

1、如何配置切换信创达梦数据库&#xff1f; livecms.ini -> [db]下面添加配置如&#xff1a; ... [db] dialectdm url dm://SYSDBA:Aa12345678localhost:5236/livegbs 2、如何配置切换高斯数据库&#xff1f; livecms.ini -> [db]下面添加配置如&#xff1a; ... [db] d…

洗地机是智商税吗?洗地机有没有必要买?2023洗地机推荐

传统的扫地拖地方式不仅时间长&#xff0c;被毛孩子和萌娃制造的顽固污渍更是让人头痛不已&#xff0c;高效又有效的地面清洁方式成了我们最大的诉求。目前洗地机受到青睐&#xff0c;异常火爆&#xff0c;也成为一众清洁扫地的选择之一&#xff0c;那洗地机到底是不是智商税呢…

物联网AI MicroPython学习之语法 umqtt客户端

学物联网&#xff0c;来万物简单IoT物联网&#xff01;&#xff01; umqtt 介绍 模块功能: MQTT客户端功能 - 连线、断线、发布消息、订阅主题、KeepAlive等功能。 MQTT协议采用订阅者/发布者模式&#xff0c;协议中定义了消息服务质量&#xff08;Quality of Service&#x…

Winform / WPF 自定义控件 —— IPV4 地址输入框

在开始阅读本文之前&#xff0c;如果您有学习创建自定义控件库并在其他项目中引用的需求&#xff0c;请参考&#xff1a;在Visual Studio中创建自定义Winform控件库并在其他解决方案中引用https://blog.csdn.net/YMGogre/article/details/126508042 0、引言 Winform / WPF 框架…

docker命令大全

1、查看Docker 容器占用的空间 docker ps -s2、查看所有容器 docker ps -a3、启动、关闭、重启一个已存在的容器 docker start <容器ID> docker stop <容器ID> docker restart <容器ID> 4、进入容器&#xff0c;退出终端的时候不会关闭container的ma…

线程池的使用

线程池的作用 降低线程创建和销毁的开销&#xff1a;线程的创建和销毁是比较昂贵的操作。通过使用线程池&#xff0c;可以避免频繁地创建和销毁线程&#xff0c;而是复用线程池中已经存在的线程&#xff0c;从而降低了开销。 控制并发度&#xff1a;通过控制线程池中线程的数量…

(个人实测保熟)记录Tecnomatix Process Simulate 16.1.2官方安装包及授权许可配置教程(Win10环境)

Tecnomatix Process Simulate 16是一款由西门子公司推出的一款工艺仿真解决方案,是虚拟制造仿真领域的领先解决方案,可帮助您数字化制造以及将创新思想和原材料转变为变革性产品的过程。在网上找了一些盗版的安装包&#xff0c;就很离谱。直接提示本"无法打开此安装程序包…

spring-cloud-alibaba-nacos

spring cloud nacos 安装和启动nacos # 解压nacos安装包 # tar -zvxf nacos-server-1.4.1.tar.gz# nacos默认是以集群的模式启动&#xff0c;此处先用单机模式 # cd /usr/local/mysoft/nacos/bin # sh startup.sh -m standalone# nacos 日志 # tail -f /usr/local/mysoft/na…

智慧隧道:TSINGSEE青犀远程视频AI智能监管平台保障隧道施工安全

一、背景与需求分析 随着我国交通运输量的增加以及新基建的不断规划和建设&#xff0c;公路建设工作也在持续开展中。高速公路隧道属于特殊构造段&#xff0c;因为隧道空间小&#xff0c;密闭性强&#xff0c;施工过程中一旦发生火灾、事故等&#xff0c;将带来重大人员伤亡和…

身份证照片怎么弄成200k以内?超级好用!

一些网站为了限制大的文件上传&#xff0c;提出了一些大小限制的要求&#xff0c;那么身份证如何弄成200k呢&#xff1f;下面介绍三种方法。 方法一&#xff1a; 使用嗨格式压缩大师 1、在电脑上打开安装好的软件&#xff0c;在首界面中点击“图片压缩”。 2、进入后上传需要…

Vue CLI脚手架安装、搭建、配置 和 CLI项目分析

目录 一、CLI快速入门 1. 官方介绍 : 2.安装Vue CLI : 3.搭建Vue CLI : 4.IDEA配置Vue CLI : 二、Vue CLI项目分析 1.结构分析 : 1.1 config 1.2 node_modules 1.3 src 1.4 static 2.流程分析 : 2.1 main.js 2.2 router/index.js 2.3 components/HelloWorld.vue 2.4 A…

“糖尿病日”感言

长期旺盛的写作欲&#xff0c;今天忽地就莫名其妙地衰退下来了。感到浑身都不舒服&#xff0c;特别是过去从未出现过的腰微痛、乏力现象发生了。 转念一想&#xff0c;或是老龄人一日不如一日的正常反应吧&#xff1f;而且&#xff0c;今天恰逢“ 联合国糖尿病日”&#xff0c…

mysql之MHA

1、定义 全称是masterhigh avaliabulity。基于主库的高可用环境下可以实现主从复制及故障切换&#xff08;基于主从复制才能故障切换&#xff09; MHA最少要求一主两从&#xff0c;半同步复制模式 2、作用 解决mysql的单点故障问题。一旦主库崩溃&#xff0c;MHA可以在0-30…

Spark读取excel文件

文章目录 一、excel数据源转成csv二、Spark读取csv文件(一)启动spark-shell(二)读取csv生成df(三)查看df内容一、excel数据源转成csv 集群bigdata - ubuntu: 192.168.191.19master(bigdata1) - centos: 192.168.23.78 slave1(bigdata2) - centos: 192.168.23.79 slave2(b…

多商家签到打卡奖励免单霸王餐小程序开发

多商家签到打卡奖励免单霸王餐小程序开发 用户注册和登录&#xff1a;提供用户注册和登录功能&#xff0c;以便用户能够参与签到打卡活动。 商家入驻&#xff1a;商家可申请入驻平台&#xff0c;提交相关资料并等待平台审核&#xff0c;审核通过后即可发布活动和奖励。 签到打…

组件库篇 | EUI | 补充知识

组件库篇 | EUI | 补充知识 编码 项目中请务必使用Unicode编码,否则会出现中文乱码问题。 设置方法:使用VS打开项目,项目-属性-高级-字符集-Unicode编码。 数据类型 C语言中的常用数据类型不作讲解,主要讲解一些C语言初学者不熟悉的类型。这些类型大都只需要你传参,因此…

风景照片不够清晰锐利,四招帮你轻松解决

我们大家在拍摄风景照的时候都希望能够拍摄出清晰锐利的照片。可能会有人问&#xff1a;“什么是锐利&#xff1f;”我们可以从锐度来给大家简单解说下。锐度是反映图片平面清晰度和图像边缘对比度的一个参数。锐度较高的画面&#xff0c;微小的细节部分也会表现得很清晰&#…