虚拟列表及其引发的思考

0 阅读3分钟

前言:

最近看到好多地方看到虚拟列表这个概念,其实之前都已经接触到了,但是我就是想不明白,在现实场景中真的会有大批量的数据不进行分页处理吗?所以就没太在意。今天换个角度一想,其实面试官想要考察的无非就是事件循环、渲染机制、处理庞大的DOM树时回流和重绘对主进程的毁灭性打击。所以今天试着写了一下,发现了很多问题,同时也加深了对前面知识的理解。

虚拟列表

  1. 场景:十万条数据的渲染(面试官特意加上“不能分页、不能下拉加载”的死规定)。
  1. 设想:10万条数据一次性全部插入DOM树中,会引发严重的回流重绘,导致主进程堵塞,页面白屏卡死。
  1. 解决方案:虚拟列表

上代码

  • 直接看实现原理太空洞,直接将代码展现出来,结合下面的实现原理更容易理解。
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树上永远只有可视区域中的那几个节点。

  1. 确定外层容器的占位: 外层容器设置一个固定宽度之后,给其一个 overflow-y: auto。等会内层容器用一个及其高的空div 将滚动条显现出来,目的就是骗过用户,让用户感觉数据都存在。

  2. 滚动监听: 监听外层容器的scroll 事件,获取当前滚动距离scrollTop

  3. 计算可视区域内的数据: 通过scrollTop和item的高度计算出开始索引结束索引 然后通过索引截取数据列表。

  4. 绝对定位: 通过开始索引itemHeight 计算对应的偏移量,通过transform进行偏移即可。

  5. 参考图片:

image-20260417161330109.png

基础代码

  • 这是为了实现而实现的代码,解决了具体的场景问题。
  • 但是通过快速滑动之后,出现了白屏问题,这是因为滚动事件没有进行优化的时候,浏览器的主进程饱和执行计算任务,画的越快,任务越多,导致真正的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总结):

      1. 与屏幕刷新率绝对同步: 无论你的代码写得多快,如果屏幕是 60Hz,rAF 的回调函数永远只会每秒执行 60 次(大约 16.6ms 一次)。它就像一辆永远按列车时刻表发车的地铁,绝不提前,绝不迟到。
      2. 后台自动暂停(省电神器): 如果你把网页切到后台,或者最小化了浏览器,rAF 会自动暂停执行回调。这极大地节省了 CPU 和电池电量(而 setTimeoutsetInterval 在后台依然会像傻子一样疯狂空转)。
      3. 避免布局抖动(Layout Thrashing): 浏览器会把所有通过 rAF 注册的回调函数放在同一批次处理,这在执行复杂的 DOM 操作时非常有利于性能优化。
  • 然后就引发我的思考,既然都是节流,用throttle 是不是也可以呢?为了模拟requestAnimationFrame 的执行频率,我设为 throttle(fn, 16)

  • 然后就引发了我对requestAnimationFramethrottle 哪个更贴切这个场景的思考?

    • requestAnimationFrame 毋庸置疑,肯定是最贴却此场景的,基于 节流,它是帧驱动的。它不看时间,只看显示器。显示器准备好要画下一张图了,它才执行。它是与浏览器渲染流水线绝对同步的。
    • lodash.throttle(fn, 16) ,基于时间的节流, 它是时间驱动的。底层主要依赖 setTimeout。你告诉它“每 16 毫秒最多执行一次”,它就会老老实实掐着表,时间一到就放行;而且setTimeout的执行不是同步的,但总体差异不大。
    • requestAnimationFrame 能够自动匹配刷新率,而**lodash.throttle(fn, 16)** 此时就会成为性能瓶颈。