上周面试,面试官甩了一道题:
"页面上要渲染 10 万条数据,不能分页,怎么做?"
我说虚拟列表。他点点头,然后说:"那你手写一个。"
我心想这还不简单?打开编辑器,用 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 个问题。每个都是进阶考点。
// ❌ padding-top 方案
<div style={{ paddingTop: offsetY }}>
// ✅ transform 方案
<div style={{ transform: `translateY(${offsetY}px)` }}>
区别: paddingTop会触发布局重排(Layout),浏览器需要重新计算所有元素的位置。transform只触发合成(Composite),GPU 直接移动图层,不经过 Layout 和 Paint。
在 10 万条数据疯狂滚动时,这个差距能达到5-10 倍帧率差异。
这是虚拟列表最难的部分。固定高度能直接算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 里。下次算偏移量时优先用真实高度。
面试时只需要说清楚这个思路就行,不需要写完整实现。
会。快速滚动时每秒可能触发 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节流可能和帧率错开,导致撕裂或跳帧。
// ❌ 用 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。
| 维度 | 虚拟列表 | 时间分片 |
|---|---|---|
| 原理 | 只渲染可视区域 | 分批渲染所有 DOM |
| DOM 数量 | 始终很少(~20-30 个) | 最终全部渲染 |
| 内存 | 低 | 高(10 万 DOM 都在内存里) |
| 首屏速度 | 快 | 快(第一批就渲染出来了) |
| 滚动体验 | 需要处理白屏 | 原生滚动,丝滑 |
| 适用场景 | 10 万 + 条数据 | 1000-5000 条 |
数据量超过 5000 条用虚拟列表,5000 以内可以用时间分片。 时间分片最终还是会把所有 DOM 挂上去,10 万条 DOM 在页面上,光内存就能吃掉 200MB+。
面试官最后抛了这个问题。
说实话,生产环境确实不该手写——用@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 写代码越来越强,能跑的代码谁都能生成。但知道它为什么在某些场景下会出问题,这需要你理解原理。面试考手写,考的不是你能不能写出来——而是你能不能在它出问题的时候知道怎么修。
你面试时被问过手写虚拟列表吗?当时写出来了吗?