长列表性能优化方案——无限滚动 虚拟滚动

博主以前美化简历(编)的时候一直搞不清无限滚动和虚拟列表,面试的时候经常开始乱说,甚至其实“长列表”这个场景也很模糊。

长列表优化是前端性能优化的重要场景,当列表数据量过大(通常超过 1000 条)时,直接渲染全部内容会导致 DOM 节点过多、内存占用飙升、页面卡顿等问题。

一、分段加载

假设一个消息中心有100条消息,那么后端不可能一次性返回100条,前端也不可能一次性渲染100条DOM。最基础的做法是第一次请求20条,在“某个时机”请求21-40条,在“下一个时机”请求41-60条,这个就叫做分段加载。

分段加载的常见形式有分页和无限滚动。在分页中,这个时机就是点击分页器。在无限滚动中,这个时机就是滚动到列表底部。

稍微有点经验的前后端一般都会这么做,但是对于自己做项目,被迫自己全栈来模拟数据的博主来说,一开始理解这个概念都费劲哈哈💧

二、分页加载

将数据按页分割,只加载当前页内容,通过分页器或加载更多按钮或“某个时机” 触发下一页加载。

后端:接口返回分页数据(pageNum、pageSize、total)。

前端:维护页码状态,合适时请求下一页数据,替换此页内容

三、无限滚动/无限加载

一些页面是不适合设计分页器的,比如消息中心、移动端列表、商品列表等。因为分页设计多少会打断用户的体验,而允许用户可以一直无限滚动的体验感会更加完整。

3.1“某个时机”

那么就需要我们在“某个时机”触发分页。这个时机通常是认为用户即将要滚动到“下一页”的内容了(此页底部)。

以下两种方式判断加载时机。

① 防抖监听滚动事件,判断以下不等式成立

scrollTop + clientHeight >= scrollHeight - 阈值

scrollTop:内容向上滚动被隐藏的部分高度

clientHeight:元素可视区域的高度

scrollHeight:元素内容的总高度

阈值:提前多少px开始加载下一页

② 列表底部放置一个触发元素,IntersectionObserver监听

通过rootMargin 配置可提前触发加载,相当于阈值

root可指定滚动容器,适合局部滚动的场景

现代浏览器原生api,异步触发,性能更好

3.2 “追加”

分页器切换下一页后直接替换本页数据即可,但无限滚动需要维护一个列表数组,并陆续把下一页追加到列表。因为无限滚动的DOM就是追加形式的,那么数据也需要是追加形式的。

3.3 isLoading和hasMore和加载完成标志

isLoadng:不是加载状态时才能触发加载,避免重复请求。

hasMore:还有更多时才能触发加载,避免没有数据了还请求。

span(v-if='!hasMore') 没有更多了

const isLoading = ref(false);
const hasMore = ref(true);

到加载时机了:
  if(isLoading && hasmore){
    isLoading.value = true;
    const response = await fetchData();
    if(pageNum * pageSize >= total){
      hasMore.value = true;
    }
    isLoading.value = false;
}

四、虚拟滚动/虚拟列表

只渲染可视区域内的列表项,通过计算滚动位置动态更新显示内容,DOM 节点数量保持在固定范围。

常适用:聊天室、社交类聊天记录、ai对话、新闻资讯列表、电商商品列表、超长数据列表等...

(以下所谓方案推荐仅结合多位ai说法,博主并没有看主流实现。)

4.1 搭建三层DOM结构

外层容器:固定可视区域的宽高,设置overflow: auto,相对定位

滚动占位容器:高度是所有数据的总高,撑起外层容器的滚动条

可视项容器:绝对定位。就像"浮"在可视区域上一样。通过 transform: translateY 调整偏移。

<div class="viewport" style="position:relative">// 外层容器
  <div class="scroll-container"></div> // 滚动占位容器
  <div class="visible-items" style="position:absolute"> // 可视项容器
    <div class="list-item">商品50</div>
    <div class="list-item">商品51</div>
    <div class="list-item">商品52</div>
  </div>
</div>

其中滚动占位容器需要维护其高度:

a. 定高。占位高度=totalLength*itemHeight

b. 不定高。占位高度=已测量数据+未测量数据=sum(高度数组)+estimatedCount*estimatedHeight

4.2 计算关键参数,维护关键数据

① 关键参数:

列表项尺寸 itemHeight:单个列表项的高度

可视区列表项数 visibleCount:可视区域内可以渲染的列表项数

滚动偏移量 scrollTop:滚动的距离,用来定位可见区域的起始索引startIndex

起始索引 startIndex:可见区域的第一个列表项的索引

结束索引 endIndex:可见区域的最后一个列表项的索引,为避免滚动空白,会额外多渲染1-2个项。

偏移距离offset:精准偏移滚动距离,比如刚好滚动到某个列表项的一半,如何实现这个滚动偏移

关键参数

定高

不定高

itemHeight

itemHeight

已渲染的,offsetHeight/getBoundingClientRect实时获取每一条itemHeight。

未渲染的,用预估高度临时占位。渲染到的时候获取真实高度并修正前缀和高度列表。

visibleCount

clientHeight/ itemHeight

~

scrollTop

scrollTop

scrollTop

startIndex

Math.floor(scrollTop/ itemHeight)

前缀和高度表第一个高度>scrollTop的索引,二分查找

endIndex

startIndex+visibleCount

前缀和高度表第一个高度>scrollTop+clientHeight的索引,二分查找

offset

startIndex*itemHeight-scorllTop

~

② 维护数据:

  • 总数据数组
  • 数据量中等,百千级;直接存储完整数组。
  • 数据量极大,万级/百万级,配合无限滚动分段拉取数据;存取部分数据+索引。

这里博主就有疑问了。由于数据还是随着滑动逐步追加到尾部,滑动到底部还是会存储全部数据呀,问ai说这里还可以优化一下:本地可以保留当前需要用的数据+少量最近的历史数据。向上滑动时,再重新请求。

  • 可视区域数据数组
  • 高度数组(不定高时)
  • 前缀和高度数组(不定高时)

4.3 触发更新各项数据

获取scrollTop,获取一系列参数

两种方案:

  1. 外层容器绑定 scroll 事件,滚动时就更新。+requestAnimationFrame。
  2. intersection API,可以在顶部和底部分别放一个哨兵元素。顶部哨兵进入可视区时说明需要加载上方的缓冲数据,底部哨兵进入可视区时说明需要加载上方的缓冲数据。

定高推荐方案一,不定高推荐IntersectionObserver。或者方案一作为更新时机触发,方案二作为预加载判断辅助。

4.4 渲染可视区域内的数据

从总数据截取 [startIndex, endIndex] 的子集。

requestAnimationFrame将DOM更新、高度测量、滚动定位等操作“对齐浏览器重绘节奏”。

看到这里可以发现,长列表的终极解决方案就是无限滚动+虚拟列表了。

五、瀑布流及其虚拟列表

移动端社交性质或者是电商性质的长列表,很有可能会使用到瀑布流布局。天真的博主之前一直以为瀑布流就是纵向的布局。

瀑布流布局:像瀑布一样,宽度固定,高度不一,元素会自动填充到合适的列中,整体错落有致。

5.1 瀑布流方案选型

① CSS Columns

使用 column-countcolumn-width 将容器分成多列,浏览器会自动把子元素分配到列。

.container {
  column-count: 4;   /* 分 4 列 */
  column-gap: 16px;  /* 列间距 */
}
.item {
  break-inside: avoid; /* 避免元素被拆开 */
  margin-bottom: 16px;
}

优点:实现简单,代码少。

缺点:只能按照从上到下,从左到右的顺序防止。列之间高度不均衡,无法完全“紧凑”。

② CSS Grid(Masonry-like)

Chrome/Firefox 已经支持 grid-template-rows: masonry(实验特性)。

类似 Pinterest 效果,自动填补空隙。

③ JavaScript 实现

第三方库实现的

Masonry.js:经典库,功能强。

Isotope:支持筛选 + 动画 + 瀑布流。

Vue/React 组件:vue-waterfall, react-masonry-component。

5.2 瀑布流实现(以主流的js+相绝定位为例)

① 获取关键参数

  • 获得容器宽度containerWidth、列数colCount、列间距gap、列宽度(colWidth - (colCount - 1) * gap) / colCount。
  • 维护一个数组 colHeights 记录每一列的当前高度。
  • 总高度colHeights[maxCol] ,即最高列的高度
  • 找到高度最小的列minCol。把 item 放进minCol,更新 colHeights[minCol] += item.height + gap。
const colHeights = Array(cols).fill(0);

items.forEach(item => {
  const minCol = colHeights.indexOf(Math.min(...colHeights));
  item.style.position = 'absolute';
  item.style.top = colHeights[minCol] + 'px';
  item.style.left = minCol * columnWidth + 'px';
  colHeights[minCol] += item.offsetHeight + gap;
});

② 更新并存储元素坐标

为每个 item 缓存绝对坐标:由上面的minCol、colWidth、gap、colHeights,可获得lefttop

更新元素坐标数组itemMeta

itemMeta = [
  {
    left = minCol * (colWidth + gap) ,
    top = colHeights[minCol],
    bottom = colHeights[minCol] + height
    height,
  },
  {},
  {},
  {},
  ...
]

渲染时,style="transform: translate(left, top)" 来定位。

5.3 瀑布流的虚拟滚动实现 (以主流的js+相绝定位为例)

① 搭建三层DOM结构

外层容器,滚动占位容器,可视项容器

可视项容器的项均要求绝对定位,并且有相应偏移

<div class="viewport" style="position:relative">// 外层容器
  <div class="scroll-container"></div> // 滚动占位容器
  <div class="visible-items" style="position:absolute"> // 可视项容器
    <div class="list-item"
      :style="{
          width: colWidth + 'px',
          transform: `translate(${item.left}px, ${item.top}px)`
        }"
      >商品50</div>
    <div class="list-item">商品51</div>
    <div class="list-item">商品52</div>
  </div>
</div>
② 维护关键数据,见5.2

获取可视区域范围

visibleTop = scrollTop

visibleBottom = scrollTop+clientHeight

③ 触发更新各项数据

获取scrollTop,获取一系列参数

两种方案:

  1. 监听 scroll 事件+ requestAnimationFrame。
  2. Intersection API

推荐IntersectionObserver

此外,可以使用 ResizeObserver 自动监听元素大小变化,触发重排。

④ 渲染可视区域的数据

二分查找itemMeta中符合的作为visibleItem

item.bottom >= visibleTop && item.top <= visibleBottom

这里与普通列表不同,不能用 index 范围去截取[startIndex, endIndex],而是要用二分查找精确比对元素坐标itemMeta[i]

可以优化,批量筛选优化:

  • 可以按“行(row)/区块”维护索引,减少 O(n) 遍历。
  • 也可维护一个 每列的 item 索引,滚动时只在当前可见列里查找。

requestAnimationFrame将DOM更新、高度测量、滚动定位等操作“对齐浏览器重绘节奏”。

下面是相关知识的一些补充。

六、拓展

6.1 描述元素尺寸、位置的属性

分类

属性

描述

是否可修改

元素尺寸

offsetWidth/offsetHeight

content + padding + border

clientWidth/clientHeight

content + padding

scrollWidth/scrollHeight

内容总大小(含溢出部分)

元素位置

offsetTop/offsetLeft

相对 offsetParent 的偏移

getBoundingClientRect()

相对视口的坐标和大小

scrollTop/scrollLeft

元素内容的滚动偏移

样式

style.width/height/top/left

行内样式

getComputedStyle()

最终渲染样式

窗口/文档

innerWidth/innerHeight

浏览器视口大小

clientWidth/clientHeight

文档可视区域大小

scrollWidth/scrollHeight

文档实际内容大小

scrollX/scrollY

页面滚动位置

6.2 浏览器观察器api

https://developer.mozilla.org/en-US/docs/Web/API#interfaces

① IntersectionObserver(交集观察器)

用途:监听元素与视口或其他元素的交集变化

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素进入视口');
    } else {
      console.log('元素离开视口');
    }
  });
}, {
  root: null, // 默认为视口
  rootMargin: '0px', // 扩展根元素边界
  threshold: 0.5 // 50%可见时触发
});

observer.observe(targetElement); 

常见应用场景:

图片懒加载

无限滚动

粘性定位控制(如你的代码中)

动画触发

② MutationObserver(变化观察器)

用途:监听DOM树的变化

const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    if (mutation.type === 'childList') {
      console.log('子节点发生变化');
    } else if (mutation.type === 'attributes') {
      console.log('属性发生变化');
    }
  });
});

observer.observe(targetElement, {
  childList: true, // 监听子节点变化
  attributes: true, // 监听属性变化
  subtree: true, // 监听所有后代节点
  attributeOldValue: true // 记录旧属性值
}); 

常见应用场景:

动态内容高度计算

表单验证

第三方库集成监听

③ ResizeObserver(尺寸观察器)

用途:监听元素尺寸变化

const observer = new ResizeObserver((entries) => {
  entries.forEach(entry => {
    const { width, height } = entry.contentRect;
    console.log(`元素尺寸变化: ${width}x${height}`);
  });
});

observer.observe(targetElement); 

常见应用场景:

响应式组件

图表自适应

布局重新计算

④ PerformanceObserver(性能观察器)

用途:监听性能指标

Performance.now():高精度时间戳(比 Date.now() 精确到微秒)。

PerformanceObserver:收集页面渲染、FPS、资源加载性能数据。

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach(entry => {
    console.log(`${entry.name}: ${entry.duration}ms`);
  });
});

observer.observe({ entryTypes: ['measure', 'navigation'] }); 

常见应用场景:

性能监控

用户体验分析

资源加载优化

FPS 计算

渲染耗时分析

6.3 和高性能动画及渲染相关的浏览器api

https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame

① requestAnimationFrame (rAF):在下一帧渲染前执行回调函数,保证和屏幕刷新率同步。

function step(timestamp) {
  // 执行动画逻辑
  requestAnimationFrame(step);
}
requestAnimationFrame(step);

cancelAnimationFrame(requestID):取消通过rAF注册的回调函数,避免已调度的回调在不需要时执行。

② Web Animations API:原生 API,直接操作 DOM 元素的动画。

比 CSS animation 更灵活,比 JavaScript 动画库 更高效。

使用场景:需要控制动画时间线、暂停、继续、反转等。

element.animate(
  [{ transform: "translateX(0px)" }, { transform: "translateX(100px)" }],
  { duration: 1000, iterations: Infinity }
);

③ requestIdleCallback

功能:在浏览器空闲时执行低优先级任务。

使用场景:

批量预加载数据。

虚拟列表在空闲时测量 item 高度。

6.4 经典场景题:渲染一万个元素你会怎么做

DocumentFragment(一次插入)

理:DocumentFragment是一种轻量级的 DOM 节点容器,但不属于真实DOM树,仅存在于内存中。先将所有节点添加到DocumentFragment,最后一次性插入文档,减少 DOM 重排次数(从 1 万次减少到 1 次)。

适用场景:数据一次性渲染且无需滚动优化。

局限性:仍会创建 1 万个 DOM 节点,可能导致内存占用过高、页面卡顿。

const fragment = document.createDocumentFragment();
for (let i = 0; i < 10000; i++) {
  const div = document.createElement('div');
  div.textContent = `Item ${i}`;
  fragment.appendChild(div);
}
document.body.appendChild(fragment); // 仅1次DOM插入

setTimeout/setInterval(分片渲染,体验一般)

原理:将 1 万个元素分成多批(如每批 100 个),通过定时器延迟渲染,避免一次性阻塞主线程。

局限性

定时器精度低(可能被延迟),导致渲染间隔不稳定。

仍会创建 1 万个 DOM 节点,最终性能与DocumentFragment相当。

let i = 0;
function renderBatch() {
  const batchSize = 100;
  const fragment = document.createDocumentFragment();
  while (i < 10000 && i < current + batchSize) {
    const div = document.createElement('div');
    div.textContent = `Item ${i}`;
    fragment.appendChild(div);
    i++;
  }
  document.body.appendChild(fragment);
  if (i < 10000) {
    setTimeout(renderBatch, 0); // 下一宏任务继续
  }
}
renderBatch();

Scheduler.postTask

原理:浏览器调度 API,控制渲染优先级,在主线程空闲时渲染,避免阻塞高优先级任务(如用户输入)。

适用场景:替代 setTimeout 的分片渲染,优化用户交互体验。

局限性:兼容性有限(Chrome 88 + 支持,需 polyfill 兼容其他浏览器)。

let i = 0;
async function renderWithScheduler() {
  while (i < 10000) {
    const div = document.createElement('div');
    div.textContent = `Item ${i}`;
    document.body.appendChild(div);
    i++;
    if (i % 100 === 0) {
      await scheduler.postTask(() => {}, { priority: 'background' }); // 空闲时继续
    }
  }
}
renderWithScheduler();

requestAnimationFrame辅助优化,非独立方案)

原理:将渲染逻辑放入浏览器重绘周期(约 16ms / 帧),避免同步渲染阻塞主线程。配合其他方案(如分片渲染),优化渲染过程的流畅度。

局限性:仅优化渲染时机,无法减少总 DOM 节点数量。

let i = 0;
function render() {
  if (i < 10000) {
    const div = document.createElement('div');
    div.textContent = `Item ${i}`;
    document.body.appendChild(div);
    i++;
    requestAnimationFrame(render); // 下一帧继续渲染
  }
}
render();

④ 虚拟列表

原理:只渲染视口内可见的元素,保持 DOM 节点数量在可控范围内(如 < 100)。

优势:DOM 节点数量固定,内存占用低,滚动流畅。

局限性:实现较复杂(需处理滚动计算、动态高度等),建议使用成熟库。

⑤ Canvas(适合纯展示场景)

原理:直接在画布上绘制 1 万个元素,所有内容由 Canvas 统一渲染,不生成 DOM 节点。

优势:渲染性能极高(Canvas 绘制效率远高于 DOM),内存占用低。

局限性

缺乏 DOM 交互能力(需手动实现点击、hover 等事件的坐标计算)。

不支持 CSS 样式、无障碍访问(a11y)。

⑥ Web Worker 预处理(优化耗时操作但不能渲染)

原理:将数据格式化、计算等耗时操作放入 Web Worker,避免阻塞主线程渲染。

局限性:Web Worker 无法操作 DOM,需将处理后的数据传递给主线程渲染。

#前端##优化##性能优化##长列表##瀑布流#
全部评论

相关推荐

投递柠檬微趣等公司10个岗位
点赞 评论 收藏
分享
评论
2
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务