一次面试,面试官问:"虚拟滚动怎么算可视区域?"我答了个大概。他接着问:"那动态高度呢?前缀和为什么要二分?"我卡住了。后来我花了两周,把这条链路从公式到代码全部跑通。这篇文章就是那两周的沉淀------从原理、图解、边界到可直接运行的 Demo,一篇讲透。
一、先把两个问题分开
很多人把"虚拟滚动"和"无限滚动"混为一谈。面试时这么答,第一句就露怯。它们解决的是两个不同的问题:
| 问题 | 虚拟滚动 | 无限滚动 |
|---|---|---|
| 核心痛点 | 渲染的 DOM 太多,页面卡 | 不知道"何时该加载下一页" |
| 解决方式 | 只渲染可视区 + 缓冲 | 用 Intersection Observer 观察底部哨兵 |
| 性能关键 | 减少渲染量、索引计算 | 避免 scroll 回调里同步查布局 |
一句话 :虚拟滚动管"渲染多少",无限滚动管"何时拉数据"。它们经常配合使用,但不能混着讲。
二、虚拟滚动:到底在解决什么?
假设你有 10000 条数据 。全量渲染,DOM 节点爆炸,滚动直接卡死。但用户屏幕一次只能看到 10 条。
虚拟滚动的思路很朴素:
只渲染用户能看到的这 10 条,再加前后几条缓冲。其他的不渲染,但用一块"空白高度"把滚动条撑起来。
text
yaml
滚动容器(可视区高度 500px)
┌─────────────────────────┐
│ ↑ 上面 9900 条,不渲染 │
│ ───────────────────── │
│ 第 5001 条 ← 真正渲染的 │
│ 第 5002 条 │
│ ... │
│ 第 5010 条 │
│ ───────────────────── │
│ ↓ 下面 4990 条,不渲染 │
└─────────────────────────┘
问题来了:用户滚动时,我怎么知道现在该渲染第几条到第几条? 这就是"动态计算可视区域"。
三、固定高度:一个除法就够了
假设每条高度都是 50px ,容器高度 500px 。用户滚了 1200px。
text
ini
1200 ÷ 50 = 24
第 24 条(从 0 开始数)正好在顶部。容器能放 500 ÷ 50 = 10 条,所以渲染第 24 到第 33 条,再加缓冲。
js
ini
startIndex = Math.floor(scrollTop / itemHeight)
endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + buffer
offsetY = startIndex * itemHeight
为什么快? 除法是常数时间,不管列表 1 万条还是 100 万条,算一次就出结果。
偏移量是什么? 你只渲染了第 24 到 33 条,但它们在页面上应该出现在"第 24 条的位置",而不是顶部。所以要把这组渲染节点往下推 24 × 50 = 1200px。
关键规则:偏移量 = 渲染区间的第一个索引 × 每条高度。永远跟渲染切片起点走,不跟可视区起点走。
一道容易错的题
scrollTop = 1000,每条 50px,容器 400px,缓冲 2 条(只加尾部),渲染哪几条?偏移多少?
text
ini
startIndex = floor(1000 / 50) = 20
visibleCount = ceil(400 / 50) = 8 → 可视区 20 ~ 27
渲染区间 = 20 ~ 27 + 2 = 20 ~ 29
偏移量 = 20 × 50 = 1000px
如果上下都加 2 条缓冲,渲染变成 18~29,偏移就变成 18 × 50 = 900px。渲染起点变了,偏移必须跟着变。
四、动态高度:为什么除法直接崩了?
真实业务里,列表项高度很少固定------消息长短不一、图片高度不同、折叠面板展开收起。
用一组具体数据看除法怎么崩的:
js
ini
heights = [40, 80, 60, 120, 60, 140, 60]
scrollTop = 200,视口顶部第一条是第几条?
硬用除法(拿平均高度 80 除):
text
ini
200 / 80 = 2.5 → 你以为第 2 条
但真实坐标是这样的:
| 索引 | 高度 | 页面坐标 |
|---|---|---|
| 0 | 40 | 0 ~ 40 |
| 1 | 80 | 40 ~ 120 |
| 2 | 60 | 120 ~ 180 |
| 3 | 120 | 180 ~ 300 |
| 4 | 60 | 300 ~ 360 |
200 落在 180 ~ 300 里,实际是第 3 条,不是第 2 条。
为什么错? 除法默认"每条一样高",但第 3 条高达 120px,把后面的坐标全推后了。平均高度只是近似,误差会累积。极端一点:
js
ini
heights = [40, 40, 40, 40, 40, 500]
scrollTop = 250
平均高度看起来很小,除法告诉你"在第 2 条附近",但实际第 5 条从 200 开始、高 500,250 明明在第 5 条里。除法彻底失效。
五、前缀和 + 二分:动态高度的正解
既然除法不行,就必须知道"前面所有项累计多高"。这就是前缀和。
5.1 前缀和是什么?
js
less
heights = [40, 80, 60, 120, 60, 140, 60]
offsets[0] = 0 // 第 0 条从 0 开始
offsets[1] = 40 // 第 1 条从 40 开始
offsets[2] = 120 // 40+80
offsets[3] = 180 // 40+80+60
offsets[4] = 300 // +120
offsets[5] = 360
offsets[6] = 500
offsets = [0, 40, 120, 180, 300, 360, 500]
offsets[i] 就是第 i 条的上边缘在页面上的坐标。
5.2 为什么能二分?------因为单调
很多人会问:"高度不同,为什么坐标还是有序的? "
因为:
js
css
offsets[i+1] = offsets[i] + heights[i]
只要 heights[i] > 0,就一定有 offsets[i+1] > offsets[i]。
高度不同,只影响"每一步跨多大",不影响"每一步都往前走"。有序的不是高度,是坐标;坐标有序不是因为排序,是因为高度恒为正、累加只增不减。
虚拟列表的高度不会为负,所以前缀和天然严格递增,不需要任何额外排序。
5.3 二分到底在比什么?
已知 scrollTop = 200,要找:
第一个满足
offsets[i] + heights[i] > 200的 i(即"覆盖 200 的那一条")
先算底部坐标:
js
ini
bottoms = [40, 120, 180, 300, 360, 500, 560]
在 bottoms 上二分找第一个 > 200 的数:
text
ini
初始: [0 ───────────────────────── 6]
第1步: [0 ───────── 3] (300 > 200,砍掉右边)
第2步: [2 ─ 3] (120 ≤ 200,砍掉左边)
第3步: [3] (180 ≤ 200,left 移到 3)
↑
答案 = 3
1 万条数据,最多约 14 次比较。这就是 O(log n)。
js
ini
function findStartIndex(scrollTop) {
let lo = 0;
let hi = TOTAL;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid] + heightFor(mid) <= scrollTop) {
lo = mid + 1; // 底部没超过 scrollTop,答案在右边
} else {
hi = mid; // 覆盖了 scrollTop,答案在左边或就是它
}
}
return Math.min(lo, TOTAL - 1);
}
易错点 :
> 目标时只能right = mid,不能mid - 1,因为mid本身可能就是答案。
5.4 动态高度的数据从哪来?------ResizeObserver
没渲染的项没有真实高度。所以前缀和里的高度是 "已测量真实值 + 未测量预估值"的混合。
js
ini
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const index = Number(entry.target.dataset.index);
const newHeight = entry.contentRect.height;
if (newHeight !== heights[index]) {
onHeightChange(index, newHeight);
}
}
});
关键认知 :高度不是"每次计算",而是 "渲染到哪条,就测哪条" 。已测过的缓存,不重复测。
5.5 高度变了,怎么更新?------增量后缀
第 k 条高度从 80 变成 120,delta = +40。只需要:
js
ini
// offsets[k+1..] 全部 +40(第 k 条之后的起点后移)
for (let i = k + 1; i <= TOTAL; i++) {
offsets[i] += delta;
}
为什么只改后缀? 因为第 k 条之前的项,位置完全没变。
text
第0条 起点 0 不变
第1条 起点 80 不变
第2条 起点 160 不变(自己起点没变,只是变高了)
第3条 起点 240 → 280 变了
第4条 起点 320 → 360 变了
滚动是高频操作,但它只做二分 O(log n);前缀和是低频操作,才做 O(n) 更新。两者不叠加。
六、滚动锚定:不修正 scrollTop,画面就会跳
这是动态高度虚拟列表区分"能用"和"好用" 的关键。
当第 k 条高度从预估变真实,且 k 在可视区上方 时,它变高会把可视区内容往下推。用户看到画面跳了一下。
解法:
js
ini
// 变化前:记录锚点
const anchorIndex = findStartIndex(scrollTop);
const anchorOffset = scrollTop - offsets[anchorIndex];
// 更新 offsets 后:把 scrollTop 修正回去
scrollTop = offsets[anchorIndex] + anchorOffset;
效果:不管第 k 条高度怎么变,用户当前看的那条内容位置不变。
同时,滚动容器要设置:
css
css
.scroll-container {
overflow-anchor: none; /* 关闭浏览器原生滚动锚定,避免两套机制打架 */
}
七、无限滚动:为什么不能用 scroll 事件?
传统写法:
js
ini
container.addEventListener('scroll', () => {
const rect = sentinel.getBoundingClientRect();
if (rect.top < window.innerHeight) loadMore();
});
问题 :scroll 事件按像素高频触发,getBoundingClientRect() 会强制浏览器立刻计算布局(同步 reflow)。滚动回调里反复查布局,主线程极易掉帧。
Intersection Observer 快在哪?
最核心两点:
第一,避免在滚动回调里强制同步布局。 IO 把"交叉判断"交给浏览器底层调度,不需要你在 JS 里同步查布局。
第二,触发频率从"每次滚动"降到"状态变化"。 scroll 滚 1000px 可能触发几十次,IO 只在"进入/离开"时各触发一次。
js
scss
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) loadMore();
}, {
root: container,
rootMargin: '0px 0px 200px 0px', // 提前 200px 触发
threshold: 0
});
io.observe(sentinel);
IO 快,不是因为"不算布局",而是因为它不在高频 scroll 回调里同步算布局,并且只在状态变化时通知你。
四个工程细节
| 细节 | 做法 | 原因 |
|---|---|---|
| rootMargin | 底部提前 200px | 给网络请求留提前量,但别太大,否则一次触发多页 |
| 单例 observer | 只建一个,回调用 ref 拿最新函数 | 避免每次 loadMore 重建 observer |
| 及时 unobserve | 加载完 io.unobserve(sentinel) |
防止无效触发和内存泄漏 |
| loading 标志位 | 请求中不重复触发 | 防止快速滚动重复请求 |
八、最容易被忽略的边界:大跳转到未渲染区域
用户从顶部直接拖滚动条到第 8000 条,但第 8000 条附近从来没渲染过,全是预估值。会发生什么?
两个独立的问题
问题 A:定位偏差。 真实平均高度 100px,预估 80px。用户想看第 8000 条,正确坐标 800000,但预估前缀和里 800000 对应第 10000 条。偏差 2000 条。
问题 B:渲染后跳动。 假设定位碰巧准了,渲染后测出真实高度 100px,比预估多 20px。可视区内容下移 20px,画面跳动。
偏差是"定位错了",跳动是"定位对了但画面不稳"。这是两个问题,解法不同。
工程上怎么兜底?
| 手段 | 解决什么 | 做法 |
|---|---|---|
| 预估值更准 | 偏差 | 样本测量、按类型分档、动态校准 |
| 拖动中近似 + 停下后校准 | 偏差 | 拖动中快速定位,debounce 100ms 后精确修正 |
| 滚动锚定 | 跳动 | 记录锚点,更新后修正 scrollTop |
| 惰性校准 | 全局误差 | 不追求全局准确,只保证可视区及附近准确 |
核心取舍:
不追求全局前缀和 100% 准确,只保证"当前可视区及附近"准确。 用户滚到哪,哪就变准。
锚定救不了偏差。 锚定只保证"当前可视区不跳",不保证"定位到用户想去的地方"。偏差要靠预估值更准或用户自己微调。面试时能区分这两者,说明你真的想通了。
九、可运行 Demo(Vue 3 单文件,双击即可跑)
下面是一个单 HTML 文件、CDN 引入 Vue 3 的完整 Demo,覆盖了上面所有点:ResizeObserver 测高、前缀和、二分定位、滚动锚定、IO 无限滚动、大跳转边界。
html
ini
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Vue3 动态高度虚拟列表 + 无限滚动</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, sans-serif; background: #f0f2f5; padding: 20px; display: flex; justify-content: center; }
.app { width: 480px; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.12); overflow: hidden; }
.header { padding: 14px 18px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; font-size: 13px; color: #666; }
.header strong { color: #1a1a1a; font-size: 15px; }
.scroll-container { height: 560px; overflow-y: auto; position: relative; overflow-anchor: none; }
.phantom { position: relative; width: 100%; }
.slice { position: absolute; top: 0; left: 0; width: 100%; will-change: transform; }
.item { padding: 14px 18px; border-bottom: 1px solid #f0f0f0; }
.item .idx { display: inline-block; font-size: 11px; font-weight: 700; color: #8b5cf6; background: #f3e8ff; padding: 2px 8px; border-radius: 99px; margin-bottom: 6px; }
.item .title { font-size: 14px; font-weight: 600; margin-bottom: 4px; }
.item .desc { font-size: 12px; color: #888; line-height: 1.5; }
.sentinel { height: 1px; }
.loading-bar { text-align: center; padding: 12px; font-size: 12px; color: #aaa; }
.controls { padding: 10px 18px; border-top: 1px solid #eee; display: flex; gap: 8px; }
.controls button { flex: 1; padding: 8px; border: 1px solid #ddd; background: #fafafa; border-radius: 8px; font-size: 12px; cursor: pointer; }
.controls button.primary { background: #8b5cf6; border-color: #8b5cf6; color: #fff; }
</style>
</head>
<body>
<div id="app">
<div class="app">
<div class="header">
<strong>动态高度虚拟列表</strong>
<span>{{ statText }}</span>
</div>
<div ref="containerRef" class="scroll-container" @scroll.passive="onScroll">
<div class="phantom" :style="{ height: phantomHeight + 'px' }">
<div class="slice" :style="{ transform: `translateY(${offsetY}px)` }">
<div v-for="item in renderItems" :key="item.index"
:ref="(el) => setItemRef(el, item.index)" class="item">
<span class="idx">#{{ item.index }}</span>
<div class="title">{{ item.data.title }}</div>
<div class="desc" :style="{ minHeight: item.data.heightHint - 70 + 'px' }">
{{ item.data.desc }}
</div>
</div>
</div>
</div>
<div ref="sentinelRef" class="sentinel"></div>
<div v-if="loading" class="loading-bar">加载中...</div>
</div>
<div class="controls">
<button @click="jumpTo(8000)">跳到第 8000 条</button>
<button @click="jumpTo(0)">回到顶部</button>
<button class="primary" @click="resetAll">重置</button>
</div>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script>
const { createApp, ref, computed, onMounted, onBeforeUnmount, nextTick, shallowRef } = Vue;
const App = {
setup() {
const TOTAL = 10000;
const ESTIMATED_HEIGHT = 80;
const BUFFER = 3;
const rawData = Array.from({ length: TOTAL }, (_, i) => ({
id: i,
title: `第 ${i} 条数据`,
desc: `这是第 ${i} 条数据的描述。高度动态变化,模拟真实业务场景。`,
heightHint: 44 + Math.floor(Math.random() * 176)
}));
const measuredHeights = new Map();
const offsets = new Float64Array(TOTAL + 1);
let observedEls = new WeakSet();
let rafId = 0;
let io = null;
let ro = null;
const loadedCount = ref(200);
const hasMore = ref(true);
const loading = ref(false);
const renderStart = ref(0);
const renderEnd = ref(0);
const renderItems = shallowRef([]);
const offsetY = ref(0);
const phantomHeight = ref(0);
const containerRef = ref(null);
const sentinelRef = ref(null);
const statText = computed(
() => `已加载 ${loadedCount.value} 条 · 渲染 ${renderStart.value}~${Math.max(0, renderEnd.value - 1)}`
);
function heightFor(i) {
const m = measuredHeights.get(i);
return m != null ? m : ESTIMATED_HEIGHT;
}
function rebuildOffsets() {
let acc = 0;
offsets[0] = 0;
for (let i = 0; i < TOTAL; i++) {
offsets[i] = acc;
acc += heightFor(i);
}
offsets[TOTAL] = acc;
}
function applyHeightDelta(k, delta) {
if (!delta) return;
for (let i = k + 1; i <= TOTAL; i++) offsets[i] += delta;
}
function findStartIndex(scrollTop) {
let lo = 0, hi = TOTAL;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (offsets[mid] + heightFor(mid) <= scrollTop) lo = mid + 1;
else hi = mid;
}
return Math.min(lo, TOTAL - 1);
}
function render() {
const el = containerRef.value;
if (!el) return;
const st = el.scrollTop, vh = el.clientHeight;
const limit = Math.min(TOTAL, loadedCount.value);
let first = findStartIndex(st);
if (first >= limit) first = Math.max(0, limit - 1);
let end = first;
while (end < limit) {
if (offsets[end] + heightFor(end) > st + vh) break;
end++;
}
end = Math.min(end + BUFFER, limit);
let start = Math.max(0, first - BUFFER);
if (start >= end) start = Math.max(0, end - 1);
renderStart.value = start;
renderEnd.value = end;
const items = [];
for (let i = start; i < end; i++) items.push({ index: i, data: rawData[i] });
renderItems.value = items;
offsetY.value = offsets[start];
phantomHeight.value = offsets[limit];
}
function setItemRef(el, index) {
if (!el || !ro) return;
el._index = index;
if (observedEls.has(el)) return;
observedEls.add(el);
ro.observe(el);
}
function onResize(entries) {
const el = containerRef.value;
if (!el) return;
const stBefore = el.scrollTop;
const anchorIndex = findStartIndex(stBefore);
const anchorOffset = stBefore - offsets[anchorIndex];
let needsRender = false, minChanged = Infinity;
for (const entry of entries) {
const target = entry.target;
const index = target._index;
if (index == null) continue;
const newH = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height;
const oldH = measuredHeights.get(index);
if (oldH == null) {
const delta = newH - ESTIMATED_HEIGHT;
measuredHeights.set(index, newH);
if (delta !== 0) {
applyHeightDelta(index, delta);
needsRender = true;
if (index < minChanged) minChanged = index;
}
} else if (Math.abs(newH - oldH) > 0.5) {
const delta = newH - oldH;
measuredHeights.set(index, newH);
applyHeightDelta(index, delta);
needsRender = true;
if (index < minChanged) minChanged = index;
}
}
if (needsRender) {
if (minChanged <= anchorIndex) el.scrollTop = offsets[anchorIndex] + anchorOffset;
render();
}
}
function onScroll() {
if (rafId) return;
rafId = requestAnimationFrame(() => { rafId = 0; render(); });
}
function setupInfinite() {
if (io) io.disconnect();
if (!sentinelRef.value) return;
io = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) return;
if (loading.value || !hasMore.value) return;
loadMore();
}, { root: containerRef.value, rootMargin: '0px 0px 200px 0px', threshold: 0 });
io.observe(sentinelRef.value);
}
function loadMore() {
if (loading.value || !hasMore.value) return;
loading.value = true;
setTimeout(() => {
loadedCount.value = Math.min(TOTAL, loadedCount.value + 200);
loading.value = false;
if (loadedCount.value >= TOTAL) {
hasMore.value = false;
if (io) io.unobserve(sentinelRef.value);
}
render();
}, 300);
}
function jumpTo(index) {
const el = containerRef.value;
if (!el) return;
if (index >= loadedCount.value) {
loadedCount.value = Math.min(TOTAL, index + 100);
phantomHeight.value = offsets[loadedCount.value];
}
nextTick(() => { el.scrollTop = offsets[index]; render(); });
}
function resetAll() {
if (io) { io.disconnect(); io = null; }
if (ro) ro.disconnect();
ro = new ResizeObserver(onResize);
observedEls = new WeakSet();
measuredHeights.clear();
rebuildOffsets();
loadedCount.value = 200;
hasMore.value = true;
loading.value = false;
renderStart.value = 0;
renderEnd.value = 0;
renderItems.value = [];
offsetY.value = 0;
phantomHeight.value = 0;
if (containerRef.value) containerRef.value.scrollTop = 0;
nextTick(() => { render(); setupInfinite(); });
}
onMounted(() => {
ro = new ResizeObserver(onResize);
rebuildOffsets();
render();
nextTick(() => setupInfinite());
});
onBeforeUnmount(() => {
if (ro) ro.disconnect();
if (io) io.disconnect();
if (rafId) cancelAnimationFrame(rafId);
});
return {
statText, containerRef, sentinelRef, phantomHeight, offsetY,
renderItems, loading, onScroll, setItemRef, jumpTo, resetAll
};
}
};
createApp(App).mount('#app');
</script>
</body>
</html>
运行方式 :保存为 index.html,双击用 Chrome 打开即可。如果 CDN 不通,把 unpkg.com 换成 cdn.jsdelivr.net/npm/vue@3/dist/vue.global.prod.js。
十、面试怎么答?
按这个结构说,面试官会觉得你有体系:
先分问题:虚拟滚动解决"渲染太多",无限滚动解决"何时加载"。
再讲方案:固定高度用除法 O(1);动态高度用 ResizeObserver + 前缀和 + 二分 O(log n);无限滚动用 IO 哨兵替代 scroll 监听。
再讲权衡:固定高度快但受限;动态高度灵活但要处理测量、锚定;IO 异步低开销,但要注意单例、rootMargin、unobserve。
最后讲坑:快速滚动白屏、高度变化跳动、大跳转偏差、重复请求。
三个高频追问的标准答案:
- 为什么不用 scroll? scroll 高频触发,
getBoundingClientRect强制同步布局;IO 由浏览器异步调度,只在状态变化时回调。 - 动态高度为什么二分? 前缀和单调递增,找"第一个底部超过 scrollTop"就是有序数组查找,O(log n)。
- 大跳转偏差怎么办? 预估值更准 + 拖动中近似停下后校准;跳动靠滚动锚定,偏差靠预估,两者独立。
十二、总结
把整条链路浓缩成一张图:
text
markdown
数据 → 预估值/实测值 → 前缀和 → 滚动时二分定位 → 渲染切片 + 偏移
↑
ResizeObserver 测高 → 增量更新后缀 → 滚动锚定
↑
IntersectionObserver 哨兵 → 拉下一页
三句话记住:
- 固定高度靠除法,动态高度靠前缀和 + 二分。 有序的不是高度,是坐标。
- 滚动只做 O(log n) 二分,前缀和只在高度变化时增量更新后缀。 两者不叠加。
- 偏差靠预估值,跳动靠锚定。 大跳转是预估值误差最大的场景,靠"拖动中近似 + 停下后校准 + 锚定防跳"兜底。