先感受一下问题
假设你面前有一本十万页的电话簿,你要找一个人。
你不会从第一页开始一页页翻——你会用手指捏住书脊,快速拨动页边,眼睛盯着页眉的字母索引,直到接近目标字母才停下来翻细看。
你跳过了你不需要看的那些页。
虚拟滚动干的就是这件事,只不过场景从翻书变成了浏览器渲染 DOM。
浏览器在干什么
要理解虚拟滚动,得先理解浏览器渲染列表时到底在干什么。
假设你有一个包含 10,000 条数据的列表,每条数据渲染成一个 <div>,高度 50px。你把这 10,000 个 div 全塞进页面里。
浏览器做了什么?
1.创建 10,000 个 DOM 节点2.为每个节点计算样式3.为每个节点做布局计算(layout / reflow)4.为每个节点绘制像素(paint)5.把所有像素合成到屏幕上(composite)
但用户的屏幕高度可能只有 800px。也就是说,用户一次最多能看到 16 条数据(800 / 50 = 16)。
你让浏览器渲染了 10,000 个节点,用户只看到了 16 个。剩下的 9,984 个节点的计算和绘制全是浪费——它们在屏幕外,谁也看不见。
DOM 节点不是免费的。每个节点是一个完整的对象,浏览器要维护它的样式、布局、事件系统、可访问性树。10,000 个节点的列表,光内存就吃掉几十 MB,滚动时每次 reflow 都要把这 10,000 个节点重新算一遍。你会看到明显的卡顿。
这就是问题。
传统分页不行吗?
合理的问题。很多场景下,滚到底加载下一页确实就够了。
关键区别在于 DOM 节点的增长方式:
•滚到底加载下一页:DOM 节点是累加的。第 1 页 50 个节点,滚到底加载第 2 页变成 100 个,再滚变 150 个。用户翻了 50 页,页面上有 2,500 个节点。虽然大部分在视口外看不见,但浏览器还在维护它们——样式、布局树、事件系统。每次滚动触发的 reflow,计算范围也越来越大。•虚拟滚动:DOM 节点是恒定的。不管用户滚了多远,DOM 里始终只有视口内那十几个节点。
一个是线性增长,一个是不管多远都是常数。
那传统方式到底什么时候扛不住?粗略的经验值:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
所以如果你的场景是"用户翻几页就走了"——商品列表、搜索结果——传统分页完全够用。用户不会翻到第 50 页,DOM 也就几百个节点,不是问题。
虚拟滚动真正该用的场景是:用户需要在大量数据中连续滚动浏览,而且很可能滚到很远。
•日志查看器:几千条日志,运维从头滚到尾找问题•大型表格/数据网格:财务系统、CRM 里的数据表,几百行到上万行•音乐/文件列表:音乐库有 5,000 首歌,按列表浏览•时间轴/聊天记录:一个群聊了几万条消息,滚动查看历史
共同点:数据量大,用户会持续滚动,而且数据通常已经全部在手里了。
还有个中间方案——虚拟滚动 + 懒加载:数据分批从服务端拉(每次 500 条),存在内存里(只是 JS 数组,不是 DOM 节点,成本很低),渲染层用虚拟滚动,DOM 始终只有可见节点,用户快滚到底部时再拉下一批。两者各取所长。
第一性原理:我们到底需要什么
把问题拆到最基本,只留你确定成立的事实:
•用户需要看到什么? 只有视口(viewport)内的那几个 item。视口外的看不见。•用户需要感受到什么? 滚动条的行为和真实长列表一样——滚动距离、滚动速度、滚到某个位置看到对应内容。•浏览器需要做什么? 只渲染视口内的节点。
从这三条出发,方案就清晰了:只渲染看得见的,但让滚动条以为全部都渲染了。
"让滚动条以为全部都渲染了"——怎么做?你需要一个占位元素,它的总高度等于所有 item 加起来的高度。这样滚动条的长度、拖动范围就和真实列表完全一致。
然后在这个占位元素内部,根据当前滚动位置,算出该显示哪些 item,只渲染这几个。
从零造一个
我们一步步来。如果你能跟着走完,你就真的理解了虚拟滚动——不是知道这个名字,而是能从零重建它。
第一步:搭骨架
<div id="container" style="height: 600px; overflow-y: auto;"><div id="content"></div></div>
container 是滚动容器,固定高度 600px,超出部分滚动。content 是内容区域,我们会动态调整它的高度和内部节点。
第二步:造数据
const itemCount = 10000;const itemHeight = 50;const data = Array.from({ length: itemCount }, (_, i) => `Item ${i + 1}`);
10,000 条数据,每条 50px 高。
第三步:让滚动条正确
const container = document.getElementById('container');const content = document.getElementById('content');// 让内容区域的总高度 = 所有 item 的总高度content.style.height = `${itemCount * itemHeight}px`;content.style.position = 'relative';
现在容器里有一个高度为 500,000px(10,000 × 50)的空 div。滚动条的长度正确了——用户拖动滚动条时,范围和真实列表完全一致。
但里面什么都没有。接下来填东西。
第四步:只渲染看得见的
function render() {const scrollTop = container.scrollTop;const containerHeight = container.clientHeight;// 计算可见区域的起始和结束索引const startIndex = Math.floor(scrollTop / itemHeight);const endIndex = Math.min(itemCount - 1,Math.ceil((scrollTop + containerHeight) / itemHeight) - 1);// 只渲染可见的 itemconst html = [];for (let i = startIndex; i <= endIndex; i++) {html.push(`<div style="height: ${itemHeight}px;position: absolute;top: ${i * itemHeight}px;display: flex;align-items: center;padding: 0 16px;box-sizing: border-box;border-bottom: 1px solid #eee;">${data[i]}</div>`);}content.innerHTML = html.join('');}
关键逻辑拆解:
•scrollTop 是用户当前滚动了多少像素。如果滚了 200px,每个 item 50px,那第一个可见的是第 4 个(Math.floor(200 / 50) = 4)。•endIndex 是可见区域的最后一个 item。scrollTop + containerHeight 是可见区域的底边,除以 itemHeight 向上取整,再减 1(因为索引从 0 开始)。•每个 item 用 position: absolute + top: i * itemHeight 定位到它"应该在"的位置。这样 item 4 会出现在 200px 处——正好是用户滚动到的位置。
第五步:监听滚动
container.addEventListener('scroll', render);render(); // 初始渲染
用户滚动时,重新计算可见区域,替换 content 的内容。
完整的代码跑起来,10,000 条数据的列表和 16 条数据的列表在 DOM 节点数量上没有区别——始终只有十几个节点。滚动流畅,内存占用极低。
核心就这几行。没有魔法。
这就好了吗?基本好了,但有几个现实问题
上面的代码假设了一个理想条件:每个 item 高度一样。真实世界没那么简单。
问题一:快速滚动时闪白
用户快速滚动时,新 item 的渲染可能跟不上滚动速度,导致视口里出现一片空白——旧 item 滚走了,新 item 还没渲染出来。
解决方法:在可见区域的上下各多渲染几个 item,叫 overscan(缓冲区)。
const overscan = 5;function render() {const scrollTop = container.scrollTop;const containerHeight = container.clientHeight;const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);const endIndex = Math.min(itemCount - 1,Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan);// ... 渲染逻辑不变}
上下各多渲染 5 个,给滚动留出缓冲。用户滚到新位置时,缓冲区的 item 已经在那里等着了。代价是多渲染 10 个节点——相比于 10,000 个,忽略不计。
问题二:item 高度不固定
这是最棘手的问题。真实场景中列表项高度往往不一样——有的标题长换了两行,有的短只有一行。
固定高度的方案很好算,因为 index × itemHeight 直接得到位置。但高度不固定时,你得先知道每个 item 多高才能算它的位置——而你不渲染它就不知道它多高。先有鸡还是先有蛋。
几种解法:
方案 A:预估高度 + 测量修正
先用一个预估高度算位置,渲染后用 ResizeObserver 或 getBoundingClientRect() 测量真实高度,缓存起来,后续用真实高度算位置。
const measuredHeights = {}; // 缓存已测量的高度const estimatedHeight = 50;function getItemHeight(index) {return measuredHeights[index] ?? estimatedHeight;}function getOffset(index) {// 累加前面所有 item 的高度let offset = 0;for (let i = 0; i < index; i++) {offset += getItemHeight(i);}return offset;}
问题:getOffset 是 O(n) 的——列表越长越慢。优化方法是用前缀和数组:每测量一个新高度就更新前缀和,查找时二分搜索,复杂度降到 O(log n)。
方案 B:二分查找定位
如果你维护了一个"已测量高度 + 预估高度"的前缀和数组,可以用二分查找快速定位某个滚动位置对应哪个 item——不需要遍历。
// prefixSum[i] = 前 i 个 item 的累计高度function findStartIndex(scrollTop) {let lo = 0, hi = itemCount;while (lo < hi) {const mid = (lo + hi) >> 1;if (prefixSum[mid] <= scrollTop) lo = mid + 1;else hi = mid;}return lo - 1;}
方案 C:强制统一高度
如果产品允许,给每个 item 设置固定高度,内容超出截断或省略。最简单,也最快。很多表格类组件就是这么做的。
问题三:滚动跳动
高度不固定时,预估高度和真实高度有差异。用户滚动过程中,你不断测量真实高度、更新总高度,导致 content 的 height 在变化,滚动条位置会跳动——用户感觉列表在"抖"。
解决思路:在测量到真实高度后,需要修正当前滚动位置。比如某个 item 预估 50px、实际 80px,多出 30px,如果这个 item 在当前滚动位置上方,你需要把 scrollTop 加 30px 来补偿。这个补偿逻辑很精细,处理不好反而更抖。
问题四:键盘导航和可访问性
用户用键盘的 Page Down、Home、End 键滚动时,浏览器需要知道列表的总高度和当前位置。虚拟滚动用 spacer 撑高度,基本能支持,但屏幕阅读器可能无法正确朗读被跳过的 item——因为它们不在 DOM 里。解法是加 aria-setsize 和 aria-posinset 属性,告诉辅助技术"这是第 N 个,总共 M 个"。
横向虚拟滚动
原理完全一样,只是把垂直方向换成水平方向:
•scrollTop 换成 scrollLeft•clientHeight 换成 clientWidth•itemHeight 换成 itemWidth•top 定位换成 left 定位
很多场景需要双向虚拟滚动(比如大型表格:行和列都虚拟化),思路是两个方向各算各的,取交集。
真实项目该用什么
你理解了原理,不代表每次都要从零造。实际项目中有成熟的库,它们处理了动态高度、双向滚动、网格布局、sticky header 等你暂时不需要自己解决的复杂情况:
|
|
|
|
|
react-window |
|
|
react-virtualized |
|
|
vue-virtual-scroller |
|
|
@tanstack/virtual |
选库的时候,先想清楚你的需求:高度固定还是可变?单向还是双向?需不需要 sticky?不要一上来就上最重的库——如果你的 item 高度固定,react-window 的 FixedSizeList 几行配置就够了,比你手写也多不了多少代码。
但你现在知道这些库内部在做什么了——和我们在上面写的那几十行代码,核心是同一个东西。
什么时候不该用虚拟滚动
虚拟滚动不是银弹。有些场景用了反而更复杂:
•列表只有几百条:直接渲染就行,浏览器处理几百个节点毫无压力。加虚拟滚动反而增加了滚动时的计算开销和代码复杂度。•item 需要被搜索定位:虚拟滚动后,不在视口内的 item 不在 DOM 里,浏览器的 Ctrl+F 搜不到。需要自己实现搜索逻辑。•需要打印整个列表:打印时只会打印 DOM 中存在的节点,虚拟滚动的列表打印出来只有可见部分。•SEO 很重要:搜索引擎爬虫看到的是空 spacer,不是真实内容。服务端渲染可以缓解,但虚拟滚动本身不解决这个问题。
核心就一句话
只渲染看得见的,让滚动条以为全部都渲染了。
剩下的所有代码——overscan、动态高度测量、前缀和、二分查找、位置修正——都是在处理这个核心思路和真实世界之间的摩擦。核心思路本身,十行代码就能说清楚。




