前端如何处理后端一次性返回的庞大数据量

6,252 阅读3分钟

背景

服务端一次性返回大量的数据,前端如何展示??

解决方案:

  • 服务端角度:分页
  • 前端角度:通过Web Worker做子线程实现(使用worker来开辟一个独立于主线程的子线程来进行哪些大量运算,这样就不会造成页面卡死。)
  • 虚拟列表
  • 分批渲染

如何使用worker

  • 检查浏览器是否支持Web Worker
if(typeof(Worker) !== 'undefined'){
    // do something
}else {
    // sorry
}
  • 基本用法

新建一个 Worker 线程,主线程采用new命令,调用Worker()构造函数.

var worker = new  Worker(worker.js)

Worker()构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。如果下载没有成功(比如404错误),Worker 就会默默地失败。

然后,主线程调用worker.postMessage()方法,向 Worker 发消息。

worker.postMessage('Hello World');
worker.postMessage({method: 'echo', args: ['Work']});

worker.postMessage()方法的参数,就是主线程传给 Worker 的数据。它可以是各种数据类型,包括二进制数据。

接着,主线程通过worker.onmessage指定监听函数,接收子线程发回来的消息。

worker.onmessage = function (event) {
  console.log('Received message ' + event.data);
  doSomething();
}

function doSomething() {
  // 执行任务
  worker.postMessage('Work done!');
}

上面代码中,事件对象的data属性可以获取 Worker 发来的数据。

Worker 完成任务以后,主线程就可以把它关掉。

worker.terminate();

虚拟列表

思想:

  • 首屏加载时,只加载可视区域内需要的列表项
  • 当滚动时,动态通过计算获取可视区域内的列表项

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

假设有1万条记录需要同时渲染,我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。

说完首次加载,再分析一下当滚动发生时,我们可以通过计算当前滚动值得知此时在屏幕可见区域应该显示的列表项。

假设滚动发生,滚动条距顶部的位置为150px,则我们可得知在可见区域内的列表项为第4项至第13项。

实现解析:

虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。

  • 计算当前可视区域起始数据索引(startIndex)
  • 计算当前可视区域结束数据索引(endIndex)
  • 计算当前可视区域的数据,并渲染到页面中
  • 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上

代码如下:

<template>
  <div ref="list" :style="{height}" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div ref="phantom" class="infinite-list-phantom"></div>
    <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>
</template>


<script>

export default {
  name:'VirtualList',
  props: {
    //所有列表数据
    listData:{
      type:Array,
      default:()=>[]
    },
    //预估高度
    estimatedItemSize:{
      type:Number,
      required: true
    },
    //缓冲区比例
    bufferScale:{
      type:Number, 
      default:1
    },
    //容器高度 100px or 50vh
    height:{
      type:String,
      default:'100%'
    }
  },
  computed:{
    _listData(){
      return this.listData.map((item,index)=>{
        return {
          _index:`_${index}`,
          item
        }
      })
    },
    visibleCount(){
      return Math.ceil(this.screenHeight / this.estimatedItemSize);
    },
    aboveCount(){
      return Math.min(this.start,this.bufferScale * this.visibleCount)
    },
    belowCount(){
      return Math.min(this.listData.length - this.end,this.bufferScale * this.visibleCount);
    },
    visibleData(){
      let start = this.start - this.aboveCount;
      let end = this.end + this.belowCount;
      return this._listData.slice(start, end);
    }
  },
  created(){
    this.initPositions();
    window.vm = this;
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  updated(){
    this.$nextTick(function () {
      if(!this.$refs.items || !this.$refs.items.length){
        return ;
      }
      //获取真实元素大小,修改对应的尺寸缓存
      this.updateItemsSize(); 
      //更新列表总高度
      let height = this.positions[this.positions.length - 1].bottom;
      this.$refs.phantom.style.height = height + 'px'
      //更新真实偏移量
      this.setStartOffset();
    })
  },
  data() {
    return {
      //可视区域高度
      screenHeight:0,
      //起始索引
      start:0,
      //结束索引
      end:0,
    };
  },
  methods: {
    initPositions(){
      this.positions = this.listData.map((d,index)=>({
          index,
          height:this.estimatedItemSize,
          top:index * this.estimatedItemSize,
          bottom:(index+1) * this.estimatedItemSize
        })
      );
    },
    //获取列表起始索引
    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);
        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;
    },
    //获取列表项的当前尺寸
    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){
        let size = this.positions[this.start].top - (this.positions[this.start - this.aboveCount] ? this.positions[this.start - this.aboveCount].top : 0);
        startOffset = this.positions[this.start - 1].bottom - size;
      }else{
        startOffset = 0;
      }
      this.$refs.content.style.transform = `translate3d(0,${startOffset}px,0)`
    },
    //滚动事件
    scrollEvent() {
      //当前滚动位置
      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>


<style scoped>
.infinite-list-container {
  overflow: auto;
  position: relative;
  -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;
  /* height:200px; */
}

</style>
<div id="app">
  <VirtualList :listData="data" :itemSize="100"/>
</div>

let d = [];
for (let i = 0; i < 1000; i++) {
  d.push({ id: i, value: i });
}

export default {
  name: "App",
  data() {
    return {
      data: d
    };
  },
  components: {
    VirtualList
  }
};

分批渲染

// ajax 请求。。。。

function loadAll(response) { 
   //将10万条数据分组, 每组500条,一共200组 
   var groups = group(response); 
   for (var i = 0; i < groups.length; i++) { 
      //闭包, 保持i值的正确性 
    window.setTimeout(function () {
    var group = groups[i];
    var index = i + 1;
      return function () {
    //分批渲染 
    loadPart( group, index ); 
   } 
   }(), 1); 
  } 
 }
 //数据分组函数(每组500条)
 function group(data) {
    var result = [];
    var groupItem;
    for (var i = 0; i < data.length; i++) {
       if (i % 500 == 0) {
         groupItem != null && result.push(groupItem);
         groupItem = []; 
   } 
     groupItem.push(data[i]); 
   } 
    result.push(groupItem); 
    return result;
 } 
 var currIndex = 0; 
 //加载某一批数据的函数
 function loadPart( group, index ) { 
    var html = "";
    for (var i = 0; i < group.length; i++) {
     var item = group[i];
    html += "<li>title:" + item.title + index + " content:" + item.content + index + "</li>"; 
   } 
 //保证顺序不错乱
     while (index - currIndex == 1) { 
     $("#content").append(html);
    currIndex = index; 
   } 
 }