前言
一个「消息记录」页面,产品说要支持无限滚动,数据量 10 万条。
前端同学很自然地写了:
jsx
{list.map(item => <MessageItem key={item.id} data={item} />)}
本地 500 条数据跑得飞快,一上灰度就炸了:首屏白屏 4 秒,滚动帧率掉到 12fps,Chrome 内存涨到 1.2G。
原因不复杂------10 万条数据就是 10 万个 DOM 节点。浏览器要为每个节点做样式计算、布局、绘制,这个成本是线性叠加的。
解法是虚拟列表:只渲染可视区域内的那几十个节点。
网上讲定高虚拟列表的文章很多,二十行代码就能跑通。但真实业务里,列表项高度几乎从来不是固定的------一条消息可能一行,也可能带图片带引用占五行。不定高才是虚拟列表真正的难点。
这篇文章从定高讲到不定高,把每一处坑都摊开讲清楚,代码可直接使用。
一、先算一笔账:为什么必须虚拟化
先明确虚拟列表到底省了什么。
| 方案 | DOM 节点数 | 首屏渲染 | 滚动帧率 | 内存 |
|---|---|---|---|---|
| 全量渲染 10 万条 | 100000+ | 3.8s | 10~15fps | ~1.2G |
| 分页加载(每页 50) | 递增 | 快 | 越滚越卡 | 递增 |
| 虚拟列表 | ~30 | 60ms | 60fps | 稳定 ~80M |
分页加载看似缓解了首屏问题,但它只是把痛苦推迟了------用户滚到第 50 页时,DOM 里依然堆着 2500 个节点。
虚拟列表的核心是让 DOM 数量与数据总量解耦,恒定在可视区能容纳的数量级。
二、定高虚拟列表:建立基本模型
定高场景下一切都可以直接算出来。三个核心公式:
ini
startIndex = floor(scrollTop / itemHeight)
visibleCount = ceil(containerHeight / itemHeight)
offsetY = startIndex * itemHeight
结构上需要三层:
- 容器层 :固定高度 +
overflow: auto,负责产生滚动 - 占位层 :高度 =
总条数 × 行高,负责把滚动条撑到正确长度 - 内容层 :绝对定位,通过
transform: translateY(offsetY)顶到正确位置
jsx
import { useState, useRef, useMemo } from 'react';
function FixedVirtualList({ data, itemHeight = 60, height = 600, renderItem }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
// 上下各多渲染 3 条,避免快速滚动时露白
const OVERSCAN = 3;
const { start, end, offsetY } = useMemo(() => {
const visibleCount = Math.ceil(height / itemHeight);
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - OVERSCAN);
const end = Math.min(data.length, start + visibleCount + OVERSCAN * 2);
return { start, end, offsetY: start * itemHeight };
}, [scrollTop, data.length, itemHeight, height]);
const visibleData = data.slice(start, end);
return (
<div
ref={containerRef}
style={{ height, overflow: 'auto', position: 'relative' }}
onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
>
{/* 占位层:撑开滚动条 */}
<div style={{ height: data.length * itemHeight }} />
{/* 内容层 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{visibleData.map((item, i) => (
<div key={item.id} style={{ height: itemHeight }}>
{renderItem(item, start + i)}
</div>
))}
</div>
</div>
);
}
这里有两个容易被忽略的细节:
1. 为什么用 transform 而不是 top?
top 会触发 layout(重排),transform 只触发 composite(合成),可以直接走 GPU。在每帧都要更新位置的滚动场景里,这个差别直接决定了能不能跑满 60fps。
2. key 必须用数据的稳定 id,不能用索引
用 key={i} 的话,滚动时索引会不断变化,React 的 diff 会认为是同一个节点在更新内容,导致内部 state(比如展开状态、输入框内容)串位。
三、不定高:真实业务的分水岭
定高方案的所有公式都依赖一个前提:任意 index 的位置可以 O(1) 算出来。
高度不确定时,这个前提没了。而且我们面对一个鸡生蛋的问题:
要知道一个元素多高,必须先把它渲染出来; 但虚拟列表的意义就是不渲染不可见的元素。
工程上的解法是三步走:先预估 → 渲染后测量 → 缓存并修正。
3.1 位置缓存表
维护一张表,记录每一项的 top / height / bottom,初始值全部用预估高度填充:
js
class PositionCache {
constructor(count, estimatedHeight = 80) {
this.estimated = estimatedHeight;
this.list = Array.from({ length: count }, (_, i) => ({
index: i,
height: estimatedHeight,
top: i * estimatedHeight,
bottom: (i + 1) * estimatedHeight,
measured: false, // 是否已被真实测量过
}));
}
get totalHeight() {
return this.list.length ? this.list[this.list.length - 1].bottom : 0;
}
/** 用真实高度更新某一项,并批量修正后续所有项的偏移 */
update(index, realHeight) {
const item = this.list[index];
const diff = realHeight - item.height;
if (diff === 0 && item.measured) return;
item.height = realHeight;
item.bottom = item.top + realHeight;
item.measured = true;
// 后续项整体平移 diff
for (let i = index + 1; i < this.list.length; i++) {
this.list[i].top += diff;
this.list[i].bottom += diff;
}
}
/** 二分查找:给定 scrollTop,找出第一个 bottom >= scrollTop 的项 */
findIndex(scrollTop) {
let lo = 0;
let hi = this.list.length - 1;
let res = this.list.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (this.list[mid].bottom >= scrollTop) {
res = mid;
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return res;
}
}
因为 list 按 bottom 天然单调递增,所以定位可以用二分,复杂度 O(log n)。10 万条数据只需要 17 次比较。
3.2 关于 update 的性能
上面的 update 里有个 O(n) 的循环。10 万条数据、每次测量都要遍历一遍,看着很吓人。
实际测下来这不是瓶颈,原因有三:
- 每项只测量一次 ,测完
measured = true,后续复用缓存 - 一次滚动只新增十几项需要测量,不是全量
- 纯数字加法的循环,10 万次在现代 V8 上约 0.3ms
如果确实要极致优化(比如 100 万条),可以换成树状数组(Fenwick Tree),把更新和前缀和查询都压到 O(log n):
js
class FenwickTree {
constructor(n) {
this.n = n;
this.tree = new Float64Array(n + 1);
}
add(i, delta) { // i 从 1 开始
for (; i <= this.n; i += i & -i) this.tree[i] += delta;
}
prefixSum(i) {
let s = 0;
for (; i > 0; i -= i & -i) s += this.tree[i];
return s;
}
}
但对绝大多数业务,数组 + 二分已经完全够用,不要过早优化。
3.3 用 ResizeObserver 做测量
早期方案是在 useEffect 里遍历 getBoundingClientRect()。问题是:图片加载完、字体加载完、内容异步展开,高度都会变,而这些时机拿不到回调。
ResizeObserver 才是正解------它能监听元素尺寸的任意变化:
jsx
import { useEffect, useRef } from 'react';
function MeasuredItem({ index, onResize, children }) {
const ref = useRef(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
// borderBoxSize 比 contentRect 更准(含 padding + border)
const box = entry.borderBoxSize?.[0];
const h = box ? box.blockSize : entry.contentRect.height;
onResize(index, h);
}
});
ro.observe(el);
return () => ro.disconnect();
}, [index, onResize]);
return <div ref={ref} data-index={index}>{children}</div>;
}
注意 onResize 必须用 useCallback 包裹,否则每次父组件渲染都会重新创建函数,导致 ResizeObserver 反复销毁重建。
3.4 完整实现
把上面几块拼起来:
jsx
import { useState, useRef, useMemo, useCallback, useEffect } from 'react';
export function VirtualList({
data,
height = 600,
estimatedItemHeight = 80,
overscan = 3,
renderItem,
}) {
const [scrollTop, setScrollTop] = useState(0);
const [, forceUpdate] = useState(0);
const cacheRef = useRef(null);
if (!cacheRef.current || cacheRef.current.list.length !== data.length) {
cacheRef.current = new PositionCache(data.length, estimatedItemHeight);
}
const cache = cacheRef.current;
const handleResize = useCallback((index, h) => {
const old = cache.list[index].height;
if (Math.abs(old - h) < 1) return; // 忽略亚像素抖动
cache.update(index, h);
forceUpdate(v => v + 1);
}, [cache]);
const { start, end, offsetY, totalHeight } = useMemo(() => {
const start = Math.max(0, cache.findIndex(scrollTop) - overscan);
let end = start;
let acc = 0;
// 从 start 开始累加高度,直到填满可视区
while (end < data.length && acc < height) {
acc += cache.list[end].height;
end++;
}
end = Math.min(data.length, end + overscan);
return {
start,
end,
offsetY: cache.list[start]?.top ?? 0,
totalHeight: cache.totalHeight,
};
}, [scrollTop, data.length, height, overscan, cache, /* eslint-disable-line */ cache.totalHeight]);
// 用 rAF 节流滚动事件,避免一帧内多次 setState
const tickingRef = useRef(false);
const onScroll = useCallback(e => {
const st = e.currentTarget.scrollTop;
if (tickingRef.current) return;
tickingRef.current = true;
requestAnimationFrame(() => {
setScrollTop(st);
tickingRef.current = false;
});
}, []);
return (
<div
style={{ height, overflow: 'auto', position: 'relative', willChange: 'transform' }}
onScroll={onScroll}
>
<div style={{ height: totalHeight }} />
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${offsetY}px)`,
}}
>
{data.slice(start, end).map((item, i) => (
<MeasuredItem key={item.id} index={start + i} onResize={handleResize}>
{renderItem(item, start + i)}
</MeasuredItem>
))}
</div>
</div>
);
}
四、五个真实踩过的坑
坑 1:滚动跳动(Scroll Jump)
现象:向上滚动时,列表突然向下窜一段,视觉上像弹了一下。
原因 :上方元素被测量后真实高度大于预估高度,totalHeight 变大,但 scrollTop 没变,导致可视内容整体偏移。
解法 :测量修正后,主动补偿 scrollTop:
js
const compensate = (container, diff) => {
if (diff !== 0) {
container.scrollTop += diff; // diff = 真实高度 - 预估高度
}
};
更彻底的做法是记录锚点元素:滚动前记下第一个可见项的 index 和它相对容器顶部的偏移,修正后重新定位到这个锚点。
坑 2:iOS 惯性滚动白屏
现象:iOS Safari 上快速甩动,出现大片空白。
原因 :iOS 惯性滚动期间 scroll 事件会被节流甚至暂停派发,JS 来不及更新可视区。
解法:
- 增大
overscan(移动端建议 5~8) - 内容层加
will-change: transform提前提升合成层 - 避免在滚动回调里做重计算
坑 3:预估高度设得太离谱
预估 80px、实际平均 300px 的话,初始 totalHeight 只有真实值的 1/4,滚动条一开始很长,随着测量不断变短,用户体验非常割裂。
建议:用真实数据统计一个平均值作为预估值。哪怕只是渲染前 20 条取平均,也远好过拍脑袋。
坑 4:图片撑高
图片没有显式尺寸时,加载完成会导致高度突变。
解法 :给图片容器加 aspect-ratio,让高度在图片加载前就确定:
css
.msg-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
这条其实对普通页面的 CLS(累积布局偏移)优化也同样有效。
坑 5:数据变化后缓存不失效
列表前面插入了一条数据,后面所有项的 top 都应该平移,但缓存还是老的,于是整个列表错位。
解法 :数据变化时不要粗暴地整表重建(会丢失所有测量结果),而是按稳定 id 做增量映射,只重算受影响的区间。
五、Vue 3 版本的关键差异
思路完全一致,只是 API 不同。Vue 里更自然的写法是把位置缓存做成 shallowRef,并用 computed 派生可视区间:
vue
<script setup>
import { ref, shallowRef, computed, onMounted } from 'vue';
const props = defineProps({
data: { type: Array, required: true },
height: { type: Number, default: 600 },
estimated: { type: Number, default: 80 },
});
const scrollTop = ref(0);
const version = ref(0); // 用于测量后触发重算
const cache = shallowRef(new PositionCache(props.data.length, props.estimated));
const range = computed(() => {
version.value; // 建立依赖
const c = cache.value;
const start = Math.max(0, c.findIndex(scrollTop.value) - 3);
let end = start, acc = 0;
while (end < props.data.length && acc < props.height) {
acc += c.list[end].height;
end++;
}
return { start, end: Math.min(props.data.length, end + 3) };
});
const onResize = (index, h) => {
cache.value.update(index, h);
version.value++;
};
</script>
关键点:用 shallowRef 存缓存对象,避免 Vue 对上万个元素的数组做深层响应式代理------那个开销比虚拟列表省下来的还多。
六、什么时候不该用虚拟列表
虚拟列表不是免费的,它有明确的代价:
- Ctrl+F 页内搜索失效(未渲染的内容搜不到)
- SEO 不友好(爬虫拿不到完整内容)
- 打印/导出只能拿到可视区
- 无障碍读屏需要额外处理
aria-setsize/aria-posinset - 代码复杂度显著上升
判断标准很简单:
| 数据量 | 建议 |
|---|---|
| < 200 条 | 直接渲染,别折腾 |
| 200 ~ 1000 条 | 先做 content-visibility: auto 试试 |
| > 1000 条 | 上虚拟列表 |
顺带一提,content-visibility: auto 是个被低估的方案,一行 CSS 就能让浏览器跳过屏幕外元素的渲染:
css
.list-item {
content-visibility: auto;
contain-intrinsic-size: auto 80px; /* 预估高度,避免滚动条抖动 */
}
在中等数据量下,它的效果接近虚拟列表,但成本只有一行 CSS。缺点是 DOM 节点依然存在,内存占用没省,且需要 Chrome 85+ / Safari 18+。
七、生产环境优先用成熟库
自己实现一遍对理解原理很有价值,但真上生产,建议直接用轮子:
| 库 | 适用 | 特点 |
|---|---|---|
| TanStack Virtual | React / Vue / Svelte | 框架无关,仅提供 hook,UI 完全自控 |
| react-virtuoso | React | 不定高开箱即用,支持分组、置顶 |
| vue-virtual-scroller | Vue | Vue 生态成熟方案 |
这些库已经处理了滚动锚定、RTL、嵌套滚动、动态数据源等一堆边缘情况,比自己维护划算得多。
总结
回顾整条链路:
- 定高是基本模型,三个公式 + 三层结构,二十行搞定
- 不定高的核心是「预估 → 测量 → 缓存修正」三步走
- 定位用二分查找 ,测量用 ResizeObserver
- 滚动跳动靠锚点补偿 ,移动端靠增大 overscan
- 数据量不大时,
content-visibility: auto可能是更划算的选择 - 生产环境优先用成熟库,自己实现主要用于理解原理
性能优化的本质从来不是把代码写得更炫,而是让浏览器少干活。虚拟列表只是这个思路在长列表场景下的一个具体应用。
如果你在项目里遇到过别的虚拟列表坑,欢迎在评论区交流。