当我们拿到后台返回的1万条数据展示时,如果完全渲染不仅渲染的时间很长,也会导致浏览器性能变差,使用过程中还可能会导致卡顿,使用体验变差。
就需要我们想办法优化这种情况,这个时候使用虚拟滚动加载就能很好的避免这种情况。
虚拟滚动的实现原理:只加载用户当前可见区域内的内容,即滚动列表时,动态改变可视区域内的渲染内容
根据这个原理,我们需要知道一屏加载多少条数据,设置一个默认的子元素高度,根据展示区域计算出一个可见条数,再设置一些缓存区数量,避免滚动就需要重新加载
// 设置的默认子元素高度
var minSize = 25
// 子元素默认高度 情况下可见数量条数
var viewCount = Math.ceil(dom.clientHeight / minSize)
// 缓存区数量(虚拟滚动当前屏展示量以外 多展示的条目)
var bufferSize = 6
// 默认条数+缓存区数量后的总条数
var bufferSizeViewCount = viewCount + bufferSize * 2
因为是不等高列表,当我们跳转指定行或者做其他更多操作时,需要知道指定行的具体信息,所以缓存所有子元素的布局信息。先设置每行默认的布局信息,后续页面滚动加载时再实时更新
// 缓存位置信息
var topNum = 0
var bottomNum = 0
var height = minSize
for (let index = 0; index < tableData.length; index++) {const item = tableData[index];topNum = index == 0 ? 0 : (topNum + height)bottomNum = topNum + heightconst obj2 = {index,height,top: topNum,bottom: bottomNum,isUpdate: false}rowPosList.push(obj2);
}
根据需要加载的开始位置和结束位置,加载虚拟滚动页面的内容,默认加载从第一行开始的数据
// 当前开始下标(滚动top)
let startIndex = 0;
// 当前结束下标(根据盒子高度时可见数量条数得到结束下标)
let endIndex = Math.min(tableData.length - 1, startIndex + viewCount);
// 算上缓存区数量后,真实需要加载的起始行和结束行下标
let startBufferIndex = Math.max(0, startIndex - bufferSize);
let endBufferIndex = Math.min(tableData.length - 1, endIndex + bufferSize);
// 生成dom节点
let documentFragment = document.createDocumentFragment()
for (let i = startBufferIndex; i <= endBufferIndex; i++) {const item = tableData[i]// 当前行展示内容let htmlText = "<li class='liText' rowindex='"+item.index+"'>序号:" + item.index +",内容:"+ item.text +"</li>"// 将 HTML 字符串转换为 DOM 元素,并添加到文档片段中const tempElement = document.createElement('template');tempElement.innerHTML = htmlText;const elList = [].slice.call(tempElement.content.children)for (const el of elList) {documentFragment.appendChild(el);}
}
listInnerDom.innerHTML = ""
listInnerDom.appendChild(documentFragment)
加载成功后就需要设置滚动条,为了后面滚动加载其他的内容,我们使用padding撑开盒子的高度,而padding的值就根据缓存的rowPosList每一元素的布局信息来设置
// 设置滚动条高度(利用上下边距撑开)
const startOffset = rowPosList[startBufferIndex].top;
const endOffset = rowPosList[rowPosList.length - 1].bottom - rowPosList[endBufferIndex].bottom;
listInnerDom.style.setProperty('padding', `${startOffset}px 0 ${endOffset}px 0`);
滚动加载就意味着需要根据滚动的高度获取需要加载内容,我们使用滚动top值从缓存信息rowPosList的top、bottom值来进行判断获取当前滚动距离对应的行下标
// 二分查询优化
function findItemIndex(scrollTop) {let low = 0;let high = rowPosList.length - 1;while (low <= high) {const mid = Math.floor((low + high) / 2);const { top, bottom } = rowPosList[mid];if (scrollTop >= top && scrollTop <= bottom) {high = mid;break;} else if (scrollTop > bottom) {low = mid + 1;} else if (scrollTop < top) {high = mid - 1;}}return high;
}
虚拟滚动实际上就是移除旧的dom,插入新dom的操作,如果操作过于频繁就会影响性能,所以我们需要进行限制。因为我们加了缓存区的概念,所以理所当然的在缓存区范围内滚动就不需要再重新加载内容
// 在缓存区范围以外才进行渲染更新
if(startIndex < startBufferIndex || endIndex > endBufferIndex){
}
而为了后续的一些针对列表的操作,例如直接查看第666行的数据,就需要对这行的数据有清楚的定位信息,且确保跳转后再次上下滚动加载不会导致出现问题(白屏情况),所以需要实时更新之前的布局信息以供这种操作
// 更新每个item的位置信息
function upCellMeasure() {const rowList = listInnerDom.querySelectorAll(".liText");if (rowList.length === 0) { return }const firstIndex = startBufferIndex;const lastIndex = endBufferIndex;// 当前渲染的数据高度是否有改动,有则需要修改当前渲染数据后面所有存储的坐标信息let hasChange = falselet dHeight = 0for (let index = 0; index < rowList.length; index++) {const rowItem = rowList[index];const rowIndex = rowItem.getAttribute("rowindex");let rowPosItem = rowPosList[rowIndex]const prevRowPosItem = rowPosList[rowIndex - 1];// 设置当前行的top|bottom|height等属性可能导致列高度的改变,已经设置过的不再重新设置if (rowPosItem && (!rowPosItem.isUpdate || (prevRowPosItem && prevRowPosItem.top + prevRowPosItem.height != rowPosItem.top))) {const rectBox = rowItem.getBoundingClientRect();const top = prevRowPosItem ? prevRowPosItem.bottom : 0;let height = rectBox.heightdHeight = dHeight + (height - rowPosItem.height)Object.assign(rowPosList[rowIndex], {height,top,bottom: top + height,isUpdate: true});hasChange = true}}// // 鼠标滑轮滚动 解决向上滚动时,前面行的坐标信息未更新,会导致后面设置padding的值导致滚动偏移// // 在直接跳转到指定行后,向上滚动,在下一篇幅重新渲染后pdfMain的scrollTop会向下偏移,所以使用之前scroll事件记录的domScrollTop进行累加计算// if(mouseInTable && firstIndex < oldFirstIndex) {// maskDom.scrollTop= domScrollTop + dHeight;// }// 更新未渲染的listItem的top值if (hasChange) {for (let i = lastIndex + 1; i < rowPosList.length; i++) {// if(!rowPosList[i].isUpdate){const prevRowPosItem = rowPosList[i - 1];const top = prevRowPosItem ? prevRowPosItem.bottom : 0;Object.assign(rowPosList[i], {top,bottom: top + rowPosList[i].height});// }}}// console.log(rowPosList)// 设置滚动条高度(利用上下边距撑开)const startOffset = rowPosList[startBufferIndex].top;const endOffset = rowPosList[rowPosList.length - 1].bottom - rowPosList[endBufferIndex].bottom;listInnerDom.style.setProperty('padding', `${startOffset}px 0 ${endOffset}px 0`);oldFirstIndex = firstIndex;
}
完整代码:
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>虚拟滚动加载不等高列表</title><style>*{margin: 0;padding: 0;}button {width: 100px;margin: 20px 0 0 50px;}#masks {position: relative;width: 500px;height: 750px;margin: 20px 50px;overflow: auto;border: 1px solid #666;}.ulBox{height: 100%;}.listInner li{list-style: none;border-bottom: 1px solid #ccc;padding: 5px 10px;}</style>
</head><body><button onclick="jumpRow(666)">跳转第666行</button><div class="pdf-main" id="masks" onscroll="myScroll()"><div class="ulBox"><ul class="listInner"><!-- 加载数据 --></ul></div></div><script>// 随机生成1万条不同内容的数组数据var tableData = []var datalist = "神鼎飞丹砂发生过大范甘迪发生过发给的孤独果然废物感染树大根深单个重新发但是山东分公司电话该发给非固定阿斯顿发顺丰阿萨法打算神鼎飞丹砂发生过电视上地方郭发的郭旦法大范甘迪的的高度房"var length = datalist.lengthfor (let index = 0; index < 10000; index++) {var randomNumber0 = Math.floor(Math.random() * length);var randomNumber1 = Math.floor(Math.random() * length);var num1 = Math.min(randomNumber0, randomNumber1)var num2 = Math.max(randomNumber0, randomNumber1)tableData.push({ index, text: datalist.slice(num1, num2) })}// 缓存的每一行的信息(height、top、bottom等等)var rowPosList = []// 设置的默认子单元高度var minSize = 25// 子单元默认高度 情况下可见数量条数var viewCount = 0// 缓存区数量(虚拟滚动当前屏展示量以外 多展示的条目)var bufferSize = 6// 默认条数+缓存区数量后的总条数var bufferSizeViewCount = 0 var maskDom = document.getElementById("masks")// 缓存位置信息var topNum = 0var bottomNum = 0var height = minSizefor (let index = 0; index < tableData.length; index++) {const item = tableData[index];topNum = index == 0 ? 0 : (topNum + height)bottomNum = topNum + heightconst obj2 = {index,height,top: topNum,bottom: bottomNum,isUpdate: false}rowPosList.push(obj2);}viewCount = Math.ceil(maskDom.clientHeight / minSize);bufferSizeViewCount = viewCount + bufferSize * 2console.log(tableData,rowPosList)var showTableDataList = [] // 当前虚拟滚动展示的数据var domScrollTop = 0 // 滚动距离var requestId = "" // 限制当前屏是否完全加载var startBufferIndex = -1 // 当前加载数据中第一个所在下标var endBufferIndex = -1 // 当前加载数据中最后一个所在下标var oldFirstIndex = 0 // 当前加载数据中第一个所在下标(旧值)var listInnerDom = document.querySelector(".listInner")// 更新加载的数据(初始页面加载、滚动加载、点击选中列)autoSizeVirtualList(0)// 更新加载的数据(初始页面加载、滚动加载、跳转指定行)function autoSizeVirtualList(scrollTop, jumpRow_no) {// 根据滚动的高度值 || 或者需要跳转至指定行的行下标,判断当前加载下标let startIndex = jumpRow_no != undefined ? jumpRow_no : findItemIndex(scrollTop);const endIndex = Math.min(tableData.length - 1, startIndex + viewCount);// 页面是否重新加载let dataChange = false// 判断当前 startIndex 是否在缓冲区内。在缓冲区内,不需要重新渲染if (endIndex > 0 && (startIndex < startBufferIndex || endIndex > endBufferIndex)) {startBufferIndex = Math.max(0, startIndex - bufferSize);endBufferIndex = Math.min(tableData.length - 1, endIndex + bufferSize);showTableDataList = []let documentFragment = document.createDocumentFragment()for (let i = startBufferIndex; i <= endBufferIndex; i++) {const item = tableData[i]const rectBox = rowPosList[i]showTableDataList.push({ item, rectBox })// 当前行展示内容let htmlText = "<li class='liText' rowindex='"+item.index+"'>序号:" + item.index +",内容:"+ item.text +"</li>"// 将 HTML 字符串转换为 DOM 元素,并添加到文档片段中const tempElement = document.createElement('template');tempElement.innerHTML = htmlText;const elList = [].slice.call(tempElement.content.children)for (const el of elList) {documentFragment.appendChild(el);}}showTableDataList = showTableDataListlistInnerDom.innerHTML = ""listInnerDom.appendChild(documentFragment)dataChange = true}// 重新加载后需要更新缓存的定位信息if (dataChange) {upCellMeasure();}// 跳转到指定行查看if (jumpRow_no != undefined) {maskDom.scrollTop = rowPosList[jumpRow_no].top}domScrollTop = maskDom.scrollToprequestId = null;}// 二分查询优化function findItemIndex(scrollTop) {let low = 0;let high = rowPosList.length - 1;while (low <= high) {const mid = Math.floor((low + high) / 2);const { top, bottom } = rowPosList[mid];if (scrollTop >= top && scrollTop <= bottom) {high = mid;break;} else if (scrollTop > bottom) {low = mid + 1;} else if (scrollTop < top) {high = mid - 1;}}return high;}// 更新每个item的位置信息 function upCellMeasure() {const rowList = listInnerDom.querySelectorAll(".liText");if (rowList.length === 0) { return }const firstIndex = startBufferIndex;const lastIndex = endBufferIndex;// 当前渲染的数据高度是否有改动,有则需要修改当前渲染数据后面所有存储的坐标信息let hasChange = falselet dHeight = 0for (let index = 0; index < rowList.length; index++) {const rowItem = rowList[index];const rowIndex = rowItem.getAttribute("rowindex");let rowPosItem = rowPosList[rowIndex]const prevRowPosItem = rowPosList[rowIndex - 1];// 设置当前行的top|bottom|height等属性可能导致列高度的改变,已经设置过的不再重新设置if (rowPosItem && (!rowPosItem.isUpdate || (prevRowPosItem && prevRowPosItem.top + prevRowPosItem.height != rowPosItem.top))) {const rectBox = rowItem.getBoundingClientRect();const top = prevRowPosItem ? prevRowPosItem.bottom : 0;let height = rectBox.heightdHeight = dHeight + (height - rowPosItem.height)Object.assign(rowPosList[rowIndex], {height,top,bottom: top + height,isUpdate: true});hasChange = true}}// // 鼠标滑轮滚动 解决向上滚动时,前面行的坐标信息未更新,会导致后面设置padding的值导致滚动偏移// // 在直接跳转到指定行后,向上滚动,在下一篇幅重新渲染后pdfMain的scrollTop会向下偏移,所以使用之前scroll事件记录的domScrollTop进行累加计算// if(mouseInTable && firstIndex < oldFirstIndex) {// maskDom.scrollTop= domScrollTop + dHeight;// }// 更新未渲染的listItem的top值if (hasChange) {for (let i = lastIndex + 1; i < rowPosList.length; i++) {// if(!rowPosList[i].isUpdate){const prevRowPosItem = rowPosList[i - 1];const top = prevRowPosItem ? prevRowPosItem.bottom : 0;Object.assign(rowPosList[i], {top,bottom: top + rowPosList[i].height});// }}}// console.log(rowPosList)// 设置滚动条高度(利用上下边距撑开)const startOffset = rowPosList[startBufferIndex].top;const endOffset = rowPosList[rowPosList.length - 1].bottom - rowPosList[endBufferIndex].bottom;listInnerDom.style.setProperty('padding', `${startOffset}px 0 ${endOffset}px 0`);oldFirstIndex = firstIndex;}// 滚动加载数据function myScroll() {if (requestId) return;requestId = requestAnimationFrame(() => {let scrollTop = maskDom.scrollToplet lastItem = tableData[tableData.length - 1]let lastShowItem = showTableDataList[showTableDataList.length - 1].item// 判断向下滚动到最后一篇幅时,就不再更新数据(即滚到底了就不更新)if (lastItem.index == lastShowItem.index && scrollTop >= domScrollTop) {requestId = null;return}domScrollTop = scrollTop// 更新加载的数据(初始页面加载、滚动加载、点击选中列)autoSizeVirtualList(scrollTop)});}// 跳转到指定行function jumpRow(jumpRow_no){autoSizeVirtualList(undefined,jumpRow_no)}</script>
</body></html>
效果如下: