携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第3天,点击查看活动详情
输出内容才能更好的理解输入的知识
前置内容基于【Event Loop】的长列表渲染优化
进阶内容深入【虚拟列表】动态高度、缓冲、异步加载... Vue实现
相关内容IntersectionObserver:实现滚动动画、懒加载、虚拟列表...
前言🎀
在某种特殊场景下,我们需要将 大量数据 使用不分页的方式渲染到列表上,这种列表叫做长列表。
长列表直接渲染会造成页面阻塞给用户带来不好的体验,一般有两种解决方式:时间分片 和 虚拟列表
在前文中分享了基于时间分片的解决方法,该方法实现简单但有不少缺陷。所以本文主要介绍使用虚拟列表的方式解决长列表渲染问题。
如果觉得有收获还望大家点个赞🌹
时间分片的缺陷📖
-
效率低时间分片相当于代码替用户去触发懒加载,伴随着 事件循环 逐次的渲染DOM,渲染消耗的总时间肯定比一次渲染所有DOM多不少。
-
不直观因为页面是逐渐渲染的,如果用户直接把滚动条拖到底部看到的并不是最后的数据,需要等待整体渲染完成。
-
性能差实际开发出的代码不是一个<tr> or <li>标签加数据绑定这么简单,随着 dom 结构的复杂(事件监听、样式、子节点...)和 dom 数量的增加,占用的内存也会更多,不可避免的影响页面性能。
分析真实业务场景,用户正常情况下是不会去浏览全部数据的。因此除特殊情况外,将全部数据渲染到列表中是无用且浪费资源的行为,我们只需要根据用户的视窗进行部分渲染即可,而这就要用到下文的虚拟列表
虚拟列表🌲
概念
虚拟列表是上述问题的一种解决方案,是按需显示的一种实现,只对可见区域渲染,对非可见区域不渲染或部分渲染,从而减少性能消耗。
虚拟列表将完整的列表分为三个区域:虚拟区 / 缓冲区 / 可视区
- 虚拟区为非可见区域不进行渲染
- 缓冲区为后续优化滚动白屏使用,暂不渲染
- 可视区为用户视窗内的数据,需要渲染对应的列表项
实现
假设列表 可见区域 的高度为 500px,列表项高度为 50px。
初始化时列表里有1w条数据本来需要同时渲染,
但列表区域中最多只能显示 500 / 50 = 10 条数据,那么在首次渲染列表时只需要加载前10条。
当列表发生滚动,计算 视窗偏移量 获得 开始索引,再根据索引获得此时可见区域内用于渲染的列表数据范围。
例如当前滚动条距离顶部150px,那么可见区域内的列表项为第 4(1 + 150 / 50) 项 至 第 13(10 + 3) 项。
无论滚动到什么位置,浏览器只需要渲染可见区域内的节点。
本文代码基于Vue,实现虚拟列表的关键点主要分为 1.模拟完整列表的页面结构调整 2.总结过程中的参数和计算公式 3.添加滚动回调时的操作
页面结构
container:列表容器,监听phantom元素的滚动条,判断当前用于渲染的列表数据范围。
phantom:占位元素,为了保持列表容器的 真正高度 并使滚动能够正常触发,我们专门使用一个div来占位生成滚动条。
content:渲染区域,用户真正看到的页面内容,一般由 缓冲区 + 可视区 组成。
<!-- 可视区域的容器 -->
<div class="container" ref="virtualList">
<!-- 占位,用于形成滚动条 -->
<div class="phantom"></div>
<!-- 列表项的渲染区域 -->
<div class="content">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
参数&计算
已知数据:
● 假定可视区域高度固定,称为 screenHeight
● 假定列表每项高度固定,称为 itemSize
● 假定列表数据称为 listData
● 假定当前距离顶部偏移量称为 scrollTop
可推算出:
● 列表总高度 listHeight = listData.length * itemSize
● 可见列表项数 visibleCount = Math.ceil(screenHeight / itemSize)
● 数据的起始索引 start = Math.ceil(scrollTop / itemSize)
● 数据的结束索引 end = startIndex + visibleCount
● 列表显示数据为 visibleData = listData.slice(start, end)
export default {
......
props: {
listData:{
type:Array,
default: () => []
},
itemSize: {
type: Number,
default: 200
}
},
computed:{
// 列表总高度
listHeight() {
return this.listData.length * this.itemSize;
},
// 可显示的列表项数
visibleCount() {
return Math.ceil(this.screenHeight / this.itemSize)
},
// 获取真实显示列表数据
visibleData() {
return this.listData.slice(this.start, this.end);
}
},
mounted() {
// 初始化数据
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
},
data() {
return {
screenHeight:0, // 可视区域高度
start:0, // 起始索引
end:null, // 结束索引
};
},
};
窗口滚动
容器滚动绑定监听事件,当滚动后,我们要获取 距离顶部的高度scrollTop ,然后计算 开始索引start 和 结束索引end ,根据他们截取数据,并计算 当前偏移量currentOffset 用于将渲染区域偏移至可见区域中 。
export default {
...
mounted() {
...
this.$refs.virtualList.addEventListener('scroll', event => this.scrollEvent(event.target))
},
data() {
return {
...
curretnOffset: 0, // 当前偏移量
};
},
...
methods: {
scrollEvent(target) {
//当前滚动位置
let scrollTop = target.scrollTop;
//此时的开始索引
this.start = ~~(scrollTop / this.itemSize);
//此时的结束索引
this.end = this.start + this.visibleCount;
//此时的偏移量
this.currentOffset = scrollTop - (scrollTop % this.itemSize);
}
}
...
}
currentOffset 为什么不是 =scrollTop?
为了正确实现滚动效果。
每次滚动生成的偏移量都只能是itemSize的倍数,你可以理解为this.currentOffset = this.start * this.itemSize
一次滚动可能不会让元素完全离开虚拟列表(比如一半在列表内一半在列表外)。
如果=scrollTop的话 刚滚下去一半 偏移量马上就跟上来了,在用户视角就是列表没有滚动。
直到偏移量够了才发生索引变化 刷新数据 才产生类似滚动的效果,你可以试一下把itemSize设置的比较大,再currentOffset = scrollTop,看看效果
具体实现
<template>
<div class="container" ref="virtualList">
// 占位元素
<div class="phantom" :style="{ height: listHeight + 'px' }"></div>
// 渲染区域
<div
class="content"
:style="{ transform: `translate3d(0, ${currentOffset}px, 0)` }"
>
<div
v-for="item in visibleData"
:key="item.id"
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
class="list-item"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
listData: [],
itemSize: 50,
screenHeight: 0,
currentOffset: 0,
start: 0,
end: 0,
};
},
mounted() {
for (let i = 1; i <= 1000; i++) {
this.listData.push({id: i, value: '字符内容' + i})
}
this.screenHeight = this.$el.clientHeight;
this.start = 0;
this.end = this.start + this.visibleCount;
this.$refs.virtualList.addEventListener("scroll", (event) =>
this.scrollEvent(event.target)
);
},
computed: {
listHeight() {
return this.listData.length * this.itemSize;
},
// 渲染区域元素数量
visibleCount() {
return Math.ceil(this.screenHeight / this.itemSize);
},
visibleData() {
return this.listData.slice(this.start, this.end);
},
},
methods: {
scrollEvent(target) {
const scrollTop = target.scrollTop;
this.start = ~~(scrollTop / this.itemSize);
this.end = this.start + this.visibleCount;
this.currentOffset = scrollTop - (scrollTop % this.itemSize);
},
},
};
</script>
<style scoped>
.container {
position: relative;
height: 90vh;
overflow: auto;
}
.phantom {
position: absolute;
top: 0;
right: 0;
left: 0;
}
.content {
position: absolute;
top: 0;
right: 0;
left: 0;
text-align: center;
}
.list-item {
padding: 10px;
border: 1px solid #999;
}
</style>
优化
滚动发生后,scroll回调会频繁触发,但并不是每一次回调都是有效的。很多时候会造成重复计算的问题,从性能上来说无疑存在浪费的情况。(滚动一下会触发几十次)
防抖&节流
我们通过 节流函数 来限制触发频率,通过 防抖函数 保证最后一次滚动的回调正确的进行。
export default {
...
mounted() {
...
// 绑定滚动事件
let target = this.$refs.virtualList
let scrollFn = (event) => this.scrollEvent(event.target)
let debounce_scroll = lodash.debounce(scrollFn, 320)
let throttle_scroll = lodash.throttle(scrollFn, 160)
target.addEventListener("scroll", debounce_scroll);
target.addEventListener("scroll", throttle_scroll);
},
....
}
更好的API
# IntersectionObserver:实现滚动动画、懒加载、虚拟列表...
也可以使用IntersectionObserver替换监听scroll事件。
IntersectionObserver可以监听目标元素是否出现在可视区域内,并异步触发监听回调,不随着目标元素的滚动而触发,性能消耗极低。
// 调用构造函数 IntersectionObserver 生成观察器
const myObserver = new IntersectionObserver(callback, options);
构造函数的返回值是一个 观察器实例 。
IntersectionObserver 接收两个参数
callback: 可见性发生变化时触发的回调函数
options: 配置对象(可选,不传时会使用默认配置)
// 开始观察 元素是否到可视区
myObserver.observe(document.getElementByIdx_x('example'));
// 停止观察
myObserver.unobserve(element);
// 关闭观察器
myObserver.disconnect();
遗留的问题
- 动态高度
多行文本、图片之类的可变内容,会导致列表项的高度并不相同。
解决方法: 以预估高度先行渲染,然后获取真实高度并缓存。 - 白屏闪烁
回调执行也有执行耗时,如果滑动过快会出现白屏/闪烁的情况。为了使页面平滑滚动,我们还需要在可见区域的上方和下方渲染额外的项目,给滚动回调一些缓冲时间。 - 响应耗时
一次性请求大量数据可能会使后端处理时间增加,过大的响应体也会导致请求中Content Download 耗时增加,建议通过请求接口分片获取渲染数据。
该部分内容过多本文暂不详述,会在后续文章进行更新
结语🎉
不要光看不实践哦,后续会持续更新前端相关的知识
写作不易,如果觉得有收获欢迎大家点个赞谢谢🌹
才疏学浅,文章如果有问题或建议欢迎大家指教