聊天讨论 面试官让我手写虚拟列表——AI 生成的版本,快速滚动几下就白屏了

193577746(kyriewen) · 2026年08月01日 · 8 次阅读

上周面试,面试官甩了一道题:

"页面上要渲染 10 万条数据,不能分页,怎么做?"

我说虚拟列表。他点点头,然后说:"那你手写一个。"

我心想这还不简单?打开编辑器,用 AI 生成了一版——能跑,看着也没问题。面试官拿过去快速滚了几下,页面闪了一下白屏。

他放下鼠标说:"知道为什么白屏吗?"

我没答上来。回去自己手写了一版,才明白 AI 那版到底差在哪。

先看 AI 生成的版本——能跑,但有坑

AI 给的代码大概长这样:

function VirtualList({ items, itemHeight = 36, containerHeight = 500 }) {
  const [scrollTop, setScrollTop] = useState(0);

  const startIndex = Math.floor(scrollTop / itemHeight);
  const endIndex = Math.min(
    startIndex + Math.ceil(containerHeight / itemHeight) + 1,
    items.length
  );

  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>{items[startIndex + i]}</div>
          ))}
        </div>
      </div>
    </div>
  );
}

逻辑上没错:算出可视区域的起止索引,只渲染这几十个 DOM 节点,用transform偏移到正确位置。

慢慢滚没问题。但快速拖动滚动条——白屏。

为什么?因为onScroll是异步的。你快速滚动时,浏览器已经把视口滚到了新位置,但 React 还没来得及重新渲染那几十个节点。中间那几帧,旧的节点被滚走了,新的还没挂上——白屏。

手写版:加一个缓冲区就够了

修复白屏的核心思路是overscan(缓冲区)——在可视区域上下多渲染几行,让快速滚动时有"存货"可以展示。

function VirtualList({ items, itemHeight = 36, containerHeight = 500 }) {
  const [scrollTop, setScrollTop] = useState(0);
  const overscan = 5;

  const visibleCount = Math.ceil(containerHeight / itemHeight);
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
  const endIndex = Math.min(
    Math.floor(scrollTop / itemHeight) + visibleCount + overscan,
    items.length
  );

  const visibleItems = items.slice(startIndex, endIndex);
  const totalHeight = items.length * itemHeight;
  const offsetY = startIndex * itemHeight;

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ transform: `translateY(${offsetY}px)` }}>
          {visibleItems.map((_, i) => (
            <div key={startIndex + i} style={{ height: itemHeight }}>{startIndex + i + 1}</div>
          ))}
        </div>
      </div>
    </div>
  );
}

核心变化就 3 行:

+ const overscan = 5;
- const startIndex = Math.floor(scrollTop / itemHeight);
+ const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
- const endIndex = startIndex + Math.ceil(containerHeight / itemHeight) + 1;
+ const endIndex = Math.floor(scrollTop / itemHeight) + visibleCount + overscan;

上下各多渲染 5 行,快速滚动时这 5 行作为缓冲——浏览器滚过去的时候它们已经在 DOM 里了,不会白屏。

面试官的 6 个追问

白屏问题解释完,面试官又追了 6 个问题。每个都是进阶考点。

追问 1:为什么用 transform 而不是 padding-top?

// ❌ padding-top 方案
<div style={{ paddingTop: offsetY }}>

// ✅ transform 方案
<div style={{ transform: `translateY(${offsetY}px)` }}>

区别: paddingTop会触发布局重排(Layout),浏览器需要重新计算所有元素的位置。transform只触发合成(Composite),GPU 直接移动图层,不经过 Layout 和 Paint。

在 10 万条数据疯狂滚动时,这个差距能达到5-10 倍帧率差异

追问 2:如果每行高度不固定怎么办?

这是虚拟列表最难的部分。固定高度能直接算scrollTop / itemHeight,不固定高度就不能这么算了。

核心思路:预估高度 + 渲染后修正。

function useDynamicVirtualList(items, estimatedHeight = 50) {
  const [scrollTop, setScrollTop] = useState(0);
  const measuredHeights = useRef(new Map());

  const getItemOffset = (index) => {
    let offset = 0;
    for (let i = 0; i < index; i++) {
      offset += measuredHeights.current.get(i) ?? estimatedHeight;
    }
    return offset;
  };

  const findStartIndex = () => {
    let offset = 0;
    for (let i = 0; i < items.length; i++) {
      const h = measuredHeights.current.get(i) ?? estimatedHeight;
      if (offset + h > scrollTop) return Math.max(0, i - 3);
      offset += h;
    }
    return 0;
  };

  const measureRef = useCallback((index, el) => {
    if (el) {
      const height = el.getBoundingClientRect().height;
      if (measuredHeights.current.get(index) !== height) {
        measuredHeights.current.set(index, height);
      }
    }
  }, []);

  return { findStartIndex, getItemOffset, measureRef, setScrollTop };
}

先用estimatedHeight预估所有未渲染行的高度,渲染后用getBoundingClientRect测量真实高度,存到 Map 里。下次算偏移量时优先用真实高度。

面试时只需要说清楚这个思路就行,不需要写完整实现。

追问 3:onScroll 会不会触发太频繁?

会。快速滚动时每秒可能触发 60+ 次onScroll,每次都setState→重新渲染。

优化方案:用requestAnimationFrame节流。

const rafRef = useRef(null);

const handleScroll = (e) => {
  const scrollTop = e.currentTarget.scrollTop;
  if (rafRef.current) cancelAnimationFrame(rafRef.current);
  rafRef.current = requestAnimationFrame(() => {
    setScrollTop(scrollTop);
  });
};

为什么用requestAnimationFrame而不是throttle?因为requestAnimationFrame自动对齐浏览器刷新帧——每帧只更新一次,和渲染节奏完全同步。用setTimeout节流可能和帧率错开,导致撕裂或跳帧。

追问 4:key 应该用什么?

// ❌ 用 index
{visibleItems.map((item, i) => (
  <div key={i}>{item}</div>
))}

// ✅ 用数据的真实ID或绝对索引
{visibleItems.map((item, i) => (
  <div key={startIndex + i}>{item}</div>
))}

虚拟列表的key和普通列表不一样。普通列表用数组 index 是因为元素不变。虚拟列表的同一个key=0在滚动前是第 1 行数据,滚动后变成第 50 行数据——React 会复用 DOM 但不更新内容,导致显示错乱。

必须用数据的唯一标识或绝对位置索引作为 key。

追问 5:虚拟列表和时间分片哪个更好?

维度 虚拟列表 时间分片
原理 只渲染可视区域 分批渲染所有 DOM
DOM 数量 始终很少(~20-30 个) 最终全部渲染
内存 高(10 万 DOM 都在内存里)
首屏速度 快(第一批就渲染出来了)
滚动体验 需要处理白屏 原生滚动,丝滑
适用场景 10 万 + 条数据 1000-5000 条

数据量超过 5000 条用虚拟列表,5000 以内可以用时间分片。 时间分片最终还是会把所有 DOM 挂上去,10 万条 DOM 在页面上,光内存就能吃掉 200MB+。

追问 6:现在有成熟的库,为什么还要手写?

面试官最后抛了这个问题。

说实话,生产环境确实不该手写——用@tanstack/react-virtual(TanStack Virtual)就行,它处理了动态高度、横向滚动、无限加载等边界情况,手写要几百行,它都封好了。

你要知道它在做什么。 当它出 bug 的时候——比如滚动到某个位置突然跳了一下,或者动态加载新数据后滚动位置偏移了——你不知道原理就只能对着黑盒猜。

这也是为什么面试还在考这道题。

虚拟列表面试速查表

考点 答案要点
核心原理 只渲染可视区域内的元素,通过计算 scrollTop 确定起止索引
白屏原因 滚动是同步的,渲染是异步的,没有缓冲区就会白屏
缓冲区 (overscan) 上下各多渲染 N 行,通常 5-10 行
定位方式 transform: translateY() 优于 paddingTop(不触发重排)
不定高处理 预估高度 + 渲染后用 getBoundingClientRect 修正
滚动节流 requestAnimationFrame 优于 setTimeout/throttle
key 的选择 用数据唯一 ID 或绝对索引,不能用遍历 index
vs 时间分片 >5000 条用虚拟列表,<5000 条可用时间分片
生产方案 @tanstack/react-virtual(原 react-virtual)

手写能力在 AI 时代还有用吗?

面试官最后说了一句话我觉得挺有道理的:

"AI 能生成虚拟列表,但它生成的版本白屏了。你知道为什么白屏、怎么修,才是你的价值。"

AI 写代码越来越强,能跑的代码谁都能生成。但知道它为什么在某些场景下会出问题,这需要你理解原理。面试考手写,考的不是你能不能写出来——而是你能不能在它出问题的时候知道怎么修。

你面试时被问过手写虚拟列表吗?当时写出来了吗?

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请 注册新账号