基于虚拟滚动的虚拟列表实现

关于虚拟滚动已经写过一篇文章,讲解了虚拟滚动实现原理和具体实现过程:虚拟滚动实现。关于不定高虚拟列表实现原理以及具体怎么实现也写过一篇文章:不定高虚拟列表的一种实现

本文是将虚拟滚动与虚拟列表结合,以便解决不定高虚拟列表遗留的问题:滑动过快导致白屏现象

基于虚拟滚动的定高统一高度虚拟列表

先实现基于虚拟滚动的定高统一高度虚拟列表,这个相对比较简单,之后再实现基于虚拟滚动的不定高虚拟列表,由浅入深。

演示效果

最终的效果如下

演示示例是有300条数据,每条数据高度50px。其内容都是简单的字符串。实现了内容区可以通过鼠标或键盘触屏板触发滚动同时右侧虚拟滚动条滚动,同时拖动右侧虚拟滚动条内容区滚动

虚拟列表组件实现

这部分说一下如何实现的上述效果。

监听wheel开启虚拟滚动

使用wheel监听键盘触屏板滑动和鼠标滚轮滚动,具体实现

js 复制代码
    <script>

export default {
  name: "virtualSrollSameHeightVirtualList",
  ...
  methods: {
    // 为盒子绑定事件 监听滚轮距离或鼠标滚动距离
    bindContainerEvent() {
      const { $container } = this.$element;
      const containerOffsetHeight = $container.offsetHeight;
      this.wheelOffset = 0;
      const bindContainerOffset = (event) => {
        event.preventDefault();

        this.wheelOffset += -event.wheelDeltaY;
        this.wheelOffset = Math.max(this.wheelOffset, 0);
        this.wheelOffset = Math.min(
          this.wheelOffset,
          this.listHeight - containerOffsetHeight
        );
        // 更新内容区偏移量
        this.updateRenderIndex();
      };
      $container.addEventListener("wheel", bindContainerOffset);
      this.unbindContainerEvent = () => {
        $container.removeEventListener("wheel", bindContainerOffset);
      };
    },
    ... 
  }
};
</script>

关于wheel事件可参考:wheel,通过事件对象的wheelDeltaY获取移动距离,this.wheelOffset为累加值,可以理解成滚动条滚动的距离。

为了符合真实的滚动效果,需要限制一下this.wheelOffset,最小为0,最大为列表项高度-内容盒子高度,[0, 列表项高度-内容盒子高度]。

更新内容区偏移量

更新内容区偏移量使用this.updateRenderIndex方法,具体实现

js 复制代码
    <script>

export default {
  name: "virtualSrollSameHeightVirtualList",
  computed: {
    // 内容区偏移量
    contentTransform() {
      return `translateY(-${this.contentOffset}px)`;
    },
    // 可视区渲染数据
    visibleData() {
      return this.listData.slice(this.start, this.end);
    },
  },
  ...
  methods: {
     updateRenderIndex(by = "content") {
      // 根据this.wheelOffset找到头部和尾部渲染索引
      const headIndex = this.findOffsetIndex(this.wheelOffset);
      const footerIndex = this.findOffsetIndex(
        this.wheelOffset + this.screenHeight
      );
      // 缓存数据this.aboveCount和this.belowCount
      this.start = Math.max(headIndex - this.aboveCount, 0);
      this.end = Math.min(footerIndex + this.belowCount, this.listData.length);
      if (by === "content") {
        this.handleOffset = this.transferOffset();
      }
     
      // 对于真实的滚动条内容区的高度contentHeight = containerHeight + maxScrollTop
      // 对于真实的滚动条滚动多少内容区就向上移动
      // contentMove = curScrollTop
      // this.wheelOffset相当于当下滚动的距离curScrollTop
      // 此处因为渲染内容高度是动态的,所以偏移量也是动态的,需要减去不渲染的那部分内容
      // 内容区的偏移量应该为this.wheelOffset - this.sumHeight(0, this.start)

      this.$nextTick(()=>{
        this.contentOffset = this.wheelOffset - this.sumHeight(0, this.start);
      })
    },
    ... 
  }
};
</script>

使用this.wheelOffset算出渲染数据的头部数据和尾部数据索引,this.aboveCountthis.belowCount为多出来渲染的缓存数据。

因为是虚拟列表所以这里只是渲染the.startthis.end之间数据,然后更新内容区偏移量,更新的关键点

js 复制代码
this.$nextTick(()=>{
    this.contentOffset = this.wheelOffset - this.sumHeight(0, this.start);
})

因为the.startthis.end可能发生了变化,所以使用this.$nextTick执行。

偏移量为什么是this.wheelOffset - this.sumHeight(0, this.start),我尝试解释。上边已经限定了this.wheelOffset的最小和最大区间[0, 列表项高度-内容盒子高度],也就是真实滚动条的滚动距离区间。

正常来说应该this.wheelOffset滚动多少,内容区就移动多少,但是此时的内容区是动态的,会根据the.startthis.end渲染指定内容。所以this.wheelOffset需要减去不渲染的那部分高度,也就是this.sumHeight(0, this.start),所以偏移量是this.wheelOffset - this.sumHeight(0, this.start)this.sumHeight是求数据总高度的方法

js 复制代码
   ...
   sumHeight(start = 0, end = 100) {
      let height = 0;
      for (let i = start; i < end; i++) {
        height += this.listData[i].height;
      }
      return height;
    }
    ...

让虚拟滚动条滚动

内容区滚动了,需要也让虚拟滚动条滚动。这个实现的关键是搞清楚内容区滚动距离和滚动条滚动距离之间的关系,实际二者有一个比例关系。

也即内容区最大可滚动距离和虚拟滚动条最大可滚动距离之间的比例,这个是固定的。因为this.wheelOffset就是内容区的瞬时滚动距离,所以虚拟滚动条的瞬时滚动距离就知道了。

也就是找到比例之后用_this.wheelOffset * assistRatio

js 复制代码
 methods: {
    // 手柄和内容之间的偏移量转换
    transferOffset(to = "handle") {
      const { $container, $slider } = this.$element;
      const contentSpace = this.listHeight - $container.offsetHeight;
      const handleSpace = $slider.offsetHeight - this.handleHeight;
      const assistRatio = handleSpace / contentSpace; // 小于1
      const _this = this;
      const computedOffset = {
        handle() {
          return _this.wheelOffset * assistRatio;
        },
        content() {
          return _this.handleOffset / assistRatio;
        },
      };
      return computedOffset[to]();
    },
    // 初始化手柄高度以及限制最小高度
    initHandleHeight() {
      const { $container, $slider } = this.$element;
      this.handleHeight =
        ($slider.offsetHeight * $container.offsetHeight) / this.listHeight;

      // 最小值为HandleMixHeight
      if (this.handleHeight < HandleMixHeight) {
        this.handleHeight = HandleMixHeight;
      }
    },
  },

上面transferOffset方法除了可以将this.wheelOffset转为虚拟滚动条的移动距离,还可以将手柄移动距离转为this.wheelOffset

另外initHandleHeight方法用来限制滚动条手柄的最小高度,方便使用手柄滚动。

监听onmousemove、onmousedown、onmouseup模拟滚动条

手柄滑动的具体实现

js 复制代码
    ...
    bindHandleEvent() {
      const { $slider, $handle } = this.$element;
      $handle.onmousedown = (e) => {
        const startY = e.clientY;
        const startTop = this.handleOffset;
        window.onmousemove = (e) => {
          const deltaX = e.clientY - startY;
          this.handleOffset =
            startTop + deltaX < 0
              ? 0
              : Math.min(
                  startTop + deltaX,
                  $slider.offsetHeight - this.handleHeight
                );
          this.wheelOffset = this.transferOffset("content");
          this.updateRenderIndex("handle");
        };

        window.onmouseup = function () {
          window.onmousemove = null;
          window.onmouseup = null;
        };
      };
    },
    saveHtmlElementById() {
      const { container, slider, handle } = this.$refs;
      this.$element = {
        $container: container,
        $slider: slider,
        $handle: handle,
      };
      this.initHandleHeight();
      this.bindContainerEvent();
      this.bindHandleEvent();
    },
    ...

实现原理是给手柄$handle添加监听事件onmousedown,通过事件对象记录按下鼠标的clientY。给window添加onmousedownonmouseup,在onmousedown函数里计算出垂直方向的移动距离deltaX

再之后限制手柄移动的距离[0, $slider.offsetHeight - this.handleHeight],同时根据上面介绍的方法this.transferOffset将手柄偏移量转为总偏移量this.wheelOffset

再之后更新内容区索引和内容区偏移量,执行this.updateRenderIndex("handle")

基于虚拟滚动的不定高虚拟列表

上面讲解了基于虚拟滚动的定高统一高度虚拟列表主要实现思路,下面在这个基础上实现基于虚拟滚动的不定高虚拟列表

不定高的难点是一开始的高度不知道,为了拿到高度可以,可以用的方案

  1. 在屏幕外渲染,但消耗性能
  2. 预估高度先行渲染,然后获取真实高度并缓存

这里采用第二个方案也是别人实现过的方案。

缓存位置点

一开始给每条数据一个预估高度itemSize,预估高度要贴近真实的数据渲染高度,可以使用数据渲染后的最小高度。之后缓存每条数据的位置信息,包括高度heighttopbottom,方便后面使用

js 复制代码
  ...
  
  <script>

export default {
  name: "VirtualScrollVirtualList",
  props: {
    // 所有列表数据
    listData: {
      type: Array,
      default: () => [],
    },
    itemSize: {
      type: Number,
      default: 100,
    },
    ...
  },
  data() {
    return {
      ...
      // 缓存位置数组
      positions: [],
    };
  },
  computed: {
    _listData() {
      return this.listData.reduce((init, cur, index) => {
        init.push({
          // _转换后的索引_第一项在原列表中的索引_本行包含几列
          _key: index,
          value: cur,
        });
        return init;
      }, []);
    },
  },
  methods: {
    // 初始化缓存
    initPositions() {
      this.positions = this._listData.map((d, index) => ({
        index,
        height: this.itemSize,
        top: index * this.itemSize,
        bottom: (index + 1) * this.itemSize,
      }));
    },

  },
  created() {
    this.initPositions();
  }
};
</script>

  

给要渲染列表数据做标记

给渲染数据列表listData添加计算属性_listData,_listData有一个唯一标识_key_key用来变更缓存数据里面对应数据的高度值,具体是_key作为渲染div的id

js 复制代码
<template>
  <div
    ref="container"
    class="infinite-list-container"
  >
    <div :style="{ transform: contentTransform }" class="infinite-list">
      <div
        ref="items"
        class="infinite-list-item-container"
        :id="row._key"
        :key="row._key"
        v-for="row in visibleData"
      >
        <div class="infinite-item">
          <slot :item="row.value" :index="row._key"></slot>
        </div>
      </div>
    </div>
    <div class="infinite-slider" ref="slider">
      <div
        class="infinite-handle"
        :style="{ transform: handleTransform, height: handleStyleHeight }"
        ref="handle"
      ></div>
    </div>
  </div>
</template>
  
  <script>
const HandleMixHeight = 20;

export default {
  name: "VirtualScrollVirtualList",
  props: {
    // 所有列表数据
    listData: {
      type: Array,
      default: () => [],
    },
    itemSize: {
      type: Number,
      default: 100,
    },
  },
  data() {
    return {
     
      ...
      // 缓存位置数组
      positions: [],
      handleHeight: HandleMixHeight,
      contentOffset: 0,
    };
  },
  computed: {
   // 内容计算属性给列表数据加标记_key
    _listData() {
      return this.listData.reduce((init, cur, index) => {
        init.push({
          // _转换后的索引_第一项在原列表中的索引_本行包含几列
          _key: index,
          value: cur,
        });
        return init;
      }, []);
    },
    ...
 
    visibleData() {
      return this._listData.slice(this.start, this.end);
    },
  },
  methods: {
    // 初始化缓存
    initPositions() {
      this.positions = this._listData.map((d, index) => ({
        index,
        height: this.itemSize,
        top: index * this.itemSize,
        bottom: (index + 1) * this.itemSize,
      }));
    },
    // 更新缓存位置数据
    updateItemsSize() {
      return new Promise((resolve) => {
        const nodes = this.$refs.items;
        nodes.forEach((node) => {
          // 获取元素自身的属性
          const rect = node.getBoundingClientRect();
          const height = rect.height;
          const index = +node.id;
          const oldHeight = this.positions[index].height;
          const dValue = oldHeight - height;
          // 存在差值
          if (dValue) {
            this.positions[index].bottom =
              this.positions[index].bottom - dValue;
            this.positions[index].height = height;
            this.positions[index].over = true; // TODO

            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;
            }
          }
        });
        resolve();
      });
    },
  },
  ...
};
</script>
  

更新缓存位置点数据

也就是为了获得渲染数据真实的高度heighttopbottom,这块使用Promise包一层,在回调函数中执行内容偏移量逻辑

js 复制代码
   ...
   updateItemsSize() {
      return new Promise((resolve) => {
        const nodes = this.$refs.items;
        nodes.forEach((node) => {
          // 获取元素自身的属性
          const rect = node.getBoundingClientRect();
          const height = rect.height;
          const index = +node.id;
          const oldHeight = this.positions[index].height;
          const dValue = oldHeight - height;
          // 存在差值
          if (dValue) {
            this.positions[index].bottom =
              this.positions[index].bottom - dValue;
            this.positions[index].height = height;
            this.positions[index].over = true; // TODO

            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;
            }
          }
        });
        resolve();
      });
    },
    ...

具体实现不定高虚拟列表

监听wheel开启虚拟滚动

这里和上面定高虚拟列表组件实现里的监听wheel开启虚拟滚动逻辑一致。

更新内容区偏移量

这里有不一致的地方,也就是在更新缓存位置数据后再更新内容的偏移量,具体是在this.updateItemsSize()then里面

js 复制代码
  updateRenderIndex(by = "content") {
      const headIndex = this.findOffsetIndex(this.wheelOffset);
      const footerIndex = this.findOffsetIndex(
        this.wheelOffset + this.screenHeight
      );
      this.start = Math.max(headIndex - this.aboveCount, 0);
      this.end = Math.min(footerIndex + this.belowCount, this._listData.length);
      this.updateItemsSize().then(() => {
        if (by === "content") {
          this.handleOffset = this.transferOffset();
        }
        this.contentOffset = this.wheelOffset - this.sumHeight(0, this.start);
      });
    },

让虚拟滚动条滚动

和上面定高虚拟列表组件实现里的让虚拟滚动条滚动逻辑一致。

监听onmousemove、onmousedown、onmouseup模拟滚动条

和上面定高虚拟列表组件实现里的监听onmousemoveonmousedownonmouseup模拟滚动条逻辑一致。

总结

看下基于虚拟滚动的不定高虚拟列表最终效果

怎么拖动都没有白屏现象了。

本文使用虚拟滚动结合虚拟列表彻底解决了不定高虚拟列表遗留的问题:滑动过快导致白屏现象。

基本思路:先收集总偏移量,再进行数据渲染,更新完毕再进行内容区的移动。

关键点:对渲染数据进行缓存,渲染后拿到数据的真实渲染高度height、top、bottom数据,再更新内容区的偏移量,内容区的偏移量=总偏移量-没有渲染的部分。

项目代码:github.com/zhensg123/r...

(本文完)

参考文章

「前端进阶」高性能渲染十万条数据(虚拟列表)

新手也能看懂的虚拟滚动实现方法

wheel

相关推荐
昨天;明天。今天。1 分钟前
案例-任务清单
前端·javascript·css
一丝晨光28 分钟前
C++、Ruby和JavaScript
java·开发语言·javascript·c++·python·c·ruby
Front思30 分钟前
vue使用高德地图
javascript·vue.js·ecmascript
zqx_71 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己1 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称2 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色2 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2342 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河2 小时前
CSS总结
前端·css
NiNg_1_2342 小时前
Vue3 Pinia持久化存储
开发语言·javascript·ecmascript