前言:
最近看到好多地方看到虚拟列表这个概念,其实之前都已经接触到了,但是我就是想不明白,在现实场景中真的会有大批量的数据不进行分页处理吗?所以就没太在意。今天换个角度一想,其实面试官想要考察的无非就是事件循环、渲染机制、处理庞大的DOM树时回流和重绘对主进程的毁灭性打击。所以今天试着写了一下,发现了很多问题,同时也加深了对前面知识的理解。
虚拟列表
- 场景:十万条数据的渲染(面试官特意加上“不能分页、不能下拉加载”的死规定)。
- 设想:10万条数据一次性全部插入DOM树中,会引发严重的回流和重绘,导致主进程堵塞,页面白屏卡死。
- 解决方案:虚拟列表
上代码
- 直接看实现原理太空洞,直接将代码展现出来,结合下面的实现原理更容易理解。
import { memo, useRef, useState } from 'react';
import type { FC, ReactNode } from 'react'
interface IProps {
children?: ReactNode
}
const VirtualList: FC<IProps> = () => {
const [scrollTop, setScrollTop] = useState<number>(0)
const containerRef = useRef<HTMLDivElement>(null)
const list = Array.from({ length: 10000 }).map((_, index) => {
return {
id: index,
name: `item-${index}`
}
})
const containerHeight = 500;
const itemHeight = 50;
const totalHeight = list.length * itemHeight;
// 计算可视区域的起始索引和结束索引(同时加上上下的缓冲区)
const startIndex = Math.max(0, Math.floor((scrollTop / itemHeight) - 50))
const endIndex = Math.min(list.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + 50)
// 计算可视区域的列表
const visibleList = list.slice(startIndex, endIndex)
// 计算y轴偏移量
const translateY = startIndex * itemHeight
// 给请求帧加个锁,防止滚动过快导致卡顿
let ticking = false
const handleContainerScroll = () => {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop)
}
ticking = false
})
}
return (
<div>
<h2>VirtualList</h2>
<div ref={containerRef}
style={{
height: `${containerHeight}px`,
overflowY: 'auto',
position: 'relative',
backgroundColor: '#f8fafc'
}}
onScroll={handleContainerScroll}
>
<div style={{ height: `${totalHeight}px` }}>
<ul style={{ transform: `translateY(${translateY}px)` }}>
{
visibleList.map(item => {
return <li key={item.id} style={{ width: '100%', height: `${itemHeight}px` }}>{item.name}</li>
})
}
</ul>
</div>
</div>
</div>
)
}
export default memo(VirtualList);
实现原理
核心思路:不管数据是10万条还是10条,我就只渲染可视区域中的元素即可。这样即使内存中有10万条数据,但DOM树上永远只有可视区域中的那几个节点。
-
确定外层容器的占位: 外层容器设置一个固定宽度之后,给其一个
overflow-y: auto。等会内层容器用一个及其高的空div将滚动条显现出来,目的就是骗过用户,让用户感觉数据都存在。 -
滚动监听: 监听外层容器的
scroll事件,获取当前滚动距离scrollTop。 -
计算可视区域内的数据: 通过
scrollTop和item的高度计算出开始索引和结束索引 然后通过索引截取数据列表。 -
绝对定位: 通过开始索引和itemHeight 计算对应的偏移量,通过transform进行偏移即可。
-
参考图片:
基础代码
- 这是为了实现而实现的代码,解决了具体的场景问题。
- 但是通过快速滑动之后,出现了白屏问题,这是因为滚动事件没有进行优化的时候,浏览器的主进程饱和执行计算任务,画的越快,任务越多,导致真正的UI严重延后,导致白屏。
- 解决方案:添加上下缓冲区,并使用
requestAnimationFrame处理提前帧动画,这样就不用密集地执行计算,在用户体验良好的基础上尽可能的优化性能。
import { memo, useRef, useState } from 'react';
import type { FC, ReactNode } from 'react'
interface IProps {
children?: ReactNode
}
const VirtualList: FC<IProps> = () => {
// 滚动距离
const [scrollTop, setScrollTop] = useState<number>(0)
const containerRef = useRef<HTMLDivElement>(null)
const list = Array.from({ length: 10000 }).map((_, index) => {
return {
id: index,
name: `item-${index}`
}
})
// 外层容器高度
const containerHeight = 500;
// 每一个item的高度
const itemHeight = 50;
// list的总高度,目的是显现滚动条
const totalHeight = list.length * itemHeight;
// 计算可视区域的起始索引和结束索引
const startIndex = Math.floor(scrollTop / itemHeight)
const endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight)
// 计算可视区域的列表
const visibleList = list.slice(startIndex, endIndex)
// 计算y轴偏移量
const translateY = startIndex * itemHeight
const handleContainerScroll = () => {
if (containerRef.current) {
setScrollTop(containerRef.current.scrollTop)
}
}
return (
<div>
<h2>VirtualList</h2>
<div ref={containerRef}
style={{
height: `${containerHeight}px`,
overflowY: 'auto',
position: 'relative',
backgroundColor: '#f8fafc'
}}
onScroll={handleContainerScroll}
>
<div style={{ height: `${totalHeight}px` }}>
{/* 使用translate3d进行y轴偏移,可以开启GPU加速 */}
<ul style={{ transform: `translate3d(0, ${translateY}px, 0)` }}>
{
visibleList.map(item => {
return <li key={item.id} style={{ width: '100%', height: `${itemHeight}px` }}>{item.name}</li>
})
}
</ul>
</div>
</div>
</div>
)
}
export default memo(VirtualList);
关于requestAnimationFrame和throttle的思考
-
回顾
requestAnimationFrame:-
requestAnimationFrame 是什么?
requestAnimationFrame是浏览器提供的一个原生 API。它的字面意思是 “请求动画帧” 。当你调用
requestAnimationFrame(callback)时,你其实是在向浏览器发送一个请求:“嘿,浏览器,我有一段代码(callback)想要执行。请你在下一次把画面绘制到屏幕上之前,帮我执行它。
-
三大神仙特性(AI总结):
- 与屏幕刷新率绝对同步: 无论你的代码写得多快,如果屏幕是 60Hz,rAF 的回调函数永远只会每秒执行 60 次(大约 16.6ms 一次)。它就像一辆永远按列车时刻表发车的地铁,绝不提前,绝不迟到。
- 后台自动暂停(省电神器): 如果你把网页切到后台,或者最小化了浏览器,rAF 会自动暂停执行回调。这极大地节省了 CPU 和电池电量(而
setTimeout和setInterval在后台依然会像傻子一样疯狂空转)。 - 避免布局抖动(Layout Thrashing): 浏览器会把所有通过 rAF 注册的回调函数放在同一批次处理,这在执行复杂的 DOM 操作时非常有利于性能优化。
-
-
然后就引发我的思考,既然都是节流,用
throttle是不是也可以呢?为了模拟requestAnimationFrame的执行频率,我设为throttle(fn, 16)。 -
然后就引发了我对
requestAnimationFrame和throttle哪个更贴切这个场景的思考?requestAnimationFrame毋庸置疑,肯定是最贴却此场景的,基于帧 节流,它是帧驱动的。它不看时间,只看显示器。显示器准备好要画下一张图了,它才执行。它是与浏览器渲染流水线绝对同步的。lodash.throttle(fn, 16),基于时间的节流, 它是时间驱动的。底层主要依赖setTimeout。你告诉它“每 16 毫秒最多执行一次”,它就会老老实实掐着表,时间一到就放行;而且setTimeout的执行不是同步的,但总体差异不大。requestAnimationFrame能够自动匹配刷新率,而**lodash.throttle(fn, 16)** 此时就会成为性能瓶颈。