【前端性能优化】不定高虚拟滚动列表源码

生成模拟数据

typescript 复制代码
import { useState } from 'react';
import React from 'react';
import classNames from 'classnames/bind';

import styles from './index.module.less';

const cx = classNames.bind(styles);

export interface VirtualListProps {}

const VirtualList: React.FC<VirtualListProps> = () => {
    const generateMockList = (length: number) => {
        const getRandomHeight = () => 50 + Math.random() * 100;
        const getRandomHex = () => Math.floor(Math.random() * 255).toString(16);
        const getRandomColor = () => `#${getRandomHex()}${getRandomHex()}${getRandomHex()}`;
        return new Array(length).fill(0).map((_, index) => {
            return (
                <div key={index} style={{ height: getRandomHeight(), background: getRandomColor(), fontSize: 20 }}>
                    {index + 1}
                </div>
            );
        });
    };

    const [mockList] = useState(generateMockList(100000));
    
    return (
        <div className={cx('container')}>
            {/* 生成10w条数据滚动体验还好,但是插入DOM的时间过长 */}
            <div className={cx('virtual-list')}>
                {mockList}
            </div>
        </div>
    );
};

export default VirtualList;

大数据量产生的问题

  1. 当列表包含多媒体资源,直接拉取浪费带宽
  2. 一次性插入过多 DOM 节点导致页面卡顿

解决方案

在计算机领域,很多问题都可以通过引入中间层解决。那既然一次性插入非常多DOM节点会导致页面卡顿,那就想办法减少实际DOM数量。在这里我会通过代理模式,判断列表元素是否需要实际插入到页面中。

使用Chrome的图层进行调试可以发现有很多DOM节点都是不可见的,这些不可见的DOM就是即将被优化的对象

计算可视区

首先要解决可视区域的计算问题,其实改变可视区的动作只有两种:

  1. 滚动容器的高度变化
  2. 滚动容器内部进行了滚动,scrollTop发生变化
scss 复制代码
const [ref, size] = useSize(); // size.height为滚动框的高度

const [scrollTop, _setScrollTop] = useState(0);
const { run: setScrollTop } = useDebounceFn(_setScrollTop, 50); // 滚动框scrollTop需要防抖,否则拖拽滚动条会卡

那么可视区的高度范围为[scrollTop, scrollTop + size.height]

计算元素高度

得到可视区高度之后还需要得到元素高度,再根据元素高度得到可视区可展示的列表序号范围。为了拿到元素高度,可以设计一个组件专门用于得到元素高度。

typescript 复制代码
import { useRef } from 'react';
import React, { useEffect } from 'react';

export interface ScrollItemProps {
    setHeight: (h: number) => void;
    children: any;
}

const ScrollItem: React.FC<ScrollItemProps> = ({ setHeight, children }) => {
    const ref = useRef<HTMLDivElement>(null);
    useEffect(() => {
        setHeight(ref.current.scrollHeight);
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    return <div ref={ref}>{children}</div>;
};

export default ScrollItem;
ini 复制代码
const [visibleMinIndex, setVisibleMinIndex] = useState(0);
const [visibleMaxIndex, setVisibleMaxIndex] = useState(10);

const itemsRef = useRef<number[]>([]); // 记录每个item的高度
const items = itemsRef.current;

// 这个items可能会等于
// 假设可视区范围是[300,500]
[
    100,
    120,
    110, // 100 + 120 + 110 = 330 是第一个大于等于300的元素,则visibleMinIndex = 2
    80,
    90,
    60, // 100 + 120 + 110 + 80 + 90 + 60 = 560 是第一个大于等于500的元素,则visibleMaxIndex = 5
    40,
    200
]
// 由此计算出可视范围内需要展示的序号

const listItemsToRender = listItems.slice(visibleMinIndex, visibleMaxIndex + 1).map((c, i) => {
    const index = i + visibleMinIndex;
    return (
        <ScrollItem index={index} key={index}>
            {c}
        </ScrollItem>
    );
});

填充占位区

不在可视区的DOM元素在清除后需要通过占位区给一个高度,否则会有滚动塌陷的问题。

ini 复制代码
// 计算占位区的方式也非常简单,对可视区上、下区域的元素高度求和
const topPlaceholderHeight = sum(items.slice(0, visibleMinIndex));
const bottomPlaceholderHeight = sum(items.slice(visibleMaxIndex + 1));

<div {...props} ref={ref} onScroll={e => setScrollTop(e.currentTarget.scrollTop)}>
    <div style={{ height: topPlaceholderHeight }}></div>
    {listItemsToRender}
    <div style={{ height: bottomPlaceholderHeight }}></div>
</div>

设置缓冲区

设置缓冲区有诸多好处:

  1. 假设用户滚动到底部了,如果有缓冲区则每次都可以多加载几个,可以一直滚动下去。否则可能需要对滚动到底部的操作做判断和处理。
  2. 用户在上下滚动时可以出现白屏的概率。
ini 复制代码
useEffect(() => {
    let height = 0;
    let minIndex = -1;
    let maxIndex = items.length - 1;
    for (let i = 0; i < items.length; i++) {
        const h = items[i];
        height += h;
        if (height >= scrollTop) {
            if (minIndex === -1) minIndex = i;
        }
        if (height > size.height + scrollTop) {
            maxIndex = i;
            break;
        }
    }
    setVisibleMinIndex(Math.max(0, minIndex - topBuffer)); // topBuffer上缓冲
    setVisibleMaxIndex(maxIndex + bottomBuffer); // bottomBuffer下缓冲
}, [size.height, scrollTop, topBuffer, bottomBuffer, items]);

实现效果

ini 复制代码
import { useRef } from 'react';
import { useEffect, useState } from 'react';
import React from 'react';
import { useDebounceFn, useSize } from '@byted/hooks';
import { sum } from 'lodash';

import ScrollItem from '../scroll-item';

export interface ScrollProps extends React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
    listItems: any[];
    topBuffer?: number;
    bottomBuffer?: number;
}

const Scroll: React.FC<ScrollProps> = ({ listItems, topBuffer = 10, bottomBuffer = 10, ...props }) => {
    const [ref, size] = useSize();

    const [scrollTop, _setScrollTop] = useState(0);
    const { run: setScrollTop } = useDebounceFn(_setScrollTop, 50);

    const [visibleMinIndex, setVisibleMinIndex] = useState(0);
    const [visibleMaxIndex, setVisibleMaxIndex] = useState(10);

    const itemsRef = useRef<number[]>([]);
    const items = itemsRef.current;

    const topPlaceholderHeight = sum(items.slice(0, visibleMinIndex));
    const bottomPlaceholderHeight = sum(items.slice(visibleMaxIndex + 1));

    useEffect(() => {
        let height = 0;
        let minIndex = -1;
        let maxIndex = items.length - 1;
        for (let i = 0; i < items.length; i++) {
            const h = items[i];
            height += h;
            if (height >= scrollTop) {
                if (minIndex === -1) minIndex = i;
            }
            if (height > size.height + scrollTop) {
                maxIndex = i;
                break;
            }
        }
        setVisibleMinIndex(Math.max(0, minIndex - topBuffer));
        setVisibleMaxIndex(maxIndex + bottomBuffer);
    }, [size.height, scrollTop, topBuffer, bottomBuffer, items]);

    const listItemsToRender = listItems.slice(visibleMinIndex, visibleMaxIndex + 1).map((c, i) => {
        const index = i + visibleMinIndex;
        return (
            <ScrollItem setHeight={h => (itemsRef.current[index] = h)} key={index}>
                {c}
            </ScrollItem>
        );
    });

    return (
        <div {...props} ref={ref} onScroll={e => setScrollTop(e.currentTarget.scrollTop)}>
            <div style={{ height: topPlaceholderHeight }}></div>
            {listItemsToRender}
            <div style={{ height: bottomPlaceholderHeight }}></div>
        </div>
    );
};

export default Scroll;
相关推荐
小白学习日记38 分钟前
【复习】HTML常用标签<table>
前端·html
丁总学Java1 小时前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
yanlele1 小时前
前瞻 - 盘点 ES2025 已经定稿的语法规范
前端·javascript·代码规范
懒羊羊大王呀2 小时前
CSS——属性值计算
前端·css
xgq2 小时前
使用File System Access API 直接读写本地文件
前端·javascript·面试
用户3157476081352 小时前
前端之路-了解原型和原型链
前端
永远不打烊2 小时前
librtmp 原生API做直播推流
前端
北极小狐2 小时前
浏览器事件处理机制:从硬件中断到事件驱动
前端
无咎.lsy2 小时前
vue之vuex的使用及举例
前端·javascript·vue.js
fishmemory7sec2 小时前
Electron 主进程与渲染进程、预加载preload.js
前端·javascript·electron