虚拟滚动必须用三层dom结构:外层容器(固定高+overflow-y:auto)、占位层(phantom,高度=总行高)、内容层(绝对定位只渲染可视项),缺一不可。

虚拟滚动必须用三层DOM结构,缺一不可
只写一个 <div class="list"> 然后靠 JS 动态 <code>innerHTML 替换内容,不是虚拟滚动——那是“假虚拟”,DOM 节点数照样爆表。真实虚拟滚动依赖 HTML 层级配合,漏掉任何一层,滚动条就失效、内容错位或白屏。
- 外层容器:
<div class="viewport" style="height: 500px; overflow-y: auto;"> —— 必须固定高度 + <code>overflow-y: auto,它是唯一可滚动的节点,scrollTop值从此读取 - 占位层(phantom):
<div class="phantom" style="height: 1200000px;"></div>—— 高度 =data.length * itemHeight,必须是容器第一个子元素,不能加事件、不能contenteditable - 内容层:
<div class="list-items" style="position: absolute; top: 0;"> —— 只放当前可视项,用 <code>top或transform: translateY()定位,禁止用margin-top或paddingTop固定行高场景下,
top比transform: translateY()更适合编辑器如果你的列表要支持
contenteditable、光标定位或选区操作,别盲目套用“GPU 加速”建议。Chrome 115–122 中,transform: translateY()会让getSelection().getRangeAt(0)返回错误坐标,导致回车错位、粘贴截断、光标跳顶。- 编辑器类场景(如代码编辑器、富文本列表):用
position: absolute; top: ${startIndex * itemHeight}px,外层容器设position: relative - 纯展示类场景(如日志列表、商品瀑布流):可用
transform: translateY(),但必须加contain: layout paint提前告知浏览器不绘制不可见区域 - 行高误差超 ±5px 就开始视觉跳动;若数据含图片或异步加载内容,必须预设最小高度并禁用动态测高
dataset.id是节点池复用的唯一安全标识用数组索引(如
nodes[i])缓存 DOM 节点,在数据排序、过滤或增删后会立即错位——你更新的是第 3 个节点,但实际对应第 7 条数据。复用失效等于全量重建,卡顿比不用虚拟滚动还严重。- 每个节点初始化时必须写死
dataset.id = dataItem.id,且结构一致(class 名、子元素层级、contenteditable属性都不能变) - 回收时不
removeChild(),只设el.style.display = 'none'并存入Map:nodePool.set(el.dataset.id, el) - 复用时先查
nodePool.get(id),存在则恢复显示 + 批量更新textContent、dataset.updated等字段,禁止增删子节点
滚动监听不加
{ passive: true }会直接拖垮帧率原生
scroll事件在 Chrome/Firefox 中默认是同步执行的,一旦 handler 里有 DOM 读写(比如读scrollTop、调querySelector),浏览器必须等它完成才能继续渲染,主线程瞬间锁死。- 绑定时必须显式传
{ passive: true }:container.addEventListener('scroll', handleScroll, { passive: true }) - handler 内部不能再调
offsetHeight、getBoundingClientRect()等触发强制布局的 API - 节流不能只靠
requestAnimationFrame——它只保证“下一帧执行”,快速滚动时任务堆积;推荐用setTimeout(handleScroll, 0)或lodash.throttle(handleScroll, 16)
真正难的不是算出
startIndex,而是让每个节点在生命周期内始终可寻址、可聚焦、可被 selection API 正确识别——这要求结构、标识、定位方式三者严丝合缝,差一点,光标就丢,输入就错。 - 编辑器类场景(如代码编辑器、富文本列表):用











