yliu

时来天地皆同力,运去英雄不自由

长列表优化


前言

在项目中如果能分页实现那最好不过了,不过很多时候长列表不可避免,这里又分两种情况

  1. 第一次不用全部加载完成,这种可以使用懒加载或者说无限滚动的方式来实现
  2. 另外一种则是一次要渲染全部数据出现

下面就来讨论这两种情况如何进行优化,可以对比列表优化具体实现的源码来看 (注:下面是用 Vue 实现的,使用其他框架并不影响)

无限滚动

实现的思路很简单就是根据滚动条是否滚动到底部(总高度 - 可见高度 - 滚动条高度),滚动到底部就添加新的数据

function scroll({ target }) {
  const DISTANCE = 40;
  const h = target.scrollHeight - (target.clientHeight + target.scrollTop);
  if (h < DISTANCE) {
    for (let i = 0, j = this.list.length, l = this.list.length; i < l; i++) {
      this.list.push(j + i);
    }
  }
}

虚拟列表

v2-f00bb3f5d9815d660d7bcbd87236af86_hd

引用一张图,可以看见我们实现的思路就是只渲染可见部分的列表,每次滚动条变化的时候更改展示的列表,在下面的演示中,我们都会用到一个基础的 html 结构,这里先贴一下

<div class="root">
  <div class="container"></div>
  <ul class="content">
    <li class="item" v-for="item of nowList" :key="item.value">
      {{ item.value }}
    </li>
  </ul>
</div>
.root {
  border: 1px solid #999;
  list-style: none;
  overflow: auto;
  height: 400px;
  position: relative;
  .container {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    z-index: -1;
  }
  .content {
    .container();
    z-index: 1;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .item {
    border-bottom: 1px solid #ccc;
    padding-left: 40px;
  }
}

上面结构做了两件事情

  1. 固定总列表的高度,让其出现滚动条
  2. 用一个遮罩 div 撑起整个列表的高度

固定

这里假设每个列表的高度为 30px,剩下的部分就是计算出列表的总体高度以及开始索引结束索引,核心代码只有不到 10 行

scroll() {
  const dom = this.$refs.root;
  const total = Math.ceil(dom.clientHeight / this.height);
  const start = Math.floor(dom.scrollTop / this.height);
  const end = start + total;
  this.start = start;
  this.end = end;
}

总索引: 当前视图的高度 / 子项的高度,不过注意需要向上取整; 开始索引: 滚动的距离 / 子项的高度 结束索: 总索引 + 开始索引 下面是完整的代码

<template>
  <div>
    <div class="root" ref="root" @scroll="scroll">
      <div class="container" :style="{ height: totalHeight }"></div>
      <ul class="content" :style="{ transform: getTransform }">
        <li
          class="item"
          :style="{ height: height + 'px', lineHeight: height + 'px' }"
          v-for="(item, i) of nowList"
          :key="i"
        >
          {{ item }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        list: Array(10000)
          .fill(1)
          .map((f, i) => i),
        height: 30,
        start: 0,
        end: 0
      };
    },
    computed: {
      totalHeight() {
        return this.height * this.list.length + "px";
      },
      nowList() {
        return this.list.slice(this.start, this.end);
      },
      getTransform() {
        return `translate3d(0,${this.start * this.height}px,0)`;
      }
    },
    mounted() {
      this.scroll();
    },
    methods: {
      scroll() {
        const dom = this.$refs.root;
        const total = Math.ceil(dom.clientHeight / this.height);
        const start = Math.floor(dom.scrollTop / this.height);
        const end = start + total;
        this.start = start;
        this.end = end;
      }
    }
  };
</script>

<style lang="less" scoped>
  .root {
    border: 1px solid #999;
    list-style: none;
    overflow: auto;
    height: 400px;
    position: relative;
    .container {
      position: absolute;
      left: 0;
      top: 0;
      right: 0;
      z-index: -1;
    }
    .content {
      .container();
      z-index: 1;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .item {
      border-bottom: 1px solid #ccc;
      padding-left: 40px;
    }
  }
</style>

非固定

非固定需要考虑的更多则是性能的问题,下面先贴一个完整的代码,在需要说明部分已经注释了

<template>
  <div>
    <div class="root" ref="root" @scroll="scroll">
      <div class="container" :style="{ height: totalHeight }"></div>
      <ul class="content" :style="{ transform: getTransform }">
        <li
          class="item"
          v-for="item of nowList"
          :style="{
            height: item.height + 'px',
            lineHeight: item.height + 'px'
          }"
          :key="item.value"
        >
          {{ item.value }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
  export default {
    data() {
      return {
        list: Array(10000)
          .fill(1)
          .map((f, i) => {
            return {
              value: i,
              height: this.getRandom(10, 100)
            };
          }),
        start: 0,
        end: 0,
        // 指针
        pointer: -1,
        // 缓存
        cache: {},
        // 初始总数
        initialHeight: 50
      };
    },
    computed: {
      totalHeight() {
        // 这里是获取总体高度,判断了两种情况,第一种是给定初始总数,另外一种则是没有,如果没有的话,高度就是已缓存的 + 未缓存的部分
        if (this.initialtotal >= 0) {
          const { top, height } =
            this.pointer >= 0
              ? this.getIndexOffset(this.pointer)
              : { top: 0, height: 0 };
          return `${top +
            height +
            (this.list.length - 1 - this.pointer) * this.initialHeight}px`;
        }
        const { height } = this.list.reduce(function(x, y) {
          return {
            height: x.height + y.height
          };
        });
        return height + "px";
      },
      // 可视数据
      nowList() {
        return this.list.slice(
          this.start,
          Math.min(this.end + 1, this.list.length)
        );
      },
      getTransform() {
        return `translate3d(0,${this.getIndexOffset(this.start).top}px,0)`;
      }
    },
    mounted() {
      this.scroll();
    },
    methods: {
      // 滚动事件
      scroll() {
        const dom = this.$refs.root;
        // 获取索引
        const start = this.getIndex(dom.scrollTop);
        // 把当前可视的高度 + 滚动条的高度,再去取索引
        const end = this.getIndex(dom.scrollTop + dom.clientHeight);
        this.start = start;
        this.end = end;
      },
      // 取出指定范围随机数
      getRandom(min, max) {
        return Math.floor(Math.random() * (max - min + 1)) + min;
      },
      // 根据滚动条y获取指定坐标
      getIndex(scrollTop) {
        // 判断思路很简单,如果高度大于滚动条肯定就出现了,另外一种则是判断了边界问题
        let total = 0;
        for (let i = 0, j = this.list.length; i < j; i++) {
          if (total >= scrollTop || j - 1 === i) {
            return i;
          }
          // 这里主要是起缓存作用的
          total += this.getIndexOffset(i).height;
        }
        return 0;
      },
      // 获取指定坐标的位置和高度
      getIndexOffset(index) {
        // 如果存在缓存中直接返回
        if (this.pointer >= index) {
          return this.cache[index];
        }
        let total = 0;
        // 这里是为了比较没有取到的情况
        if (this.pointer >= 0) {
          const li = this.cache[this.pointer];
          total = li.top + li.height;
        }
        // 注意上面因为取的值是li.top + li.height,所以i从 + 1开始
        for (let i = this.pointer + 1; i <= index; i++) {
          const size = this.list[i].height;
          this.cache[i] = {
            top: total,
            height: size
          };
          total += size;
        }
        if (index > this.pointer) {
          this.pointer = index;
        }

        return this.cache[index];
      }
    }
  };
</script>

<style lang="less" scoped>
  .root {
    border: 1px solid #999;
    list-style: none;
    overflow: auto;
    height: 400px;
    position: relative;
    .container {
      position: absolute;
      left: 0;
      top: 0;
      right: 0;
      z-index: -1;
    }
    .content {
      .container();
      z-index: 1;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    .item {
      border-bottom: 1px solid #ccc;
      padding-left: 40px;
    }
  }
</style>

上面只最终的实现,实际上跟固定高度相比就是增加了获取索引的方法,固定高度我们是知道对应子项的高度,所以可以通过可视高度来计算,而这里我用了随机数来设置高度,所以需要获取到对应的索引。 上面代码同时也做了两点优化,一是缓存,二是总高度优化

总高度的实现有两种思路:

  1. 计算所有的高度,这种实际上有点浪费性能;
  2. 给定一个大概的值,拿缓存的值 + 没有缓存的值,没有缓存的值就是对应数据的长度 - 已缓存的坐标,之后每次缓存变化的时候再计算;

缓存则比较简单了,每次计算的时候把指针移动到计算的位置,同时将值添加上

参考文章

  1. https://zhuanlan.zhihu.com/p/34585166