长列表优化之虚拟列表

avatar
Ctrl+C、V工程师 @豌豆公主

背景

这几个月接了一个日志收集系统的活,因为这个系统是属于传承的项目,所以我也想在系统上做一些标志性的改动,作为接力棒传递下去。

这个日志系统从前端到服务端,都做了不小的改动,比如虚拟列表,electron热更新,sql优化,增加了用户的概念,也就是需要登录,把数据库升级为Elasticsearch,等等。我之后会把一些我感觉比较好玩的东西分享出来。

我最先操刀修改的是日志list的展示。这个list就是比较常规的堆节点,页面会随着时间的增加导致DOM节点越来越多,这是一个不得不改的问题。

分析需求

首先,因为同事们对这个列表的长时间的使用已经习惯了,所以最好在体验上不要进行大的修改。为此,我需要把之前大概的功能列举出来:

  • 每次列表有新的消息传入的时候,都要能看到最新的那条数据
  • 当用户点击列表中的其中一条数据的时候,列表需要停止更新,也就是停止滚动
  • 当列表处于锁定状态,滚动条滚动到最底部的时候,列表恢复自动滚动
  • 当列表中有被选中状态的数据时,可以通过上下左右键来让聚焦移动

总结完之前的功能之后,我需要再梳理一下我的需求:

  • 列表随着时间会越来越长,需要控制展示的节点数量
  • 列表长度随着WebSocket的通信而增加,数据更新频度过快,需要有缓冲池
  • 增加一个列表锁定的提示,可以手动解开列表的锁定
  • 移动聚焦的时候会随即展示日志详情,因为移动速度过快,所以需要增加防抖

梳理完成之后,经过考虑我决定使用虚拟列表来代替现有的长列表,这也是踩坑之路的开始。

开始开发

长列表转虚拟列表

可能之前大家多多少少会听说过这个概念甚至开发过虚拟列表,我不管。在开始之前我还是先和大家捋一下概念。(敲黑板!!!)

为什么需要虚拟列表

我们知道,在浏览器渲染页面的时候,当DOM节点的数量越多,每一次重绘的时候,对性能的影响也就越大。

假如我们需要展示一个信息量很大,大约有数十万条数据。遇到这样子的情况,其实现在有许多的方案,我们最常见的方案就类似PC上的下一页、上一页,但是这个方案在体验上其实并不友好。大部分的用户会比较喜欢不停的向下滚动就可以看到新的内容,但是这个就会遇到一个问题,不停的加载数据,导致页面堆积的节点越来越多,所消耗的内存不断增大,最后连滚动都会卡顿。

这时候我们重新分析一下,就会发现其实有很多数据我们大多数情况下是不需要看见的,如果只考虑我们能看到数据的话,其实需要渲染的数据量就会非常的少了,很好的提高了渲染的效率,减少因为大量的重绘照成不必要的影响。

这么一梳理一下,答案简直呼之欲出----虚拟列表。

什么是虚拟列表

虚拟列表其实没有什么特别神奇的地方,说白了就是一种展示列表的思路,在页面上创建一个容器作为可视区,在这个可视区内展示长列表中的一部分,也就是在可视区渲染列表。

如图中所示,是一个简单的虚拟列表的模型,图中有几个概念需要大家稍微了解一下:

  • 可视区。
  • 真实列表。
  • startIndex。
  • endIndex。
可视区

可视区大家可以这么理解,我们现在有一个<div class="show-box">,给这个元素加一些样式。

.show-box{
    width: 375px;
    height: 500px;
    margin: 0 auto;
    position: relative;
}

通过这个样式我们可以看出这个可视区容器的高度为500px

真实列表

真实列表就是会被渲染出来的列表,这么说可能不太理解,举个栗子:现在需要被渲染出来的列表数量一共有1000条,但是实际上在页面需要被渲染的列表数量(需要被看到的数据)只需要100条,这个100条就是所谓的真实列表。

<div class="list-body-box" @scroll="listScroll"> ----- 真实列表
  <div class="list-body"> ------ 载体
  </div>
</div>
-------------------------- style --------------------------------
.list-body {
  min-height: 10px;
  position: absolute;
  width: 100%;
}

在这里,建议真实列表的长度需要比可视区的高度长一些,有一个滚动条的话,之后可以通过scroll监听做一些其他的操作。

可能有一个点需要和大家解释一下为什么我的<div class="list-body">绝对定位

当你的某一个元素会频繁发生变化的时候,最好将这个模块通过绝对定位的方式,脱离文档流,可以减少回流带来的影响。

我们先看一下浏览器的渲染机制

  • 解析HTML,生成DOM树,解析CSS,生成CSSOM树
  • 将DOM树和CSSOM树结合,生成渲染树(Render Tree)
  • Layout(回流):根据生成的渲染树,进行回流(Layout),得到节点的几何信息(位置,大小)
  • Painting(重绘):根据渲染树以及回流得到的几何信息,得到节点的绝对像素
  • Display:将像素发送给GPU,展示在页面上。

绝对定位或者浮动脱离了正常的文档流,相当于只是在节点上存放了一个token,然后通过这个token去进行映射,所以如果你采用了绝对定位的方法,也只会对这一块元素进行重绘。

startIndex

之前也说到了,真实列表实际上只是总列表其中很小的一部分,在这之外还有很多列表需要被渲染。因此,大家可以把真实列表理解为一个片段。被渲染的第一个元素的index就是片段中第一个元素在总列表中的位置,也就是数组中的index

举个栗子:我的总列表(数组)的长度为1000,而需要渲染的列表片段为100—200,那么这个开始的位置,也就是数组的index则为99

edIndex

解释同上,最后一个元素的index199

虚拟列表的实现

这里要提一下,我的框架用的是vue,所以虚拟列表的实现也是比较方便的。

<div class="list-body-box">
  <div class="list-body">
    <templete v-for="(item, idx) in list" >
      <div 
        v-if="idx >= startIdx && idx <= endIdx" 
        :key="idx"
        class="list-row">
        <div class="col-item col-1">{{item.col_1}}</div>
        <div class="col-item col-2">{{item.col_2}}</div>
        <div class="col-item col-3">{{item.col_3}}</div>
        <div class="col-item col-4">{{item.col_4}}</div>
        <div class="col-item col-5">{{item.col_5}}</div>
        <div class="col-item col-6">{{item.col_6}}</div>
        <div class="col-item col-7">{{item.col_7}}</div>
      </div>
    </templete>
  </div>
</div>

模板上,没有什么太特别的地方,主要就是通过v-if去控制列表的展示,通过startIdxendIdx的增减,去展示不同位置的数据,让这两个值递增就可以实现列表滚动

下边我们会说一下自动滚动在代码上的实现,主要是通过一个主动的事件去频繁的触发对startIdxendIdx递增或者递减。

let time = null;
...
autoScroll(){
  time = setTimeout(()=>{
    let listLen = this.list.length - 1;
    this.endIdx = listLen;
    this.startIdx = (listLen + 1) <= 100 ? 0 : listLen - 100;
    this.autoScroll();
  },300);
}

如上代码所示,我只需要再让一个方法去触发autoScroll(),这个方法就会在setTimeout的作用下自调用,startIdxendIdx会不断递增列表就可以自动滚动了,在这里边有一个表达式

this.startIdx = (listLen + 1) <= 100 ? 0 : listLen - 100;

这一块的话主要是解决当页面刚打开或者清空列表的时候,实际上列表的长度比较短,是不需要进行滚动的,换句话说,startIndex需要在列表总长度在到达一个值之前一直为0

到这里,简单的虚拟列表就实现了。

WebSocket缓冲池

我们使用的是WebSocket来传递数据,数据量不少。因此很可能会出现过于频繁更新数据的情况,数据一更新,页面也会随之改变,这样会对性能照成一定的影响。所以我们需要对这个频度进行把控。目前的方案是加一个缓冲池。

这缓冲池的思路大概是这样的,WebSocket传递数据的时候,我们把这段时间的数据先存在一个数组中,然后每隔一段时间,比如500ms,再把数据push到完整的列表中,这个方案可能就会涉及到节流。

let socketPool = [];    //存储一段时间的数据
let socketTimer;
socketFun( (data) => {
  //先制造一个缓存区间,用来做缓存socket的数据
  socketPool.push(data);
  //每次都把当前的数据进行push到list
  if(!socketTimer){
    socketTimer = setTimeout(()=>{
      this.appendRecord(socketPool);
      socketPool.length = 0;
      this.scrollToBottom();
      socketTimer = null;
    },500);
  }
});

在这里边appendRecord()是用来处理数据,并且把数据放入list中的方法,而scrollToBottom()就是为了当数据push到list之后,列表能直接展示最新的数据,也就是让页面滚动到列表的最底部。

缓冲池其实也是提升性能的一个方案,这个方案最核心的地方就是减少页面渲染的次数。大家可以这么理解:每秒钟可能会有10条数据需要被渲染,假如我每次都老老实实的渲染,那么10秒的时间我就要渲染10次,其实是没有必要的,因此我们可以考虑每2秒渲染一次,这样10s的时间内我的渲染次数就会减少到5次。你可以理解为性能提升了一倍

列表锁定

按照之前的需求,当用户点击列表中的其中一条数据的时候,列表是需要停止滚动的。所以我加了一个滚动锁autoScrollLoack,这个锁的作用就是当我点击到列表中的某一条的时候,执行autoScrollLoack = true页面就不会滚动了。这个锁的判断会放在this.scrollToBottom()中,代码大家稍微看看就行。

scrollToBottom(){
  if(autoScrollLoack){
    return;
  }
  ...do something
},

这个autoScrollLoack在页面中会与一个单选框进行双向绑定,因此用户就可以通过改变单选框的选中状态来控制锁的状态,其实在有了这个锁之后,页面如果因为需求停止滚动了,用户也能有所感知,不至于突然滚动就停止了,看起来像个bug。

聚焦移动

聚焦移动的功能之前需求也说过了,就是选中了一条信息,可以通过上下键将聚焦指向上一个或者下一个,这个其实也比较好实现

<div 
  v-for="(item, idx) in list" 
  v-if="idx >= startIdx && idx <= endIdx" 
  :key="item.id"
  :class="{'active':curIdx==idx}"
  class="list-row"
  @click="showDetail(item.id)">

在这里,大家可以看到,active就是聚焦的时候列表的样式。在逻辑上,把当前选中项的index赋给curIndex,前端模板上通过vue对class的绑定来控制样式,判断条件就是curIndex == index

聚焦功能已经实现了,那么接下来要实现通过键盘中的上下键,实现移动聚焦的效果。这个功能很简单,我们完全可以通过vue提供的监听事件来实现,具体的实现大家可以在官网上搜一下keyup

<div class="list-body-box" @scroll="listScroll" @keyup="moveFocus">
...
...
moveFocus(e){
  let keyCode = Number(e.keyCode);
  switch(keyCode){
    case 38:
      this.curIdx -= 1;
      this.showDetail();
      break;
    case 40:
      this.curIdx += 1;
      this.showDetail();
      break;
  }
},

这段代码实现了聚焦的上下挪动。根据需求我们每一次聚焦的时候需要展示聚焦项对应的日志详情,详情是需要发ajax请求来获取的。问题来了,有一个场景:我想通过键盘把当前的聚焦向下挪动10次,在不停聚焦的过程中我会触发10次请求,这个其实没必要,我在快速移动的过程中,是不care详情的,我只需要展示目标详情就行了。综上,我们需要再加一个防抖。

let detailTimer;
showDetail(id){
  if(detailTimer){
    clearTimeout(detailTimer);
  }
  detailTimer = setTimeOut(() => {
    $.post('...',{
      id:id
    }).then((res) => {
      do something...  
    });
  },300);
}

从上边的逻辑我们可以看出来,当用户在快速挪动聚焦的时候是不会触发请求的,实际上这个改动很大程度上提升了用户的流畅度。

总结

其实虚拟列表的开发还是比较简单的,但是实际意义却比较大,在这个过程中会涉及到不少页面的优化,感兴趣的童鞋可以尝试一下。