vue造轮子之虚拟列表

12 篇文章 0 订阅

前提:

在我负责一个模块中,有一个是日志管理模块,它会输出很多很多的操作的日志,起初与后台对接对接的时候并没有考虑到性能渲染问题,只是简单的用v-for把所有的数据都渲染出来,那么这样导致的后果就是页面卡顿,这个原因是你的模块中渲染出太多的DOM节点导致,为了避免这样的问题我采取了虚拟列表来进行渲染。

什么是虚拟列表

虚拟列表就是一个按需渲染的过程,简单来说就是渲染你所看到的内容,对于你非可视的内容不进行渲染,达到性能的优化。

在这里插入图片描述
在图中我们可以看到,你所能看到的就是元素7到元素14的内容,当我们的滚动条进行滚动的时候,我们只需要通过滚动条的scrollTop对startIndex和endIndex进行改变,此外我们还需要一个data.length * itemHeight 的高度容器去撑开外围DIV,从而去获取滚动条。

实现虚拟列表就是在处理用户滚动时,要改变列表在可视区域的渲染部分,其具体步骤如下:
计算当前可见区域起始数据的 startIndex
计算当前可见区域结束数据的 endIndex
计算当前可见区域的数据,并渲染到页面中
计算 startIndex 对应的数据在整个列表中的偏移位置 startOffset,并设置到列表上
计算 endIndex 对应的数据相对于可滚动区域最底部的偏移位置 endOffset,并设置到列表上

这里的滚动条组件,我是使用element中el-scrollBar滚动条组件。我们假设现在每个item都是固定的高度值为30。

virtualList/index.vue

<template>
  <el-scrollbar class="virtualList z-h-100" ref="scrollbar">
    <div class="virtualList-phantom" ref="content" :style="{height:contentHeight}">
      <div class="virtualList-wrapper" ref="wrapper">
        <div
          class="virtualList-phantom-item"
          v-for="item in visibleData" :key="item.id"
          :style="{height:itemHeight+'px'}"
        >
          <!-- <slot ref="slot" :item="item.item"></slot> -->
          <slot ref="slot" :item="item"></slot>
         </div>
      </div>
    </div>
  </el-scrollbar>
</template>
<script>
export default {
  name: "virtualList",
  props: {
    listData: {
      type: Array,
      default: () => []
    },
    itemHeight: {
      type: Number,
      default: 30
    }
  },
  computed: {
    contentHeight() {
      return this.listData.length * this.itemHeight + "px"; // 计算总高度用来撑开滚动条
    }
  },
  data() {
    return {
      visibleData: [] // 可视的数据
    };
  },
  mounted () {
    this.updateVisibleData();
    this.handleScroll();
  },
  methods: {
    updateVisibleData(scrollTop = 0) {
      const visibleCount = Math.ceil(this.$el.clientHeight / this.itemHeight);
      const start = Math.floor(scrollTop / this.itemHeight);
      const end = start + visibleCount;
      this.visibleData = this.listData.slice(start, end);
      let pH = start * this.itemHeight;
      this.$refs.content.style.webkitTransform = `translate3d(0, ${-pH}px, 0)`;
      this.$refs.wrapper.style.webkitTransform = `translate3d(0, ${pH * 2}px, 0)`;
    },
    handleScroll() {
      this.$nextTick(() => {
        let scrollbarEl = this.$refs.scrollbar.wrap;
        scrollbarEl.onscroll = () => {
          var st = this.$refs.scrollbar.$refs.wrap.scrollTop; // 滚动条距离顶部的距离
          this.updateVisibleData(st);
        };
      });
    },
  }
};
</script>

<style lang="scss">
.z-h-100{
  height: 100%;
}
.virtualList{
  width:100%;
  height:100%;
  position: relative;
  overflow: hidden;
  border: 1px solid #aaa;
  .virtualList-phantom{
    width:100%;
  }
  .virtualList-wrapper{
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
  }
  .el-scrollbar__wrap{
    overflow-x: hidden;
  }
}
</style>

item.vue

<template>
  <p>
    <span style="color:red">{{item.id}}</span>
    {{item.value}}
  </p>
</template>


<script>
export default {
  props: {
    // 所有列表数据
    item: {
      type: Object,
      default: () => ({})
    }
  }
};
</script>


<style scoped>
</style>

父组件调用

<template>
  <div class="virtualListView">
    <virtualList :listData="data" v-slot="slotProps">
      <Item :item="slotProps.item"/>
    </virtualList>
  </div>
</template>

<script>
import virtualList from "@/components/virtualList/index";
import Item from "@/components/virtualList/item";
export default {
  name: "virtualListView",
  components: {
    virtualList,
    Item
  },
  data() {
    return {
      data: Array.from({ length: 3000 }, (k, v) => ({
        date: "2016-05-02" + v,
        name: "王小虎" + v,
        value: "上海市普陀区金沙江路 1518 弄" + v,
        id: v
      }))
    };
  }
};
</script>

<style>
.virtualListView{
  padding:10px;
  height: 100%;
}
</style>

通过打开F12我们可以发现,你所需渲染的节点,你所看不到的dom节点,都会动态的消失和隐藏掉。
在这里插入图片描述
不定高度的虚拟列表
由于业务的需求,这个时候每个列表的高度,都是不统一的,有些文字长,有些文字短,那么这种情况就很烦恼了,由于高度不知道,我们无法计算出总高度,那么也无法计算出当前渲染显示出多少个列表元素,为了解决这个问题,我可是撒费了苦心,都不知道查阅了多少资料和熬掉了多少根头发。

我们需要一个estimatedItemSize变量去预设置每个列表的高度,为了数据缓存起见,我们需要一个position数组来对每个item存放它的下标,高度(预设高度或者真实高度),item距离顶部的大小,当前列表的总高度。

<template>
  <el-scrollbar class="virtualList z-h-100" ref="scrollbar">
      <div ref="list" :style="{height:contentHieghtS+'px'}" class="infinite-list-container">
        <div ref="content" class="infinite-list">
          <div class="infinite-list-item" ref="items" :id="item._index" :key="item._index" v-for="item in visibleData">
            <slot ref="slot" :item="item.item"></slot>
          </div>
      </div>
      </div>
  </el-scrollbar>
</template>


<script>

export default {
  name: "VirtualList",
  props: {
    // 所有列表数据
    listData: {
      type: Array,
      default: () => []
    },
    // 预估高度
    estimatedItemSize: {
      type: Number,
      required: true
    },
  },
  computed: {
    _listData() {
      return this.listData.map((item, index) => ({
        _index: `_${index}`,
        item
      }));
    },
    visibleData() { // 可见的数据
      let start = this.start;
      let end = this.end;
      return this._listData.slice(start, end);
    },
    visibleCount() { // 可视区域存放的数量
      return Math.ceil(this.screenHeight / this.estimatedItemSize);
    },
  },
  created() {
    this.initPositions();
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  updated() {
    console.log("updated");
    this.$nextTick(() => {
      if (!this.$refs.items || !this.$refs.items.length) {
        return ;
      }
      // 更新列表总高度
      let height = this.positions[this.positions.length - 1].bottom;
      this.contentHieghtS = height;
    });
  },
  data() {
    return {
      // 可视区域高度
      screenHeight: 0,
      // 起始索引
      start: 0,
      // 结束索引
      end: 0,
      contentHieghtS: 0 // 内容撑开的真实高度
    };
  },
  methods: {

    initPositions() {
      this.positions = this.listData.map((d, index) => ({
        index,
        height: this.estimatedItemSize,
        top: index * this.estimatedItemSize,
        bottom: (index + 1) * this.estimatedItemSize
      })
      );
    },
  }
};
</script>


<style  lang="scss">
.z-h-100{
  height: 100%;
}
 .el-scrollbar__wrap{
    overflow-x: hidden !important;
  }
// .virtualList{
//   .el-scrollbar__wrap{
//     overflow-x: hidden;
//   }
// }
.infinite-list-container {
  overflow: hidden;
  position: relative;
  height: 900px;
  // -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;
}
/* .infinite-list-scrollItem{
  padding: 5px;
  color: #555;
  box-sizing: border-box;
  border-bottom: 1px solid #999;
} */

</style>

注意:列表的最后一项的bottom值是当前列表的总高度。虽然目前是能够渲染出来数据,但仍然无法变化到每个item的高度,目前他们的高度还是由父组件传递过来的estimatedItemSize值(假设100),所以我们还需要在update生命周期中改变每个列表当前的height值,

<script>
export default {
  name: "VirtualList",
  props: {
    // 所有列表数据
    listData: {
      type: Array,
      default: () => []
    },
    // 预估高度
    estimatedItemSize: {
      type: Number,
      required: true
    },
  },
  computed: {
    _listData() {
      return this.listData.map((item, index) => ({
        _index: `_${index}`,
        item
      }));
    },
    visibleData() { // 可见的数据
      let start = this.start;
      let end = this.end;
      return this._listData.slice(start, end);
    },
    visibleCount() { // 可视区域存放的数量
      return Math.ceil(this.screenHeight / this.estimatedItemSize);
    },
  },
  created() {
    this.initPositions();
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  updated() {
    console.log("updated");
    this.$nextTick(() => {
      if (!this.$refs.items || !this.$refs.items.length) {
        return ;
      }
      // 获取真实元素大小,修改对应的尺寸缓存
      this.updateItemsSize();
      // 更新列表总高度
      let height = this.positions[this.positions.length - 1].bottom;
      this.contentHieghtS = height;
    });
  },
  data() {
    return {
      // 可视区域高度
      screenHeight: 0,
      // 起始索引
      start: 0,
      // 结束索引
      end: 0,
      contentHieghtS: 0 // 内容撑开的真实高度
    };
  },
  methods: {
    // 初始化位置
    initPositions() {
      this.positions = this.listData.map((d, index) => ({
        index,
        height: this.estimatedItemSize,
        top: index * this.estimatedItemSize,
        bottom: (index + 1) * this.estimatedItemSize
      })
      );
    },
    // 获取列表项的当前尺寸
    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++) { // 后面的元素top和bottom也要发生变化
            this.positions[k].top = this.positions[k - 1].bottom;
            this.positions[k].bottom = this.positions[k].bottom - dValue;
          }
        }
      });
    },
  }
};
</script>

另外还需要获取可视区域的偏移值,保证可视区域在容器里面

<template>
  <el-scrollbar class="virtualList z-h-100" ref="scrollbar">
      <div ref="list" :style="{height:contentHieghtS+'px'}" class="infinite-list-container">
        <div ref="content" class="infinite-list">
          <div class="infinite-list-item" ref="items" :id="item._index" :key="item._index" v-for="item in visibleData">
            <slot ref="slot" :item="item.item"></slot>
          </div>
      </div>
      </div>
  </el-scrollbar>
</template>


<script>

export default {
  name: "VirtualList",
  props: {
    // 所有列表数据
    listData: {
      type: Array,
      default: () => []
    },
    // 预估高度
    estimatedItemSize: {
      type: Number,
      required: true
    },
  },
  computed: {
    _listData() {
      return this.listData.map((item, index) => ({
        _index: `_${index}`,
        item
      }));
    },
    visibleData() { // 可见的数据
      let start = this.start;
      let end = this.end;
      return this._listData.slice(start, end);
    },
    visibleCount() { // 可视区域存放的数量
      return Math.ceil(this.screenHeight / this.estimatedItemSize);
    },
  },
  created() {
    this.initPositions();
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
    this.handleScroll();
  },
  updated() {
    console.log("updated");
    this.$nextTick(() => {
      if (!this.$refs.items || !this.$refs.items.length) {
        return ;
      }
      // 获取真实元素大小,修改对应的尺寸缓存
      this.updateItemsSize();
      // 更新列表总高度
      let height = this.positions[this.positions.length - 1].bottom;
      this.contentHieghtS = height;
      // 更新真实偏移量
      this.setStartOffset();
    });
  },
  data() {
    return {
      // 可视区域高度
      screenHeight: 0,
      // 起始索引
      start: 0,
      // 结束索引
      end: 0,
      contentHieghtS: 0 // 内容撑开的真实高度
    };
  },
  methods: {
    handleScroll() {
      this.$nextTick(() => {
        let scrollbarEl = this.$refs.scrollbar.wrap;
        scrollbarEl.onscroll = () => {
          var st = this.$refs.scrollbar.$refs.wrap.scrollTop;
          this.scrollEvent(st);
        };
      });
    },
    // 初始化位置
    initPositions() {
      this.positions = this.listData.map((d, index) => ({
        index,
        height: this.estimatedItemSize,
        top: index * this.estimatedItemSize,
        bottom: (index + 1) * this.estimatedItemSize
      })
      );
    },
    // 获取列表项的当前尺寸
    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;
          }
        }
      });
    },
    // 获取当前的偏移量
    setStartOffset() {
      let startOffset;
      if (this.start >= 1) {
        startOffset = this.positions[this.start - 1].bottom;
      } else {
        startOffset = 0;
      }
      this.$refs.content.style.transform = `translate3d(0,${startOffset}px,0)`;
    },
    // 获取列表起始索引
    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, 10);
        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;
    },
    // 滚动事件
    scrollEvent(scrollTop) {
      // 当前滚动位置
      // 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();
    }
  }
};
</script>

其实这个时候留白的区域还是挺多的,所以我们就打算渲染多几个列表,所以我们就借助几个变量

computed:{
	 aboveCount() {
      return Math.min(this.start, this.bufferScale * this.visibleCount);
    },
    belowCount() {
      return Math.min(this.listData.length - this.end, this.bufferScale * this.visibleCount);
    },
}
props:{
	// 缓冲区比例
    bufferScale: {
      type: Number,
      default: 1
    },
}

完整的源代码

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值