虚拟列表
1. 基于【Event Loop】的长列表渲染优化
背景
日志查看、指令展示 等 列表很长,且不能使用分页、触底懒加载的场景
使用 vue+element-ui 表格展示接口返回数据,数据量为万级。
页面内容白屏二十秒左右后显示出数据,期间页面其他功能/按钮无法正常的交互,之后页面滚动、点击等事件也十分卡顿,用户体验较差。
最开始的数据量接近 10w,分类型后数据变少了渲染也变快了,但还是要为大数据量情况做准备
排查页面性能消耗
一、性能概览
通过 dev tools 查看 performance 渲染分析 通过概要 可以发现主要耗时是 scripting 和 Rendring
二、内存消耗
打印内存快照进行对比,其实在 performance 可以看到 Js Heap 和 Nodes 发生了明显的增加
Delta(内存回收差值) = #New (新分配内存) - #Deleted (销毁内存)
再去 Memory 打印 内存快照(take heap snapshot) 跟正常页面内存快照进行 对比(Comparison) #Delta 可以发现:
- InternalNode (内置节点)、Array、Closure(闭包) 有明显提升
- 其实 listener 也有明显提升 但在写文之前已经优化过了
解决问题
分析原因
通过 performance 的分析,可以发现长时间的 Js 任务阻塞了渲染。
为什么 JS 任务会阻塞渲染?
实际是因为浏览器的 GUI 渲染线程和 JavaScript 引擎线程之间的互斥,JavaScript 在执行期间会阻塞 UI 的渲染,如果脚本执行时间太长会由于页面长时间无响应而崩溃。
为什么页面事件会没有响应?
用户的操作会触发事件触发线程,当事件被触发后,会把事件添加到任务队列的最尾端,等待 js 引擎处理执行
Event Loop
执行代码的 JS 引擎线程是单线程的,同一时间只能做一件事情。
浏览器上发生很多事件,比如页面渲染、文件读取、脚本执行、网络请求等等。
但并不是所有事件的回调都是立即执行的,为了协调这些事件的处理顺序,浏览器使用异步任务回调通知模式
Js 代码会分为 同步任务 和 异步任务 ,同步任务直接放在 Js 引擎线程 上执行,形成一个 执行栈。
异步任务分别进入 异步 http 请求线程 或 定时器触发线程。当符合一定条件时(如定时器到时,请求响应)就会把回调加入任务队列。
执行栈上的同步任务完成后,Js 线程空闲,系统会读取任务队列,将其中的异步任务回调添加到执行栈中。
为什么渲染会耗时这么久?
而 Js 执行完成后 生成了 大量 DOM,GUI 线程需要一次性将它们渲染到页面上,要解析 HTML、CSS 构成 DOMTree 和 RenderTree,然后 Layout、Paint 将消耗大量时间(效果表现解析并为 丢帧 和 长时间空白)
优化方式
如果可以把 Js 任务 拆分成多个片段,每个片段只解析一定量的数据,将整个流程控制为一边解析数据一边渲染。
那用户就能立刻看到页面,并且在没有明显渲染感觉的情况下正常使用页面
但如何衡量每个片段应该分配多少 执行时间 ,解析多少 数据量, 何时执行渲染 呢
关于 浏览器渲染 和 requestAnimationFram
浏览器渲染
如图所示,浏览器一帧里包含:页面事件-->执行脚本代码(宏任务+微任务)-->执行 rAF-->渲染(样式计算,布局,重绘)-->执行 rIC
不一定每一轮 event loop 都会对应一次浏览 器渲染,要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定,通常来说这个渲染间隔是固定的。
浏览器会尽可能的保持帧率稳定,例如页面性能无法维持 60fps(每 16.66ms 渲染一次)的话,那么浏览器就会选择 30fps 的更新速率,而不是偶尔丢帧。
如果浏览器上下文不可见,那么页面会降低到 4fps 左右甚至更低。
如果满足以下条件,也会跳过渲染:
- 浏览器判断更新渲染不会带来视觉上的改变。
- map of animation frame callbacks 为空,也就是帧动画回调为空,可以通过 requestAnimationFrame 来请求帧动画。
rIC (requestIdelCallback),rIC 不是每一帧结束都会执行,只有在一帧的 16.6ms 中做完了前面 6 件事 且还有剩余时间,才会执行。
长时间的事件和任务会 阻塞渲染,导致页面空白。
Event Loop 中,当 JS 引擎的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
requestAnimationFrame
requestAnimationFrame 在重新渲染之前执行,使用一个回调函数作为参数,可以在浏览器进行下一个渲染前执行回调。
浏览器页面刷新频率一般与设备保持一致,当页面每秒绘制的帧数(FPS)达到 60 时,人眼才会觉得流畅
window.requestAnimationFrame() 告诉浏览器——希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
实现代码
利用 rAF 将 Js 代码执行平均分配在每一帧的渲染
总体上页面渲染耗时变长了,但用户是立即看到页面和数据,也可以像访问正常页面一样去操作页面
总结
因为 JS 的 Event Loop 机制,JS 引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
通过 requestAnimationFrame 可以由浏览器来决定回调函数的执行时机,并将大量数据的多次渲染分为多个片段,在每个片段中解析定量数据交给浏览器渲染,第一时间将页面展现给用户。
虽然问题看起来解决了,但其实遗留的缺点并不少:
页面渲染效率,页面性能,DOM 节点多、结构复杂...
- 时间分片相当于代码替用户去触发懒加载,DOM 是逐次渲染的,渲染消耗的总时间肯定比一次渲染所有 DOM 要慢不少
- 因为页面是逐渐渲染的,如果直接把滚动条拖到底部看到的并不是最后的数据,需要等待渲染完成
- 实际开发出的代码不是一个 or
- 标签加数据绑定这么简单,随着 dom 结构的复杂度(事件监听、样式、子节点...)和 dom 数量的增加,占用的内存也会更多,不可避免的影响页面性能。
2. 基于【虚拟列表】高性能渲染海量数据
前言
在某种特殊场景下,需要将 大量数据 使用不分页的方式渲染到列表上,这种列表叫做长列表。
长列表直接渲染会造成页面阻塞给用户带来不好的体验,一般有两种解决方式:时间分片 和 虚拟列表
在前文中分享了基于时间分片的解决方法,该方法实现简单但有不少缺陷。所以本文主要介绍使用虚拟列表的方式解决长列表渲染问题。
时间分片的缺陷
- 效率低时间分片相当于代码替用户去触发懒加载,伴随着 事件循环 逐次的渲染 DOM,渲染消耗的总时间肯定比一次渲染所有 DOM 多不少。
- 不直观因为页面是逐渐渲染的,如果用户直接把滚动条拖到底部看到的并不是最后的数据,需要等待整体渲染完成。
- 性能差实际开发出的代码不是一个 or
- 标签加数据绑定这么简单,随着 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);
}
}
...
}
具体实现
<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 替换监听 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
耗时增加,建议通过请求接口分片获取渲染数据。
3. 深入【虚拟列表】动态高度、缓冲、异步加载... Vue实现
前言
在前文中了解到:
在某种特殊场景下,需要将 大量数据 使用不分页的方式渲染到列表上,这种列表叫做长列表。
因为事件循环的机制,一次性大量的渲染耗时较长,并且渲染期间会阻塞页面交互事件,所以使用时间分片机制将渲染分为多次。
分析真实业务场景,将全部数据渲染到列表中是无用且浪费资源的行为,只需要根据用户的视窗进行部分渲染即可,所以使用到虚拟列表技术。
前文中根据 "无论滚动到什么位置,浏览器只需要渲染可见区域内的节点" 的思路实现了虚拟列表解决了长列表问题,但在一些细节和特殊情况的处理上还是有所欠缺,例如:
高度不定的列表项会导致内容出现错位、偏移等情况。
列表项含有异步资源,会在渲染后再次改变高度。
一次性大量数据的请求导致请求响应与数据处理时间过长。
在本文中就来一起研究这些场景,并对原版的虚拟列表做出优化 🚀
动态高度
分析
在前文的虚拟列表实现中,列表项高度 itemSize 都是固定的。
// template -> list-item
:style="{ height: itemSize + 'px', lineHeight: itemSize + 'px' }"
// export defualt -> data
itemSize: 50,
因此很多直接与 列表项高度 itemSize 关联的属性,都很容易计算:
- 列表总高度 listHeight= listData.length * itemSize
- 当前窗口偏移量 currentOffset= scrollTop - (scrollTop % itemSize)
- 列表数据的开始/结束索引 start/end= ~~(scrollTop / itemSize)
但在实际情况中列表元素多为高度不固定的列表项,它可能是多行文本、图片之类的可变内容,如系统日志、微博等等。
不固定的高度会导致上述的属性无法正常计算。
对于高度不固定的列表项,遇到的问题如下:
- 如何获取真实高度?
- 相关的属性该如何计算?
- 列表渲染的方式有何改变?
方案
如何获取真实高度?
如果能获得列表项高度数组,真实高度问题就很好解决。但在实际渲染之前是很难拿到每一项的真实高度的,所以采用预估一个高度渲染出真实 DOM,再根据 DOM 的实际情况去设置真实高度。
创建一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在渲染后根据 DOM 实际情况更新缓存列表。
相关的属性该如何计算?
显然以前的计算方式都无法使用了,因为那都是针对固定值设计的。
于是根据缓存列表重写 计算属性、滚动回调函数,例如列表总高度的计算可以使用缓存列表最后一项的定位字段的值。
列表渲染的方式有何改变?
因为用于渲染页面元素的数据是根据 开始/结束索引 在 数据列表 中筛选出来的,所以只要保证索引的正确计算,那么渲染方式是无需变化的。
对于开始索引,将原先的计算公式改为:在 缓存列表 中搜索第一个底部定位大于 列表垂直偏移量 的项并返回它的索引。
对于结束索引,它是根据开始索引生成的,无需修改。
实现
预估&初始化列表
先设置一个虚拟 预估高度 preItemSize,用于列表初始化。
同时维护一个记录真实列表项数据的 缓存列表 positions。
data() {
return {
// . . . . . .
// 预估高度
preItemSize: 50,
// 缓存列表
positions = [
// 列表项对象
{
index: 0, // 对应listData的索引
top: 0, // 列表项顶部位置
bottom: 50, // 列表项底部位置
height: 50, // 列表项高度
}
]
}
}
在创建组件时先用 preItemSize 对 positions 进行初始化,在后续更新时再进行替换。
created() {
this.initPositions(this.listData, this.positions)
},
methods: {
initPositions(listData, itemSize) {
this.positions = listData.map((item, index) => {
return {
index,
top: index * itemSize,
bottom: (index + 1) * itemSize,
height: itemSize,
}
})
}
}
注:listData 即数据列表,里面是每一项数据对应的内容。
列表总高度 listHeight 的计算方式改变为缓存列表 positions 最后一项的 bottom:
computed: {
listHeight() {
// return this.listData.length * this.itemSize;
return this.positions[this.positions.length - 1].bottom;
},
}
更新真实数据
在每次渲染后,获取真实 DOM 的高度去替换 positions 里的预估高度。
∵ updated 生命周期在数据变化视图更新过后触发所以能获取到真实 DOM
∴ 利用 Vue 的 updated 钩子来实现这一功能
期间遍历真实列表的每一个节点,对比 节点 和 列表项 生成高度差 dValue 判断是否需要更新:
updated() {
this.$nextTick(() => {
// 根据真实元素大小,修改对应的缓存列表
this.updatePositions()
})
},
methods: {
updatePositions() {
let nodes = this.$refs.items;
nodes.forEach((node) => {
// 获取 真实DOM高度
const { height } = node.getBoundingClientRect();
// 根据 元素索引 获取 缓存列表对应的列表项
const index = +node.id
let oldHeight = this.positions[index].height;
// dValue:真实高度与预估高度的差值 决定该列表项是否要更新
let dValue = oldHeight - height;
// 如果有高度差 !!dValue === true
if(dValue) {
// 更新对应列表项的 bottom 和 height
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
// 依次更新positions中后续元素的 top bottom
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;
}
}
})
}
}
此外在更新完 positions 后,当前窗口偏移量 currentOffset 也要根据真实情况重新赋值:
updated() {
this.$nextTick(() => {
// 根据真实元素大小,更新对应的缓存列表
this.updatePositions()
// 更新完缓存列表后,重新赋值偏移量
this.currentOffset = this.getCurrentOffset()
})
},
methods: {
updatePositions() {
//. . .
}
getCurrentOffset() {
if(this.start >= 1) {
this.currentOffset = this.positions[this.start - 1].bottom
} else {
this.currentOffset = 0;
}
}
}
重写滚动回调
滚动触发的回调函数里计算了 开始/结束索引 start/end 和 当前窗口偏移量 currentOffset ,现在高度不固定后都需要重新计算,而结束索引依赖于开始索引所以不需要修改。
- 重新计算 开始索引 start
定高时不必建立数组(建立了也只是重复的数据),直接根据 scrollTop 与 itemSize 计算索引即可
this.start = ~~(scrollTop / this.itemSize);
但不定高时,只能带着 scrollTop 在列表中逐个寻找(后续使用搜索算法优化)。两个计算的最终目的都是找到当前位置对应的数据索引。
列表数据开始索引 start 的计算方法修改为:遍历 缓存列表 positions 匹配第一个大于当前滚动距离 scrollTop 的项,并返回该项的索引。
mounted() {
// . . . . . .
// 绑定滚动事件
let target = this.$refs.virtualList
let scrollFn = (event) => this.scrollEvent(event.target)
target.addEventListener("scroll", scrollFn);
},
methods: {
scrollEvent(target) {
const scrollTop = target.scrollTop;
// this.start = ~~(scrollTop / this.itemSize);
this.start = this.getStartIndex(scrollTop)
this.end = this.start + this.visibleCount;
this.currentOffset = scrollTop - (scrollTop % this.itemSize);
},
getStartIndex(scrollTop = 0) {
let item = this.positions.find(item => item && item.bottom > scrollTop);
return item.index;
}
},
- 重新计算 窗口偏移量 currentOffset
滚动后立即根据 positions 的预估值(此时数据还未更新)计算窗口偏移量 currentOffset:
scrollEvent() {
// . . . . . .
// this.currentOffset = scrollTop - (scrollTop % this.itemSize);
this.currentOffset = this.getCurrentOffset()
},
优化
positions 是遍历 listData 生成的,listData 本是有序的,所以 positions 也是一个顺序数组。
Array.find 方法 时间复杂度 O(n)O(n)O(n),查找 索引 start 效率较低 ❌
二分查找十分适合顺序存储结构 时间复杂度 log2nlog_2{n}log2n,效率较高 ✔️
<script>
// . . . . . .
var binarySearch = function (list, target) {
const len = list.length;
let left = 0,
right = len - 1;
let tempIndex = null;
while (left <= right) {
let midIndex = (left + right) >> 1;
let midVal = list[midIndex].bottom;
if (midVal === target) {
return midIndex;
} else if (midVal < target) {
left = midIndex + 1;
} else {
// list不一定存在与target相等的项,不断收缩右区间,寻找最匹配的项
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
right--;
}
}
// 如果没有搜索到完全匹配的项 就返回最匹配的项
return tempIndex;
};
export default {
// . . . . . .
methods: {
// . . . . . .
getStartIndex(scrollTop = 0) {
// let item = this.positions.find(i => i && i.bottom > scrollTop);
// return item.index;
return binarySearch(this.positions, scrollTop);
},
},
};
</script>
运行查看一下效果,不定高问题已经解决了
滚动缓冲
分析
上文中,为了正确计算不定高列表项,同时在 updated 生命周期 和 滚动回调 中增加了额外操作,这都增加了浏览器负担。
因此快速滚动列表时,很明显的观察到白屏闪烁的情况,即滚动后,先加载出白屏内容(没有渲染)然后迅速替换为表格内容,制造出一种闪烁的现象。
注:白屏闪烁是浏览器性能低导致的,事件循环中的渲染操作没有跟上窗口的滚动,额外操作只是加剧了这种情况。
方案
为了使页面平滑滚动,在原先的列表结构上再加入缓冲区,渲染区域由可视区+缓冲区共同组成,这给滚动回调和页面渲染更多处理时间。
用户在可视区滚动,脱离可视区后立即进入缓冲区,同时渲染下一部分可视区的数据。在脱离缓冲区后新的数据大概率也渲染完成了。
而缓冲区域包含多少个元素呢?
创建一个变量表示比例数值,这个比例数值是相对于 最大可见列表项数 的,根据这个 相对比例 和 开始/结束索引 计算上下缓冲区的大小。
对渲染流程有什么影响?
列表显示数据 原先是根据索引计算,现在额外加入上下缓冲区大小重新计算,会额外渲染缓冲元素。
实现
创建一个属性代表比例值:
data: {
bufferPercent: 0.5, // 即每个缓冲区只缓冲 0.5 * 最大可见列表项数 个元素
},
创建三个计算属性,分别代表 缓冲区标准多少个元素 + 上下缓冲区实际包含多少个元素:
computed: {
bufferCount() {
return this.visibleCount * this.bufferPercent >> 0; // 向下取整
},
// 使用索引和缓冲数量的最小值 避免缓冲不存在或者过多的数据
aboveCount() {
return Math.min(this.start, this.bufferCount);
},
belowCount() {
return Math.min(this.listData.length - this.end, this.bufferCount);
},
}
重写 列表显示数据 visibleData 的计算方式:
computed: {
visibleData() {
// return this.listData.slice(this.start, this.end);
return this.listData.slice(this.start - this.aboveCount, this.end + this.belowCount);
},
}
因为多出了缓冲区域所以窗口偏移量 currentOffset 也要根据缓冲区的内容重新计算:
getCurrentOffset() {
if(this.start >= 1) {
// return this.positions[this.start - 1].bottom;
let size = this.positions[this.start].top - (
this.positions[this.start - this.aboveCount] ?
this.positions[this.start - this.aboveCount].top : 0);
// 计算偏移量时包括上缓冲区的列表项
return this.positions[this.start - 1].bottom - size;
} else {
return 0;
}
}
运行看一下效果,闪烁问题已经完美解决了。
异步加载
其实在列表项中包含图片的场景,图片多为高度固定的缩略图,只需要在计算时根据图给每个列表项加一个固定高度,多于一行的图片直接省略。这样异步加载对于虚拟列表就没有影响了。
如果实在要处理图片不定高的场景,只有在列表中的图片完全加载后再重新更新 positions 了,利用 Image.onload 或 DOM.resizeObserver 在异步加载后回调滚动函数。试了下应该都是可行的。
<div v-for="item in visibleData" :key="item.id" :id="item.id" ref="items" class="list-item">
{{ item.value }}
<img :src="item.img" @load="updatePositions" />
</div>
// or
mounted() {
let content = this.$refs.content
let resizeObserver = new ResizeObserver(() => this.updatePositions())
resizeObserver.observe(content)
},
懒加载数据
一次性请求大量数据可能会使后端处理时间增加,过大的响应体也会导致整体请求响应耗时增加,用户等待时间较长体感较差。
因此结合懒加载的方式,在每次滚动触底时加载部分新数据并更新 positions,避免单次请求等待时间过长。
// 滚动回调
scrollEvent(target) {
const { scrollTop, scrollHeight, clientHeight } = target;
this.start = this.getStartIndex(scrollTop);
this.end = this.start + this.visibleCount;
this.currentOffset = this.getCurrentOffset()
// 触底
if ((scrollTop + clientHeight) === scrollHeight) {
// 模拟数据请求
let len = this.listData.length + 1
for (let i = len; i <= len + 100; i++) {
this.listData.push({id: i, value: i + '字符内容'.repeat(Math.random() * 20) })
}
this.initPositions(this.listData, this.preItemSize)
}
},
懒加载时初始数据量较少,会导致滚动条很短,间接给用户一种数据量很少的错觉。
对于这种情况需要跟后端做好协调,接口返回的数据格式大致规定为这样
data: {
page: 1,
size: 1000,
count: 10000,
list: [1...1000],
updateTime: '...',
// . . . . . .
}
然后使用 data.count 初始化 positions,在后续懒加载到对应索引的数据时,替换 positions 里的内容。
总结
在最后简单总结一下,为了优化虚拟列表做了哪些操作。
- 不定高:由于很难在渲染之前拿到元素真实高度,采取预估高度初始化后重新渲染的方案来正确渲染不定高内容。并重写了滚动回调函数和部分与 itemSize 相关的计算属性。
- 缓冲区:为了解决性能低时数据渲染不及时造成的白屏闪烁,创建上下缓冲区额外渲染数据,为可视区的渲染提供更多缓冲时间。为此要重写 start/end 和 currentOffset 的计算方式。
- 异步加载:如果一定要处理列表异步加载不定高元素的场景,通过 img.onload 和 ResizeObserver 在加载完成后更新列表。
懒加载:一次性大量数据的请求可能会导致请求响应时间变长,使用触底加载新数据并更新 positions 的方式来分化单次请求的数据量。
4. IntersectionObserver实现滚动动画、懒加载、虚拟列表
前言 🎀
在过去为了实现 懒加载、滚动动画 等需求并不容易,需要获取 元素与视窗的交叉状态,这通常使用 滚动事件 + 计算偏移量 + 判断逻辑 实现,再辅以防抖节流等优化。
现如今随着技术的发展,浏览器推出了多种 观察器,有更好的方式,去便捷、高效的收集页面与元素的信息。
本文,一起学习适用于 监听元素与视窗交叉状态 的观察器:IntersectionObserver(交叉观察器)
,了解它的相关知识与应用。
简介
IntersectionObserver API
提供了一种创建IntersectionObserver
对象的方法,对象用于监测目标元素与视窗(viewport)的交叉状态,并在交叉状态变化时执行回调函数,回调函数可以接收到元素与视窗交叉的具体数据。
一个 IntersectionObserver
对象可以监听多个目标元素,并通过队列维护回调的执行顺序。
IntersectionObserver
特别适用于:滚动动画、懒加载、虚拟列表等场景。
回调异步执行,不阻塞主线程。且监听不随着目标元素的滚动而触发,性能消耗极低。
API
构造函数
IntersectionObserver
构造函数 接收两个参数:
- callback: 当元素可见比例达到指定阈值后触发的回调函数
- options: 配置对象(可选,不传时会使用默认配置)
IntersectionObserver
构造函数 返回观察器实例,实例携带四个方法:
- observe:开始监听目标元素
- unobserve:停止监听目标元素
- disconnect:关闭观察器
- takeRecords:返回所有观察目标的
IntersectionObserverEntry
对象数组
// 调用构造函数 生成IntersectionObserver观察器
const myObserver = new IntersectionObserver(callback, options);
// 开始监听 指定元素
myObserver.observe(element);
// 停止对目标的监听
myObserver.unobserve(element);
// 关闭观察器
myObserver.disconnect();
构造参数
- callback
回调函数,当交叉状态发生变化时(可见比例超过或者低于指定阈值)会进行调用,同时传入两个参数:
- entries:
IntersectionObserverEntry
数组,每项都描述了目标元素与 root 的交叉状态 - observer:被调用的
IntersectionObserver
实例
- options
配置参数,通过修改配置参数,可以改变进行监听的视窗,可以缩小或扩大交叉的判定范围,或者调整触发回调的阈值(交叉比例)。
属性 | 说明 |
---|---|
root | 所监听对象的具体祖先元素,默认使用顶级文档的视窗(一般为 html)。 |
rootMargin | 计算交叉时添加到根(root)边界盒bounding box的矩形偏移量, 可以有效的缩小或扩大根的判定范围从而满足计算需要。所有的偏移量均可用像素(px)或百分比(%)来表达, 默认值为"0px 0px 0px 0px"。 |
threshold | 一个包含阈值的列表, 按升序排列, 列表中的每个阈值都是监听对象的交叉区域与边界区域的比率。当监听对象的任何阈值被越过时,都会触发 callback。默认值为 0。 |
- IntersectionObserverEntry
属性 | 说明 |
---|---|
boundingClientRect | 返回包含目标元素的边界信息,返回结果与 element.getBoundingClientRect() 相同 |
intersectionRatio | 返回目标元素出现在可视区的比例 |
intersectionRect | 用来描述 root 和目标元素的相交区域 |
isIntersecting | 返回一个布尔值,下列两种操作均会触发 callback:1. 如果目标元素出现在 root 可视区,返回 true。2. 如果从 root 可视区消失,返回 false |
rootBounds | 用来描述交叉区域观察者(intersection observer)中的根. |
target | 目标元素:与根出现相交区域改变的元素 (Element) |
time | 返回一个记录从 IntersectionObserver 的时间原点到交叉被触发的时间的时间戳 |
应用
懒加载
核心是延迟加载不可视区域内的资源,在元素标签中存储 srcdata-src="xxx"
,在元素进入视窗时进行加载。
注意设置容器的预设高度,避免页面初始化时元素进入视窗
<div class="skin_img">
<img
class="lazyload"
data-src="//game.gtimg.cn/images/lol/act/img/skinloading/412017.jpg"
alt="灵魂莲华 锤石"
/>
</div>
.skin_img {
margin-bottom: 20px;
width: auto;
height: 500px;
overflow: hidden;
position: relative;
}
const imgList = [...document.querySelectorAll("img")];
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((item) => {
// isIntersecting是一个Boolean值,判断目标元素当前是否可见
if (item.isIntersecting) {
console.log(item.target.dataset.src);
item.target.src = item.target.dataset.src;
// 图片加载后即停止监听该元素
observer.unobserve(item.target);
}
});
},
{
root: document.querySelector(".root"),
}
);
// observe遍历监听所有img节点
imgList.forEach((img) => observer.observe(img));
滚动动画
在元素进入视窗时添加动画样式,让内容出现的更加平滑。
const elements = document.querySelectorAll('.observer-item')
const observer = new IntersectionObserver(callback);
elements.forEach(ele => {
ele.classList.add('opaque')
observer.observe(ele);
})
function callback(entries, instance) {
entries.forEach(entry => {
if (entry.isIntersecting) {
const element = entry.target;
element.classList.remove("opaque");
element.classList.add("come-in");
instance.unobserve(element);
}
})
}
// css
.come-in {
opacity: 1;
transform: translateY(150px);
animation: come-in 1s ease forwards;
}
.come-in:nth-child(odd) {
animation-duration: 1s;
}
@keyframes come-in {
100% {
transform: translateY(0);
}
}
无限滚动
添加底部占位元素lastContentRef
,在元素和视窗交叉回调时添加loading
并加载新数据。
const [list, setList] = useState(new Array(10).fill(null));
const [loading, setLoading] = useState(false);
const lastContentRef = useRef(null);
const loadMore = useCallback(async () => {
if (timer) return;
setLoading(true);
await new Promise(
(resolve) => (timer = setTimeout(() => resolve((timer = null)), 1500))
);
setList((prev) => [...prev, ...new Array(10).fill(null)]);
setLoading(false);
}, [loading]);
useEffect(() => {
const io = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && !loading) {
loadMore();
}
});
lastContentRef?.current && io.observe(lastContentRef?.current);
}, []);
虚拟列表
options
参数中的rootMargin
特别符合虚拟列表中缓存区的设计,再根据元素的可见性 element.visible ? content : (clientHeight || estimateHeight)
。
<template v-for="(item, idx) in listData" :key="item.id">
<div class="content-item" :data-index="idx">
<template v-if="item.visible">
<!-- 模仿元素内容渲染 -->
{{ item.value }}
</template>
</div>
</template>
_entries.forEach((row) => {
const index = row.target.dataset.index;
// 判断是否在可视区域
if (!row.isIntersecting) {
// 离开可视区时设置实际高度进行占位 并使数据无法渲染
if (!isInitial) {
row.target.style.height = `${row.target.clientHeight}px`;
listData.value[index].visible = false;
}
} else {
// 元素进入可视区,使数据可以渲染
row.target.style.height = "";
listData.value[index].visible = true;
}
});
可能有小伙伴会说这是虚拟列表吗?这么多 div 都在页面上?
这些 DOM 是用于 占位撑起高度 和 供观察器监听,在callback
时渲染成 实际内容/占位元素。
虚拟列表的核心是 只渲染可视区内的内容,而在窗口外的元素都是空div
,性能开销小到忽略不计(在页面上建 10w 个空 div 都不会卡顿)。
当然这里只是简单实现,还有很多优化方向;
- 选取部分内容监听,避免全量监听浪费资源
- 合并视窗外的元素,避免空 div 的性能消耗和渲染成本
- 缓存渲染完成的 DOM,避免重复渲染
. . . . . .
这里主要讨论 API 的使用,对虚拟列表感兴趣的同学可以看看我的另一篇文章:
深入【虚拟列表】动态高度、缓冲、异步加载... Vue 实现
兼容性
发展成熟,除了 IE 以外多数浏览器已经很好的支持了该功能
总结
通过IntersectionObserver
能够轻松获取获取元素与视窗的交叉状态,除了前文中的应用,还有诸如埋点监控、视差滚动、自动播放等多种场景都可以使用IntersectionObserver
,感兴趣可以尝试。
IntersectionObserver
性能表现良好,用法简洁,能够准确把控交叉的每一个阶段。它为前端带来了更好的便利性和用户体验,非常值得尝试!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南