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

生成模拟数据

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;
相关推荐
用户21411832636021 分钟前
首发!即梦 4.0 接口开发全攻略:AI 辅助零代码实现,开源 + Docker 部署,小白也能上手
前端
gnip2 小时前
链式调用和延迟执行
前端·javascript
SoaringHeart2 小时前
Flutter组件封装:页面点击事件拦截
前端·flutter
杨天天.2 小时前
小程序原生实现音频播放器,下一首上一首切换,拖动进度条等功能
前端·javascript·小程序·音视频
Dragon Wu2 小时前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss2 小时前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师2 小时前
React面试题
前端·javascript·react.js
木兮xg2 小时前
react基础篇
前端·react.js·前端框架
ssshooter3 小时前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘3 小时前
HTML--最简的二级菜单页面
前端·html