长列表性能优化方案——无限滚动 虚拟滚动
博主以前美化简历(编)的时候一直搞不清无限滚动和虚拟列表,面试的时候经常开始乱说,甚至其实“长列表”这个场景也很模糊。
长列表优化是前端性能优化的重要场景,当列表数据量过大(通常超过 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,获取一系列参数
两种方案:
- 外层容器绑定 scroll 事件,滚动时就更新。+requestAnimationFrame。
- intersection API,可以在顶部和底部分别放一个哨兵元素。顶部哨兵进入可视区时说明需要加载上方的缓冲数据,底部哨兵进入可视区时说明需要加载上方的缓冲数据。
定高推荐方案一,不定高推荐IntersectionObserver。或者方案一作为更新时机触发,方案二作为预加载判断辅助。
4.4 渲染可视区域内的数据
从总数据截取 [startIndex, endIndex] 的子集。
requestAnimationFrame将DOM更新、高度测量、滚动定位等操作“对齐浏览器重绘节奏”。
看到这里可以发现,长列表的终极解决方案就是无限滚动+虚拟列表了。
五、瀑布流及其虚拟列表
移动端社交性质或者是电商性质的长列表,很有可能会使用到瀑布流布局。天真的博主之前一直以为瀑布流就是纵向的布局。
瀑布流布局:像瀑布一样,宽度固定,高度不一,元素会自动填充到合适的列中,整体错落有致。
5.1 瀑布流方案选型
① CSS Columns
使用 column-count
或 column-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,可获得left、top。
更新元素坐标数组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,获取一系列参数
两种方案:
- 监听 scroll 事件+ requestAnimationFrame。
- 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,需将处理后的数据传递给主线程渲染。
#前端##优化##性能优化##长列表##瀑布流#