虚拟滚动的方案
1. 为什么需要使用虚拟滚动技术?
在前端开发中,会碰到一些不能使用分页方式来加载列表数据的业务形态,我们称这种列表叫做长列表。比如ant-design下拉框的数据项,如果直接将所有的数据都生成dom,页面会非常卡顿。
因此,虚拟滚动的核心思想就是只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
2. 关键技术点?
虚拟滚动的核心是只在视口中显示有限数量的元素,其他不在视口中的元素并不渲染在 DOM 中。随着用户滚动,动态更新显示的内容。
- 计算可见区域的元素数量:数据视口、每个数据项都得有高度,根据视口高度和每个元素的高度,计算出视口中能显示的最大元素数量(visibleItemsCount)
- 取出可见区域数据的开始和结束下标: 根据滚动位置,只渲染可见元素
startIndex = Math.floor(container.scrollTop / itemHeight);
endIndex = startIndex + visibleItemsCount; - 产生滚动条:使用一个内容容器来产生高度,以便能让最外层的容器出现滚动条,内容容器的高度为每个数据项高度*数据条数
- 动态渲染:根据滚动位置,只渲染可见元素
- 可见数据项与滚动条的位置同步: 需要设置可视区域内每个元素项相对于内容容器的偏移量,以实现跟滚动条位置同步
itemDomOffset = (startIndex + itemIndex) * itemHeight;
dom结构:
<div id="scroll-container" style="height: 300px; overflow-y: auto; position: relative;"> <div id="scroll-content" style="position: relative; width: 100%;"></div> </div>
scroll-container
是用于滚动的容器,设置height
和overflow-y: auto
,保证出现滚动条。scroll-content
是内容容器,用来设置整体高度以支持滚动效果。
javascript代码:
const container = document.getElementById("scroll-container"); const content = document.getElementById("scroll-content"); const data = Array.from({length:100}).map((v,i)=>`第${i+1}项`); const itemHeight = 30; // 每个项的高度 const totalItems = data.length; // 列表中的总项数 const visibleItemsCount = Math.ceil(container.clientHeight / itemHeight) + 1; // 可视区域最多能显示的项数,+1是为了预防最后一个项可能只有部分可见(即一部分处于容器内,另一部分被滚动条挡住) // 设置内容容器的总高度,使container能出现滚动条 content.style.height = `${totalItems * itemHeight}px`; function updateVisibleItems(){ const startIndex = Math.floor(container.scrollTop / itemHeight); const endIndex = startIndex + visibleItemsCount; if(endIndex > totalItems) return; const items = data.slice(startIndex,endIndex); const dataDom = document.createDocumentFragment(); items.forEach((item,itemIndex) => { const itemDom = document.createElement("div"); //每个数据项的偏移量. const itemDomOffset = (startIndex + itemIndex) * itemHeight; itemDom.style.cssText = ` height:${itemHeight}px; position:absolute; top:0; left:0; transform:translateY(${itemDomOffset}px) `; itemDom.textContent = `${item}`; dataDom.appendChild(itemDom); }) //清除上一次的内容 content.innerHTML = ``; content.appendChild(dataDom); } // 监听滚动事件 container.addEventListener("scroll", updateVisibleItems); // 初始化可视区域内容 updateVisibleItems();
高度不固定的虚拟滚动
1. 假设每条数据的高度一致
算出视窗的高度和每个元素的高度,然后就能得出可渲染的条数。
2. 缓存每条数据的 top
、bottom
和 height
表示元素相对于容器顶部的位置,height
是元素的高度。topbottom
表示元素底部的位置,bottom = top + height
在虚拟滚动中,我们需要缓存这些信息,以便在滚动时能够快速判断哪些元素是可见的。
3. 开始下标和结束下标的计算
-
开始下标:通过与当前
scrollTop
对比,计算出哪些元素处于可见区域。通常,开始下标是从scrollTop
所在位置开始,根据元素的top
和bottom
来判断。可以通过二分查找或者简单的线性查找来得到开始下标。-
条件:
top <= scrollTop <= bottom
,这意味着元素的top
在当前视口内,或者scrollTop
在元素的top
和bottom
之间。
-
-
结束下标:结束下标一般是
开始下标 + 可见条数
,即从开始下标开始加载一定数量的元素。
4. 动态更新每条数据的实际布局
-
在首次渲染后滚动完成渲染后,计算并更新每个元素的
top
、bottom
和height
。
5. 添加上下内边距(padding)
为了模拟滚动条滚动后的效果,我们需要给容器添加上下内边距,以便给容器的高度和滚动行为创建“虚拟的”空间。这样做的目的是避免直接渲染所有的数据,而是根据当前可见区域来动态渲染和更新元素,提升性能。
-
上内边距:设置为从容器顶部到第一个可见元素
top
的距离。 -
下内边距:设置为容器底部到最后一个可见元素
bottom
的距离。
6. 示例说明:
-
假设每个元素的高度是 30px,容器高度是 300px。
-
当前滚动位置
scrollTop
为 100px。 -
容器内最多可以展示 10 个元素(300px / 30px)。
-
通过对比
top
和bottom
的值,可以判断哪些元素是当前可见的,并且可以计算出开始和结束的下标。
假设第 4 个元素的 top = 120px
,bottom = 150px
,height = 30px
,在 scrollTop = 100px
时,这个元素就会出现在可视区域。
<!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; word-wrap: break-word; } </style> </head> <body> <button id="jump">跳转第666行</button> <div class="pdf-main" id="masks"> <div class="ulBox"> <ul class="listInner"> <!-- 加载数据 --> </ul> </div> </div> <script> function generateRandomString(x, y) { // 生成随机长度 const length = getRandomNumber(x, y); const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; const charactersLength = characters.length; // 生成随机字符串 for (let i = 0; i < length; i++) { result += characters.charAt( Math.floor(Math.random() * charactersLength) ); } return result; } function getRandomNumber(x, y) { return Math.floor(Math.random() * (y - x + 1)) + x; } class VirtualScroll { constructor(containerSelector, maskSelector, data) { // 初始化参数 this.tableData = data || []; this.rowPosList = []; this.minSize = 25; this.viewCount = 0; this.bufferSize = 6; this.bufferSizeViewCount = 0; this.maskDom = document.getElementById(maskSelector); this.listInnerDom = document.querySelector(containerSelector); this.domScrollTop = 0; this.requestId = null; this.startBufferIndex = -1; this.endBufferIndex = -1; this.oldFirstIndex = 0; this.showTableDataList = []; // 生成每行的位置信息 this.generateRowPosList(); // 设置视图的显示条数 this.viewCount = Math.ceil(this.maskDom.clientHeight / this.minSize); this.bufferSizeViewCount = this.viewCount + this.bufferSize * 2; // 初始渲染 this.autoSizeVirtualList(0); } // 随机生成位置缓存 generateRowPosList() { let topNum = 0; let bottomNum = 0; const height = this.minSize; this.tableData.forEach((_, index) => { topNum = index === 0 ? 0 : topNum + height; bottomNum = topNum + height; this.rowPosList.push({ index, height, top: topNum, bottom: bottomNum, isUpdate: false, }); }); } // 更新渲染的数据 autoSizeVirtualList(scrollTop, jumpRow_no) { const startIndex = jumpRow_no !== undefined ? jumpRow_no : this.findItemIndex(scrollTop); const endIndex = Math.min( this.tableData.length - 1, startIndex + this.viewCount ); let dataChange = false; if ( endIndex > 0 && (startIndex < this.startBufferIndex || endIndex > this.endBufferIndex) ) { this.startBufferIndex = Math.max(0, startIndex - this.bufferSize); this.endBufferIndex = Math.min( this.tableData.length - 1, endIndex + this.bufferSize ); this.showTableDataList = []; const documentFragment = document.createDocumentFragment(); for (let i = this.startBufferIndex; i <= this.endBufferIndex; i++) { const item = this.tableData[i]; const rectBox = this.rowPosList[i]; this.showTableDataList.push({ item, rectBox }); const htmlText = `<li class='liText' rowindex='${item.index}'>序号:${item.index},内容:${item.text}</li>`; const tempElement = document.createElement("template"); tempElement.innerHTML = htmlText; [...tempElement.content.children].forEach((el) => documentFragment.appendChild(el) ); } this.listInnerDom.innerHTML = ""; this.listInnerDom.appendChild(documentFragment); dataChange = true; } if (dataChange) { this.upCellMeasure(); } if (jumpRow_no !== undefined) { this.maskDom.scrollTop = this.rowPosList[jumpRow_no].top; } this.domScrollTop = this.maskDom.scrollTop; this.requestId = null; } // 二分查找当前元素 findItemIndex(scrollTop) { let low = 0; let high = this.rowPosList.length - 1; while (low <= high) { const mid = Math.floor((low + high) / 2); const { top, bottom } = this.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; } // 更新每个行的位置信息 upCellMeasure() { const rowList = this.listInnerDom.querySelectorAll(".liText"); if (rowList.length === 0) return; const firstIndex = this.startBufferIndex; const lastIndex = this.endBufferIndex; let hasChange = false; let dHeight = 0; rowList.forEach((rowItem, index) => { const rowIndex = rowItem.getAttribute("rowindex"); const rowPosItem = this.rowPosList[rowIndex]; const prevRowPosItem = this.rowPosList[rowIndex - 1]; if ( rowPosItem && (!rowPosItem.isUpdate || (prevRowPosItem && prevRowPosItem.top + prevRowPosItem.height !== rowPosItem.top)) ) { const rectBox = rowItem.getBoundingClientRect(); const top = prevRowPosItem ? prevRowPosItem.bottom : 0; const height = rectBox.height; dHeight += height - rowPosItem.height; Object.assign(this.rowPosList[rowIndex], { height, top, bottom: top + height, isUpdate: true, }); hasChange = true; } }); if (hasChange) { for (let i = lastIndex + 1; i < this.rowPosList.length; i++) { const prevRowPosItem = this.rowPosList[i - 1]; const top = prevRowPosItem ? prevRowPosItem.bottom : 0; Object.assign(this.rowPosList[i], { top, bottom: top + this.rowPosList[i].height, }); } } const startOffset = this.rowPosList[this.startBufferIndex].top; const endOffset = this.rowPosList[this.rowPosList.length - 1].bottom - this.rowPosList[this.endBufferIndex].bottom; this.listInnerDom.style.setProperty( "padding", `${startOffset}px 0 ${endOffset}px 0` ); this.oldFirstIndex = firstIndex; } // 滚动加载数据 myScroll() { if (this.requestId) return; this.requestId = requestAnimationFrame(() => { const scrollTop = this.maskDom.scrollTop; const lastItem = this.tableData[this.tableData.length - 1]; const lastShowItem = this.showTableDataList[this.showTableDataList.length - 1].item; if ( lastItem.index === lastShowItem.index && scrollTop >= this.domScrollTop ) { this.requestId = null; return; } this.domScrollTop = scrollTop; this.autoSizeVirtualList(scrollTop); }); } // 跳转到指定行 jumpRow(jumpRow_no) { this.autoSizeVirtualList(undefined, jumpRow_no); } } // 使用示例: const tableData = Array.from({ length: 1000 }, (_, index) => ({ index, text: generateRandomString(20, 300), })); const virtualScroll = new VirtualScroll(".listInner", "masks", tableData); document.getElementById("masks").addEventListener("scroll", () => virtualScroll.myScroll()); document.getElementById('jump').addEventListener('click',()=>virtualScroll.jumpRow(666)); </script> </body> </html>