# 虚拟列表

# 1、简介

在web开发的过程中,或多或少都会遇到大列表渲染的场景,例如全国城市列表、通讯录列表、聊天记录列表等等。当列表数据量为几百条时,依靠浏览器本身的性能基本可以支撑,一般不会出现卡顿的情况。但当列表数量级达到上千,页面渲染或操作就可能会出现卡顿,而当列表数量突破上万甚至十几万时,网页可能会出现严重卡顿甚至直接崩溃。为了解决大列表造成的渲染压力,便出现了虚拟滚动技术。本文主要介绍虚拟滚动的基本原理,以及子项定高的虚拟滚动列表的简单实现。

虽然大列表的数据量很大,但是设备的显示区域是有限的,也就是说在同一时间,用户看到的内容是有限的。利用这一特点,可以将大列表按需渲染。也就是只渲染某一时刻用户看的到的内容,当用户滚动页面时,再通过JS的计算重现调整视窗内的内容,这样可以把列表子项的数量级别从几万降到几十。

借助按需渲染的思想来优化大列表在实现层面可以分成三步,一是确定当前视窗在哪,二是确定当前要真实渲染哪些节点,三是把渲染的节点移动到视窗内。对于问题一,视窗的位置对于长列表来说,其开始位置为列表滚动区域的scrollTop。对于问题二,按照视窗外内容不渲染的思路,则应该渲染数组索引从Math.floor(scrollTop/clientHeight)开始共Math.ceil(clientHeight/itemHeight)个元素。对于问题三,有多种实现思路,以下将介绍几种常见虚拟滚动的实现方式。

  • scrollTop:列表滚动区域的scrollTop
  • itemHeight:子节点的高度
  • clientHeight:视窗的高度

# 2、实现

# 2.1 transform

该方案主要是通过监听滚动区域的滚动事件,动态计算视窗内渲染节点的开始索引以及偏移量,然后重新触发渲染节点的渲染并将内容通过transform属性将该部分内容移动到视窗内。

至于为什么要移到视窗内, 其实显示的内容也可以一直固定在视口内,仅通过滚动距离的计算来更新要显示的元素, 但是这样子会有一个问题就是看不出滚动的效果。

而通过 transform之后, 内容会跟着滚动,视觉上更自然,只有在滚动距离超过一个子项高度时,才会触发重新渲染, 重新更新数据节点和transform的值。下面的position: absolute也是一样的道理。

可以查看demo

下面是一个关于原理的图

列表开始渲染的开始值:start = Math.floor(scrollTop / itemHeight) 视口里面需要渲染的个数:count = Math.ceil(clientHeight / itemHeight) translateY的值:translateY = start * itemHeight

下面是一个简单的Vue2.0的Demo

<template>
  <div class="virtual-list-wrapper"
       @scroll="onScroll($event)">
    <div :style="{height: totalHeight + 'px'}">
      <div class="virtual-content"
           ref="ContentRef">
        <div v-for="item in subList"
             :key="item"
             class="virtual-item"
             :style="{height: itemHeight + 'px', overflow: 'hidden'}">{{item}}</div>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: 'VirtualList',
  // mixins: [],
  // components: {},
  props: {
    list: {
      required: true,
      type: Array,
      default: []
    },
    itemHeight: {
      required: true,
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      start: 0,
      count: 0,
      totalHeight: 0
    };
  },
  computed: {
    subList() {
      return this.list.slice(this.start, this.start + this.count);
    }
  },
  // watch:{},
  created() {
    this.totalHeight = this.itemHeight * this.list.length;
  },
  mounted() {
    this.count = Math.ceil(this.$el.clientHeight / this.itemHeight);
    this.ContentEl = this.$refs.ContentRef;
  },
  // beforeRouteEnter(to, from, next) { next(vm => {}) },
  // beforeRouteUpdate(to, from, next) {},
  // beforeRouteLeave(to, from, next) {},
  // beforeDestroy() {},
  // destroyed() {},
  methods: {
    onScroll(e) {
      const scrollTop = e.target.scrollTop;
      console.debug(scrollTop);
      this.start = Math.floor(scrollTop / this.itemHeight);
      this.ContentEl.style.transform = `translate3d(0, ${this.start * this.itemHeight}px, 0)`;
    }
  }
};
</script>
<style scoped>
.virtual-list-wrapper {
  height: 100%;
  width: 100%;
  overflow-y: auto;
}
.virtual-item {
  box-sizing: border-box;
  border-bottom: 1px solid #333;
}
</style>

# 2.2 absolute

该方案与transform方案类似,都是通过监听滚动区域的滚动事件,动态的计算要显示的内容。但transform方案显示内容的偏量是动态计算并赋值的,而该方案则是利用absolute属性直接将待渲染的节点定位到其该出现的位置。如果把所有子节点包裹起来的话,父元素的top值为 start * itemHeight,这与视窗位置无关。视窗只决定了要渲染那些子节点,不影响子节点的相对位置。

查看一个Vue2.0的实现demo

<template>
  <div class="virtual-list-wrapper"
       @scroll="onScroll($event)">
    <div :style="{height: totalHeight + 'px'}">
    </div>
    <div class="virtual-content"
         ref="ContentRef"
         :style="{
           top: start * itemHeight + 'px'
         }">
      <div v-for="(item) in subList"
           :key="item"
           class="virtual-item"
           :style="{
              height: itemHeight + 'px', 
              overflow: 'hidden'
             }">{{item}}</div>
    </div>
  </div>
</template>
<script>
export default {
  name: 'VirtualList',
  // mixins: [],
  // components: {},
  props: {
    list: {
      required: true,
      type: Array,
      default: []
    },
    itemHeight: {
      required: true,
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      start: 0,
      count: 0,
      totalHeight: 0
    };
  },
  computed: {
    subList() {
      return this.list.slice(this.start, this.start + this.count);
    }
  },
  // watch:{},
  created() {
    this.totalHeight = this.itemHeight * this.list.length;
  },
  mounted() {
    this.count = Math.ceil(this.$el.clientHeight / this.itemHeight);
    this.ContentEl = this.$refs.ContentRef;
  },
  methods: {
    onScroll(e) {
      const scrollTop = e.target.scrollTop;
      console.debug(scrollTop);
      const newStart = Math.floor(scrollTop / this.itemHeight);
      if (newStart === this.start) {
        return;
      }
      // 只有滚动过一个itemHeight的距离才会触发重新渲染
      this.start = newStart;
    }
  }
};
</script>
<style scoped>
.virtual-list-wrapper {
  height: 100%;
  width: 100%;
  overflow-y: auto;
  position: relative;
}
.virtual-content {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  transform: top 1s;
}
.virtual-item {
  box-sizing: border-box;
  border-bottom: 1px solid #333;
  width: 100%;
}
</style>

# 2.3 Padding

该方案与以上两种方案有较大的差别,主要体现在以下两点:一是列表高度撑起的方式不同,以上两种方案的高度是通过设置height = list.length * itemHeight的方式撑起来的,而该方案则是通过paddingTop + paddingBottom + renderHeight的方式来撑起来的。二是列表的重新渲染时机不同,以上两种方案会在Math.floor(scrollTop / itemHeight)值变化时重新渲染,而该方案则是在渲染节点"不够"在视窗内显示时触发。

举个例子,假定视窗一次可以显示10个,同时配置虚拟滚动组件一次渲染50节点,那么当屏幕滚动到第11个时并不需要渲染,因为此时显示的是11-20个节点,而将要显示的21-50已经渲染好了。只有当滚动到第41个的时候才需要重新渲染,因为屏幕外已经没有渲染好的节点了,再滚动就要显示白屏了。根据以上例子进一步的分析临界条件,当前渲染位置为[itemHeight * start, itemHeight * (start + count)],视窗显示的位置为[scrollTop, scrollTop + clientHeight]。

当scrollTop + clientHeight >= itemHeight * (start + count)时,说明视窗显示位置超过了渲染的最大位置,重新触发渲染调整渲染位置,避免底部白屏。

当scrollTop <= itemHeight * start时,说明视窗显示位置不足渲染的最小位置,重新触发渲染调整渲染位置,避免顶部白屏。

这种方式的优点就是可以一次性渲染较多的元素,同时触发重新渲染的次数可以减少

demo

<template>
  <div class="virtual-list-wrapper"
       @scroll="onScroll($event)">
    <div :style="{height: totalHeight + 'px'}">
      <div class="virtual-content"
           ref="ContentRef"
           :style="{
             paddingTop: paddingTop + 'px',
             
           }">
        <div v-for="(item) in subList"
             :key="item"
             class="virtual-item"
             :style="{
              height: itemHeight + 'px', 
              overflow: 'hidden',
             }">
          <slot name="listItem" :item="item"></slot>
        </div>
      </div>
    </div>
  </div>
</template>
<script>
/* eslint-disable */
export default {
  name: 'VirtualList',
  // mixins: [],
  // components: {},
  props: {
    list: {
      required: true,
      type: Array,
      default: []
    },
    itemHeight: {
      required: true,
      type: Number,
      default: 0
    },
    count: {
      required: true,
      type: Number,
      default: 0
    }
  },
  data() {
    return {
      start: 0,
      totalHeight: 0
    };
  },
  computed: {
    subList() {
      return this.list.slice(this.start, this.start + this.count);
    },
    paddingTop() {
      return this.itemHeight * this.start;
    },
    paddingBottom() {
      return this.totalHeight - this.itemHeight * this.start - this.clientHeight;
    }
  },
  // watch:{},
  created() {
    this.totalHeight = this.itemHeight * this.list.length;
    this.clientHeight = this.itemHeight * this.count;
  },
  mounted() {
    // this.count = Math.ceil(this.$el.clientHeight / this.itemHeight);
    this.ContentEl = this.$refs.ContentRef;
  },
  // beforeRouteEnter(to, from, next) { next(vm => {}) },
  // beforeRouteUpdate(to, from, next) {},
  // beforeRouteLeave(to, from, next) {},
  // beforeDestroy() {},
  // destroyed() {},
  methods: {
    onScroll(e) {
      const { scrollTop, clientHeight } = e.target;
      const { itemHeight, start, count } = this;
      if (scrollTop + clientHeight >= itemHeight * (start + count) || scrollTop <= itemHeight * start) {
        const newStart = Math.floor(scrollTop / itemHeight);
        console.debug(newStart);
        if (newStart !== this.start) {
          this.start = Math.min(newStart, this.list.length - count);
        }
      }
    }
  }
};
</script>
<style scoped>
.virtual-list-wrapper {
  height: 100%;
  width: 100%;
  overflow-y: auto;
}
.virtual-content {
  position: relative;
}
.virtual-item {
  box-sizing: border-box;
  border-bottom: 1px solid #333;
}
</style>

用法

<VirtualList3 :list="list"
              :itemHeight="60"
              :count="100">
  <template v-slot:listItem="scope">
    <div class="list-item">项目{{scope.item}}</div>
  </template>
</VirtualList3>

比较流行的虚拟滚动的库,如果是比较简单的需求,不需要库那种兼容多种方案的情况,可以依据原理自己实现

  1. React: react-window (opens new window)react-virtualized (opens new window)
  2. Vue:vue-virtual-scroll-list (opens new window)vue-virtual-scroller-Vue2.0 (opens new window)
上次更新: 1/22/2025, 9:39:13 AM