美文网首页
前端性能提升长列表优化解决方案

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

作者: 咕叽咕叽先生 | 来源:发表于2022-06-30 10:11 被阅读0次

    1.超长列表优化思路

    1.1 概念

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

    1.2 方案

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

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

    1.2.2 浏览器中的 EventLoop
    eventloop.jpg
    1.2.3 运行机制
    implement.jpg
    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. 开发一个【虚拟列表】组件
    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)功能,能够提高数据量大时候长列表的性能。

    相关文章

      网友评论

          本文标题:前端性能提升长列表优化解决方案

          本文链接:https://www.haomeiwen.com/subject/mrhcbrtx.html