虚拟列表的简单实现

本文最后更新于:2022年4月21日 上午

https://mp.weixin.qq.com/s/8uttkBw-dwYXsybKePQ3nA

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>虚拟列表</title>
    <style>
      html {
        height: 100%;
      }
      body {
        height: 100%;
        margin: 0;
      }
      #app {
        height: 100%;
      }
      .visible-area{
        height: 100%;
      }
      .infinite-list-container {
        overflow: auto;
        position: relative;
        -webkit-overflow-scrolling: touch;
        border: 2px solid black;
      }

      .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 {
        margin: 5px;
        color: #555;
        /* box-sizing: border-box; */
        border:6px solid gray;
        /* height:200px; */
      }
    </style>
  </head>
  <body>
    <div id="app">
      <div class="visible-area">
        我是可视区域      
        <virtual-list :list-data="list" :estimated-item-size="100" :buffer-scale="5"></virtual-list>
      </div>

      </virtual-list>
    </div>
    <script src="https://cdn.bootcdn.net/ajax/libs/Faker/3.1.0/faker.js"></script>
    <script type="module">
      import Vue from "https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js";
      Vue.component("virtual-list", {
        template: `
        <div ref="list" :style="{height}" class="infinite-list-container" @scroll="scrollEvent($event)">
          <div ref="phantom" class="infinite-list-phantom"></div>
          <div ref="content" class="infinite-list">
            <div class="infinite-list-item" ref="items" :id="item.id" :key="item.id" v-for="item in visibleData">
              <p><span style="color:red">{{item.id}}</span>{{item.value}}</p>
            </div>
          </div>
        </div>
        `,
        props: {
          //列表数据
          listData: {
            type: Array,
            default: () => [],
          },
          //预估高度
          estimatedItemSize: {
            type: Number,
            required: true,
          },
          //缓冲区比例
          bufferScale: {
            type: Number,
            default: 1,
          },
          //容器高度 100px or 50vh
          height: {
            type: String,
            default: "100%",
          },
        },
        computed: {
          // 可视区域内能显示几条数据
          visibleCount() {
            return Math.ceil(this.screenHeight / this.estimatedItemSize);
          },
          // 可视区域上方数据条数
          // Math.min取缓冲区值
          aboveCount() {
            return Math.min(this.start, this.bufferScale * this.visibleCount);
          },
          // 可视区域下方数据条数
          belowCount() {
            return Math.min(
              this.listData.length - this.end,
              this.bufferScale * this.visibleCount
            );
          },
          visibleData() {
            // 需要渲染的数据 = 显示的数据加上上下缓冲区
            let start = this.start - this.aboveCount;
            let end = this.end + this.belowCount;
            return this.listData.slice(start, end);
          },
        },
        created() {
          this.initPositions();
        },
        mounted() {
          this.screenHeight = this.$el.clientHeight;
          this.start = 0;
          this.end = this.start + this.visibleCount;
        },
        updated() {
          this.$nextTick(function () {
            if (!this.$refs.items || !this.$refs.items.length) {
              return;
            }
            //获取真实元素大小,修改对应的尺寸缓存
            this.updateItemsSize();
            //更新列表总高度
            let height = this.positions[this.positions.length - 1].bottom;
            this.$refs.phantom.style.height = height + "px";
            //更新真实偏移量
            this.setStartOffset();
          });
        },
        data() {
          return {
            //可视区域高度
            screenHeight: 0,
            //起始索引
            start: 0,
            //结束索引
            end: 0,
          };
        },
        methods: {
          initPositions() {
            this.positions = this.listData.map((d, index) => ({
              index,
              height: this.estimatedItemSize,
              top: index * this.estimatedItemSize,
              bottom: (index + 1) * this.estimatedItemSize,
            }));
          },
          //获取列表起始索引
          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 = end - 1;
              }
            }
            return tempIndex;
          },
          //更新列表项的当前尺寸
          updateItemsSize() {
            let nodes = this.$refs.items;
            nodes.forEach((node) => {
              let rect = node.getBoundingClientRect();
              let height = rect.height;
              let index = +node.id.slice(1);
              let oldHeight = this.positions[index].height;
              let dValue = oldHeight - height;
              //存在差值
              if (dValue) {
                this.positions[index].bottom =
                  this.positions[index].bottom - dValue;
                this.positions[index].height = height;
                for (let k = index + 1; k < this.positions.length; k++) {
                  this.positions[k].top = this.positions[k - 1].bottom;
                  this.positions[k].bottom = this.positions[k].bottom - dValue;
                }
              }
            });
          },
          //计算偏移量并translate
          setStartOffset() {
            let startOffset;
            if (this.start >= 1) {
              let size =
                this.positions[this.start].top -
                (this.positions[this.start - this.aboveCount]
                  ? this.positions[this.start - this.aboveCount].top
                  : 0);
              startOffset = this.positions[this.start - 1].bottom - size;
            } else {
              startOffset = 0;
            }
            this.$refs.content.style.transform = `translate3d(0,${startOffset}px,0)`;
          },
          //滚动事件
          scrollEvent() {
            //当前滚动位置
            let scrollTop = this.$refs.list.scrollTop;
            // let startBottom = this.positions[this.start - ]
            //此时的开始索引
            this.start = this.getStartIndex(scrollTop);
            //此时的结束索引
            this.end = this.start + this.visibleCount;
            //此时的偏移量
            this.setStartOffset();
          },
        },
      });
      new Vue({
        el: "#app",
        data: {
          dataLength: 100000,
          list: [],
        },
        created() {
          for (let id = 0; id < this.dataLength; ++id) {
            this.list.push({
              id,
              value: faker.lorem.sentences(),
            });
          }
        },
      });
    </script>
  </body>
</html>

虚拟列表的简单实现
http://yoursite.com/2022/02/24/虚拟列表的简单实现/
作者
tatekii
发布于
2022年2月24日
许可协议