React前端性能提升长列表优化解决方案

1.超长列表优化思路

1.1 概念

数据量较大且无法使用分页方式来加载的列表。比如淘宝的商品列表页,一次请求10个商品,一次请求10个商品和50个商品数据返回所需要的时间相差不大。但是却会多出4次的接口请求,造成资源浪费。

1.2 方案
  • 分片渲染(通过浏览器事件环机制,也就是 EventLoop,分割渲染时间)
  • 虚拟列表(只渲染可视区域)
1.2.1 进程与线程

进程是系统进行资源分配和调度的一个独立单位,一个进程内包含多个线程。常说的 JS 是单线程的,是指 JS 的主进程是单线程的。

1.2.2 浏览器中的 EventLoop

在这里插入图片描述

1.2.3 运行机制

在这里插入图片描述

1.2.4 宏任务包含:

script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate(Node.js 环境)

1.2.5 微任务包含:

Promise.then
Object.observe
MutationObserver
process.nextTick(Node.js 环境)

1.3 思路
  1. 【分片渲染】 启用使用API setTimeout 分片渲染 每次渲染50条,进入宏任务列队进行页面渲染以提高效率。
  2. 开发一个【虚拟列表】组件

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X86iqJj4-1656642284323)(./img/img1.jpg)]

在这里插入图片描述

长列表的渲染有以下几种情况:
1、列表项高度固定,且每个列表项高度相等
2、列表项高度固定不相等,但组件调用方可以明确的传入形如(index: number)=>number的getter方法去指定每个索引处列表项的高度
3、列表项高度不固定,随内容适应,且调用方无法确定具体高度

每种情况大致思路相似,计算出totalHeight撑起容器,并在滚动事件触发时根据scrollTop值不断更新startIndex以及endIndex,以此从列表数据listData中截取元素。

1.3.1 列表项高度固定,且每个列表项高度相等

核心代码

    <!-- 为容器绑定 scrollTop,且实时更新scrollTop值  -->
    private onScroll() {
        this.setState({scrollTop: this.container.current?.scrollTop || 0});
    }

    <!-- 计算 数据截取始末index  -->
    private calcListToDisplay(params: {
        scrollTop: number,
        listData: any[],
        itemHeight: number,
        bufferNumber: number,
        containerHeight: number,
    }) {
        const {scrollTop, listData, itemHeight, bufferNumber, containerHeight} = params;
        // 考虑到buffer
        let startIndex = Math.floor(scrollTop / itemHeight);
        startIndex = Math.max(0, startIndex - bufferNumber);  //计算出 带buffer 的数据起点 取最大值防止起点为负数
        const displayCount = Math.ceil(containerHeight / itemHeight);
        let lastIndex = startIndex + displayCount;  
        lastIndex = Math.min(listData.length, lastIndex + bufferNumber);  //计算出 带buffer 的数据终点,取最小值防止数据溢出

        return {
            data: listData.slice(startIndex, lastIndex + 1), //截取的数据
            offset: startIndex * itemHeight //顶部偏移量
        }
    }


    render() {
    const {itemHeight, listData, height: containerHeight, bufferNumber = 10} = this.props;
    const {scrollTop} = this.state;
    const totalHeight = itemHeight * listData.length;
    const { data: listToDisplay, offset } = 
        this.calcListToDisplay({scrollTop, listData, itemHeight, bufferNumber, containerHeight});
        return (
            <!-- 外层容器 -->
            <div ref={this.container} onScroll={this.onScroll} style={{height: `${containerHeight}px`, overflow: 'auto'}}>
            <!-- 计算所有数据高度,用于显示滚动条 -->
                <div className="virtual-list-wrapper" style={{height: `${totalHeight}px`}}>
                <!-- 展示内容 使用 transform 时刻保持在屏幕中央 -->
                    <div style={{transform: `translateY(${offset}px)`}}>
                        {
                            listToDisplay.map((item, index) => {
                                return (
                                    <ListItem key={item.key ? item.key: index}>
                                        <img src={item.img}/>
                                        <div>{item.text}</div>
                                    </ListItem>
                                )
                            })
                        }
                    </div>
                </div>
            </div>
        )
    }

    <!-- 调用组件 -->
    <VirtualList height={300} itemHeight={38} listData={generateList()} />
1.3.2 列表项高度固定不相等,但组件调用方可以明确的传入形如(index: number)=>number的getter方法去指定每个索引处列表项的高度

由于传入了Getter方法,相当于已知每个列表项的高度。我们可以维护一个数组posInfo来存储每个节点到容器顶部的距离,posInfo[i]即为第i项距离顶部的偏移量。

那么不考虑bufferNumber,只需要找出满足posInfo[k] < scrollTop,且posInfo[k+1] > scrollTop的k即可,由于posInfo一定是递增序列,可以采用二分法查找提高效率。

    <!-- 为容器绑定 scrollTop,且实时更新scrollTop值  -->
    private onScroll() {
        this.setState({scrollTop: this.container.current?.scrollTop || 0});
    }

    
    <!-- 调用初始化高度 -->
    componentWillMount() {
        const { listData, heightGetter } = this.props;
        if (heightGetter instanceof Function) {
            this.initItemPosition(listData, heightGetter);
        }
    }
    <!-- 使用 posInfo 存储每个节点到容器顶部的距离 -->
    private initItemPosition(listData: any[], heightGetter: heightGetter) {
        this.totalHeight = listData.reduce((total: number, item: any, index: number) => {
            const height = heightGetter(index);
            this.posInfo.push(total);
            return total + height;
        }, 0);
    }

    <!-- 截取数据 获取 lastIndex -->
    private getListToDisplay(params: {
        scrollTop: number;
        listData: any[];
        posInfo: number[];
        containerHeight: number;
        bufferNumber: number;
    }) {
        const { scrollTop, listData, posInfo, containerHeight, bufferNumber } = params;
        let startIndex = this.searchPos(posInfo, scrollTop);
        let lastIndex = listData.length - 1;
        const lastIndexDistance = containerHeight + scrollTop;
        for (let index = startIndex; index < listData.length; index++) {
            if (posInfo[index] >= lastIndexDistance) {
                lastIndex = index;
                break;
            }
        }
        // 考虑buffer
        startIndex = Math.max(0, startIndex - bufferNumber);
        lastIndex = Math.min(listData.length - 1, lastIndex + bufferNumber);
        return {
            data: listData.slice(startIndex, lastIndex + 1),
            offset: posInfo[startIndex]
        }
    }


    <!-- 使用二分法得到开始的index  -->
    <!-- 即找出满足posInfo[k] < scrollTop,且posInfo[k+1] > scrollTop的k即可 -->
    private searchPos(posInfo: number[], scrollTop: number) {
        const _binarySearchPos = (start: number, end: number): number => {
            if (end - start <= 1) {
                return start;
            }
            const mid = Math.ceil((start + end) / 2);
            if (posInfo[mid] === scrollTop) {
                return mid;
            } else if (posInfo[mid] < scrollTop) {
                if (posInfo[mid + 1] && posInfo[mid + 1] >= scrollTop) {
                    return mid;
                } else {
                    return _binarySearchPos(mid + 1, end);
                }
            } else {
                if (posInfo[mid - 1] && posInfo[mid - 1] <= scrollTop) {
                    return mid - 1;
                } else {
                    return _binarySearchPos(start, mid - 1);
                }
            }
        }
        return _binarySearchPos(0, posInfo.length - 1);
    }

    <!-- 不变 -->
    render() {
    const {itemHeight, listData, height: containerHeight, bufferNumber = 10} = this.props;
    const {scrollTop} = this.state;
    const totalHeight = itemHeight * listData.length;
    const { data: listToDisplay, offset } = 
        this.calcListToDisplay({scrollTop, listData, itemHeight, bufferNumber, containerHeight});
        return (
            <!-- 外层容器 -->
            <div ref={this.container} onScroll={this.onScroll} style={{height: `${containerHeight}px`, overflow: 'auto'}}>
            <!-- 计算所有数据高度,用于显示滚动条 -->
                <div className="virtual-list-wrapper" style={{height: `${totalHeight}px`}}>
                <!-- 展示内容 使用 transform 时刻保持在屏幕中央 -->
                    <div style={{transform: `translateY(${offset}px)`}}>
                        {
                            listToDisplay.map((item, index) => {
                                return (
                                    <ListItem key={item.key ? item.key: index}>
                                        <img src={item.img}/>
                                        <div>{item.text}</div>
                                    </ListItem>
                                )
                            })
                        }
                    </div>
                </div>
            </div>
        )
    }

    <!-- 调用组件 -->
    <VirtualList height={300} heightGetter={(index) => { return listData[index].height }} listData={listData} />
1.3.3 列表项高度不固定,随内容适应,且调用方无法确定具体高度

核心代码

    <!-- 设置默认虚拟 高度 fuzzyItemHeight -->
    <!-- 由于无法得知节点具体高度,可以通过给出一个模糊高度fuzzyItemHeight来初始化一个并不准确的高度撑起容器。接着在滚动过程中,item组件挂载后可以得到准确的高度,此时更新totalHeight,使totalHeight趋于准确-->

    componentWillMount() {
    <!-- 所有元素虚拟高度集合 不准确-->
        this.heightCache = new Array(this.props.listData.length).fill(this.props.fuzzyItemHeight || 30);
    }

  

    <!-- 子组件命周期componentDidMount内更新totalHeight-->
    onMount(index: number, height: number) {
        if (index > this.lastCalcIndex) {
                
            this.heightCache[index] = height;  // heightCache数组存储已挂载过的列表项的高度
            this.lastCalcIndex = index;  //lastCalcIndex记录最后一个已挂载节点的索引
            this.lastCalcTotalHeight += height;  //lastCalcTotalHeight记录已挂载节点的全部高度和
            //趋于准确
            this.totalHeight = this.lastCalcTotalHeight + (this.props.listData.length - 1 - this.lastCalcIndex) * (this.props.fuzzyItemHeight || 30);
        }
    }



<!-- 计算可见节点
遍历已缓存的节点高度,calcHeight记录已遍历的节点总高度,直到calcHeight > scrollTop,记录当前节点索引为startIndex,同理找出calcHeight > scrollTop + containerHeight的节点索引为endIndex。与此同时,posInfo记录各节点到顶部的距离,以便直接给出偏移量offset = posInfo[startIndex] -->

    private getListToDisplay(params: {
        scrollTop: number;
        containerHeight: number;
        itemHeights: number[];
        bufferNumber: number;
        listData: any[];
    }) {
        const { scrollTop, containerHeight, itemHeights, bufferNumber, listData } = params;
        let calcHeight = itemHeights[0]; //初始化(已遍历的节点总高度) 值为 第一个已遍历节点的高度 
        let startIndex = 0;
        let lastIndex = 0;
        const posInfo = []; // posInfo记录各节点到顶部的距离
        posInfo.push(0);
        for (let index = 1; index < itemHeights.length; index++) {
            //已遍历节点的总高度 > scrollTop滚动距离
            if (calcHeight > scrollTop) {
                startIndex = index - 1;
                break;
            }
            posInfo.push(calcHeight);
            calcHeight += itemHeights[index];
        }
        for (let index = startIndex; index < itemHeights.length; index++) {
            if (calcHeight > scrollTop + containerHeight) {
                lastIndex = index;
                break;
            }
            calcHeight += itemHeights[index];
        }
        startIndex = Math.max(0, startIndex - bufferNumber);
        lastIndex = Math.min(itemHeights.length - 1, lastIndex + bufferNumber);
        return {
            data: listData.slice(startIndex, lastIndex + 1),
            offset: posInfo[startIndex]
        }
    }


    <!-- 渲染 -->
    render() {
    const { height: containerHeight, listData, bufferNumber = 10 } = this.props;
    const { scrollTop } = this.state;
    <!-- 
    scrollTop 滚动距离
    itemHeights 需要挂在的元素 高度 默认为 30 若挂载过 则会更新高度值 Arr 
    containerHeight 容器高度 【固定】
    bufferNumber 缓冲元素数
     -->
    const { data: _listToDisplay, offset } = this.getListToDisplay({ scrollTop, listData, itemHeights: this.heightCache, containerHeight, bufferNumber });

    return (
        <div ref={this.container} onScroll={this.onScroll} style={{ height: `${containerHeight}px`, overflow: 'auto' }}>
            <div className="virtual-list-wrapper" style={{ height: `${this.totalHeight}px` }}>
                <div style={{ transform: `translateY(${offset}px)`, willChange: 'transform' }}>
                    {
                        _listToDisplay.map((item, index) => {
                            return (
                                <ListItem key={item.key ? item.key : index} onMount={this.onMount.bind(this, listData.indexOf(item))}>
                                    {/* <img src={item.img} /> */}
                                    <div>{item.text}</div>
                                </ListItem>
                            )
                        })
                    }
                </div>
            </div>
        </div>
    )
    }


1.4 推荐

react-virtualized
如果使用react开发,可以使用antdesign官网推荐的组件,结合 react-virtualized 实现滚动加载无限长列表,带有虚拟化(virtualization)功能,能够提高数据量大时候长列表的性能。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值