虚拟列表进阶:不定高度方案完整实现(附源码)
虚拟列表进阶:不定高度方案完整实现(附源码)
上一篇我们实现了固定高度的虚拟列表,有读者在评论区问:
“现实业务里,列表项高度都不一样啊!聊天消息长短不一,评论有的一行有的十行,怎么办?”
先回顾一下固定高度为什么简单:
// 固定高度:直接用除法算索引,O(1)const startIndex = Math.floor(scrollTop / ITEM_HEIGHT);
每条高度一样,知道滚了多少像素,直接除以行高就知道第几条。
Item 0:高度 45px → top: 0, bottom: 45Item 1:高度 120px → top: 45, bottom: 165Item 2:高度 60px → top: 165, bottom: 225Item 3:高度 200px → top: 225, bottom: 425...
每条高度不同,根本没法直接算。scrollTop = 200 到底在第几条?必须遍历或二分查找位置缓存才能知道。
这就是不定高度的核心难点:位置不能直接计算,必须维护一份位置缓存表。
机制一:预估高度 + 位置缓存
数据还没渲染时,我们不知道每条的真实高度,怎么办?
ResizeObserver 或 getBoundingClientRect() 测量真实高度,然后更新位置缓存表。 positions 数组(位置缓存表):[ { index: 0, height: 80(预估), top: 0, bottom: 80 }, { index: 1, height: 80(预估), top: 80, bottom: 160 }, { index: 2, height: 80(预估), top: 160, bottom: 240 }, ...]渲染后测量真实高度,更新:[ { index: 0, height: 45(真实), top: 0, bottom: 45 }, { index: 1, height: 120(真实), top: 45, bottom: 165 }, { index: 2, height: 60(真实), top: 165, bottom: 225 }, ...]
机制二:二分查找起始索引
有了 positions 数组后,查找 scrollTop 对应的起始索引,用二分查找把复杂度从 O(n) 降到 O(log n):
function binarySearch(positions, scrollTop) { let low = 0, high = positions.length - 1; while (low <= high) { const mid = Math.floor((low + high) / 2); if (positions[mid].bottom === scrollTop) return mid + 1; else if (positions[mid].bottom < scrollTop) low = mid + 1; else high = mid - 1; } return low;}
机制三:渲染后修正位置
每次渲染完成后,遍历新渲染的 DOM 节点,用 getBoundingClientRect() 获取真实高度,如果与缓存不同就更新 positions,并向后级联更新所有受影响条目的 top/bottom。
<!DOCTYPE html><html lang="zh-CN"><head> <meta charset="UTF-8"> <title>不定高度虚拟列表</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f6fa; } h2 { text-align: center; padding: 24px 0 12px; color: #1a1a2e; font-size: 18px; } /<em> 滚动容器:固定高度 + overflow:auto </em>/ container { position: relative; height: 600px; overflow-y: auto; width: 480px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); } /<em> 占位元素:撑开总高度,让滚动条比例正确 </em>/ phantom { position: absolute; top: 0; left: 0; right: 0; pointer-events: none; } /<em> 实际渲染区:绝对定位,用 transform 偏移 </em>/ list-area { position: absolute; top: 0; left: 0; right: 0; } /<em> 列表项:高度不固定 </em>/ .list-item { padding: 14px 20px; border-bottom: 1px solid #f0f2f5; background: #fff; transition: background 0.15s; } .list-item:hover { background: #fafbff; } .item-header { display: flex; align-items: center; margin-bottom: 8px; } .item-avatar { width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 14px; font-weight: bold; color: #fff; flex-shrink: 0; margin-right: 10px; } .item-meta { flex: 1; } .item-name { font-size: 14px; font-weight: 600; color: #1a1a2e; } .item-time { font-size: 12px; color: #999; margin-top: 2px; } .item-content { font-size: 14px; color: #444; line-height: 1.7; word-break: break-all; } .item-tag { display: inline-block; margin-top: 8px; padding: 2px 10px; border-radius: 10px; font-size: 12px; color: #fff; } .item-index { position: absolute; right: 16px; top: 16px; font-size: 11px; color: #ccc; } .stats { text-align: center; padding: 10px; font-size: 13px; color: #888; background: #f8f9ff; border-radius: 0 0 12px 12px; } </style></head><body><h2>不定高度虚拟列表演示(共 10000 条评论)</h2><div id="container"> <div id="phantom"></div> <div id="list-area"></div></div><div class="stats" id="stats">正在初始化...</div><script>// ==================== 配置 ====================const ESTIMATED_HEIGHT = 100; // 预估行高(px),尽量贴近真实均值const BUFFER_COUNT = 5; // 上下缓冲条数const TOTAL_COUNT = 10000; // 总数据量// ==================== 生成模拟数据 ====================// 模拟不同长度的评论,高度自然不同const CONTENTS = [ '写得太好了,学到了很多!', '虚拟列表这个概念我之前一直没搞懂,看完这篇文章终于明白了。原来核心就是只渲染可见区域,其他的用占位撑高。', '请问动态高度的方案,positions 数组初始化时全部用预估高度,那第一次渲染的 scrollbar 位置不会不准吗?滚到中间再回来会不会有偏差?', '感谢分享!', '我们项目里用的 react-window,但遇到了一个问题:列表项里有图片,图片加载后高度会变化,导致滚动位置乱跳。这种情况有没有好的解决方案?希望作者能出一篇专门讲这个的文章。', '收藏了,之后用到再看。代码写得很规范,注释也很清楚,点个赞!', '面试刚被问到这道题,没答好,来恶补一下。', '不定高度确实是难点。我之前实现过一版,用 IntersectionObserver 来触发高度更新,不过性能不太好。后来改成 ResizeObserver 才稳定下来。', '666', '大佬,代码里的 binarySearch 函数有个边界情况没处理:当所有 bottom 都小于 scrollTop 时,返回值会越界,需要加一个 Math.min(result, total-1) 的保护。',];const COLORS = ['#667eea','#f093fb','#4facfe','#43e97b','#fa709a','#30cfd0','#ffeaa7','#fd79a8'];const NAMES = ['前端小王','码农老李','React爱好者','性能优化达人','Vue忠实用户','全栈工程师','架构师张三','前端新手小白'];const TAGS_LIST = ['干货','有帮助','Mark','已解决','求解答','已收藏'];const DATA = Array.from({ length: TOTAL_COUNT }, (_, i) => ({ id: i + 1, name: NAMES[i % NAMES.length], content: CONTENTS[i % CONTENTS.length] + (i % 3 === 0 ? (第${i+1}楼) : ''), color: COLORS[i % COLORS.length], tag: TAGS_LIST[i % TAGS_LIST.length], time: ${Math.floor(Math.random()*24)}小时前}));// ==================== 位置缓存初始化 ====================// 每一项记录:index / height(预估或真实)/ top / bottomlet positions = DATA.map((_, i) => ({ index: i, height: ESTIMATED_HEIGHT, top: i * ESTIMATED_HEIGHT, bottom: (i + 1) * ESTIMATED_HEIGHT}));// ==================== DOM 引用 ====================const container = document.getElementById('container');const phantom = document.getElementById('phantom');const listArea = document.getElementById('list-area');const statsEl = document.getElementById('stats');// ==================== 步骤1:初始化占位高度 ====================function updatePhantom() { // 总高度 = 最后一条的 bottom phantom.style.height = positions[positions.length - 1].bottom + 'px';}updatePhantom();// ==================== 步骤2:二分查找起始索引 ====================// 在 positions 中找到第一个 bottom > scrollTop 的条目function binarySearchStart(scrollTop) { let low = 0, high = positions.length - 1, result = 0; while (low <= high) { const mid = (low + high) >> 1; if (positions[mid].bottom > scrollTop) { result = mid; high = mid - 1; } else { low = mid + 1; } } return result;}// ==================== 步骤3:渲染可见区域 ====================let startIndex = 0;let endIndex = 0;function renderList() { const scrollTop = container.scrollTop; // 用二分找到第一条可见数据 const rawStart = binarySearchStart(scrollTop); startIndex = Math.max(0, rawStart - BUFFER_COUNT); endIndex = Math.min( TOTAL_COUNT - 1, rawStart + Math.ceil(container.clientHeight / ESTIMATED_HEIGHT) + BUFFER_COUNT ); // 生成 DOM const fragment = document.createDocumentFragment(); for (let i = startIndex; i <= endIndex; i++) { const item = DATA[i]; const div = document.createElement('div'); div.className = 'list-item'; div.dataset.index = i; // 关键:记录索引,方便后续测量时找到对应 position div.style.position = 'relative'; div.innerHTML = <br> <span class="item-index">#${item.id}</span><br> <div class="item-header"><br> <div class="item-avatar" style="background:${item.color}">${item.name[0]}</div><br> <div class="item-meta"><br> <div class="item-name">${item.name}</div><br> <div class="item-time">${item.time}</div><br> </div><br> </div><br> <div class="item-content">${item.content}</div><br> <span class="item-tag" style="background:${item.color}">${item.tag}</span><br> ; fragment.appendChild(div); } listArea.innerHTML = ''; listArea.appendChild(fragment); // 步骤4:偏移渲染区到正确位置 listArea.style.transform = translateY(${positions[startIndex].top}px); // 步骤5:渲染完成后测量真实高度并更新缓存 // 用 requestAnimationFrame 确保 DOM 已绘制完毕 requestAnimationFrame(measureAndUpdate); // 更新状态栏 statsEl.textContent = 实际渲染:第 ${startIndex+1} ~ ${endIndex+1} 条(共 ${endIndex - startIndex + 1} 个DOM节点);}// ==================== 步骤5:测量真实高度,更新 positions ====================function measureAndUpdate() { const items = listArea.querySelectorAll('.list-item'); let hasChanged = false; items.forEach(el => { const index = parseInt(el.dataset.index); const realHeight = el.getBoundingClientRect().height; // 只有高度确实变化了才更新(避免不必要的级联计算) if (Math.abs(positions[index].height - realHeight) > 1) { positions[index].height = realHeight; hasChanged = true; } }); // 如果有高度变化,级联更新后续所有 top/bottom if (hasChanged) { rebuildPositions(); // 重新设置占位高度 updatePhantom(); // 重新调整渲染区偏移(因为 positions 变了) listArea.style.transform = translateY(${positions[startIndex].top}px); }}// ==================== 步骤6:级联更新 positions ====================// 从第 0 条开始重新计算所有条目的 top/bottom// 优化:如果业务数据量极大,可以只从最早改动的索引开始更新function rebuildPositions() { for (let i = 0; i < positions.length; i++) { if (i === 0) { positions[i].top = 0; positions[i].bottom = positions[i].height; } else { positions[i].top = positions[i - 1].bottom; positions[i].bottom = positions[i].top + positions[i].height; } }}// ==================== 步骤7:监听滚动,节流触发 ====================let ticking = false;container.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(() => { renderList(); ticking = false; }); ticking = true; }});// ==================== 初次渲染 ====================renderList();</script></body></html>
步骤 1 — 位置缓存初始化
let positions = DATA.map((_, i) => ({ index: i, height: ESTIMATED_HEIGHT, // 先全部用预估高度 top: i * ESTIMATED_HEIGHT, bottom: (i + 1) * ESTIMATED_HEIGHT}));
程序启动时,所有数据都还没渲染,高度未知。先用预估值(比如100px)填满整个 positions 数组,让滚动条比例大致正确。等渲染后再用真实高度覆盖。
function binarySearchStart(scrollTop) { let low = 0, high = positions.length - 1, result = 0; while (low <= high) { const mid = (low + high) >> 1; if (positions[mid].bottom > scrollTop) { result = mid; high = mid - 1; // 往左找更早的起始点 } else { low = mid + 1; // 这条已经滚过去了,往右找 } } return result;}
找到第一条 bottom > scrollTop 的记录,即第一条”没被完全滚过去”的数据,也就是可见区的起始条目。
用二分而不是线性遍历,1万条数据只需最多14次比较(log₂10000 ≈ 13.3)。
startIndex = Math.max(0, rawStart - BUFFER_COUNT);endIndex = Math.min( TOTAL_COUNT - 1, rawStart + Math.ceil(container.clientHeight / ESTIMATED_HEIGHT) + BUFFER_COUNT);
上下各加 5 条缓冲,快速滚动时不会出现白屏区域。
listArea.style.transform = translateY(${positions[startIndex].top}px);
实际渲染的 DOM 从 startIndex 开始,但它的视觉位置需要对应 positions[startIndex].top。通过 translateY 把整个渲染区向下推到正确位置,视觉上看起来就像完整列表一样。
const realHeight = el.getBoundingClientRect().height;if (Math.abs(positions[index].height - realHeight) > 1) { positions[index].height = realHeight; hasChanged = true;}
DOM 渲染完成后,遍历所有新渲染的节点,测量真实高度。只有偏差超过 1px 才认为需要更新,避免浮点数精度导致的无限更新循环。
function rebuildPositions() { for (let i = 0; i < positions.length; i++) { positions[i].top = i === 0 ? 0 : positions[i-1].bottom; positions[i].bottom = positions[i].top + positions[i].height; }}
某一条高度变了,它之后所有条目的 top/bottom 都要跟着变,这步是必须的。
性能优化点:
如果数据量极大(>10万),可以只从第一个发生变化的索引开始更新,而不是从第0条重算。
| 对比维度 | 固定高度 | 不定高度 | |———|———|———| | 起始索引计算 | scrollTop / itemHeight(O(1)) | 二分查找 positions(O(log n)) | | 位置缓存 | 不需要 | 必须维护 positions 数组 | | 高度来源 | 配置常量 | 预估 → 渲染后测量真实值 | | 偏移计算 | startIndex * itemHeight | positions[startIndex].top | | 实现复杂度 | ★★☆☆☆ | ★★★★☆ | | 适合场景 | 表格、固定卡片 | 聊天消息、评论区、Feed流 |
坑1:预估高度偏差太大
预估高度与真实高度差距过大,会导致页面刚加载时滚动条跳动。
坑2:图片加载后高度二次变化
文字内容高度在 DOM 渲染后就稳定了,但如果列表项里有图片,图片加载完才会撑高容器,这时候需要二次触发高度更新。
load 事件,加载完成后重新 measureAndUpdate(),或者给图片设置固定宽高比(aspect-ratio: 16/9)避免高度跳变。 坑3:滚动到底部数据项高度乱跳
快速滚到底部时,中间大量未渲染的条目位置仍是预估值,导致滚动位置偏移。
visibility: hidden 渲染 + 测量 + 不展示),或者在用户滚动到某范围前提前渲染该范围的数据(预热)。 坑4:频繁 rebuildPositions 性能差
每次滚动都重算全量 positions,10万条数据会很慢。
minChangedIndex,只从这里开始级联更新,后面未变化的跳过。 不定高度虚拟列表的本质:预估占位 → 渲染测量 → 修正缓存 → 级联更新,每次滚动都是这个循环的轻量执行。
夜雨聆风