背景
最近在研究虚拟列表,对于这个前端考点,我们来一起研究它的简单实现
任务拆分
就像小品台词一样:要想实现一个虚拟列表总共分几步??
答案: 4步
第一步:创建容器 & 监听滚动 - 算滚动距离 scrollTop
为什么要算 scrollTop?
因为我们知道,其实虚拟列表就是一种顺滑的翻页,我们得知道滑了几页了,才能渲染当前页的数据 所以需要当前的startIndex,那么怎么计算出来呢?
我们得随时监听滑动距离,
我们从头往下捋,当累计高度> scrollTop的位置,就是 startIndex
如何实现呢?
基础版
useEffect(() => {
const el = containerRef.current;
const onScroll = () => {
// 把更新 scrollTop 交给调度器,一帧只执行一次
setScrollTop(el.scrollTop)
};
// passive: true → 不阻塞浏览器滚动,性能更好
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
进阶版
基础版有什么问题呢?
我们知道 只要我们开始滑动,onScroll 事件会非常频繁的触发,有一种办法是做节流,但是有一种更好的办法是对它做帧级别的防抖
先看下实现
// ==============================================
// 调度器(核心优化:防止滚动时频繁更新导致卡顿)
// 作用:把多次 setState 合并到一帧执行,保证 60fps
// ==============================================
const Scheduler = {
queue: [], // 存放要执行的任务
running: false, // 是否正在执行
rafId: null, // requestAnimationFrame 的标识
// 添加任务到队列
push(task) {
this.queue.push(task);
this.flush();
},
// 执行任务:一帧最多执行 16ms(保证流畅)
flush() {
if (this.running) return;
this.running = true;
const run = () => {
// 一帧时间 16ms,到点就停,不阻塞浏览器
const end = performance.now() + 16;
while (this.queue.length && performance.now() < end) {
this.queue.shift()();
}
// 还有任务 → 下一帧继续
if (this.queue.length) {
this.rafId = requestAnimationFrame(run);
} else {
this.running = false;
}
};
// 取消上一帧,避免重复执行
cancelAnimationFrame(this.rafId);
this.rafId = requestAnimationFrame(run);
},
};
关键在最后两句:
只要一帧内不停的触发,我只执行最后一次
总结:节流是妥协,帧级防抖才是原生级流畅!
1. 普通节流(throttle)
throttle(fn, 16)
原理:
- 不管触发多少次,每隔 16ms 才执行一次
- 时间是人为定死的
缺点:
- 时间是人工估算的,不一定和浏览器渲染同步
- 容易出现:
- 执行早了 → 卡
- 执行晚了 → 掉帧、不跟手
- 不是和浏览器“同频”
2. 帧级防抖(真正的王者)
cancelAnimationFrame(id)
requestAnimationFrame(fn)
它的真正强大之处:
① 天然和浏览器 60fps 完全对齐
- 一帧 16ms
- 只在渲染前执行
- 时间绝对精准,不是人工估算
② 滚动期间:只保留最后一次更新
- 疯狂滚动 → 不断取消旧更新
- 只保留最新、最准确的一次
- 计算量最少 → 最不卡
③ 滚动停止:下一帧立刻更新
- 无延迟、不抖动
- 视图永远是最新状态
④ 不丢帧、不错位、视觉最顺滑
3. 为什么帧级防抖 > 节流?(核心区别)
节流:定时执行(人定的)
帧级防抖:按浏览器节奏执行(浏览器定的)
- 节流:强行限制频率
- 帧防抖:和渲染同步,只执行必要的更新
流畅度差距巨大!
第二步:计算每一项高度(动态高度核心)
基础版
const measureItem = (idx: number, el: HTMLDivElement | null) => {
if (!el) return;
const h = el.getBoundingClientRect().height;
heightsRef.current[idx] = h;
};
进阶版
const observeItem = useCallback((el, index) => {
// 固定高度 / 没有元素 → 不监听
if (isFixedHeight || !el) return;
// 已经监听 → 不重复监听
if (observedMap.current.has(el)) return;
observedMap.current.set(el, index);
// 第一次创建 ResizeObserver
if (!ro.current) {
ro.current = new ResizeObserver((entries) => {
entries.forEach(entry => {
const idx = observedMap.current.get(entry.target);
// 高度变化 → 更新
updateItemHeight(idx, entry.contentRect.height);
});
});
}
// 开始监听当前元素
ro.current.observe(el);
}, [isFixedHeight, updateItemHeight]);
一、第一段:基础版 measureItem
功能
渲染完成后,立刻获取元素高度。
const measureItem = (idx: number, el: HTMLDivElement | null) => {
if (!el) return;
// 获取元素真实高度
const h = el.getBoundingClientRect().height;
// 存入高度缓存
heightsRef.current[idx] = h;
};
原理
- 元素渲染 → ref 存在 → 立刻读高度
- 只执行一次
- 同步、阻塞式
优点
- 简单
- 直观
- 一次测量够用
缺点(致命)
- 元素高度变化不会重新测量 折叠面板、图片加载、富文本变化 → 高度变了 → 列表直接错位
- 图片加载完成后高度变化无法感知
- 窗口 resize 后变化无法感知
适用场景
高度永远不变的简单动态列表
二、第二段:进阶版 observeItem
功能
监听元素尺寸变化,自动更新高度,永久保持正确。
这是工业级虚拟列表必须用的方案。
const observeItem = useCallback((el, index) => {
// 1. 固定高度不需要监听
if (isFixedHeight || !el) return;
// 2. 防止重复监听(性能关键)
if (observedMap.current.has(el)) return;
observedMap.current.set(el, index);
// 3. 全局唯一监听实例(性能爆炸强)
if (!ro.current) {
ro.current = new ResizeObserver((entries) => {
entries.forEach(entry => {
const idx = observedMap.current.get(entry.target);
// 4. 高度变化 → 自动更新整个列表的位置
updateItemHeight(idx, entry.contentRect.height);
});
});
}
// 5. 开始监听元素
ro.current.observe(el);
}, [isFixedHeight, updateItemHeight]);
🔥 这段代码的精妙之处(面试必问)
1. ResizeObserver:浏览器原生监听尺寸变化
- 元素高度变化 自动触发
- 图片加载完成
- 文字展开/收起
- 窗口 resize
- 富文本渲染完成
全部能捕获!
2. 全局唯一实例,性能极高
整个列表只创建一个 ResizeObserver 监听所有元素,而不是每个元素创建一个。
性能提升 10~100 倍。
3. Map 防止重复监听
避免反复 observe 同一个元素。
4. 高度变化自动更新
调用 updateItemHeight
- 修改当前项高度
- 偏移后面所有项的 top
- 更新总高度
- 触发视图刷新
列表永远不会错位!
5. 固定高度直接跳过,极致优化
三、两者最核心区别
| 方式 | 测量时机 | 高度变化 | 图片/折叠/resize | 工业级可用 |
|---|---|---|---|---|
| measureItem | 渲染一次 | 不更新 | 不支持 | ❌ 不可用 |
| observeItem | 持续监听 | 自动更新 | 完美支持 | ✅ 生产可用 |
第三步:计算 startIndex 和 endIndex(最关键)
visibleItems = 全部数据.slice(startIndex, endIndex)
基础版
const getStartIndex = () => {
let sum = 0;
const h = heightsRef.current;
for (let i = 0; i < h.length; i++) {
sum += h[i] || 50; // 预估默认高度50
if (sum > scrollTop) {
return Math.max(0, i - buffer);
}
}
return 0;
};
进阶版
// ==============================================
// 4. 二分查找:找到当前滚动位置对应的起始项
// 比遍历快非常多
// ==============================================
const findStartIndex = useCallback(() => {
const arr = positions.current;
let l = 0, r = arr.length - 1;
// 二分查找:找最后一个 top <= scrollTop 的项
while (l <= r) {
const mid = (l + r) >> 1; // 等价 Math.floor((l+r)/2)
if (arr[mid].top <= scrollTop) l = mid + 1;
else r = mid - 1;
}
// 结果就是可视区第一项
return Math.max(0, r);
}, [scrollTop]);
一、先看本质区别(最重要)
基础版(遍历)
从头开始一项一项加高度,直到超过 scrollTop 复杂度:O(n)
进阶版(二分)
利用提前算好的 top 数组,每次砍掉一半查找 复杂度:O(log n)
二、基础版遍历查找(getStartIndex)
const getStartIndex = () => {
let sum = 0;
const h = heightsRef.current;
for (let i = 0; i < h.length; i++) {
sum += h[i] || 50; // 累加高度
if (sum > scrollTop) {
return i - buffer; // 找到了
}
}
return 0;
};
它在干嘛?
像翻书一样从头翻: 第1项多高?加起来。 第2项多高?加起来。 第3项多高?加起来…… 直到总和超过滚动距离,找到了!
缺点
- 1万条要算1万次
- 10万条要循环10万次
- 滚动越往下,越慢、越卡
- 属于暴力解法
三、进阶版二分查找(findStartIndex)
const findStartIndex = useCallback(() => {
const arr = positions.current;
let l = 0, r = arr.length - 1;
while (l <= r) {
const mid = (l + r) >> 1;
if (arr[mid].top <= scrollTop) l = mid + 1;
else r = mid - 1;
}
return Math.max(0, r);
}, [scrollTop]);
它在干嘛?
positions 里提前存好了每一项的 top:
[0, 100, 200, 300, 400, 500...]
这是天然有序数组! 二分查找就是: 每次取中间 → 判断 scrollTop 在左边还是右边 → 直接砍掉一半
速度恐怖对比
- 10000 条数据
- 遍历:10000 次
- 二分:14 次
四、最核心的一句话总结(面试必背)
基础遍历:
从头累加高度找位置,数据越多越慢。
进阶二分:
利用提前计算好的有序 top 数组,O(log n) 瞬间定位,海量数据也不卡。
五、为什么二分能这么快?
因为:
positions 里的 top 是严格递增、提前算好、永久缓存的!
0 → 100 → 200 → 300 → 400 → 500...
天生满足二分查找条件!
第四步:渲染 + 上下占位(让滚动条正常)
基础版
return (
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
width: 505,
height: 500,
border: '1px solid #000',
overflowY: 'auto',
padding: 20, // 直接给滚动容器 padding,正确!
boxSizing: 'border-box',
}}
>
{/* 顶部占位 */}
<div style={{ height: topBlank }} />
{/* 只渲染可见区域 */}
{visibleList.map((item, idx) => {
const realIdx = startIndex + idx;
return (
<div
key={item.id}
ref={(el) => measureItem(realIdx, el)}
style={{
padding: 10,
borderBottom: '1px solid #eee',
background: '#fff',
}}
>
{item.content}
</div>
);
})}
{/* 底部占位 */}
<div style={{ height: totalHeight - topBlank }} />
</div>
);
进阶版
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
overflowY: 'auto', // 纵向滚动
position: 'relative',// 子项绝对定位
...style,
}}
>
{/* 这一层用来撑开滚动条:高度 = 总高度 */}
<div style={{ height: totalHeight.current }} />
{/* 只渲染 start ~ end 之间的项!虚拟列表核心 */}
{Array.from({ length: end - start }, (_, i) => {
const idx = start + i;
const pos = positions.current[idx];
return (
<div
key={idx}
// 固定高度不绑定 ref → 关闭监听
ref={isFixedHeight ? null : (el) => observeItem(el, idx)}
style={{
position: 'absolute',
top: pos.top, // 项的位置
left: 0,
width: '100%',
}}
>
{/* 渲染每一项 */}
{itemContent(idx)}
</div>
);
})}
</div>
);
一、核心差异一句话总结
第一段:上下空白占位 + 正常流布局(简易版)
第二段:绝对定位 + 计算 top + 无重排 + 支持动态高度(正式版)
二、详细对比
布局方式
第一段(简易版)
- 正常文档流
- 靠
topBlank和bottomBlank撑开高度 - 优点:简单
- 缺点:项多了会频繁重排、回流、卡顿
第二段(工业版)
- 所有项绝对定位
top: xxx精确放置- 优点:几乎无回流、渲染极快
- 滚动流畅度 高 10 倍以上
总结
原来 “虚拟列表”有这么多可以挖的点,真的不要小看喽!
完整代码
import {
useRef,
useState,
useCallback,
useLayoutEffect,
useEffect,
} from 'react';
// ==============================================
// 调度器(核心优化:防止滚动时频繁更新导致卡顿)
// 作用:把多次 setState 合并到一帧执行,保证 60fps
// ==============================================
const Scheduler = {
queue: [], // 存放要执行的任务
running: false, // 是否正在执行
rafId: null, // requestAnimationFrame 的标识
// 添加任务到队列
push(task) {
this.queue.push(task);
this.flush();
},
// 执行任务:一帧最多执行 16ms(保证流畅)
flush() {
if (this.running) return;
this.running = true;
const run = () => {
// 一帧时间 16ms,到点就停,不阻塞浏览器
const end = performance.now() + 16;
while (this.queue.length && performance.now() < end) {
this.queue.shift()();
}
// 还有任务 → 下一帧继续
if (this.queue.length) {
this.rafId = requestAnimationFrame(run);
} else {
this.running = false;
}
};
// 取消上一帧,避免重复执行
cancelAnimationFrame(this.rafId);
this.rafId = requestAnimationFrame(run);
},
};
// ==============================================
// 手写极简版 Virtuoso 虚拟列表
// 支持:固定高度 / 动态高度
// ==============================================
export default function Virtuoso({
totalCount, // 总条数
itemHeight, // 项高度(固定高度传数字,动态高度不传)
itemContent, // 渲染每一项的内容
style = {}, // 容器样式
}) {
// 滚动容器 DOM 引用
const containerRef = useRef(null);
// 当前滚动距离 scrollTop
const [scrollTop, setScrollTop] = useState(0);
// 可视区域高度(一屏能显示多少)
const [containerHeight, setContainerHeight] = useState(500);
// 存放每一项的位置信息:{ top, height }
const positions = useRef([]);
// 列表总高度(用于撑开滚动条)
const totalHeight = useRef(0);
// 是否固定高度:itemHeight 是数字 → 固定高度模式
const isFixedHeight = typeof itemHeight === 'number';
// ==============================================
// 动态高度监听(固定高度不会用到)
// ==============================================
const ro = useRef(null); // ResizeObserver 实例
const observedMap = useRef(new Map()); // 存储已监听的元素,避免重复监听
// ==============================================
// 1. 初始化所有项的位置 & 高度
// 执行时机:DOM 更新后、浏览器绘制前(useLayoutEffect)
// ==============================================
useLayoutEffect(() => {
const pos = [];
let top = 0; // 第一项 top 从 0 开始
// 遍历所有项,计算位置
for (let i = 0; i < totalCount; i++) {
let height;
// 固定高度 → 直接用传入的 itemHeight
if (isFixedHeight) {
height = itemHeight;
}
// 动态高度 → 用缓存的高度,没有就用默认 60
else {
height = positions.current[i]?.height ?? 60;
}
pos.push({ top, height });
top += height; // 下一项的 top = 上一项 top + height
}
// 保存所有位置信息
positions.current = pos;
// 总高度 = 最后一项的 top + 最后一项的 height
totalHeight.current = top;
}, [totalCount, itemHeight, isFixedHeight]);
// ==============================================
// 2. 监听容器高度(窗口 resize 时更新)
// ==============================================
useLayoutEffect(() => {
const update = () => {
if (containerRef.current) {
// 获取容器可视高度
setContainerHeight(containerRef.current.clientHeight);
}
};
update(); // 初始化执行一次
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// ==============================================
// 3. 监听滚动事件
// 使用调度器 Scheduler 合并更新 → 不卡顿
// ==============================================
useEffect(() => {
const el = containerRef.current;
const onScroll = () => {
// 把更新 scrollTop 交给调度器,一帧只执行一次
Scheduler.push(() => setScrollTop(el.scrollTop));
};
// passive: true → 不阻塞浏览器滚动,性能更好
el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll);
}, []);
// ==============================================
// 4. 二分查找:找到当前滚动位置对应的起始项
// 比遍历快非常多
// ==============================================
const findStartIndex = useCallback(() => {
const arr = positions.current;
let l = 0, r = arr.length - 1;
// 二分查找:找最后一个 top <= scrollTop 的项
while (l <= r) {
const mid = (l + r) >> 1; // 等价 Math.floor((l+r)/2)
if (arr[mid].top <= scrollTop) l = mid + 1;
else r = mid - 1;
}
// 结果就是可视区第一项
return Math.max(0, r);
}, [scrollTop]);
// ==============================================
// 5. 计算可视区域:start 到 end
// 从 start 开始累加高度,直到填满屏幕 + 缓冲区
// ==============================================
const getVisibleRange = useCallback(() => {
const pos = positions.current;
const start = findStartIndex();
let end = start;
let sum = 0;
// 缓冲区 300px:上下多渲染一点,防止滚动白屏
const buffer = 300;
// 累加高度,直到超过屏幕高度 + buffer
while (end < totalCount && sum < containerHeight + buffer) {
sum += pos[end].height;
end++;
}
return { start, end: Math.min(end, totalCount) };
}, [findStartIndex, totalCount, containerHeight]);
// ==============================================
// 6. 更新项高度(仅动态高度使用)
// 某项高度变化 → 更新后面所有项的 top
// ==============================================
const updateItemHeight = useCallback((index, newHeight) => {
// 固定高度直接跳过,不执行
if (isFixedHeight) return;
const pos = positions.current;
const diff = newHeight - pos[index].height;
// 高度没变,不更新
if (diff === 0) return;
// 更新当前项高度
pos[index].height = newHeight;
// 后面所有项的 top 都要同步偏移
for (let i = index + 1; i < totalCount; i++) {
pos[i].top += diff;
}
// 总高度也更新
totalHeight.current += diff;
// 触发视图刷新(用调度器不卡)
Scheduler.push(() => setScrollTop((s) => s));
}, [totalCount, isFixedHeight]);
// ==============================================
// 7. 监听元素尺寸变化(仅动态高度)
// 使用 ResizeObserver 监听高度变化
// ==============================================
const observeItem = useCallback((el, index) => {
// 固定高度 / 没有元素 → 不监听
if (isFixedHeight || !el) return;
// 已经监听 → 不重复监听
if (observedMap.current.has(el)) return;
observedMap.current.set(el, index);
// 第一次创建 ResizeObserver
if (!ro.current) {
ro.current = new ResizeObserver((entries) => {
entries.forEach(entry => {
const idx = observedMap.current.get(entry.target);
// 高度变化 → 更新
updateItemHeight(idx, entry.contentRect.height);
});
});
}
// 开始监听当前元素
ro.current.observe(el);
}, [isFixedHeight, updateItemHeight]);
// 计算出当前要渲染的 start 和 end
const { start, end } = getVisibleRange();
// ==============================================
// 渲染结构
// ==============================================
return (
<div
ref={containerRef}
style={{
width: '100%',
height: '100%',
overflowY: 'auto', // 纵向滚动
position: 'relative',// 子项绝对定位
...style,
}}
>
{/* 这一层用来撑开滚动条:高度 = 总高度 */}
<div style={{ height: totalHeight.current }} />
{/* 只渲染 start ~ end 之间的项!虚拟列表核心 */}
{Array.from({ length: end - start }, (_, i) => {
const idx = start + i;
const pos = positions.current[idx];
return (
<div
key={idx}
// 固定高度不绑定 ref → 关闭监听
ref={isFixedHeight ? null : (el) => observeItem(el, idx)}
style={{
position: 'absolute',
top: pos.top, // 项的位置
left: 0,
width: '100%',
}}
>
{/* 渲染每一项 */}
{itemContent(idx)}
</div>
);
})}
</div>
);
}