前端虚拟列表深度拆解
虚拟列表是为了解决什么问题
真实项目中的痛点:
想象一个后台系统:用户列表:10 万条;订单列表:20 万条;日志列表:百万级;表格里还有:多列、复杂 DOM、hover、操作按钮、状态标签
直接 map 渲染:
data.map(item => <Row key={item.id} />)会遇到:首次渲染卡死、滚动严重掉帧、内存暴涨和浏览器直接崩
根因只有一个:DOM 太多,浏览器不是怕 JS,浏览器最怕的是成千上万个 DOM 节点
总的来说虚拟列表就是只渲染可视区域内的列表项,而其余的用占位高度“假装存在”
虚拟列表的核心思想
我总结主要要理解这四点:
1.可视区域(viewport):屏幕当前能看到的那一段高度
2.列表总高度(total height):假设所有 item 都渲染后的总高度(假的,但要算出来)
3.起始索引和结束索引:根据滚动距离,计算现在应该显示哪几条
4. 偏移量(offset / translateY):让当前渲染的 items 看起来在正确的位置
虚拟列表的本质实现原理
假设每一项高度固定,这个是最简单的,真是项目中大量列表数据一般都是每一项数据都是高度固定的
itemHeight = 50px 容器高度 = 500px那屏幕最多能显示:500 / 50 = 10 条
通常会多渲染几条(缓冲区):实际渲染 = 10 + 4 = 14 条
根据滚动距离算索引
startIndex = Math.floor(scrollTop / itemHeight) endIndex = startIndex + visibleCount不渲染所有 DOM,但要让滚动条是对的
<div> <div></div> <!-- 撑开高度 --> <div></div> <!-- 只放可见项 --> </div>.phantom { height: totalCount * itemHeight; }偏移当前渲染区域
offsetY = startIndex * itemHeight.list { transform: translateY(offsetY); }视觉效果:DOM 只有十几条、滚动条像真的有十万条
真实项目React中使用demo整体代码(可直接使用)
import React, { useRef, useState, useEffect, useMemo,useCallback } from 'react' interface VirtualListProps<T> { data: T[] height: number // 容器高度 itemHeight: number // 每一项高度(固定) renderItem: (item: T, index: number) => React.ReactNode buffer?: number // 缓冲区 } export function VirtualList<T>({ data, height, itemHeight, renderItem, buffer = 5, }: VirtualListProps<T>) { const containerRef = useRef<HTMLDivElement>(null) const [scrollTop, setScrollTop] = useState(0) /** 可视区能显示的条数 */ const visibleCount = Math.ceil(height / itemHeight) /** 开始索引 */ const startIndex = Math.max( Math.floor(scrollTop / itemHeight) - buffer, 0 ) /** 结束索引 */ const endIndex = Math.min( startIndex + visibleCount + buffer * 2, data.length ) /** 当前渲染的数据 */ const visibleData = useMemo( () => data.slice(startIndex, endIndex), [data, startIndex, endIndex] ) /** 偏移量 */ const offsetY = startIndex * itemHeight /** 总高度(关键:撑开滚动条) */ const totalHeight = data.length * itemHeight /** 滚动事件 */ const onScroll = useCallback(() => { if (!containerRef.current) return setScrollTop(containerRef.current.scrollTop) }, []) return ( <div ref={containerRef} style={{ height, overflowY: 'auto', position: 'relative', border: '1px solid #ddd', }} onScroll={onScroll} > {/* 撑开高度 */} <div style={{ height: totalHeight }} /> {/* 实际渲染内容 */} <div style={{ position: 'absolute', top: 0, left: 0, right: 0, transform: `translateY(${offsetY}px)`, }} > {visibleData.map((item, index) => ( <div key={startIndex + index} style={{ height: itemHeight, boxSizing: 'border-box', borderBottom: '1px solid #eee', }} > {renderItem(item, startIndex + index)} </div> ))} </div> </div> ) }如何使用这个 VirtualList
const data = Array.from({ length: 100000 }, (_, i) => `Row ${i}`) export default function App() { return ( <VirtualList data={data} height={500} itemHeight={50} renderItem={(item) => <div>{item}</div>} /> ) }针对这个demo,我总结出几个关键点
1.下面这行代码,不显示任何内容,只是为了撑开滚动条
<div style={{ height: totalHeight }} />2.为什么 content 要 absolute + translateY?因为transform不会出发布局重排,性能比设置top好
transform: translateY(offsetY)3.buffer 的意义,是防止滚动过快出现白屏,提前渲染上下几条
buffer = 54.为什么 key 用startIndex + index?因为:同一条数据在不同 scrollTop 下会复用 DOM,key 必须全局唯一
可以优化的地方:可以对scroll进行一个节流处理
用 ref 记录 RAF 状态
const rafIdRef = useRef<number | null>(null)改变 onScroll函数
const onScroll = useCallback(() => { if (!containerRef.current) return // 如果当前帧已经有任务了,直接 return if (rafIdRef.current !== null) return rafIdRef.current = requestAnimationFrame(() => { setScrollTop(containerRef.current!.scrollTop) rafIdRef.current = null }) }, [])