虚拟列表从入门到出门

135 阅读4分钟

虚拟列表

目标:让页面只渲染可见的少量节点,其余都是空白高度

固定高度

🌰来喽

每一项高度固定(假设50px)

若滚动区域高度500px,则可显示 (500 / 50 = 10项)

此时快速滚动到了2000项

真实渲染的列表内容:

只渲染 10~15 个 DOM 节点(pool)

把它们整体 transform 下移 offset 像素

在它们里面显示第 2000~2015 条数据

  • 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版

    • import 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;
      
    • import { 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优化版

    • 方向

      •   data.slice(start, end).map()
        
      • 每次滚动都会生成新数组,会导致对象频繁创建
      • 数组改变后,React会diff整个可视区域
      • key每次新增/删除导致卸载挂载
    • 优化后

      • DOM数量保持不变
      • DOM完全复用
      • transform: translateY(offset)不触发回流
    • 去掉不必要的diff 去掉不必要的DOM创建/删除 去掉不必要的布局 去掉不必要的渲染

    • import 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的高度并将其存起来

    • import { 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;
      
    • import {
        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版

    • <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>
      
    • <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只会将其内容改变,而不是删除/创建
    •   <div key={i}>{text}</div>
      
  • transform位移

  • 使用useLayoutEffect

    • height测量在绘制前完成

    •   render
        ↓
        useLayoutEffect(DOM 已存在,但尚未绘制)
        ↓
        浏览器绘制
        ↓
        useEffect
      

前缀和相关

一维前缀和

leetcode.cn/problems/ra…

当计算数组区间和时,可以通过前缀和的方式,本文在计算不定高度的item和时使用

二维前缀和

leetcode.cn/problems/ra…