原生JS实现一个不固定高度的虚拟列表核心算法
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>不定高度的虚拟列表</title> </head> <body> <style> .list { height: 400px; width: 300px; outline: 1px solid seagreen; overflow-x: hidden; } .list-item { outline: 1px solid red; outline-offset:-2px; background-color: #fff; } </style> <div class="list"> <div class="list-inner"></div> </div> <script> // 快速移动滚动条,中间未渲染部分,导致渲染后高度偏移差问题 // 参考链接:https://lkangd.com/post/virtual-infinite-scroll/ // const throttle = (callback) => { // let isThrottled = false; // return (...args)=> { // if (isThrottled) return; // callback.apply(this, args); // isThrottled = true; // requestAnimationFrame(() => { // isThrottled = false; // }); // } // } // function run(task, taskEndCallback) { // let oldDate = Date.now(); // requestAnimationFrame(() => { // let now = Date.now(); // console.log(now - oldDate) // if(now - oldDate <= 16.5) { // const result = task(); // taskEndCallback(result); // }else { // run(task, render); // } // }) // } // function debounce(callback) { // let timerId; // return function() { // if (timerId) { // cancelAnimationFrame(timerId); // } // timerId = requestAnimationFrame(() => { // callback.apply(this, arguments); // }); // }; // } function throttle(callback) { let requestId; return (...args) => { if (requestId) {return} requestId = requestAnimationFrame(() => { callback.apply(this, args); requestId = null; }); }; } const randomIncludes = (min, max) => { return Math.floor(Math.random()*(max - min + 1) + min); } const clientHeight = 400; const listEl = document.querySelector('.list'); const listInner = document.querySelector('.list-inner'); function initAutoSizeVirtualList(props) { const cache = []; window.cache = cache; let oldFirstIndex = 0; const { listEl, listInner, minSize = 30, clientHeight, items } = props; // 默认情况下可见数量 const viewCount = Math.ceil(clientHeight / minSize); // 缓存区数量 const bufferSize = 6; listEl.style.cssText += `height:${clientHeight}px;overflow-x: hidden`; // const findItemIndex = (startIndex, scrollTop) => { // scrollTop === undefined && ( // scrollTop = startIndex, // startIndex = 0 // ) // let totalSize = 0; // for(let i = startIndex; i < cache.length; i++) { // totalSize += cache[i].height; // if(totalSize >= scrollTop || i == cache.length - 1) { // return i; // } // } // return startIndex; // } // 二分查询优化 const findItemIndex = (scrollTop) => { let low = 0; let high = cache.length - 1; while(low <= high) { const mid = Math.floor((low + high) / 2); const { top, bottom } = cache[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的位置信息 const upCellMeasure = () => { const listItems = listInner.querySelectorAll('.list-item'); if(listItems.length === 0){return} const firstItem = listItems[0]; const firstIndex = +firstItem.dataset.index; const lastIndex = +listItems[listItems.length - 1].dataset.index; // 解决向上缓慢滚动时,高度存在的偏移差问题,非常重要 if(firstIndex < oldFirstIndex && !cache[firstIndex].isUpdate) { const dHeight = firstItem.getBoundingClientRect().height - cache[firstIndex].height listEl.scrollTop += dHeight; } [...listItems].forEach((listItem) => { const rectBox = listItem.getBoundingClientRect(); const index = listItem.dataset.index; const prevItem = cache[index-1]; const top = prevItem ? prevItem.bottom : 0; Object.assign(cache[index], { height: rectBox.height, top, bottom: top + rectBox.height, isUpdate: true }); }); // 切记一定要更新未渲染的listItem的top值 for(let i = lastIndex+1; i < cache.length; i++) { const prevItem = cache[i-1]; const top = prevItem ? prevItem.bottom : 0; Object.assign(cache[i], { top, bottom: top + cache[i].height }); } oldFirstIndex = firstIndex; } const getTotalSize = () => { return cache[cache.length - 1].bottom; } const getStartOffset = (startIndex) => { return cache[startIndex].top; } const getEndOffset = (endIndex) => { return cache[endIndex].bottom; } // 缓存位置信息 items.forEach((item, i) => { cache.push({ index:i, height: minSize, top: minSize * i, bottom: minSize * i + minSize, isUpdate: false }); }); return function autoSizeVirtualList(renderItem, rendered) { const startIndex = findItemIndex(listEl.scrollTop); const endIndex = startIndex + viewCount; // const visiblityEndIndex = findItemIndex(clientHeight + listEl.scrollTop); const startBufferIndex = Math.max(0, startIndex - bufferSize); const endBufferIndex = Math.min(items.length-1, endIndex + bufferSize); const renderItems = []; for(let i = startBufferIndex; i <= endBufferIndex; i++) { renderItems.push(renderItem(items[i], cache[i])) } const startOffset = getStartOffset(startBufferIndex); const endOffset = getTotalSize() - getEndOffset(endBufferIndex); rendered(renderItems); // 渲染完成后,才更新缓存的高度信息 upCellMeasure(); listInner.style.setProperty('padding', `${startOffset}px 0 ${endOffset}px 0`); } } // 模拟1万条数据 const count = 10000; const items = Array.from({ length: count }).map((item, i) => ({ name: `item ${(i)}`, height: randomIncludes(40, 120) }) ); const autoSizeVirtualList = initAutoSizeVirtualList({ listEl, listInner, clientHeight, items }); document.addEventListener('DOMContentLoaded', () => { autoSizeVirtualList((item, rectBox) => { return `<div class="list-item" data-index="${rectBox.index}" style="height:${item.height}px">${item.name}</div>` }, (renderItems) => { listInner.innerHTML = renderItems.join(''); }); }); listEl.addEventListener('scroll', throttle(() => { autoSizeVirtualList((item, rectBox) => { return `<div class="list-item" data-index="${rectBox.index}" style="height:${item.height}px">${item.name}</div>` }, (renderItems) => { listInner.innerHTML = renderItems.join(''); }); })); </script> </body> </html>