背景
这几个月接了一个日志收集系统的活,因为这个系统是属于传承的项目,所以我也想在系统上做一些标志性的改动,作为接力棒传递下去。
这个日志系统从前端到服务端,都做了不小的改动,比如虚拟列表,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
解释同上,最后一个元素的index
是199
。
虚拟列表的实现
这里要提一下,我的框架用的是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
去控制列表的展示,通过startIdx
和endIdx
的增减,去展示不同位置的数据,让这两个值递增就可以实现列表滚动。
下边我们会说一下自动滚动在代码上的实现,主要是通过一个主动的事件去频繁的触发对startIdx
和endIdx
递增或者递减。
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
的作用下自调用,startIdx
和endIdx
会不断递增列表就可以自动滚动了,在这里边有一个表达式
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);
}
从上边的逻辑我们可以看出来,当用户在快速挪动聚焦的时候是不会触发请求的,实际上这个改动很大程度上提升了用户的流畅度。
总结
其实虚拟列表的开发还是比较简单的,但是实际意义却比较大,在这个过程中会涉及到不少页面的优化,感兴趣的童鞋可以尝试一下。