一、为什么长列表会卡:DOM 节点爆炸
当你需要把十万条、甚至百万条数据一次性塞进前端页面时,直接渲染所有 DOM 节点会让浏览器瞬间卡死。原因很直接:每个 DOM 节点都要占用内存、参与布局(Layout)与绘制(Paint),节点数量从几百涨到十万,主线程被布局计算占满,滚动自然掉帧。这正是{L_PERFORM}里反复强调的“减少主线程工作量”的典型场景——而虚拟列表就是最直接的降本手段。
虚拟列表(Virtual List,也叫虚拟滚动 Virtual Scrolling)的核心思路只有一句话:只渲染可视区域内的少量节点,配合滚动偏移伪造出“完整长列表”的体验。无论底层数据有 1 万条还是 100 万条,屏幕上始终只有几十个真实 DOM 节点。
二、虚拟列表的核心原理
把长列表想象成一条很长的传送带。用户通过滚动条决定“当前看到哪一段”。虚拟列表要做的三件事:
1)计算可视区范围:根据 scrollTop 和容器高度,算出当前应该渲染第几项到第几项;2)撑开总高度:用一个足够高的占位元素把原生滚动条“骗”出来,保证滚动手感正常;3)偏移定位:把渲染出来的那一小段节点用 transform 或 padding 推到正确位置,让它能对齐到真实坐标。
固定高度下的可视区公式
当每一项高度固定为 H、容器可视高度为 V 时,公式极其简单:起始下标 start = floor(scrollTop / H),可视数量 count = ceil(V / H)。这就是固定高度虚拟列表的全部数学。
三、固定高度虚拟列表:从公式到实现
下面给出不依赖任何框架的纯计算核心,理解了它,上层的 React/Vue 封装都只是壳。
// 固定高度虚拟列表:只算可视区要渲染哪一段
const ITEM_HEIGHT = 40; // 每项固定高度(px)
const VIEWPORT_HEIGHT = 400; // 容器可视高度
function getVisibleRange(scrollTop, total) {
const start = Math.floor(scrollTop / ITEM_HEIGHT);
const visibleCount = Math.ceil(VIEWPORT_HEIGHT / ITEM_HEIGHT);
const end = Math.min(total, start + visibleCount);
return { start, end };
}
// 总高度撑开滚动条(让原生滚动条比例正确)
const totalHeight = list.length * ITEM_HEIGHT;
// 偏移:把渲染出来的片段“推”到正确位置
const offsetY = start * ITEM_HEIGHT;
渲染时只取 list.slice(start, end),外层包一个高度为 totalHeight 的占位容器,内部用 translateY(offsetY) 把片段定位到正确位置。这样无论数据多大,真实 DOM 永远只有十几行。
四、不定高度怎么处理:预估 + 测量 + 缓存
真实业务里,评论流、卡片流往往高度不一,无法用乘法直接算偏移。业界成熟做法是三步:先用一个预估高度(estimatedItemHeight)把整体高度大致撑开;滚动到某一项时再实测真实高度并写回缓存;最后用前缀和数组(positions[i] = 前 i 项累计高度)把“下标→偏移”的查询变成 O(1)。
实测高度建议放在节点渲染完成后的 useLayoutEffect / onMounted 里读取 offsetHeight,并缓存起来。只要用户不频繁增删,这套缓存就能让后续滚动精确无跳动。
| 维度 | 固定高度 | 不定高度 |
|---|---|---|
| 计算偏移 | 下标 × 高度(O(1)) | 前缀和数组查询(O(1)) |
| 首屏 | 精确 | 需预估后校准 |
| 复杂度 | 极低 | 需测量 + 缓存 |
| 适用 | 表格 / 菜单 / 日志 | 卡片 / 评论流 / 聊天 |
五、三个关键性能优化点
1. 用 transform 代替 top/margin
定位偏移优先用 transform: translateY(),它只触发合成层(Composite),不触发 Layout 和 Paint;而改 top 会触发全量重排,长列表下差距巨大。
2. 用 requestAnimationFrame 合并滚动
scroll 事件触发频率远高于屏幕刷新率。用{L_EVENT}里提到的事件循环机制来理解:把多次 scroll 合并到一帧里处理,能显著降低主线程压力。
let ticking = false;
container.addEventListener("scroll", (e) => {
if (!ticking) {
ticking = true;
requestAnimationFrame(() => {
render(e.target.scrollTop); // 合并成一帧,避免抖动
ticking = false;
});
}
});
3. 加 overscan 缓冲区
只渲染“刚好可视”的几行,快速滚动时容易出现白屏。在可视区上下各多渲染 overscan(通常 4~8 行)作为缓冲,能消除滚动时的闪烁感,成本只是多了几个 DOM 节点。
六、横向虚拟列表简介
横向虚拟列表与纵向完全同构,只是把“高度/scrollTop”换成“宽度/scrollLeft”,并把 translateY 换成 translateX。日历甘特图、图片墙、大数据表格的固定列都常用到,核心公式一模一样。
七、用 IntersectionObserver 做懒加载兜底
如果列表项里嵌了图片或重量级组件,光做虚拟列表还不够。可以给可视区内的项再叠加 IntersectionObserver,等项真正进入视口附近才加载图片,离开就卸载,进一步压低内存占用。虚拟列表负责“少渲染”,懒加载负责“渲染的东西也别太重”,二者互补。
八、主流方案对比:自研还是用库
业务里大多数情况不必从零造轮子,但要懂原理才能选对。下面按生态做个横向对比。
| 方案 | 适用场景 | 高度策略 | 体积 | 备注 |
|---|---|---|---|---|
| react-window | React 中长列表 | 定高/变高 | 极小 | 官方推荐,API 简单 |
| react-virtuoso | 聊天流/复杂变高 | 自动测量 | 中等 | 自带下拉刷新 |
| vue-virtual-scroller | Vue 生态 | 定高/变高 | 小 | 社区成熟 |
| 自研 | 特殊布局/横滑 | 自定义 | 0 | 可控但需踩坑 |
选型建议:中后台表格优先 react-window / vue-virtual-scroller;聊天、信息流这类高度完全不可控的,直接上 react-virtuoso;只有当你需要横滑、二维、或和自研渲染引擎耦合时,才值得自研。
九、React 实战:写一个 useVirtual 钩子
下面把前面的原理封装成一个可复用的 React Hook,配合{L_REACT}里的 memo/useCallback 思路,保证只渲染可视切片。
import { useState, useCallback } from "react";
export function useVirtual({ itemCount, itemHeight, viewportHeight, overscan = 4 }) {
const [scrollTop, setScrollTop] = useState(0);
const onScroll = useCallback((e) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const visibleCount = Math.ceil(viewportHeight / itemHeight) + overscan * 2;
const end = Math.min(itemCount, start + visibleCount);
const offsetY = start * itemHeight;
const totalHeight = itemCount * itemHeight;
return { start, end, offsetY, totalHeight, onScroll };
}
组件层把它消费掉即可:外层固定高度 + overflow:auto 负责滚动,占位层撑总高,内层用 translateY 偏移。
function VirtualList({ items, itemHeight = 40, height = 400 }) {
const { start, end, offsetY, totalHeight, onScroll } = useVirtual({
itemCount: items.length,
itemHeight,
viewportHeight: height,
});
const slice = items.slice(start, end);
return (
<div style={{ height, overflow: "auto" }} onScroll={onScroll}>
<div style={{ height: totalHeight, position: "relative" }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{slice.map((it, i) => (
<div key={start + i} style={{ height: itemHeight }}>
{it.label}
</div>
))}
</div>
</div>
</div>
);
}
十、Vue3 实战:可复用虚拟列表组件
Vue3 用 computed 把“滚动偏移 → 切片”做成响应式派生,代码更短。
<template>
<div class="viewport" ref="box" :style="{ height: h + 'px' }" @scroll="onScroll">
<div class="spacer" :style="{ height: total + 'px' }">
<div class="items" :style="{ transform: `translateY(${offset}px)` }">
<div v-for="(it, i) in slice" :key="start + i" class="row">
{{ it.label }}
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
const props = defineProps({ items: Array, row: { type: Number, default: 40 }, h: Number });
const scrollTop = ref(0);
const start = computed(() => Math.max(0, Math.floor(scrollTop.value / props.row) - 4));
const end = computed(() => Math.min(props.items.length, start.value + Math.ceil(props.h / props.row) + 8));
const offset = computed(() => start.value * props.row);
const total = computed(() => props.items.length * props.row);
const slice = computed(() => props.items.slice(start.value, end.value));
const onScroll = (e) => (scrollTop.value = e.target.scrollTop);
</script>
十一、常见坑与排查清单
· 白屏闪烁:overscan 太小或没用 rAF 合并,调大缓冲并合帧。 · 滚动条比例失真:占位高度没用真实总高,检查 totalHeight 计算。 · 不定高跳动:预估高度与实测差异大,确保测量后回写缓存并触发重排。 · 事件穿透:内层 translateY 后点击坐标错乱,给每项稳定 key(start+i)并避免复用错位。 · 内存不降:只做虚拟列表但项内图片不卸载,叠加 IntersectionObserver 懒加载。
十二、落地 Checklist
1)确认数据量级是否真需要虚拟列表(< 200 条通常没必要);2)固定高度优先,省去测量复杂度;3)偏移用 transform 而非 top;4)scroll 必须 rAF 合帧;5)加 overscan 防白屏;6)不定高务必做测量缓存;7)横向场景同理换成 X 轴;8)重组件叠加 IntersectionObserver。照着这份清单走,百万级列表也能稳在 60fps。
十三、总结
虚拟列表不是黑魔法,它只是把“全量渲染”这个朴素但昂贵的选择,换成了“按需渲染 + 偏移伪装”。掌握固定高度公式、transform 定位、rAF 合帧这三板斧,你就拥有了在任何框架里手写高性能长列表的能力;而在大多数业务里,直接用 react-window 或 vue-virtual-scroller 会更省心。无论自研还是用库,记住目标只有一个——让屏幕上的真实 DOM 永远保持很少。




