长列表优化之虚拟列表(项目)


长列表优化之虚拟列表

用Vue代码实现上面的问题

好的,以下是一个使用Vue实现虚拟列表的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<template>
<div class="list-container" ref="listContainer" @scroll="handleScroll">
<div class="list-item" v-for="(item, index) in visibleItems" :key="item.id">
{{ item.text }}
</div>
</div>
</template>

<script>
export default {
data() {
return {
items: [], // 列表数据
visibleStartIndex: 0, // 当前可见列表项的起始索引
visibleItemCount: 0, // 当前可见列表项的数量
itemHeight: 50 // 每个列表项的高度
};
},
mounted() {
// 模拟异步获取数据
this.fetchData().then((data) => {
this.items = data;
});
},
computed: {
// 计算当前可见列表项
visibleItems() {
return this.items.slice(
this.visibleStartIndex,
this.visibleStartIndex + this.visibleItemCount
);
}
},
methods: {
// 处理滚动事件
handleScroll() {
const scrollTop = this.$refs.listContainer.scrollTop;
this.visibleStartIndex = Math.floor(scrollTop / this.itemHeight);
this.visibleItemCount = Math.ceil(
this.$refs.listContainer.clientHeight / this.itemHeight
);
},
// 模拟异步获取数据
fetchData() {
return new Promise((resolve) => {
setTimeout(() => {
const data = [];
for (let i = 0; i < 1000; i++) {
data.push({
id: i,
text: `Item ${i}`
});
}
resolve(data);
}, 1000);
});
}
}
};
</script>

<style>
.list-container {
height: 500px;
overflow-y: scroll;
}
.list-item {
height: 50px;
line-height: 50px;
border-bottom: 1px solid #eee;
}
</style>

这个示例中,我们首先通过fetchData方法模拟异步获取列表数据,然后在mounted生命周期函数中获取数据,并将其保存到items数据属性中。在模板中,我们使用一个v-for指令来动态渲染当前可见的列表项,并使用visibleItems计算属性来计算当前可见的列表项。在handleScroll方法中,我们处理滚动事件,根据滚动条的位置计算出当前可见列表项的起始索引和数量,然后更新visibleStartIndex和visibleItemCount数据属性。最后,我们使用一些简单的样式来设置列表的高度和样式。

分析需求

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

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

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

  1. 列表随着时间会越来越长,需要控制展示的节点数量。
  2. 列表长度随着WebSocket的通信而增加,数据更新频度过快,需要有缓冲池。
  3. 增加一个列表锁定的提示,可以手动解开列表的锁定。
  4. 移动聚焦的时候会随即展示日志详情,因为移动速度过快,所以需要增加防抖。
    梳理完成之后,经过考虑我决定使用虚拟列表来代替现有的长列表,这也是踩坑之路的开始。

开始开发
长列表转虚拟列表

为什么需要虚拟列表

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

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

这时候我们重新分析一下,就会发现其实有很多数据我们大多数情况下是不需要看见的,如果只考虑我们能看到数据的话,其实需要渲染的数据量就会非常的少了,很好的提高了渲染的效率,减少因为大量的重绘照成不必要的影响。
这么一梳理一下,答案简直呼之欲出—-虚拟列表。

什么是虚拟列表

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

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

  1. 可视区。
  2. 真实列表。
  3. startIndex。
  4. endIndex。

    可视区

    可视区大家可以这么理解,我们现在有一个<div class="show-box">,给这个元素加一些样式。
1
2
3
4
5
6
.show-box{
width: 375px;
height: 500px;
margin: 0 auto;
position: relative;
}

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

真实列表

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

1
2
3
4
5
6
7
8
9
10
<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,所以虚拟列表的实现也是比较方便的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<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递增或者递减。

1
2
3
4
5
6
7
8
9
10
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会不断递增列表就可以自动滚动了,在这里边有一个表达式

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

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

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

WebSocket缓冲池

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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() 中,代码大家稍微看看就行。

1
2
3
4
5
6
scrollToBottom(){
if(autoScrollLoack){
return;
}
...do something
},

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

聚焦移动

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

1
2
3
4
5
6
7
<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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<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详情的,我只需要展示目标详情就行了。综上,我们需要再加一个防抖。

1
2
3
4
5
6
7
8
9
10
11
12
13
let detailTimer;
showDetail(id){
if(detailTimer){
clearTimeout(detailTimer);
}
detailTimer = setTimeOut(() => {
$.post('...',{
id:id
}).then((res) => {
do something...
});
},300);
}

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

总结

  1. 计算可见列表项:首先,我需要计算当前可见的列表项数量和位置。这可以通过测量列表容器和列表项高度,以及滚动条的位置来实现。

  2. 动态渲染列表项:接下来,我需要根据当前可见的列表项数量和位置,动态地渲染这些列表项。这可以通过使用一个v-for指令来实现,只渲染当前可见的列表项。

  3. 优化性能:为了优化性能,我可以使用一些技巧,例如使用keep-alive缓存列表项,避免不必要的渲染,或使用Vue组件的生命周期钩子函数来处理列表项的渲染和销毁,以提高性能。

  4. 处理滚动事件:当用户滚动列表时,我需要根据滚动条的位置,重新计算当前可见的列表项,并相应地更新列表。这可以通过添加滚动事件监听器来实现。

  5. 处理列表项点击事件:最后,我需要处理列表项的点击事件。由于虚拟列表只渲染了当前可见的列表项,所以我需要确保只为当前可见的列表项添加事件处理程序,以避免不必要的性能开销。

总之,实现虚拟列表需要一定的计算和渲染技巧,需要注意性能优化和细节处理,可以借助第三方库来提高开发效率。当回答这个问题时,我需要清晰地表达我的思路和实现方案,注重细节和实现的可行性,并尽可能展示我的Vue编程技能和经验。

文章长列表优化这一块讲讲是怎么做的?

  1. 发现问题
  2. 解决问题 (原理 ->实现思路)

    1. 发现问题

项目中文档分析模块显示的文档长列表会卡顿(如果数据量大的较大大约有数十万条数据,直接渲染所有数据会导致页面加载缓慢、卡顿等问题)–(performance查看可以看到执行栈task-帧执行(正常15ms,超过60ms))—-长列表是指包含大量数据项的列表,如果在渲染这些数据项时没有进行优化,则会导致应用程序变慢并占用更多的系统资源,从而影响用户体验。因此,需要对长列表进行优化。

2. 解决问题

优化的方法包括使用虚拟列表、懒加载、分页、无限滚动等技术。

原理
  • 简要解释虚拟列表的概念和原理:虚拟列表是一种在渲染大型列表时非常有效的优化技术,它只会渲染可见的列表项,而不会渲染整个列表。它的原理是通过监听列表的滚动事件,在用户滚动列表时动态地渲染新出现的列表项,并销毁已经滚出视图的列表项,从而避免了长列表中大量无用数据的渲染和占用内存资源。
实现思路
讲解虚拟列表的优点:虚拟列表的主要优点包括:

优化性能:虚拟列表可以极大地减少在渲染长列表时的开销,从而提高应用程序的性能和响应速度。
节省内存:虚拟列表只渲染可见的列表项,而不会渲染整个列表,因此可以节省内存资源。
支持无限滚动:虚拟列表可以支持无限滚动,用户可以不断向下滚动列表,而不必等待整个列表渲染完成。
支持异步数据加载:虚拟列表可以与异步数据加载技术结合使用,当用户滚动到新的列表区域时,可以异步地加载数据并渲染新的列表项。
说明虚拟列表的缺点:虚拟列表的主要缺点是在实现时需要更多的代码复杂度和技术要求。特别是需要进行高级优化时,可能需要进行更多的计算和代码调试。此外,虚拟列表在处理动态高度的列表项时可能会存在一些问题,需要进行更多的测试和优化。

面试问你为什么这里使用虚拟列表而不使用懒加载、分页、无限滚动该怎么回答呢
  1. 虚拟列表可以提升用户体验:虚拟列表可以在页面上显示一定数量的列表项,而不会导致整个页面的渲染和布局都被阻塞。因此,当用户在列表中滚动时,新的列表项会被动态加载,而不会导致页面的重新渲染,从而提升了用户的体验。相比之下,懒加载、分页、无限滚动的加载方式可能会导致页面闪烁或整体布局变化,用户体验相对较差。

  2. 虚拟列表可以提高页面性能:由于虚拟列表只会渲染当前可视区域内的列表项,因此可以大大降低页面的渲染负担,提高页面的性能。相比之下,懒加载、分页、无限滚动的加载方式可能会导致不必要的DOM操作和数据绑定,从而影响页面性能。

  3. 虚拟列表适用于大数据量的列表展示:当需要在页面上展示大量数据时,虚拟列表是一种非常适用的方式。相比之下,懒加载、分页、无限滚动的加载方式通常只适用于数据量较小的列表展示,因为这些方式需要预先加载所有数据,并在用户滚动到相应位置时再进行渲染。

综上所述,虚拟列表相比于懒加载、分页、无限滚动具有更好的用户体验和页面性能,并且适用于大数据量的列表展示。因此,当需要在Web应用中展示大量数据时,使用虚拟列表是一种非常优秀的解决方案。

参考文章

长列表优化之虚拟列表