各位前端同仁,下午好!
欢迎来到今天的“React 虚拟化滚动进阶”现场。我是你们的老朋友,一个在浏览器里搬砖搬了多年的“资深”专家。
今天我们不聊那些花里胡哨的框架新特性,也不聊怎么把 TypeScript 写得像 TypeScript。我们要聊一个痛点,一个让无数新手抓耳挠腮、让资深工程师半夜惊醒的“千古难题”——虚拟化滚动中的动态高度项。
如果你还在用 map 循环渲染几千条数据,恭喜你,你的浏览器内存正在哭泣,你的用户正在疯狂点击“返回上一页”。但如果你已经实现了虚拟化,却发现列表里的图片、卡片高度各不相同,滚动时列表像得了帕金森一样抖动,那……好吧,至少你在正确的路上,只是你还没找到那把钥匙。
今天,我们就来把这把钥匙——动态高度预估与抖动消除——彻底掰开揉碎了讲。
第一部分:为什么“动态高度”是个坑?
我们先来回顾一下。静态高度,多简单啊。就像盖俄罗斯方块,你心里清楚每个方块都是 20×20 像素。计算偏移量?小学数学题。scrollTop / itemHeight,完事。
但是,现实世界不是俄罗斯方块,现实世界是乱七八糟的 HTML。
想象一下你的列表项:
- 一张 400×400 的美女照片。
- 一段 5 行的文本,高度 60px。
- 一个复杂的表单,高度 120px。
- 一个只有两行字的摘要,高度 30px。
这就是动态高度。每个 Item 的 clientHeight 都是不一样的。
当我们滚动列表时,浏览器需要知道:“嘿,我现在滚到了位置 Y,这个位置底下压着的是第几行数据?”
如果高度是固定的,这很简单。但如果是动态的,你就得知道从第 1 行到第 100 行,它们的总高度加起来是多少。如果你算错了,好,你的列表会跳变;如果你算得太慢,你的页面就会卡顿。
这就是我们今天要解决的两个核心问题:
- 如何快速、准确地计算偏移量?
- 如何消除那些令人作呕的抖动?
第二部分:偏移量的艺术——从暴力遍历到二分查找
1. 暴力破解法(不可取)
最朴素的想法是:既然不知道高度,那就每次滚动都算一遍呗。
function getOffsetForIndex(items, index) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += items[i].height; // 假设我们拿到了真实高度
}
return offset;
}
听起来很合理对吧?如果你有 100 个项目,没问题。如果你有 100,000 个项目,而且你滚动的速度很快,这个函数会被调用成千上万次。每次调用都要循环几千次,浏览器直接给你表演一个“假死”。
2. 策略性猜测(动态高度的核心)
既然不能每次都从头算,那我们怎么算?
这里我们引入一个核心概念:预估。
因为动态高度项目很多,你不可能在渲染前就把所有项目的高度都测出来(那不叫虚拟化,那叫全量渲染)。所以,我们通常会有一个“预估高度”。
比如,我们规定一个 estimatedItemSize = 100。
当我们想找第 50 个项目在哪里时,我们会说:“嘿,前 49 个项目平均高度是 100,所以第 50 个项目大概在 4900px 的位置。”
但是,这是“大概”。如果第 50 个项目其实只有 50px,那我们的偏移量就错了。
这就引出了“抖动”的来源。
3. 进阶技巧:缓存与二分查找(二分查找法)
为了优化性能,我们不能每次都暴力循环。我们需要一种数据结构来记录高度。
const itemSizeMap = new Map(); // key: index, value: height
但是,每次都要遍历 Map 找 offset 吗?Map 的遍历也是 O(N)。
这时候,我们的老朋友——二分查找 就要登场了。是的,二分查找不仅用于排序,也用于快速定位。
如果我们把数据按高度排个序(或者更常见的是,我们维护一个有序的索引列表,记录了每个项目的真实高度),我们就可以在 O(log N) 的时间内找到偏移量。
但是! 在 React 虚拟化中,我们通常不需要那么复杂的排序。我们通常采用一种更实用的“累积高度数组”策略,或者简单的“缓存计算结果”。
不过,为了今天的讲座深度,我们来点更硬核的。
核心逻辑:
- 我们维护一个
heightCache。 - 当我们首次渲染一个项目时,用
ResizeObserver或getBoundingClientRect拿到它的真实高度。 - 更新
heightCache。 - 当滚动时,如果发现
heightCache不够,我们再动态计算。
这听起来很完美,但有个坑:初始渲染时,heightCache 是空的! 那怎么办?
答案就是:初始渲染必须用预估值,渲染后再修正。 这就是“抖动”的温床。
第三部分:抖动——是幽灵,还是恶魔?
让我们来模拟一下抖动的产生过程。
场景: 列表有 1000 项,预估高度是 100px。
操作: 用户滚动到第 50 项。
-
渲染阶段:
- 滚动位置:5000px。
- 算法认为:5000 / 100 = 第 50 项。
- 算法计算偏移量:0 + 100 + … + 100 = 5000px。
- 结果: 第 50 项被渲染在 5000px 的位置。
-
测量阶段(下一帧):
- React 渲染完 DOM。
ResizeObserver触发,告诉我们要第 50 项的真实高度是 60px(而不是 100px)。- 我们更新了状态,告诉列表:“嘿,刚才那个位置不对,现在偏移量要减去 40px。”
-
重绘阶段:
- 列表发现状态变了。
- 它重新计算偏移量:应该是 4960px。
- 它重新渲染。
- 视觉结果: 第 50 项从 5000px 跳到了 4960px。往下跳了 40px。
- 用户体验: 咦?怎么抖了一下?
这就是“抖动”。如果这种跳变发生在滚动过程中,用户会觉得你的软件像是在“抽筋”。
第四部分:如何消灭抖动?(干货时间)
消灭抖动,我们需要一套组合拳。
1. 增加缓冲区
这是最简单、最有效的“掩耳盗铃”法。
不要只渲染 Math.floor(scrollTop / estimatedSize) 附近的几个项目。多渲染几个!
比如,你只渲染当前可见的 10 个,加上可见区域上下各 5 个。这样,当真实高度测量出来并导致偏移量变化时,受影响的只是那些多渲染的“缓冲区”项目,而不是正在滚动的核心项目。
代码示例:
const buffer = 5; // 缓冲区大小
const startIndex = Math.max(0, Math.floor(scrollTop / estimatedSize) - buffer);
const endIndex = Math.min(totalItems, startIndex + visibleCount + buffer * 2);
虽然这增加了 DOM 节点数量,但换来的是极其稳定的体验。
2. 使用 ResizeObserver 而不是 onResize
window.onresize 是老大爷用的,太慢了,而且还会触发防抖,导致测量延迟。
ResizeObserver 是现代浏览器的亲儿子。它直接监听 DOM 节点大小变化。当项目内容变长(比如图片加载完)或者变短时,立即通知我们。
关键点: 必须在 useEffect 的依赖里加上 ResizeObserver,确保组件卸载时取消监听,防止内存泄漏。
3. useMemo 的神助攻
这是最容易被忽视的一点。
在计算偏移量时,如果计算逻辑里包含了大量的循环或复杂判断,一定要用 useMemo 包裹。
为什么?因为 React 的渲染是同步的。如果每次滚动事件都触发一个复杂的计算函数,那么在计算完成之前,浏览器无法渲染下一帧,导致滚动卡顿。抖动往往伴随着卡顿。
const totalHeight = useMemo(() => {
let height = 0;
for (let i = 0; i < visibleEndIndex; i++) {
height += getItemSize(i);
}
return height;
}, [visibleEndIndex, getItemSize]); // 依赖变化才重算
4. “交错测量”策略
这是高端玩家的玩法。
不要等所有项目都渲染完了再测。项目渲染出来了,它的大小也就确定了(比如图片加载完了)。
我们可以设置一个 measureTimeout。当项目进入视口时,不要立刻测量。等 100ms(比如下一帧)再测量。
如果测量结果和预估值有差异,不要直接修改 scrollTop(那样会导致页面跳动),而是把这个差异记录下来,作为一个“修正值”,应用到下一次渲染中。
第五部分:实战代码——打造一个健壮的 useDynamicVirtual
好了,理论讲得差不多了,口水都流干了。现在,我们手把手写一个能处理动态高度、能消除抖动的 Hook。
这个组件需要解决以下问题:
- 动态高度计算。
- 滚动位置计算。
- 基于二分查找或累积数组的偏移量计算。
- 防抖处理。
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
// 1. 模拟数据生成器
const generateItems = (count) => {
return Array.from({ length: count }, (_, i) => ({
id: i,
content: `这是一条动态长度的数据,内容长度随机变化。第 ${i + 1} 条。`,
// 模拟不同高度:有的短,有的长(图片或大文本)
height: i % 5 === 0 ? 200 : (i % 3 === 0 ? 300 : 100)
}));
};
const DynamicVirtualList = ({ itemCount = 1000, containerHeight = 600 }) => {
const [items, setItems] = useState(generateItems(itemCount));
// 状态管理
const [scrollTop, setScrollTop] = useState(0);
const [startIdx, setStartIdx] = useState(0);
const [endIdx, setEndIdx] = useState(0);
// 核心数据结构:存储真实高度
const itemSizeMapRef = useRef(new Map());
const containerRef = useRef(null);
// 预估高度,用于初始计算
const ESTIMATED_SIZE = 100;
// 2. 计算偏移量的函数 (使用二分查找逻辑的简化版,为了演示清晰,这里用线性查找+Map缓存优化)
// 在生产环境中,建议对 itemSizeMap 进行排序或使用二分查找
const getOffsetForIndex = useCallback((index) => {
let offset = 0;
for (let i = 0; i < index; i++) {
// 优先从 Map 取真实高度,没有则用预估高度
offset += itemSizeMapRef.current.get(i) || ESTIMATED_SIZE;
}
return offset;
}, []);
// 3. 核心:计算可见区域
useEffect(() => {
// 计算起始索引
// 这里的逻辑是:找到第一个使得 offset >= scrollTop 的索引
let low = 0;
let high = itemCount;
let start = 0;
while (low < high) {
const mid = Math.floor((low + high) / 2);
const offset = getOffsetForIndex(mid);
if (offset < scrollTop) {
low = mid + 1;
} else {
high = mid;
}
}
// 修正:如果正好等于,可能要再往前找找,防止切分点在半空中
// 这里简化处理
let calculatedStart = Math.max(0, low - 1);
// 4. 增加缓冲区
const buffer = 2;
calculatedStart = Math.max(0, calculatedStart - buffer);
// 计算结束索引
let end = calculatedStart;
let totalHeight = 0;
// 简单的循环累加,直到超出容器高度
while (totalHeight < containerHeight + scrollTop && end < itemCount) {
totalHeight += itemSizeMapRef.current.get(end) || ESTIMATED_SIZE;
end++;
}
setStartIdx(calculatedStart);
setEndIdx(Math.min(end, itemCount));
}, [scrollTop, itemCount, containerHeight, getOffsetForIndex]);
// 5. 处理滚动事件
const handleScroll = useCallback((e) => {
// 使用 requestAnimationFrame 优化滚动性能
requestAnimationFrame(() => {
setScrollTop(e.target.scrollTop);
});
}, []);
// 6. 测量真实高度 (ResizeObserver)
useEffect(() => {
const observer = new ResizeObserver((entries) => {
for (let entry of entries) {
const { target } = entry;
const index = target.dataset.index;
const height = entry.contentRect.height;
if (height > 0) {
// 只有当高度变化且不为0时才更新
itemSizeMapRef.current.set(Number(index), height);
}
}
});
// 监听所有可视区域内的元素
const visibleElements = containerRef.current?.querySelectorAll('[data-index]');
if (visibleElements) {
visibleElements.forEach(el => observer.observe(el));
}
return () => observer.disconnect();
}, [startIdx, endIdx]);
// 7. 计算总高度
const totalHeight = useMemo(() => {
let h = 0;
for (let i = 0; i < itemCount; i++) {
h += itemSizeMapRef.current.get(i) || ESTIMATED_SIZE;
}
return h;
}, [itemCount]);
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', fontFamily: 'sans-serif' }}>
<h2>React 动态高度虚拟化列表</h2>
{/* 外层容器,撑开总高度 */}
<div
ref={containerRef}
style={{
height: `${containerHeight}px`,
overflowY: 'auto',
position: 'relative',
border: '2px solid #ccc',
background: '#f0f0f0'
}}
onScroll={handleScroll}
>
{/* 占位符:撑开滚动条,防止高度塌陷 */}
<div style={{ height: `${totalHeight}px`, position: 'absolute', top: 0, left: 0, width: '1px' }}></div>
{/* 实际渲染区域 */}
<div style={{ position: 'absolute', top: 0, left: 0, width: '100%' }}>
{items.slice(startIdx, endIdx).map((item) => {
const offset = getOffsetForIndex(item.id);
return (
<div
key={item.id}
data-index={item.id}
style={{
height: `${itemSizeMapRef.current.get(item.id) || ESTIMATED_SIZE}px`,
position: 'absolute',
top: `${offset}px`,
width: '100%',
backgroundColor: item.id % 2 === 0 ? '#fff' : '#f9f9f9',
border: '1px solid #eee',
padding: '10px',
boxSizing: 'border-box',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{item.content}
</div>
);
})}
</div>
</div>
<div style={{ padding: '10px' }}>
当前渲染范围: {startIdx} - {endIdx} (共 {endIdx - startIdx} 项)
</div>
</div>
);
};
export default DynamicVirtualList;
第六部分:深度解析代码中的“黑魔法”
上面的代码跑起来了吗?如果跑起来了,恭喜你,你已经实现了一个基础版。
但让我们来聊聊代码里那些容易翻车的地方。
1. position: absolute 的奥秘
你可能会问:“为什么不用 marginTop?”
如果用 marginTop,浏览器在计算布局时需要重新计算每个元素的偏移。对于虚拟化列表,这意味着每次滚动,浏览器都要重新计算几千个元素的位置。这会导致巨大的性能开销。
而使用 position: absolute 并手动计算 top 值,我们是在告诉浏览器:“我知道它在哪里,你别管了,直接画上去。” 这就是性能优化的精髓——减少浏览器的重排。
2. ResizeObserver 的异步陷阱
注意看 useEffect 里的 ResizeObserver。
const observer = new ResizeObserver((entries) => { ... });
当 DOM 更新后,ResizeObserver 会回调。但是,这个回调是在浏览器的渲染循环里执行的。
如果我们在回调里直接修改了 React 的 state(比如 setStartIdx),React 会触发一个重渲染。重渲染会再次计算偏移量。
这时候,如果两个状态更新同时发生,可能会导致计算逻辑混乱。
最佳实践:
ResizeObserver 的回调只负责更新 itemSizeMapRef(Map 是可变的,不需要触发重渲染)。只有当所有测量都完成(或者至少当前可见区域的测量完成)后,才通过某种机制去更新 scrollTop 或者触发一次重绘。
在我们的代码里,我们利用了 getOffsetForIndex 的读取机制。只要 itemSizeMapRef 更新了,下一次 useEffect 计算 startIdx 时,就会自动使用新的高度,从而修正位置。这实际上是一种“反应式”的修正方式,非常优雅。
3. 滚动条抖动
有时候你会发现,列表不动,但滚动条自己在动。
这是因为我们的 totalHeight 是基于 itemSizeMap 计算的,而 itemSizeMap 是异步更新的。
如果图片加载慢,itemSizeMap 是空的,totalHeight 就会偏小。滚动条就上不去。
修复方案:
在 totalHeight 的计算逻辑里,对于未测量的项,必须使用一个“保底高度”(比如图片的最小高度 50px)。不要用 0,否则高度会塌陷。
第七部分:进阶优化——从“能用”到“好用”
现在我们的代码能跑,也不抖了。但作为一个资深专家,我们还能做得更好。
1. 窗口调整大小
别忘了 window.resize。当容器高度改变时,我们需要重新计算可见范围。
useEffect(() => {
const handleResize = () => {
// 获取新的容器高度
const newHeight = containerRef.current.clientHeight;
// 重新触发计算逻辑...
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
2. 懒加载图片
如果你的动态高度项目里有图片,图片加载完之前,高度是未知的。
这就形成了一个死循环:
- 图片没加载,高度是 0(或默认 100)。
- 列表渲染,位置正确。
- 图片加载完,触发 ResizeObserver。
- 高度变成 200。
- 列表重排,位置跳变。
解决方案:
在图片加载前,给图片容器设置一个 min-height。等图片加载完,ResizeObserver 会把高度从 min-height 调整为真实高度。虽然会有跳变,但因为 min-height 已经占位了,视觉上会平滑很多。
3. 性能监控
在生产环境中,如何知道虚拟化是否生效?
你可以加一个 console.log,打印每次渲染的 startIdx 和 endIdx。
如果 endIdx - startIdx 始终保持在 20 左右,说明虚拟化工作正常。
如果它随着滚动一直增加到 1000,说明你渲染了太多项目,虚拟化失效了。
第八部分:总结——成为虚拟化大师
好了,各位,今天的讲座就到这里。
回顾一下我们今天攻克了什么:
- 理解了动态高度的复杂性:它不是简单的加减法,而是充满了“预估”和“修正”的艺术。
- 掌握了偏移量计算:从暴力遍历到基于 Map 的优化计算。
- 消灭了抖动:通过缓冲区、ResizeObserver 和异步修正策略。
- 编写了健壮的代码:使用了
position: absolute、useCallback和useMemo来优化性能。
最后送给大家一句话:
虚拟化滚动的本质,不是“偷懒不渲染”,而是“精确计算”。你要像一位严谨的会计,精确记录每一像素的高度,才能让用户的滚动体验丝般顺滑。
不要害怕抖动,抖动是调试的信号。只要你能用代码解释它,你就能消灭它。
现在,去你的项目里试试吧。如果还是抖,那就把缓冲区再加大点,实在不行,多渲染两个项目,用户的眼睛比你的算法更宽容。
谢谢大家,下课!