列表之虚拟滚动节点的实现

前言

这是我参与新手入门的第2篇文章

为了坚持心中所想,想来想去,还是准备写写这个,揭开虚拟滚动节点的面纱。

需求场景

综合之前开发经验,我们会发现一般列表的要求也很简单,首先固定表头,其次加载不可太慢。

  • 固定表头。关于这一点,我之前都是通过ul和li这种有序列表组合,再加上css定位和滚动计算实现表头的固定的。前两天,我就在想,那么几个公开的框架是怎么实现的呢?打开几个流行的组件架构,我发现原来是我的格局小了,原生的table是可以做到表头的固定的。这里大家稍微研究一下就可以实现,不在多说,节点结构大概如下:
//表头
<table>
  <colgroup>
    <col name="s-table-columnid" width="150" align="center">
    ....
  </colgroup>
  <thead>
    <tr>
      <th class="s-table-columnid">序号</th>
      ...
    </tr>
  </thead>
</table>

//表体 
<table>
  <colgroup>
    <col name="s-table-columnid" width="150" align="center">
    ....
  </colgroup>
  <tbody>
    <tr>
      <td class="s-table-columnid">XXX</td>
      ...
    </tr>
  </tbody>
</table>

复制代码

这里具体的代码,我后面会给出

  • 加载渲染不可太慢。这一点抛除掉接口响应导致的问题,前端一般的处理就是写好列表渲染结构,然后加上分页,一般浏览器响应速度都是杠杠的,但是如果一旦一页数据量过大,且列过多,,往往就会不那么流畅了,这个时候我们又该怎么办呢?一般,用vue做开发的就会想到虚拟滚动节点了。其思想就是通过减少真实渲染的dom节点,实现视觉上的快速响应。也许我这么说,还不好理解。更简单点来说就是,我们只渲染可以看到的那块区域的列表数据节点,看不到的默认隐藏起来,这样就大大的提高了页面的视觉响应了。

具体实现步骤

  • 准备好列表
  • 准备好大的数据量
  • 精准计算当前显示的列表数据

1.准备好列表。根据上面的双table结构,我们可以很快的写出一个常规列表。这里是我的代码


//grid.vue 主结构
<template>
  <div class="s-grid">
    <div class="s-grid-header" >
      <gridHeader :columnData="columnList" :verticalBar="hasScrollBar" ref="tableTitle"></gridHeader>
    </div>
    <div class="s-grid-body" ref="tableBody" @scroll.passive="onBodyScroll" >
      <gridBody :columnData="columnList" :list="renderData" :scrollStyle="getBodyWrapperStyle"></gridBody>
    </div>
  </div>
</template>

//gridHeader.vue
<template>
  <div class="s-gridHeader">
    <table>
      <colgroup>
        <col v-for="(th,index) in columnData" :name="'s-table-column'+ th.name" :key="index" :width="th.width" :align="th.textAlign"> 
        <col :width="scrollWidth" v-show="verticalBar">
      </colgroup>
      <thead>
        <tr>
          <th v-for="(th,index) in columnData" :class="'s-table-column'+ th.name " :key="index">
            <div>
              {{$i18nc.message(th.i18n,th.text)}}        
            </div>
          </th>
          <th class="gutter" v-show="verticalBar"></th>
        </tr>
      </thead>
    </table>
  </div>
</template>

//gridBody.vue
<template>
  <div class="s-gridBody" :style="scrollStyle">
    <table>
      <colgroup>
        <col  v-for="(th,index) in columnData" :name="'s-table-column'+ th.name" :key="index" :width="th.width" :align="th.textAlign">
      </colgroup>
      <tbody>
        <tr v-for="(tr,index) in list"  :key="index">
          <td v-for="(td,indexx) in columnData" :class="'s-table-column'+ td.name " :key="indexx" >
            <div>{{tr[td.name]}}</div>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

复制代码

这里唯一需要注意的一点就是,我在表头中预留出了滚动条的区域。这是因为,一旦数据超出body可存放时,会出现滚动条,而双table会因为滚动条的出现,导致上下错位情况出现。

2.准备好大的数据量。这个准备点假数据,循环遍历生成就好了。

//grid.vue
data(){
  return{
    columnList:[
      {
        type:"column",
        xtype:"textfield",
        name:"id",
        textAlign:"center",
        text:"序号",
        i18n:"column.orderNumber",
        width:150,
      }
      ....
    ]
  }
}  

mounted(){
    const vm = this;
    let dataList = [];
    for(let i=0;i<200;i++){
      let obj = {
        id:i,
        name:vm.nameList[i%4],
        sex:i%2 == "1" ? "女" : "男",
        province:"省份" + i,
        province1:"省份1" + i,
        province2:"省份2" + i,
        province3:"省份3" + i,
        province4:"省份4" + i,
        province5:"省份5" + i,
        date:"2021-05-27",
        address:"地址" + i,
        partment:"部门" + i
      }
      dataList.push(obj);
    }
    vm.tableData = dataList;
    console.log(vm.tableData)
  },

复制代码

3.精准计算当前显示的列表数据。这也是整个功能的核心,也是最重要的一点。其实就是每次精准的算出body所需要的列表数据集合。这里实现的思路我们可以分为这几步:

  • 取到每行的高度,这里我们暂时固定行高为 40。
  • 要渲染的行数,这里选择为 30
  • 监听滚动事件,然后每次滚动后,重现计算显示区域的列表那30条数据

这里先说横轴滚动的处理,这里的处理其实就是让表头跟着表体一起滚动,使其看起来像一个整体。主要方法如下:

onBodyScroll(){
  window.requestAnimationFrame(() => {
    const { scrollLeft, scrollTop } = this.$refs.tableBody;
    const { tableTitle } = this.$refs;
    tableTitle.$el.style.transform = `translateX(-${scrollLeft}px) translateZ(0)`;


    if (this.scrollLeft !== scrollLeft) {
      const { tableTitle } = this.$refs;
      const type = scrollLeft ? "add" : "remove";
      tableTitle.$el.style.transform = `translateX(-${scrollLeft}px) translateZ(0)`;
      this.scrollLeft = scrollLeft;
    } else {
      this.refreshRenderData(scrollTop);
    }
  });
},
复制代码

纵轴的处理稍微麻烦点,主要就是计算当前渲染的列表的数据集合。代码如下:

//grid.vue
混入了tableBody.js
mixins:[tableBody],

//tableBody.js
import Virtual from "./virtual";

const ESTIMATE_SIZE = 40; // 每行高度

const VirtualList = {
  data() {
    return {
      range: null,
      keeps: 30, // 渲染的行数
      itemHeight: ESTIMATE_SIZE,
      renderData: []
    };
  },
  watch: {
    tableData() {
      if(!this.virtual) return
      this.refreshVirtual();
    }
  },
  computed: {
    getBodyWrapperStyle() {
      //表体padding
      const { padFront, padBehind } = this.range || {};
      return { padding: `${padFront}px 0px ${padBehind}px` };
    }
  },
  mounted() {
    this.$nextTick(() => {
      this.installVirtual();
    });
  },
  beforeDestroy() {
    this.virtual.destroy();
  },
  methods: {
    refreshVirtual() {
      this.virtual.updateParam("uniqueIds", this.getUniqueIdFromDataSources());
      this.virtual.handleDataSourcesChange();
    },
    installVirtual() {
      this.virtual = new Virtual(
        {
          keeps: this.keeps,
          estimateSize: this.itemHeight,
          buffer: Math.round(this.keeps / 3), // recommend for a third of keeps
          uniqueIds: this.getUniqueIdFromDataSources()
        },
        this.onRangeChanged
      );
      this.range = this.virtual.getRange();
    },
    getUniqueIdFromDataSources() {
      return this.tableData.map(dataSource => dataSource.id);
    },
    onRangeChanged(range) {
      this.range = range;
      this.renderData = this.getRenderData();
    },
    onVirtualScroll() {
      if (this.scrollFlag) return;
      window.requestAnimationFrame(() => {
        const { scrollLeft, scrollTop } = this.$refs.sunTable;
        if (this.scrollLeft !== scrollLeft) {
          const { tableTitle } = this.$refs;
          const type = scrollLeft ? "add" : "remove";
          tableTitle.$el.style.transform = `translateX(-${scrollLeft}px) translateZ(0)`;
          this.scrollLeft = scrollLeft;
        } else {
          this.refreshRenderData(scrollTop);
        }
      });
    },
    refreshRenderData(offset) {
      this.virtual.handleScroll(offset);
    },
    getRenderData() {
      //视图上展示的列表数据
      const renderData = [];
      const { start, end } = this.range;
      const { tableData } = this;
      for (let index = start; index <= end; index++) {
        const item = tableData[index];
        item.__vindex = index;
        renderData.push(item);
      }
      return renderData;
    }
  }
};

export default VirtualList;


//virtual.js
const DIRECTION_TYPE = {
  FRONT: 'FRONT', // scroll up or left
  BEHIND: 'BEHIND' // scroll down or right
}
const CALC_TYPE = {
  INIT: 'INIT',
  FIXED: 'FIXED',
  DYNAMIC: 'DYNAMIC'
}
const LEADING_BUFFER = 2

export default class Virtual {
  constructor (param, callUpdate) {
    this.init(param, callUpdate)
  }

  init (param, callUpdate) {
    // param data
    this.param = param
    this.callUpdate = callUpdate

    // size data
    this.sizes = new Map()
    this.firstRangeTotalSize = 0
    this.firstRangeAverageSize = 0
    this.lastCalcIndex = 0
    this.fixedSizeValue = 0
    this.calcType = CALC_TYPE.INIT

    // scroll data
    this.offset = 0
    this.direction = ''

    // range data
    this.range = Object.create(null)
    if (param) {
      this.checkRange(0, param.keeps - 1)
    }

    // benchmark test data
    // this.__bsearchCalls = 0
    // this.__getIndexOffsetCalls = 0
  }

  destroy () {
    this.init(null, null)
  }

  // return current render range
  getRange () {
    const range = Object.create(null)
    range.start = this.range.start
    range.end = this.range.end
    range.padFront = this.range.padFront
    range.padBehind = this.range.padBehind
    return range
  }

  isBehind () {
    return this.direction === DIRECTION_TYPE.BEHIND
  }

  isFront () {
    return this.direction === DIRECTION_TYPE.FRONT
  }

  // return start index offset
  getOffset (start) {
    return start < 1 ? 0 : this.getIndexOffset(start)
  }

  updateParam (key, value) {
    if (this.param && (key in this.param)) {
      // if uniqueIds reducing, find out deleted id and remove from size map
      if (key === 'uniqueIds' && (value.length < this.param[key].length)) {
        this.sizes.forEach((v, key) => {
          if (!value.includes(key)) {
            this.sizes.delete(key)
          }
        })
      }
      this.param[key] = value
    }
  }

  // save each size map by id
  saveSize (id, size) {
    this.sizes.set(id, size)

    // we assume size type is fixed at the beginning and remember first size value
    // if there is no size value different from this at next comming saving
    // we think it's a fixed size list, otherwise is dynamic size list
    if (this.calcType === CALC_TYPE.INIT) {
      this.fixedSizeValue = size
      this.calcType = CALC_TYPE.FIXED
    } else if (this.calcType === CALC_TYPE.FIXED && this.fixedSizeValue !== size) {
      this.calcType = CALC_TYPE.DYNAMIC
      // it's no use at all
      delete this.fixedSizeValue
    }

    // calculate the average size only in the first range
    if (this.calcType !== CALC_TYPE.FIXED && typeof this.firstRangeTotalSize !== 'undefined') {
      if (this.sizes.size < Math.min(this.param.keeps, this.param.uniqueIds.length)) {
        this.firstRangeTotalSize = this.firstRangeTotalSize + size
        this.firstRangeAverageSize = Math.round(this.firstRangeTotalSize / this.sizes.size)
      } else {
        // it's done using
        delete this.firstRangeTotalSize
      }
    }
  }

  // in some special situation (e.g. length change) we need to update in a row
  // try goiong to render next range by a leading buffer according to current direction
  handleDataSourcesChange () {
    let start = this.range.start

    if (this.isFront()) {
      start = start - LEADING_BUFFER
    } else if (this.isBehind()) {
      start = start + LEADING_BUFFER
    }

    start = Math.max(start, 0)

    this.updateRange(this.range.start, this.getEndByStart(start))
  }

  // when slot size change, we also need force update
  handleSlotSizeChange () {
    this.handleDataSourcesChange()
  }

  // calculating range on scroll
  handleScroll (offset) {
    this.direction = offset < this.offset ? DIRECTION_TYPE.FRONT : DIRECTION_TYPE.BEHIND
    this.offset = offset

    if (this.direction === DIRECTION_TYPE.FRONT) {
      this.handleFront()
    } else if (this.direction === DIRECTION_TYPE.BEHIND) {
      this.handleBehind()
    }
  }

  // ----------- public method end -----------

  handleFront () {
    const overs = this.getScrollOvers()
    // should not change range if start doesn't exceed overs
    if (overs > this.range.start) {
      return
    }

    // move up start by a buffer length, and make sure its safety
    const start = Math.max(overs - this.param.buffer, 0)
    this.checkRange(start, this.getEndByStart(start))
  }

  handleBehind () {
    const overs = this.getScrollOvers()
    // range should not change if scroll overs within buffer
    if (overs < this.range.start + this.param.buffer) {
      return
    }

    this.checkRange(overs, this.getEndByStart(overs))
  }

  // return the pass overs according to current scroll offset
  getScrollOvers () {
    const offset = this.offset
    if (offset <= 0) {
      return 0
    }

    // if is fixed type, that can be easily
    if (this.isFixedType()) {
      return Math.floor(offset / this.fixedSizeValue)
    }

    let low = 0
    let middle = 0
    let middleOffset = 0
    let high = this.param.uniqueIds.length

    while (low <= high) {
      // this.__bsearchCalls++
      middle = low + Math.floor((high - low) / 2)
      middleOffset = this.getIndexOffset(middle)

      if (middleOffset === offset) {
        return middle
      } else if (middleOffset < offset) {
        low = middle + 1
      } else if (middleOffset > offset) {
        high = middle - 1
      }
    }

    return low > 0 ? --low : 0
  }

  // return a scroll offset from given index, can efficiency be improved more here?
  // although the call frequency is very high, its only a superposition of numbers
  getIndexOffset (givenIndex) {
    if (!givenIndex) {
      return 0
    }

    let offset = 0
    let indexSize = 0
    for (let index = 0; index < givenIndex; index++) {
      // this.__getIndexOffsetCalls++
      indexSize = this.sizes.get(this.param.uniqueIds[index])
      offset = offset + (typeof indexSize === 'number' ? indexSize : this.getEstimateSize())
    }

    // remember last calculate index
    this.lastCalcIndex = Math.max(this.lastCalcIndex, givenIndex - 1)
    this.lastCalcIndex = Math.min(this.lastCalcIndex, this.getLastIndex())

    return offset
  }

  // is fixed size type
  isFixedType () {
    return this.calcType === CALC_TYPE.FIXED
  }

  // return the real last index
  getLastIndex () {
    return this.param.uniqueIds.length - 1
  }

  // in some conditions range is broke, we need correct it
  // and then decide whether need update to next range
  checkRange (start, end) {
    const keeps = this.param.keeps
    const total = this.param.uniqueIds.length

    // datas less than keeps, render all
    if (total <= keeps) {
      start = 0
      end = this.getLastIndex()
    } else if (end - start < keeps - 1) {
      // if range length is less than keeps, corrent it base on end
      start = end - keeps + 1
    }

    if (this.range.start !== start) {
      this.updateRange(start, end)
    }
  }

  // setting to a new range and rerender
  updateRange (start, end) {
    this.range.start = start
    this.range.end = end
    this.range.padFront = this.getPadFront()
    this.range.padBehind = this.getPadBehind()
    this.callUpdate(this.getRange())
  }

  // return end base on start
  getEndByStart (start) {
    const theoryEnd = start + this.param.keeps - 1
    const truelyEnd = Math.min(theoryEnd, this.getLastIndex())
    return truelyEnd
  }

  // return total front offset
  getPadFront () {
    if (this.isFixedType()) {
      return this.fixedSizeValue * this.range.start
    } else {
      return this.getIndexOffset(this.range.start)
    }
  }

  // return total behind offset
  getPadBehind () {
    const end = this.range.end
    const lastIndex = this.getLastIndex()

    if (this.isFixedType()) {
      return (lastIndex - end) * this.fixedSizeValue
    }

    // if it's all calculated, return the exactly offset
    if (this.lastCalcIndex === lastIndex) {
      return this.getIndexOffset(lastIndex) - this.getIndexOffset(end)
    } else {
      // if not, use a estimated value
      return (lastIndex - end) * this.getEstimateSize()
    }
  }

  // get the item estimate size
  getEstimateSize () {
    return this.isFixedType() ? this.fixedSizeValue : (this.firstRangeAverageSize || this.param.estimateSize)
  }
}



复制代码

最终展现结果如图

虚拟列表.png

本次描述可能不够详尽,大部分都是代码,需要大家自行运行测试下,其实核心就那么点东西,后期考虑看如何更好的描述出来,如果有说的不到位的地方,还望大家见谅哈。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享