怎么解决长列表的渲染问题

3 阅读5分钟

无限加载

无限加载指的用户滑动到页面底部再去请求下一屏数据,所以核心便是怎么判断用户是否滑动到底部,这里有两种方法:

IntersectionObserver

const io = new IntersectionObserver(callback, option);

**介绍:**上述代码中, IntersectionObserver 接受两个参数:callback 是可见性变化时的回调函数,option 是一个可选的配置项。构造函数的返回值是一个观察器实例。实例的 observe 方法可以指定观察哪个 DOM 节点

**做法:**设定某个哨兵元素(通常是 DIV)放置在列表底部,当监听到它进入视口时,就触发下一屏数据的加载

const observer = new IntersectionObserver(entries => {
  if (entries[0].isIntersecting) {
    loadMoreData(); // 加载下一页数据
  }
});

observer.observe(document.getElementById('sentinel'));

监听 ScrollTop + 容器高度

**介绍: **

document.body.scrollTop // 浏览器滚动了的距离
ele.scrollHeight // 元素内容总高度
ele.clientHeight // 元素可视区域高度

用法:通过手动监听滚动事件,根据通过手动监听滚动事件,根据scrollTop + clientHeight >= scrollHeight判断是否到底判断是否到底

const list = document.getElementById('list');

list.addEventListener('scroll', () => {
  if (list.scrollTop + list.clientHeight >= list.scrollHeight - 10) {
    loadMoreData(); // 加载下一页数据
  }
});

需要注意的是,由于会频繁触发scroll事件,所以需要对 scroll 的回调函数添加节流处理

// 节流函数,确保在 delay 毫秒内只执行一次
function throttle(fn, delay) {
  let mark = null;
  return function (...args) {
    if (!mark) {
      mark = setTimeOut(() => {
        fn.call(this, args)
        mark = null
      } , delay)
    }
  };
}

const list = document.getElementById('list');

function handleScroll() {
  if (list.scrollTop + list.clientHeight >= list.scrollHeight - 10) {
    loadMoreData(); // 加载下一页数据
  }
}

// 加上节流,100ms 执行一次
list.addEventListener('scroll', throttle(handleScroll, 100));

对比

监听 scroll 事件虽然可行,但是其加了节流,仍会多次触发滚动事件,造成性能上的低劣。

优化

当然,在实际使用中,我们可以在用户真正滑到“底部”之前,提前一段距离触发数据加载,让下一页数据在用户到达时已经准备好,避免加载等待,做到“无感下滑

const observer = new IntersectionObserver(entries => {
  if (entries[0].isIntersecting) {
    loadMoreData();
  }
}, {
  root: list, // 滚动容器
  rootMargin: '0px 0px 300px 0px' // 提前 300px 触发
});

observer.observe(document.getElementById('sentinel'));

虚拟列表

当页面需要一次性加载渲染大量数据时,虚拟列表无疑是最好的方案。其是一种只渲染可视窗口内的元素而其余元素不进行渲染的技术,从而极大程度上减少 DOM 数量,提升渲染性能

listItem 定高

原理:

虚拟列表主要依赖两个核心进行计算:

- 当前滚动位置
- 列表项的高度

通过滚动的位置+每一项的高度,我们就可以推断出当前应该渲染的列表项的起始 Index,并将容器下方和容器上方顶起来,这样就形成了一个简单的虚拟列表,示意图如下:

+------------------------------------+
|  [ 顶部占位高度 (paddingTop) ]     |
|  [ 可视区域:只渲染可见的几项 ]     |
|  [ 底部占位高度 (paddingBottom) ]  |
+------------------------------------+

实现:

基于此,我们可以用代码简单的实现一下:

import React, { useRef, useState, useEffect } from 'react';

const VirtualList = ({
  height = 400,            // 容器高度
  itemHeight = 40,         // 单项高度
  data = [],               // 列表数据
  renderItem,              // 渲染函数
  buffer = 5               // 额外缓冲项数量
}) => {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);

  const totalCount = data.length;
  const visibleCount = Math.ceil(height / itemHeight) + buffer;

  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(startIndex + visibleCount, totalCount);

  const offsetTop = startIndex * itemHeight;
  const visibleData = data.slice(startIndex, endIndex);

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  return (
    <div
      ref={containerRef}
      style={{ height, overflowY: 'auto', border: '1px solid #ccc' }}
      onScroll={handleScroll}
    >
      <div style={{ height: totalCount * itemHeight, position: 'relative' }}>
        <div style={{ paddingTop: offsetTop }}>
          {visibleData.map((item, index) => (
            <div key={startIndex + index} style={{ height: itemHeight, borderBottom: '1px solid #eee' }}>
              {renderItem(item, startIndex + index)}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

listItem 不定高

定高的虚拟列表基本可以 cover 朋友们的简历内容了,但是为了防止面试官深入挖下去,所以准备一下不定高的虚拟列表的实现方案也是很有必要的。比如对于某些商品长列表,图片稍微大点或者介绍信息稍微长点就会形成这种不定高的列表项。

那么,不定高的虚拟列表的难点在哪里呢,简单来说,其难点有两个:

- 首先外部容器的总长不确定
- 其次无法通过 scrollTop / 元素高度获得初识索引值

对应的解决措施可以归纳为:

- 针对难点 1,可以初始假定一个足够元素长度,因为精确地计算出容器总高度的意义不是很大,之后在每次滚动时,累加计算已经滚动过的元素高度,加上剩余元素的假定高度,实时更新容器的最终高度,但是 pc 端会出现拖拽滑轮滚动比不一致情况
- 针对难点 2,根据视口高度累加展示的元素高度和,如果滑动到的高度高于已经累加过的元素高度和,则再进行累加,直到满足高度。但是需要一个 map 用来存储已经滑动过的元素高度信息,包括元素自身的高度和它之前的总高度 => 双链表+哈希

可参考代码如下:

import React, { useRef, useState, useEffect } from 'react';

const VirtualListDynamic = ({
  data = [],
  estimatedHeight = 100,
  containerHeight = 400,
  overscan = 5,
  renderItem
}) => {
  const containerRef = useRef(null);
  const itemRefs = useRef({});
  const heightMap = useRef(new Map()); // index => { height, offsetTop }

  const [scrollTop, setScrollTop] = useState(0);
  const [totalHeight, setTotalHeight] = useState(data.length * estimatedHeight);

  // 计算 offsetMap 用于累加高度定位起始项
  const buildOffsetMap = () => {
    let offset = 0;
    const map = new Map();
    for (let i = 0; i < data.length; i++) {
      const height = heightMap.current.get(i)?.height ?? estimatedHeight;
      map.set(i, { height, offsetTop: offset });
      offset += height;
    }
    setTotalHeight(offset);
    return map;
  };

  const offsetMap = buildOffsetMap();

  // 找到可视区域的起始 index
  const getStartIndex = () => {
    let i = 0;
    while (i < data.length) {
      const { offsetTop, height } = offsetMap.get(i);
      if (offsetTop + height > scrollTop) break;
      i++;
    }
    return Math.max(0, i);
  };

  const startIndex = getStartIndex();
  const endIndex = Math.min(data.length, startIndex + overscan + 20);

  const topPadding = offsetMap.get(startIndex)?.offsetTop ?? 0;
  const bottomPadding = totalHeight - (offsetMap.get(endIndex)?.offsetTop ?? totalHeight);

  // 监听滚动
  const onScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  // 渲染后记录真实高度
  useEffect(() => {
    const updated = new Map(heightMap.current);
    let changed = false;

    for (let i = startIndex; i < endIndex; i++) {
      const el = itemRefs.current[i];
      if (el) {
        const height = el.getBoundingClientRect().height;
        if (!updated.has(i) || updated.get(i).height !== height) {
          updated.set(i, { height, offsetTop: 0 }); // offsetTop 会在 buildOffsetMap 中更新
          changed = true;
        }
      }
    }

    if (changed) {
      heightMap.current = updated;
    }
  }, [startIndex, endIndex]);

  return (
    <div
      ref={containerRef}
      style={{ height: containerHeight, overflowY: 'auto', border: '1px solid #ccc' }}
      onScroll={onScroll}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ paddingTop: topPadding, paddingBottom: bottomPadding }}>
          {data.slice(startIndex, endIndex).map((item, i) => {
            const index = startIndex + i;
            return (
              <div
                key={index}
                ref={(el) => (itemRefs.current[index] = el)}
                style={{ marginBottom: 4 }}
              >
                {renderItem(item, index)}
              </div>
            );
          })}
        </div>
      </div>
    </div>
  );
};

export default VirtualListDynamic;

无限加载+虚拟列表

但是在实际的场景应用中,即便我们使用的无限加载,但是当用户多刷几屏之后,当前页面需要维护的 DOM 元素也变得多了起来,那么根据上述所讲的内容,我们就需要给页面加上虚拟列表的功能。

所以综合来说,对于长列表渲染的问题,使用无限加载+虚拟列表才是优化长列表渲染的终极方案

分页懒加载