乐于分享
好东西不私藏

虚拟列表进阶:不定高度方案完整实现(附源码)

虚拟列表进阶:不定高度方案完整实现(附源码)

虚拟列表进阶:不定高度方案完整实现(附源码)

上一篇我们实现了固定高度的虚拟列表,有读者在评论区问:

“现实业务里,列表项高度都不一样啊!聊天消息长短不一,评论有的一行有的十行,怎么办?”
 这个问题问得很好。不定高度虚拟列表确实是进阶难点,也是大厂面试的加分题。今天我们就从原理到完整代码,把它彻底搞透。 

## 固定高度 vs 不定高度,难在哪里? 

先回顾一下固定高度为什么简单:

// 固定高度:直接用除法算索引,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 到底在第几条?必须遍历或二分查找位置缓存才能知道。

这就是不定高度的核心难点:位置不能直接计算,必须维护一份位置缓存表


## 不定高度虚拟列表的三大核心机制 

机制一:预估高度 + 位置缓存

数据还没渲染时,我们不知道每条的真实高度,怎么办?

先用一个预估高度(estimatedHeight)占位,等真正渲染后再用 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。


## 完整代码实现(原生 JS,含详细注释) 
<!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>      &lt;span class="item-index"&gt;#${item.id}&lt;/span&gt;<br>      &lt;div class="item-header"&gt;<br>        &lt;div class="item-avatar" style="background:${item.color}"&gt;${item.name[0]}&lt;/div&gt;<br>        &lt;div class="item-meta"&gt;<br>          &lt;div class="item-name"&gt;${item.name}&lt;/div&gt;<br>          &lt;div class="item-time"&gt;${item.time}&lt;/div&gt;<br>        &lt;/div&gt;<br>      &lt;/div&gt;<br>      &lt;div class="item-content"&gt;${item.content}&lt;/div&gt;<br>      &lt;span class="item-tag" style="background:${item.color}"&gt;${item.tag}&lt;/span&gt;<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 数组,让滚动条比例大致正确。等渲染后再用真实高度覆盖。


### 步骤 2 — 二分查找起始索引 
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)。


### 步骤 3 — 计算渲染范围,加缓冲区 
startIndex = Math.max(0, rawStart - BUFFER_COUNT);endIndex   = Math.min(  TOTAL_COUNT - 1,  rawStart + Math.ceil(container.clientHeight / ESTIMATED_HEIGHT) + BUFFER_COUNT);

上下各加 5 条缓冲,快速滚动时不会出现白屏区域。


### 步骤 4 — translateY 偏移渲染区 
listArea.style.transform = translateY(${positions[startIndex].top}px);

实际渲染的 DOM 从 startIndex 开始,但它的视觉位置需要对应 positions[startIndex].top。通过 translateY 把整个渲染区向下推到正确位置,视觉上看起来就像完整列表一样


### 步骤 5 — 渲染后测量真实高度 
const realHeight = el.getBoundingClientRect().height;if (Math.abs(positions[index].height - realHeight) > 1) {  positions[index].height = realHeight;  hasChanged = true;}

DOM 渲染完成后,遍历所有新渲染的节点,测量真实高度。只有偏差超过 1px 才认为需要更新,避免浮点数精度导致的无限更新循环。


### 步骤 6 — 级联更新 top/bottom 
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条重算。

## 固定高度 vs 不定高度实现对比 

| 对比维度 | 固定高度 | 不定高度 | |———|———|———| | 起始索引计算 | scrollTop / itemHeight(O(1)) | 二分查找 positions(O(log n)) | | 位置缓存 | 不需要 | 必须维护 positions 数组 | | 高度来源 | 配置常量 | 预估 → 渲染后测量真实值 | | 偏移计算 | startIndex * itemHeight | positions[startIndex].top | | 实现复杂度 | ★★☆☆☆ | ★★★★☆ | | 适合场景 | 表格、固定卡片 | 聊天消息、评论区、Feed流 |


## 不定高度方案的常见坑 

坑1:预估高度偏差太大

预估高度与真实高度差距过大,会导致页面刚加载时滚动条跳动。

解决方案: 对真实业务数据先统计一个平均值,作为 ESTIMATED_HEIGHT,偏差尽量控制在 ±30px 以内。 

坑2:图片加载后高度二次变化

文字内容高度在 DOM 渲染后就稳定了,但如果列表项里有图片,图片加载完才会撑高容器,这时候需要二次触发高度更新

解决方案: 监听图片的 load 事件,加载完成后重新 measureAndUpdate(),或者给图片设置固定宽高比(aspect-ratio: 16/9)避免高度跳变。 

坑3:滚动到底部数据项高度乱跳

快速滚到底部时,中间大量未渲染的条目位置仍是预估值,导致滚动位置偏移。

解决方案: 进场后先快速预渲染一遍(设置 visibility: hidden 渲染 + 测量 + 不展示),或者在用户滚动到某范围前提前渲染该范围的数据(预热)。 

坑4:频繁 rebuildPositions 性能差

每次滚动都重算全量 positions,10万条数据会很慢。

解决方案: 记录最小变更索引 minChangedIndex,只从这里开始级联更新,后面未变化的跳过。 

## 一句话总结 
不定高度虚拟列表的本质:预估占位 → 渲染测量 → 修正缓存 → 级联更新,每次滚动都是这个循环的轻量执行。
 掌握了这个机制,你就彻底搞懂了虚拟列表的完整技术栈。 

关注”IT老司机Ochmd”,持续输出前端硬核干货。上一篇:[固定高度虚拟列表完整实现](https://mp.weixin.qq.com/…),欢迎对照阅读。