WEBKT

前端虚拟列表性能优化实战:减少重绘,处理动态高度,缓存策略全解析

5 0 0 0

前言

虚拟列表的原理

性能优化的关键点

1. 减少重绘

2. 处理动态高度

3. 使用缓存

4. 其他优化技巧

总结

延伸阅读

实践建议

前言

嘿,前端的同学们,最近在搞什么炫酷的东东呢?是不是也遇到了需要展示海量数据的情况?比如一个几千甚至几万条数据的列表?如果直接把这些数据一股脑儿渲染到页面上,那你的浏览器可能就要崩溃了。卡顿、白屏、用户体验差……这些都是我们不想看到的。这时候,虚拟列表就该闪亮登场了!

虚拟列表是一种优化长列表渲染的技术,它只渲染可视区域内的列表项,而对于非可视区域的列表项则不进行渲染,从而提高渲染性能。虽然虚拟列表已经是一个非常成熟的技术,但想要实现高性能的虚拟列表,还是有一些门道的。今天,我就来和大家聊聊虚拟列表的性能优化,咱们一起探讨如何减少重绘、处理动态高度、以及如何使用缓存等技巧,让你的虚拟列表应用更上一层楼!

虚拟列表的原理

在深入优化之前,我们先来简单回顾一下虚拟列表的原理。

核心思想: 只渲染可视区域内的列表项。

实现方式:

  1. 计算: 根据列表的总高度、单个列表项的高度、以及可视区域的高度,计算出当前可视区域内应该渲染哪些列表项。
  2. 渲染: 只渲染计算出来的列表项,并设置列表容器的高度,撑开整个列表。
  3. 滚动: 当用户滚动列表时,重新计算可视区域内的列表项,并更新渲染。

简单来说,就是“按需渲染”。

性能优化的关键点

虚拟列表的性能优化主要集中在以下几个方面:

  • 减少重绘: 重绘是导致性能问题的“元凶”之一,我们要想方设法减少重绘的次数和范围。
  • 处理动态高度: 如果列表项的高度是不固定的,那么计算可视区域的列表项就会变得复杂,需要额外的处理。
  • 使用缓存: 缓存可以减少重复计算,提高性能。
  • 其他优化: 例如防抖、节流等。

下面,我们就来逐一攻破这些关键点。

1. 减少重绘

重绘是指当元素的外观(例如颜色、背景等)发生变化时,浏览器需要重新绘制该元素。重绘是避免不了的,但是我们可以尽量减少重绘的次数和范围。

技巧:

  • 避免频繁修改 DOM: 尽量减少对 DOM 的操作,因为每次修改 DOM 都会触发重绘或回流。
  • 使用 CSS 动画代替 JavaScript 动画: CSS 动画通常比 JavaScript 动画性能更好,因为它可以在合成层上进行,而不会触发重绘或回流。
  • 使用 will-change 属性: will-change 属性可以提前告知浏览器哪些属性将会发生变化,从而优化渲染性能。
  • 批量更新 DOM: 将多次 DOM 操作合并成一次,减少重绘次数。

代码示例:

假设我们有一个虚拟列表,需要根据数据更新列表项的内容。如果直接在循环中更新每个列表项的 DOM,那么就会触发多次重绘。

// 错误示例:频繁修改 DOM
const listContainer = document.getElementById('list-container');
const data = generateData(1000); // 模拟数据
for (let i = 0; i < data.length; i++) {
const listItem = document.createElement('div');
listItem.textContent = data[i].text;
listContainer.appendChild(listItem);
}

优化后的代码:

// 优化示例:批量更新 DOM
const listContainer = document.getElementById('list-container');
const data = generateData(1000); // 模拟数据
const fragment = document.createDocumentFragment(); // 创建文档片段
for (let i = 0; i < data.length; i++) {
const listItem = document.createElement('div');
listItem.textContent = data[i].text;
fragment.appendChild(listItem);
}
listContainer.appendChild(fragment); // 一次性添加到 DOM

解释:

我们使用 document.createDocumentFragment() 创建一个文档片段,然后在循环中将列表项添加到文档片段中,最后一次性将文档片段添加到 DOM 中。这样就将多次 DOM 操作合并成了一次,减少了重绘的次数。

2. 处理动态高度

如果列表项的高度是固定的,那么计算可视区域内的列表项就非常简单。但是,如果列表项的高度是不固定的,例如列表项的内容是动态的,或者包含图片等,那么计算就会变得复杂。

解决思路:

  1. 预估高度: 我们可以先预估一下列表项的高度,例如根据列表项的平均高度或者最大高度来预估。这种方法简单,但是不够准确,可能会导致列表项的错位。
  2. 测量高度: 我们可以先渲染一部分列表项,然后测量它们的实际高度,再根据测量结果来计算可视区域内的列表项。这种方法比较准确,但是需要额外的渲染和测量操作。
  3. 缓存高度: 我们可以缓存已经测量过的列表项的高度,避免重复测量。

代码示例:

假设我们有一个虚拟列表,列表项的高度是不固定的,并且包含图片。

// 预估高度
class VirtualList {
constructor(options) {
this.data = options.data;
this.itemHeight = options.itemHeight; // 预估高度
this.containerHeight = options.containerHeight;
this.bufferSize = options.bufferSize || 10; // 缓冲数量
this.startIndex = 0;
this.endIndex = 0;
this.visibleData = [];
this.container = document.getElementById(options.containerId);
this.list = document.createElement('div');
this.list.style.position = 'relative';
this.container.appendChild(this.list);
this.render();
this.container.addEventListener('scroll', this.handleScroll.bind(this));
}
// 计算可视区域的起始和结束索引
calculateIndexes() {
const scrollTop = this.container.scrollTop;
this.startIndex = Math.floor(scrollTop / this.itemHeight) - this.bufferSize;
this.endIndex = Math.ceil((scrollTop + this.containerHeight) / this.itemHeight) + this.bufferSize;
this.startIndex = Math.max(0, this.startIndex);
this.endIndex = Math.min(this.data.length, this.endIndex);
}
// 获取可视区域的数据
getVisibleData() {
return this.data.slice(this.startIndex, this.endIndex);
}
// 计算列表的总高度
getTotalHeight() {
return this.data.length * this.itemHeight;
}
// 设置列表的偏移量
setListOffset() {
this.list.style.height = this.getTotalHeight() + 'px';
this.list.style.transform = `translateY(${this.startIndex * this.itemHeight}px)`;
}
// 渲染列表
render() {
this.calculateIndexes();
this.visibleData = this.getVisibleData();
this.setListOffset();
this.list.innerHTML = '';
this.visibleData.forEach((item, index) => {
const listItem = document.createElement('div');
listItem.textContent = item.text;
listItem.style.height = this.itemHeight + 'px';
listItem.style.position = 'absolute';
listItem.style.top = (this.startIndex + index) * this.itemHeight + 'px';
this.list.appendChild(listItem);
});
}
// 滚动事件处理函数
handleScroll() {
this.render();
}
}

说明:

  • 我们首先通过itemHeight预估列表项的高度,然后通过 calculateIndexes 函数来计算起始和结束索引。
  • render 函数中,我们根据索引来获取 visibleData,并根据预估高度设置每个列表项的高度和位置。
  • 这种方法实现简单,但是如果预估高度与实际高度偏差较大,那么列表项的位置就会出现错位。

测量高度

class VirtualList {
constructor(options) {
this.data = options.data;
this.containerHeight = options.containerHeight;
this.bufferSize = options.bufferSize || 10;
this.startIndex = 0;
this.endIndex = 0;
this.visibleData = [];
this.itemHeights = {}; // 缓存高度
this.container = document.getElementById(options.containerId);
this.list = document.createElement('div');
this.list.style.position = 'relative';
this.container.appendChild(this.list);
this.render();
this.container.addEventListener('scroll', this.handleScroll.bind(this));
}
// 计算可视区域的起始和结束索引
calculateIndexes() {
const scrollTop = this.container.scrollTop;
let offset = 0;
let startIndex = 0;
let endIndex = 0;
// 遍历所有数据,计算开始和结束索引
for (let i = 0; i < this.data.length; i++) {
let itemHeight = this.itemHeights[i];
if (!itemHeight) {
itemHeight = 50; // 默认高度
}
if (scrollTop >= offset && scrollTop < offset + itemHeight) {
startIndex = i;
}
if (scrollTop + this.containerHeight >= offset && scrollTop + this.containerHeight <= offset + itemHeight) {
endIndex = i + 1;
}
if (scrollTop + this.containerHeight > offset + itemHeight) {
endIndex = i + 1;
}
offset += itemHeight;
}
this.startIndex = Math.max(0, startIndex - this.bufferSize);
this.endIndex = Math.min(this.data.length, endIndex + this.bufferSize);
}
// 获取可视区域的数据
getVisibleData() {
return this.data.slice(this.startIndex, this.endIndex);
}
// 计算列表的总高度
getTotalHeight() {
let totalHeight = 0;
for (let i = 0; i < this.data.length; i++) {
totalHeight += this.itemHeights[i] || 50; // 默认高度
}
return totalHeight;
}
// 设置列表的偏移量
setListOffset() {
let offset = 0;
for (let i = 0; i < this.startIndex; i++) {
offset += this.itemHeights[i] || 50; // 默认高度
}
this.list.style.height = this.getTotalHeight() + 'px';
this.list.style.transform = `translateY(${offset}px)`;
}
// 渲染列表
render() {
this.calculateIndexes();
this.visibleData = this.getVisibleData();
this.setListOffset();
this.list.innerHTML = '';
this.visibleData.forEach((item, index) => {
const dataIndex = this.startIndex + index;
const listItem = document.createElement('div');
listItem.textContent = item.text;
listItem.style.position = 'absolute';
let top = 0;
for (let i = 0; i < dataIndex; i++) {
top += this.itemHeights[i] || 50; // 默认高度
}
listItem.style.top = top + 'px';
this.list.appendChild(listItem);
// 测量高度
const img = new Image();
img.src = item.imgSrc;
img.onload = () => {
const height = listItem.offsetHeight;
this.itemHeights[dataIndex] = height;
// 重新渲染
this.render();
};
});
}
// 滚动事件处理函数
handleScroll() {
this.render();
}
}

说明:

  • 我们使用 itemHeights 对象来缓存每个列表项的高度。
  • render 函数中,我们首先计算每个列表项的位置和高度,然后将列表项添加到列表中。
  • 对于包含图片的列表项,我们使用 img.onload 事件来测量图片的实际高度,并将高度缓存起来。
  • 当图片加载完成后,我们重新渲染列表,更新列表项的位置和高度。
  • 这种方法比较准确,但是需要额外的渲染和测量操作,可能会导致性能下降。

缓存高度

class VirtualList {
constructor(options) {
this.data = options.data;
this.containerHeight = options.containerHeight;
this.bufferSize = options.bufferSize || 10;
this.startIndex = 0;
this.endIndex = 0;
this.visibleData = [];
this.itemHeights = {}; // 缓存高度
this.container = document.getElementById(options.containerId);
this.list = document.createElement('div');
this.list.style.position = 'relative';
this.container.appendChild(this.list);
this.render();
this.container.addEventListener('scroll', this.handleScroll.bind(this));
}
// 计算可视区域的起始和结束索引
calculateIndexes() {
const scrollTop = this.container.scrollTop;
let offset = 0;
let startIndex = 0;
let endIndex = 0;
// 遍历所有数据,计算开始和结束索引
for (let i = 0; i < this.data.length; i++) {
let itemHeight = this.itemHeights[i];
if (!itemHeight) {
itemHeight = 50; // 默认高度
}
if (scrollTop >= offset && scrollTop < offset + itemHeight) {
startIndex = i;
}
if (scrollTop + this.containerHeight >= offset && scrollTop + this.containerHeight <= offset + itemHeight) {
endIndex = i + 1;
}
if (scrollTop + this.containerHeight > offset + itemHeight) {
endIndex = i + 1;
}
offset += itemHeight;
}
this.startIndex = Math.max(0, startIndex - this.bufferSize);
this.endIndex = Math.min(this.data.length, endIndex + this.bufferSize);
}
// 获取可视区域的数据
getVisibleData() {
return this.data.slice(this.startIndex, this.endIndex);
}
// 计算列表的总高度
getTotalHeight() {
let totalHeight = 0;
for (let i = 0; i < this.data.length; i++) {
totalHeight += this.itemHeights[i] || 50; // 默认高度
}
return totalHeight;
}
// 设置列表的偏移量
setListOffset() {
let offset = 0;
for (let i = 0; i < this.startIndex; i++) {
offset += this.itemHeights[i] || 50; // 默认高度
}
this.list.style.height = this.getTotalHeight() + 'px';
this.list.style.transform = `translateY(${offset}px)`;
}
// 渲染列表
render() {
this.calculateIndexes();
this.visibleData = this.getVisibleData();
this.setListOffset();
this.list.innerHTML = '';
this.visibleData.forEach((item, index) => {
const dataIndex = this.startIndex + index;
const listItem = document.createElement('div');
listItem.textContent = item.text;
listItem.style.position = 'absolute';
let top = 0;
for (let i = 0; i < dataIndex; i++) {
top += this.itemHeights[i] || 50; // 默认高度
}
listItem.style.top = top + 'px';
this.list.appendChild(listItem);
});
}
// 滚动事件处理函数
handleScroll() {
this.render();
}
}

说明:

  • 我们使用 itemHeights 对象来缓存每个列表项的高度。
  • render 函数中,我们首先计算每个列表项的位置和高度,然后将列表项添加到列表中。
  • 对于已经测量过高度的列表项,我们直接使用缓存的高度,避免重复测量。
  • 这种方法可以有效地提高性能,但是需要额外的存储空间来缓存高度。

3. 使用缓存

缓存是提高性能的“利器”。在虚拟列表中,我们可以缓存以下内容:

  • 列表项高度: 缓存列表项的高度,避免重复测量。
  • DOM 元素: 缓存已经创建好的 DOM 元素,避免重复创建。
  • 计算结果: 缓存一些计算结果,例如可视区域的起始和结束索引,避免重复计算。

代码示例:

class VirtualList {
constructor(options) {
this.data = options.data;
this.containerHeight = options.containerHeight;
this.itemHeight = options.itemHeight; // 预估高度
this.bufferSize = options.bufferSize || 10;
this.startIndex = 0;
this.endIndex = 0;
this.visibleData = [];
this.itemHeights = {}; // 缓存高度
this.domCache = {}; // 缓存 DOM 元素
this.container = document.getElementById(options.containerId);
this.list = document.createElement('div');
this.list.style.position = 'relative';
this.container.appendChild(this.list);
this.render();
this.container.addEventListener('scroll', this.handleScroll.bind(this));
}
// 计算可视区域的起始和结束索引
calculateIndexes() {
const scrollTop = this.container.scrollTop;
this.startIndex = Math.floor(scrollTop / this.itemHeight) - this.bufferSize;
this.endIndex = Math.ceil((scrollTop + this.containerHeight) / this.itemHeight) + this.bufferSize;
this.startIndex = Math.max(0, this.startIndex);
this.endIndex = Math.min(this.data.length, this.endIndex);
}
// 获取可视区域的数据
getVisibleData() {
return this.data.slice(this.startIndex, this.endIndex);
}
// 计算列表的总高度
getTotalHeight() {
return this.data.length * this.itemHeight;
}
// 设置列表的偏移量
setListOffset() {
this.list.style.height = this.getTotalHeight() + 'px';
this.list.style.transform = `translateY(${this.startIndex * this.itemHeight}px)`;
}
// 获取 DOM 元素,优先从缓存中获取
getListItem(index) {
if (this.domCache[index]) {
return this.domCache[index];
}
const listItem = document.createElement('div');
listItem.textContent = this.data[index].text;
listItem.style.height = this.itemHeight + 'px';
listItem.style.position = 'absolute';
listItem.style.top = index * this.itemHeight + 'px';
this.domCache[index] = listItem; // 缓存 DOM 元素
return listItem;
}
// 渲染列表
render() {
this.calculateIndexes();
this.visibleData = this.getVisibleData();
this.setListOffset();
this.list.innerHTML = '';
this.visibleData.forEach((item, index) => {
const dataIndex = this.startIndex + index;
const listItem = this.getListItem(dataIndex);
this.list.appendChild(listItem);
});
}
// 滚动事件处理函数
handleScroll() {
this.render();
}
}

说明:

  • 我们使用 domCache 对象来缓存已经创建好的 DOM 元素。
  • getListItem 函数中,我们首先从缓存中查找 DOM 元素,如果找到了,就直接返回,否则就创建一个新的 DOM 元素,并缓存起来。
  • 使用缓存可以有效地减少 DOM 的创建和销毁操作,提高性能。

4. 其他优化技巧

除了上述技巧,还有一些其他的优化技巧,例如:

  • 防抖(Debounce)和节流(Throttle): 避免在滚动事件中频繁触发渲染函数。
  • 使用 Web Workers: 将计算任务放到 Web Workers 中,避免阻塞主线程。
  • 懒加载图片: 对于图片,可以使用懒加载技术,只加载可视区域内的图片。
  • 优化 CSS 选择器: 优化 CSS 选择器,避免复杂的选择器导致性能问题。

总结

虚拟列表是一个强大的前端技术,可以帮助我们解决海量数据渲染的性能问题。通过减少重绘、处理动态高度、使用缓存等技巧,我们可以进一步优化虚拟列表的性能,提升用户体验。希望今天的分享能帮助你更好地掌握虚拟列表的性能优化,在你的项目中发挥更大的作用!

延伸阅读

实践建议

  • 结合实际场景: 根据你的实际场景,选择合适的优化技巧。
  • 性能测试: 在优化后,进行性能测试,确保优化效果。
  • 持续优化: 性能优化是一个持续的过程,需要不断地尝试和改进。

希望这篇文章能帮助你!如果你有任何问题或者更好的优化技巧,欢迎在评论区留言,我们一起交流学习!

前端老司机 虚拟列表前端性能优化ReactVue前端开发

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/8540