第二章 前端渲染优化:虚拟列表原理与实现

前端渲染优化,说白了就是解决「数据太多,页面卡死」的问题。

我在低代码平台项目中遇到过真实场景:一个表单里塞了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

嗯,虚拟列表看起来简单,但真正做好,要考虑的细节不少。尤其是低代码平台这种复杂场景,每个字段都可能是个「定时炸弹」。但只要掌握了核心原理,遇到问题就能快速定位。

下一章,我们会聊低代码平台的「运行时性能监控与诊断」。到时候我会分享一套我自己写的性能监控工具,专门用来抓低代码平台的性能瓶颈。