虚拟列表
目标:让页面只渲染可见的少量节点,其余都是空白高度
固定高度
🌰来喽
每一项高度固定(假设50px)
若滚动区域高度500px,则可显示 (500 / 50 = 10项)
此时快速滚动到了2000项
真实渲染的列表内容:
只渲染 10~15 个 DOM 节点(pool)
把它们整体 transform 下移 offset 像素
在它们里面显示第 2000~2015 条数据
-
html版
html<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Document</title> <style> .virtual-list { height: 300px; overflow-y: auto; border: 1px solid #ccc; position: relative; } </style> </head> <body> <!-- 页面滚动容器 --> <div id="app" class="virtual-list"></div> <script> // 列表数据总数 const total = 10000; // 列表项高度 const itemHeight = 30; // 可视区域渲染数量 +2 作为缓冲 避免闪烁 const visibleCount = Math.ceil(300 / itemHeight) + 2; const data = Array.from({ length: total }, (_, i) => `Item ${i}`); // 容器 const container = document.getElementById('app'); // 列表容器 需要撑开总高度 const wrap = document.createElement('div'); // 计算容器总高度 wrap.style.height = total * itemHeight + 'px'; // 列表项使用absolute定位 wrap.style.position = 'relative'; // 列表项 const list = document.createElement('div'); // 使用absolute定位 使listItem 在list内偏移而不改变list高度 list.style.position = 'absolute'; list.style.top = 0; list.style.left = 0; list.style.right = 0; wrap.appendChild(list); container.appendChild(wrap); function render() { // 读取容器当前垂直滚动偏移 const scrollTop = container.scrollTop; // 计算起始位置 const start = Math.floor(scrollTop / itemHeight); // 计算结束为止 const end = Math.min(start + visibleCount, total); /** * 设置偏移 使内容位置正确 * 使用transform 将list 整体下移 start * itemHeight * transform性能较直接设置top更优 * */ list.style.transform = `translateY(${start * itemHeight}px)`; // 渲染可视区域数据,重置innerHTML 清空之前渲染的内容,优化见下文 list.innerHTML = ''; // 渲染数据 for (let i = start; i < end; i++) { const div = document.createElement('div'); div.style.height = `${itemHeight}px`; div.style.lineHeight = `${itemHeight}px`; div.style.borderBottom = '1px solid #eee'; div.textContent = data[i]; list.appendChild(div); } } render(); // 滚动监听 container.addEventListener('scroll', render); </script> </body> </html> -
React版
tsximport VirtualList from './pages/virtual-list/fixed-height-virtual-list'; function App() { const data = Array.from({ length: 100000 }, (_, i) => `Item ${i}`); return ( <div style={{ padding: 20 }}> <h2>React 虚拟列表示例</h2> <VirtualList itemHeight={30} height={400} data={data} /> </div> ); } export default App;tsximport { useRef, useState } from 'react'; interface VirtualListProps { itemHeight: number; height: number; data: string[]; } const FixHeightVirtualList = ({ itemHeight = 30, height = 300, data = [], }: VirtualListProps) => { const containerRef = useRef<HTMLDivElement>(null); const [scrollTop, setScrollTop] = useState(0); const total = data.length; const visible = Math.ceil(height / itemHeight) + 2; const start = Math.floor(scrollTop / itemHeight); const end = Math.min(start + visible, total); const onScroll = () => { if (containerRef.current) { const top = containerRef.current.scrollTop; setScrollTop(top); } }; return ( <div ref={containerRef} onScroll={onScroll} style={{ height, overflowY: 'auto', border: '1px solid #ccc', position: 'relative', }} > <div style={{ height: total * itemHeight, position: 'relative' }}> <div style={{ position: 'absolute', top: '0', left: '0', right: '0', transform: `translateY(${start * itemHeight}px)`, }} > {data.slice(start, end).map((item, index) => ( <div key={start + index} style={{ height: itemHeight, lineHeight: `${itemHeight}px`, borderBottom: '1px solid #eee', paddingLeft: '10px', }} > {item} </div> ))} </div> </div> </div> ); }; export default FixHeightVirtualList; -
React优化版
-
方向
pythondata.slice(start, end).map()- 每次滚动都会生成新数组,会导致对象频繁创建
- 数组改变后,React会diff整个可视区域
- key每次新增/删除导致卸载挂载
-
优化后
- DOM数量保持不变
- DOM完全复用
- transform: translateY(offset)不触发回流
-
去掉不必要的diff 去掉不必要的DOM创建/删除 去掉不必要的布局 去掉不必要的渲染
tsximport React, { useCallback, useMemo, useRef, useState } from 'react'; interface VirtualListProps { itemHeight: number; height: number; data: string[]; buffer: number; renderItem?: (item: string, index: number) => React.ReactNode; } const FixHeightVirtualListV2 = ({ itemHeight = 50, height = 400, data = [], buffer = 2, renderItem, }: VirtualListProps) => { // 存DOM引用,读取scrollTop const containerRef = useRef<HTMLDivElement>(null); // 保存滚动实时值,不触发渲染,避免频繁setState const scrollTopRef = useRef(0); //标识是否已经有rAF回调 const tickingRef = useRef(false); const [scrollTop, setScrollTop] = useState(0); const total = data.length; // 容器可见行数 const visible = Math.ceil(height / itemHeight); // 真正创建的DOM节点数 = 可视区域 + 缓冲区 缓冲越多滚动越平滑但DOM更多 const poolCount = visible + buffer; // 起始位置 const start = Math.max(0, Math.floor(scrollTop / itemHeight)); const onScroll = useCallback(() => { // 每次滚动把最新的scrollTop存入ref const top = containerRef.current?.scrollTop || 0; scrollTopRef.current = top; // 如果没有排队的rAF,就排一个 if (!tickingRef.current) { tickingRef.current = true; requestAnimationFrame(() => { setScrollTop(scrollTopRef.current); tickingRef.current = false; }); } }, []); const containerStyle = useMemo<React.CSSProperties>( () => ({ overflowY: 'auto', height: height, border: '1px solid #ddd', position: 'relative', WebkitOverflowScrolling: 'touch', }), [height] ); const spacerStyle = useMemo<React.CSSProperties>( () => ({ height: total * itemHeight, position: 'relative', }), [total, itemHeight] ); const innerStyle = useMemo<React.CSSProperties>( () => ({ position: 'absolute', top: 0, left: 0, right: 0, transform: `translateY(${start * itemHeight}px)`, willChange: 'transform', }), [start, itemHeight] ); const itemBaseStyle = useMemo<React.CSSProperties>( () => ({ height: itemHeight, lineHeight: `${itemHeight}px`, borderBottom: '1px solid #eee', padding: '0 12px', boxSizing: 'border-box', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', }), [itemHeight] ); return ( <div ref={containerRef} style={containerStyle} onScroll={onScroll}> <div style={spacerStyle}> <div style={innerStyle}> {/* key使用index做索引而非dataIndex 在diff的时候不会将节点当作新节点创建或删除,而是复用DOM只替换文本*/} {Array.from({ length: poolCount }).map((_, index) => { const dataIndex = start + index; const item = dataIndex < total ? data[dataIndex] : null; const content = item === null ? null : renderItem ? renderItem(item, dataIndex) : item ?? String(item); return ( <div key={index} style={{ ...itemBaseStyle }} data-index={dataIndex} > {content} </div> ); })} </div> </div> </div> ); }; export default FixHeightVirtualListV2; -
不定高度
-
React版
-
相比于定长列表,我们首先需要获取每个item的高度并将其存起来
tsximport { useState } from 'react'; import VariableHeightVirtualList from './pages/virtual-list/variable-height-virtual-list'; function App() { const [dataVariable] = useState(() => new Array(1000).fill(0).map((_, i) => ({ id: i, text: `Row ${i}`, height: 20 + Math.round(Math.random() * 80), })) ); return ( <VariableHeightVirtualList data={dataVariable} poolCount={15} estimatedItemHeight={50} containerHeight={500} renderItem={(item: any) => ( <div style={{ padding: '10px', borderBottom: '1px solid #eee', background: '#fafafa', height: item.height, // 不定高 }} > {item.text} --- height: {item.height} </div> )} /> ); } export default App;tsximport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; interface VirtualListProps { data: string[]; renderItem: (item: string, index: number) => React.ReactNode; poolCount?: number; estimatedItemHeight?: number; containerHeight?: number; } const VariableHeightVirtualList = ({ data, renderItem, poolCount = 15, estimatedItemHeight = 40, containerHeight = 400, }: VirtualListProps) => { const total = data.length; // heightMap存储单项高度,prefixHeight存储累计高度 // 用来存储已测量的真实高度 const heightMap = useRef<Record<number, number>>({}); // 前缀和数组 prefixHeight[i] 表示 0~i 项的总高度 用于快速用二分查找scrollTop对应的startIndex const prefixHeight = useRef<number[]>([]); // 驱动视图更新 const [scrollTop, setScrollTop] = useState(0); // 保持池内每个DOM节点的引用 const itemRefs = useRef<Array<React.RefObject<HTMLDivElement>>>([]); // 计算前缀和 在不定高情况下,无法直接计算startIndex,需要通过前缀和与二分查找来定位scrollTop对应的item const calcPrefix = useCallback(() => { const arr = new Array(total); let sum = 0; for (let i = 0; i < total; i++) { const h = heightMap.current[i] || estimatedItemHeight; sum += h; arr[i] = sum; } prefixHeight.current = arr; }, [total, estimatedItemHeight]); // 首次挂载时执行 useEffect(() => { calcPrefix(); }, [calcPrefix]); // 二分查找 // 给定当前scrollTOp,在prefixHeight中通过二分查找找到最小的k,使prefixHeight[k] >= scrollTop,也就是scrollTop所在Item的索引 const findStartIndex = useCallback(() => { const arr = prefixHeight.current; const target = scrollTop; let left = 0; let right = arr.length - 1; while (left < right) { const mid = (left + right) >> 1; if (arr[mid] < target) left = mid + 1; else right = mid; } return left; }, [scrollTop]); // 得到起始索引 const [startIndex, setStartIndex] = useState(0); useEffect(() => { setStartIndex(findStartIndex()); }, [scrollTop, findStartIndex]); // 把DOM池视觉定位到startIndex位置 const offset = useMemo( () => (startIndex === 0 ? 0 : prefixHeight.current[startIndex - 1]), [startIndex] ); const scrollLock = useRef(false); const onScroll = (e: React.UIEvent<HTMLDivElement>) => { const nextTop = (e.target as HTMLDivElement)?.scrollTop; if (!scrollLock.current) { scrollLock.current = true; requestAnimationFrame(() => { setScrollTop(nextTop); scrollLock.current = false; }); } }; // 在DOM更新并在浏览器绘制前,测量池中每个已渲染节点的真实offsetHeight,把测量结果写入heightMap // useLayoutEffect比useEffect执行更早, useLayoutEffect(() => { let changed = false; for (let i = 0; i < poolCount; i++) { const realIndex = startIndex + i; if (realIndex >= total) break; const dom = itemRefs.current[i]; if (dom) { const h = dom.current?.offsetHeight || 0; if (heightMap.current[realIndex] !== h) { heightMap.current[realIndex] = h; changed = true; } } } if (changed) calcPrefix(); }); return ( // 最外层滚动容器 <div style={{ height: containerHeight, overflow: 'auto', position: 'relative', border: '1px solid #ddd', }} onScroll={onScroll} > <div style={{ height: prefixHeight.current[total - 1] || 0, position: 'relative', }} > <div style={{ transform: `translateY(${offset}px`, position: 'absolute', left: 0, right: 0, color: 'black', }} > {/* 构建DOM池,固定长度为poolCount的数组并map出池内一个个槽位 */} {Array.from({ length: poolCount }).map((_, i) => { const dataIndex = startIndex + i; if (dataIndex >= total) return null; return ( <div key={i} ref={el => (itemRefs.current[i] = el)} style={{ boxSizing: 'border-box', width: '100%' }} data-index={dataIndex} > {renderItem(data[dataIndex], dataIndex)} </div> ); })} </div> </div> </div> ); }; export default VariableHeightVirtualList;-

-
整体经历以下几个阶段
- 用户滚动
- 更新scrollTop
- 通过前缀和prefixHeight 二分查找 startIndex
- 计算offset
- 渲染DOM池
- useLayoutEffect测量真实高度
- 写入heightMap
- 重新计算prefixHeight
- 视图稳定,等待下一次更新
-
-
Vue版
html<template> <div style="padding: 20px"> <h3>Variable Height Virtual List</h3> <VariableHeightVirtualList :items="data" :containerHeight="600" :estimatedItemHeight="72" v-slot="{ item, index }" ref="vlist" > <div @click="toggleExpand(index)" :style="itemStyle(item, index)"> <strong>#{{ index }}</strong> - {{ item.text }} <div v-if="expanded[index]" style="margin-top: 8px"> 额外内容:{{ item.largeText }} </div> </div> </VariableHeightVirtualList> </div> </template> <script lang="ts" setup> import { ref } from 'vue'; import VariableHeightVirtualList from '../component/VariableHeightVirtualList.vue'; const vlist = ref<any>(null); const data = new Array(2000).fill(0).map((_, i) => ({ id: i, text: 'Item ' + i, largeText: '详细内容 '.repeat((i % 6) + 1), hasImage: i % 10 === 0, })); const expanded = ref<Record<number, boolean>>({}); function toggleExpand(idx: number) { expanded.value = { ...expanded.value, [idx]: !expanded.value[idx] }; } function itemStyle(item: any, idx: number) { return { padding: '12px', background: idx % 2 === 0 ? '#fff' : '#fafafa', borderBottom: '1px solid #eee', cursor: 'pointer', }; } </script>html<script lang="ts" setup> import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount, } from 'vue'; /** * Props * - items: 数据数组 * - containerHeight: 可视区高度 (px) * - estimatedItemHeight: 估算高度(用于初始 prefix) * - poolCount: 可选,覆盖计算得到的池大小 * - render-slot: 默认插槽 renderItem(item, index) */ interface Props<T = any> { items: T[]; containerHeight: number; estimatedItemHeight?: number; poolCount?: number; itemKey?: (item: any, index: number) => string | number; } const props = withDefaults(defineProps<Props>(), { estimatedItemHeight: 56, poolCount: undefined, itemKey: undefined, }); const emit = defineEmits<{ (e: 'measure', info: { index: number; height: number }): void; }>(); // 容器元素引用 const containerRef = ref<HTMLElement | null>(null); // 池子 const poolRefs = ref<Array<HTMLElement | null>>([]); const ro = ref<ResizeObserver | null>(null); const items = computed(() => props.items); const total = computed(() => items.value.length); const estimatedItemHeight = computed(() => props.estimatedItemHeight!); // 存储item高度 <索引,高度> const heightMap = ref<Record<number, number>>({}); // 前缀和数组 const prefix = ref<number[]>([]); const pendingScroll = ref<number | null>(null); // 当前滚动位置 const scrollTop = ref(0); // 计算可视数量 & 池大小 const visibleEstimate = computed(() => Math.max(1, Math.ceil(props.containerHeight / estimatedItemHeight.value)) ); const pool = computed(() => props.poolCount ?? visibleEstimate.value + 3); // 起始索引与偏移 const startIndex = ref(0); const offset = ref(0); // 构建前缀和数组 function calcPrefix() { const n = total.value; const arr: number[] = new Array(n); let s = 0; for (let i = 0; i < n; i++) { s += heightMap.value[i] ?? estimatedItemHeight.value; arr[i] = s; } prefix.value = arr; } // 二分法查找起始item function binaryFindStart(target: number) { const arr = prefix.value; if (!arr.length) return 0; let l = 0, r = arr.length - 1; while (l < r) { const m = (l + r) >> 1; if (arr[m] < target) l = m + 1; else r = m; } return l; } // 整个列表总高度 const totalHeight = computed(() => { const last = prefix.value[prefix.value.length - 1]; if (last != null) return last; return total.value * estimatedItemHeight.value; }); // 滚动时处理 function onScroll(e: Event) { const el = e.target as HTMLElement; const top = el.scrollTop; if (pendingScroll.value === null) { pendingScroll.value = top; requestAnimationFrame(() => { scrollTop.value = pendingScroll.value as number; pendingScroll.value = null; }); } else { pendingScroll.value = top; } } // 监听滚动位置变化,更新起始索引与偏移 watch(scrollTop, top => { if (!prefix.value.length) { startIndex.value = 0; offset.value = 0; return; } const s = binaryFindStart(top); startIndex.value = s; offset.value = s === 0 ? 0 : prefix.value[s - 1] ?? 0; }); // 观察元素高度变化 function observeEl(el: HTMLElement | null) { if (!el || !ro.value) return; ro.value.observe(el); } // 测量池中所有项目实际高度,并更新 async function measurePool() { await nextTick(); let changed = false; for (let i = 0; i < pool.value; i++) { const realIndex = startIndex.value + i; if (realIndex >= total.value) break; const el = poolRefs.value[i]; if (!el) continue; const h = Math.round(el.offsetHeight); if (heightMap.value[realIndex] !== h) { heightMap.value = { ...heightMap.value, [realIndex]: h }; emit('measure', { index: realIndex, height: h }); changed = true; } observeEl(el); } if (changed) { requestAnimationFrame(() => calcPrefix()); } } // 组件挂载时创建 监听元素尺寸变化并更新 onMounted(() => { ro.value = new ResizeObserver(entries => { let changed = false; for (const ent of entries) { const el = ent.target as HTMLElement; const idxAttr = el.dataset.vIndex; if (!idxAttr) continue; const idx = Number(idxAttr); const newH = Math.round(ent.contentRect.height); if (heightMap.value[idx] !== newH) { heightMap.value = { ...heightMap.value, [idx]: newH }; emit('measure', { index: idx, height: newH }); changed = true; } } if (changed) requestAnimationFrame(() => calcPrefix()); }); calcPrefix(); }); // 组件卸载前断开观察 onBeforeUnmount(() => { ro.value?.disconnect(); ro.value = null; }); // 监听 items 变化,重建 prefix 并测量池 watch([startIndex, () => items.value.length], () => { measurePool(); }); // 组件挂载后初始化 onMounted(() => { nextTick(() => { calcPrefix(); measurePool(); }); }); </script> <template> <!-- 滚动容器 --> <div :style="{ height: props.containerHeight + 'px', overflowY: 'auto', position: 'relative', }" ref="containerRef" @scroll="onScroll" > <!-- 实际元素容器 --> <div :style="{ height: totalHeight + 'px', position: 'relative' }"> <div :style="{ transform: `translateY(${offset}px)`, position: 'absolute', left: 0, right: 0, }" > <template v-for="i in pool"> <div v-if="startIndex + (i - 1) < total" :key="i - 1" :ref="el => (poolRefs[i - 1] = el)" :data-v-index="startIndex + (i - 1)" class="vhvl-item" style="width: 100%; box-sizing: border-box" > <slot :item="items[startIndex + (i - 1)]" :index="startIndex + (i - 1)" > <div style="padding: 8px; border-bottom: 1px solid #eee"> {{ items[startIndex + (i - 1)] }} </div> </slot> </div> </template> </div> </div> </div> </template> <style scoped></style>
思路
虚拟列表本质就是:用极少数DOM,模拟海量的内容渲染,并保持页面流畅
JavaScript → Style → Layout → Paint → Composite
-
减少DOM数量
- DOM越少,Layout和Paint成本越低
-
DOM池复用
-
不创建/销毁DOM,减少渲染
-
在React升级版中,我们会看到如下写法:
- 无论ItemIndex怎么变化,DOM是不变的,React只会将其内容改变,而不是删除/创建
css<div key={i}>{text}</div> -
-
transform位移
-
使用useLayoutEffect
-
height测量在绘制前完成
render ↓ useLayoutEffect(DOM 已存在,但尚未绘制) ↓ 浏览器绘制 ↓ useEffect -
前缀和相关
一维前缀和
当计算数组区间和时,可以通过前缀和的方式,本文在计算不定高度的item和时使用
二维前缀和


