第二章 前端渲染优化:虚拟列表原理与实现
前端渲染优化,说白了就是解决「数据太多,页面卡死」的问题。
我在低代码平台项目中遇到过真实场景:一个表单里塞了3000行数据,页面直接白屏5秒。用户当场就炸了。嗯,虚拟列表就是用来解决这个问题的。
2.1 虚拟列表的核心思想
虚拟列表的原理其实很简单——只渲染用户能看到的那部分DOM。
你想想看,一个列表有10万条数据,但屏幕一次只能显示20条。那剩下的99980条,为什么要渲染出来?
虚拟列表的做法是:
- 计算当前滚动位置
- 算出应该显示哪几条数据
- 只渲染这几条的DOM
- 用padding或transform撑起滚动条高度
核心公式:
可视区域起始索引 = Math.floor(scrollTop / itemHeight)
可视区域结束索引 = 起始索引 + 可视区域能容纳的条数
2.2 固定高度虚拟列表
固定高度是最简单的情况。每个列表项高度一样,比如50px。
我个人的习惯是先用固定高度版本跑通,再考虑动态高度。因为固定高度的计算量最小,性能最好。
// 固定高度虚拟列表核心实现
function VirtualListFixed({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
// 计算可见范围
const startIdx = Math.floor(scrollTop / itemHeight);
const endIdx = Math.min(
startIdx + Math.ceil(containerHeight / itemHeight) + 1,
items.length
);
// 只渲染可见项
const visibleItems = items.slice(startIdx, endIdx);
return (
<div
style={{ height: containerHeight, overflowY: 'auto' }}
onScroll={(e) => setScrollTop(e.target.scrollTop)}
>
<div style={{ height: items.length * itemHeight }}>
{visibleItems.map((item, i) => (
<div
key={item.id}
style={{
position: 'absolute',
top: (startIdx + i) * itemHeight,
height: itemHeight
}}
>
{item.content}
</div>
))}
</div>
</div>
);
}
避坑指南:
我曾经在低代码表单里直接用绝对定位,结果发现滚动时白屏闪烁。原因是没加 overscan 缓冲区。建议上下各多渲染5-10条,这样快速滚动时不会露白。
2.3 动态高度虚拟列表
动态高度就麻烦多了。每个列表项高度不确定,可能是20px,也可能是200px。
为什么会这样?低代码表单里,有的字段是单行输入,有的字段是富文本编辑器,高度天差地别。
我的做法是:
- 先预估一个默认高度(比如40px)
- 渲染完成后,用ResizeObserver获取真实高度
- 维护一个高度缓存数组
- 用二分查找定位当前滚动位置对应的索引
// 动态高度虚拟列表 - 高度缓存管理
class HeightCache {
constructor(estimatedHeight = 40) {
this.estimatedHeight = estimatedHeight;
this.heights = new Map(); // 存储每个item的真实高度
this.totalHeight = 0;
}
setHeight(index, height) {
this.heights.set(index, height);
this.totalHeight = this._recalculateTotal();
}
getHeight(index) {
return this.heights.get(index) || this.estimatedHeight;
}
// 二分查找:给定scrollTop,找到对应的索引
findIndex(scrollTop) {
let low = 0;
let high = this.heights.size - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midTop = this._getOffsetTop(mid);
if (midTop < scrollTop) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return low;
}
_getOffsetTop(index) {
let offset = 0;
for (let i = 0; i < index; i++) {
offset += this.getHeight(i);
}
return offset;
}
_recalculateTotal() {
let total = 0;
for (let i = 0; i < this.heights.size; i++) {
total += this.getHeight(i);
}
return total;
}
}
注意:
动态高度下,滚动条会「跳动」。因为用户滚动时,新渲染的项会更新高度缓存,导致总高度变化。我建议在滚动结束后再更新缓存,或者用requestAnimationFrame做节流。
2.4 虚拟列表在低代码表单中的应用
低代码表单有个特点:字段数量可能上千,但每个字段的渲染逻辑复杂(校验、联动、条件显示)。
我在项目中遇到过最头疼的问题:表单字段之间有联动关系。比如A字段选了「是」,B字段才显示。如果用虚拟列表,B字段可能还没渲染,联动逻辑就失效了。
解决方案是这样的:
- 把联动规则提取到「元数据层」,不依赖DOM
- 虚拟列表只负责渲染,不负责逻辑
- 字段的显示/隐藏状态,在数据层计算好再传给虚拟列表
| 场景 | 固定高度 | 动态高度 |
|---|---|---|
| 简单输入框列表 | ✅ 推荐 | ✅ 可用 |
| 富文本/图片字段 | ❌ 不适用 | ✅ 推荐 |
| 表格行(每行高度一致) | ✅ 推荐 | ✅ 可用 |
| 嵌套表单(子表单展开) | ❌ 不适用 | ✅ 必须用 |
2.5 懒加载与无限滚动优化
懒加载和虚拟列表是两回事,但经常一起用。
虚拟列表解决的是「渲染太多DOM」的问题。懒加载解决的是「加载太多数据」的问题。
我建议的搭配方案:
- 首次加载:只加载前100条数据
- 滚动到底部:触发懒加载,追加100条
- 虚拟列表:只渲染当前可视区域的20条
// 无限滚动 + 虚拟列表组合
function InfiniteVirtualList() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
// 监听滚动到底部
const handleScroll = useCallback((e) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 距离底部200px时触发加载
if (scrollTop + clientHeight >= scrollHeight - 200) {
loadMore();
}
}, []);
const loadMore = async () => {
if (loading || !hasMore) return;
setLoading(true);
const newItems = await fetchData(items.length, 100);
setItems(prev => [...prev, ...newItems]);
setHasMore(newItems.length === 100);
setLoading(false);
};
// 这里再套用虚拟列表组件
return (
<VirtualList
items={items}
itemHeight={50}
onScroll={handleScroll}
loading={loading}
/>
);
}
性能优化小技巧:
我在低代码平台里做了个「滚动节流」:滚动事件每100ms才触发一次位置计算。另外,用IntersectionObserver代替scroll事件监听底部,性能更好,而且不会触发回流。
2.6 实战中的避坑总结
最后,分享几个我踩过的坑:
- 坑1: 虚拟列表里用transition动画。动画会导致频繁重排,建议用transform代替
- 坑2: 低代码表格的列宽可拖拽。每次拖拽都要重新计算所有行的高度,我后来改成只重新计算可视区域的行
- 坑3: 键盘导航(Tab键)在虚拟列表里会乱跳。解决方案是维护一个「焦点索引」,用aria-activedescendant来管理
- 坑4: 虚拟列表和搜索功能结合时,搜索后列表高度变化,滚动位置会错乱。我建议搜索后重置scrollTop到0
嗯,虚拟列表看起来简单,但真正做好,要考虑的细节不少。尤其是低代码平台这种复杂场景,每个字段都可能是个「定时炸弹」。但只要掌握了核心原理,遇到问题就能快速定位。
下一章,我们会聊低代码平台的「运行时性能监控与诊断」。到时候我会分享一套我自己写的性能监控工具,专门用来抓低代码平台的性能瓶颈。