学完这篇,你能用不到 60 行原生 JS 给 10 万行列表做虚拟滚动,滚动不卡、内存不爆,并且知道哪些坑会让它看起来“优化了但还是卡”。
第一步:先确认问题出自 DOM 数量,而不是样式
打开控制台跑一行:
console.log(document.querySelectorAll('#list .row').length);
如果这个数字是几千甚至上万,卡顿基本与 CSS 无关了。浏览器要为每个元素建布局盒、参与样式重算,滚动时还要反复做合成。哪怕每行只是一个 <div>,5000 行也会让滚动帧率掉到 30 以下。
结论:一次只让 DOM 里存在“屏幕能看到的行 + 少量缓冲行”,其余全部虚拟掉。
第二步:记住三个量
虚拟滚动只依赖三个数值:
itemHeight:单行高度(固定行高场景)
scrollTop:容器已滚动的距离
clientHeight:容器可视高度
由此可得:首行索引 = Math.floor(scrollTop / itemHeight),渲染行数 = Math.ceil(clientHeight / itemHeight) + 2 × 缓冲。
第三步:搭一个三层的 DOM 骨架
<div id="viewport" style="height:600px;overflow:auto;position:relative">
<div id="phantom"></div> <!-- 撑出真实滚动高度 -->
<div id="list" style="position:absolute;top:0;left:0;right:0"></div>
</div>
#phantom 的高度设成 总行数 × itemHeight,负责让滚动条长度正确;#list 绝对定位,只装可视区那几十行,靠 transform: translateY() 挪到对应位置。
注意:不要用 top 挪位置,用 transform。top 会触发布局重排,transform 只走合成层,滚动时不掉帧。
第四步:写渲染函数
const H = 40, BUFFER = 5;
phantom.style.height = data.length * H + 'px';
function render() {
const top = viewport.scrollTop;
const start = Math.max(0, Math.floor(top / H) - BUFFER);
const end = Math.min(data.length, Math.ceil((top + viewport.clientHeight) / H) + BUFFER);
list.style.transform = `translateY(${start * H}px)`;
let html = '';
for (let i = start; i < end; i++) {
html += `<div class="row" style="height:${H}px">${data[i]}</div>`;
}
list.innerHTML = html;
}
缓冲行(BUFFER)的作用是在快速滚动时留出安全区,避免出现短暂白屏。
第五步:滚动节流到一帧一次
let ticking = false;
viewport.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => { render(); ticking = false; });
}, { passive: true });
passive: true 让浏览器知道你不会在滚动回调里阻止默认行为,滚动更顺。窗口 resize 和 orientationchange 时也要重跑一次 render()。
第六步:行高不固定怎么处理
内容长短不一时,固定行高会截断文字。两种做法:
- 改为限定行高:用
-webkit-line-clamp 把每行限制在 2–3 行,视觉上仍然是固定高度,成本最低。
- 真实动态行高:维护一个累加高度数组
offsets[i],用二分查找定位 scrollTop 落在哪一行;首次渲染后用 ResizeObserver 量出真实高度并回写。复杂度明显上升,非必要不用。
简单场景还有一招降维:给列表项加
.row { content-visibility: auto; contain-intrinsic-size: 0 40px; }
浏览器会跳过屏幕外元素的渲染工作,配合 contain-intrinsic-size 占位,通常能解决 80% 的长列表卡顿。
第七步:几个必须避开的坑
注意:行高的计算要含 padding 和 border。给行加 box-sizing: border-box,否则 scrollTop / H 会算错,滚到底部时列表错位一大截。
注意:innerHTML 整块替换会丢失内部状态。行内如果有输入框、展开面板、播放中的视频,用户一滚动状态就没了。要么把这些交互行移出虚拟列表单独渲染,要么改用节点复用池(复用固定数量的 DOM,只改内容)。
注意:每行单独绑事件会造成大量监听器。用事件委托,把监听挂在 #list 上,通过 e.target.closest('.row').dataset.index 找索引。
注意:Ctrl+F 搜不到未渲染的内容,读屏软件也读不到。产品上要么提供“加载全部”开关,要么把搜索做成数据层过滤而不是依赖浏览器查找。
注意:图片、字体加载完成后行高可能变化。字体用 document.fonts.ready.then(render) 重算一次,图片给固定宽高比占位。
小结
- 长列表卡顿的主要原因是 DOM 数量太多,与 CSS 无关
- 核心公式:
start = floor(scrollTop / H),end = ceil((scrollTop + clientHeight) / H) + 缓冲
- 三层结构:滚动容器 + 撑高占位层 + 绝对定位的可视层
- 位置用
transform: translateY,滚动回调用 requestAnimationFrame 节流并加 passive
- 行高必须含 padding/border,动态行高要额外维护偏移数组 + 二分查找
- 整块
innerHTML 会丢失行内状态,交互行要单独处理或做节点复用
- 简单场景优先试
content-visibility: auto,成本最低